forked from TrueCloudLab/distribution
e72294d075
Move implementation of the index from the manifestlist package to the ocischema package so that other modules making empty imports support the manifest types their authors would expect. This is a breaking change to distribution as a library but not the registry. As OCI 1.0 released the manifest and index together, that is a good package from which to initialise both manifests. The docker manifest and manifest list remain in separate packages because one was released later. The image index and manifest list still share common code in many functions not intended for import by other modules. Signed-off-by: Bracken Dawson <abdawson@gmail.com>
596 lines
17 KiB
Go
596 lines
17 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/distribution/distribution/v3"
|
|
"github.com/distribution/distribution/v3/manifest"
|
|
"github.com/distribution/distribution/v3/manifest/ocischema"
|
|
"github.com/distribution/distribution/v3/manifest/schema1"
|
|
"github.com/distribution/distribution/v3/reference"
|
|
"github.com/distribution/distribution/v3/registry/storage/cache/memory"
|
|
"github.com/distribution/distribution/v3/registry/storage/driver"
|
|
"github.com/distribution/distribution/v3/registry/storage/driver/inmemory"
|
|
"github.com/distribution/distribution/v3/testutil"
|
|
"github.com/docker/libtrust"
|
|
"github.com/opencontainers/go-digest"
|
|
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
|
)
|
|
|
|
type manifestStoreTestEnv struct {
|
|
ctx context.Context
|
|
driver driver.StorageDriver
|
|
registry distribution.Namespace
|
|
repository distribution.Repository
|
|
name reference.Named
|
|
tag string
|
|
}
|
|
|
|
func newManifestStoreTestEnv(t *testing.T, name reference.Named, tag string, options ...RegistryOption) *manifestStoreTestEnv {
|
|
ctx := context.Background()
|
|
drvr := inmemory.New()
|
|
registry, err := NewRegistry(ctx, drvr, options...)
|
|
if err != nil {
|
|
t.Fatalf("error creating registry: %v", err)
|
|
}
|
|
|
|
repo, err := registry.Repository(ctx, name)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting repo: %v", err)
|
|
}
|
|
|
|
return &manifestStoreTestEnv{
|
|
ctx: ctx,
|
|
driver: drvr,
|
|
registry: registry,
|
|
repository: repo,
|
|
name: name,
|
|
tag: tag,
|
|
}
|
|
}
|
|
|
|
func TestManifestStorage(t *testing.T) {
|
|
k, err := libtrust.GenerateECP256PrivateKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testManifestStorage(t, true, BlobDescriptorCacheProvider(memory.NewInMemoryBlobDescriptorCacheProvider(memory.UnlimitedSize)), EnableDelete, EnableRedirect, Schema1SigningKey(k), EnableSchema1)
|
|
}
|
|
|
|
func TestManifestStorageV1Unsupported(t *testing.T) {
|
|
k, err := libtrust.GenerateECP256PrivateKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testManifestStorage(t, false, BlobDescriptorCacheProvider(memory.NewInMemoryBlobDescriptorCacheProvider(memory.UnlimitedSize)), EnableDelete, EnableRedirect, Schema1SigningKey(k))
|
|
}
|
|
|
|
func testManifestStorage(t *testing.T, schema1Enabled bool, options ...RegistryOption) {
|
|
repoName, _ := reference.WithName("foo/bar")
|
|
env := newManifestStoreTestEnv(t, repoName, "thetag", options...)
|
|
ctx := context.Background()
|
|
ms, err := env.repository.Manifests(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
m := schema1.Manifest{
|
|
Versioned: manifest.Versioned{
|
|
SchemaVersion: 1,
|
|
},
|
|
Name: env.name.Name(),
|
|
Tag: env.tag,
|
|
}
|
|
|
|
// Build up some test layers and add them to the manifest, saving the
|
|
// readseekers for upload later.
|
|
testLayers := map[digest.Digest]io.ReadSeeker{}
|
|
for i := 0; i < 2; i++ {
|
|
rs, dgst, err := testutil.CreateRandomTarFile()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error generating test layer file")
|
|
}
|
|
|
|
testLayers[dgst] = rs
|
|
m.FSLayers = append(m.FSLayers, schema1.FSLayer{
|
|
BlobSum: dgst,
|
|
})
|
|
m.History = append(m.History, schema1.History{
|
|
V1Compatibility: "",
|
|
})
|
|
|
|
}
|
|
|
|
pk, err := libtrust.GenerateECP256PrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error generating private key: %v", err)
|
|
}
|
|
|
|
sm, merr := schema1.Sign(&m, pk)
|
|
if merr != nil {
|
|
t.Fatalf("error signing manifest: %v", err)
|
|
}
|
|
|
|
_, err = ms.Put(ctx, sm)
|
|
if err == nil {
|
|
t.Fatalf("expected errors putting manifest with full verification")
|
|
}
|
|
|
|
// If schema1 is not enabled, do a short version of this test, just checking
|
|
// if we get the right error when we Put
|
|
if !schema1Enabled {
|
|
if err != distribution.ErrSchemaV1Unsupported {
|
|
t.Fatalf("got the wrong error when schema1 is disabled: %s", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
switch err := err.(type) {
|
|
case distribution.ErrManifestVerification:
|
|
if len(err) != 2 {
|
|
t.Fatalf("expected 2 verification errors: %#v", err)
|
|
}
|
|
|
|
for _, err := range err {
|
|
if _, ok := err.(distribution.ErrManifestBlobUnknown); !ok {
|
|
t.Fatalf("unexpected error type: %v", err)
|
|
}
|
|
}
|
|
default:
|
|
t.Fatalf("unexpected error verifying manifest: %v", err)
|
|
}
|
|
|
|
// Now, upload the layers that were missing!
|
|
for dgst, rs := range testLayers {
|
|
wr, err := env.repository.Blobs(env.ctx).Create(env.ctx)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error creating test upload: %v", err)
|
|
}
|
|
|
|
if _, err := io.Copy(wr, rs); err != nil {
|
|
t.Fatalf("unexpected error copying to upload: %v", err)
|
|
}
|
|
|
|
if _, err := wr.Commit(env.ctx, distribution.Descriptor{Digest: dgst}); err != nil {
|
|
t.Fatalf("unexpected error finishing upload: %v", err)
|
|
}
|
|
}
|
|
|
|
var manifestDigest digest.Digest
|
|
if manifestDigest, err = ms.Put(ctx, sm); err != nil {
|
|
t.Fatalf("unexpected error putting manifest: %v", err)
|
|
}
|
|
|
|
exists, err := ms.Exists(ctx, manifestDigest)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error checking manifest existence: %#v", err)
|
|
}
|
|
|
|
if !exists {
|
|
t.Fatalf("manifest should exist")
|
|
}
|
|
|
|
fromStore, err := ms.Get(ctx, manifestDigest)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching manifest: %v", err)
|
|
}
|
|
|
|
fetchedManifest, ok := fromStore.(*schema1.SignedManifest)
|
|
if !ok {
|
|
t.Fatalf("unexpected manifest type from signedstore")
|
|
}
|
|
|
|
if !bytes.Equal(fetchedManifest.Canonical, sm.Canonical) {
|
|
t.Fatalf("fetched payload does not match original payload: %q != %q", fetchedManifest.Canonical, sm.Canonical)
|
|
}
|
|
|
|
_, pl, err := fetchedManifest.Payload()
|
|
if err != nil {
|
|
t.Fatalf("error getting payload %#v", err)
|
|
}
|
|
|
|
fetchedJWS, err := libtrust.ParsePrettySignature(pl, "signatures")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing jws: %v", err)
|
|
}
|
|
|
|
payload, err := fetchedJWS.Payload()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error extracting payload: %v", err)
|
|
}
|
|
|
|
// Now that we have a payload, take a moment to check that the manifest is
|
|
// return by the payload digest.
|
|
|
|
dgst := digest.FromBytes(payload)
|
|
exists, err = ms.Exists(ctx, dgst)
|
|
if err != nil {
|
|
t.Fatalf("error checking manifest existence by digest: %v", err)
|
|
}
|
|
|
|
if !exists {
|
|
t.Fatalf("manifest %s should exist", dgst)
|
|
}
|
|
|
|
fetchedByDigest, err := ms.Get(ctx, dgst)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching manifest by digest: %v", err)
|
|
}
|
|
|
|
byDigestManifest, ok := fetchedByDigest.(*schema1.SignedManifest)
|
|
if !ok {
|
|
t.Fatalf("unexpected manifest type from signedstore")
|
|
}
|
|
|
|
if !bytes.Equal(byDigestManifest.Canonical, fetchedManifest.Canonical) {
|
|
t.Fatalf("fetched manifest not equal: %q != %q", byDigestManifest.Canonical, fetchedManifest.Canonical)
|
|
}
|
|
|
|
sigs, err := fetchedJWS.Signatures()
|
|
if err != nil {
|
|
t.Fatalf("unable to extract signatures: %v", err)
|
|
}
|
|
|
|
if len(sigs) != 1 {
|
|
t.Fatalf("unexpected number of signatures: %d != %d", len(sigs), 1)
|
|
}
|
|
|
|
// Now, push the same manifest with a different key
|
|
pk2, err := libtrust.GenerateECP256PrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error generating private key: %v", err)
|
|
}
|
|
|
|
sm2, err := schema1.Sign(&m, pk2)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error signing manifest: %v", err)
|
|
}
|
|
_, pl, err = sm2.Payload()
|
|
if err != nil {
|
|
t.Fatalf("error getting payload %#v", err)
|
|
}
|
|
|
|
jws2, err := libtrust.ParsePrettySignature(pl, "signatures")
|
|
if err != nil {
|
|
t.Fatalf("error parsing signature: %v", err)
|
|
}
|
|
|
|
sigs2, err := jws2.Signatures()
|
|
if err != nil {
|
|
t.Fatalf("unable to extract signatures: %v", err)
|
|
}
|
|
|
|
if len(sigs2) != 1 {
|
|
t.Fatalf("unexpected number of signatures: %d != %d", len(sigs2), 1)
|
|
}
|
|
|
|
if manifestDigest, err = ms.Put(ctx, sm2); err != nil {
|
|
t.Fatalf("unexpected error putting manifest: %v", err)
|
|
}
|
|
|
|
fromStore, err = ms.Get(ctx, manifestDigest)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error fetching manifest: %v", err)
|
|
}
|
|
|
|
fetched, ok := fromStore.(*schema1.SignedManifest)
|
|
if !ok {
|
|
t.Fatalf("unexpected type from signed manifeststore : %T", fetched)
|
|
}
|
|
|
|
if _, err := schema1.Verify(fetched); err != nil {
|
|
t.Fatalf("unexpected error verifying manifest: %v", err)
|
|
}
|
|
|
|
_, pl, err = fetched.Payload()
|
|
if err != nil {
|
|
t.Fatalf("error getting payload %#v", err)
|
|
}
|
|
|
|
receivedJWS, err := libtrust.ParsePrettySignature(pl, "signatures")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error parsing jws: %v", err)
|
|
}
|
|
|
|
receivedPayload, err := receivedJWS.Payload()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error extracting received payload: %v", err)
|
|
}
|
|
|
|
if !bytes.Equal(receivedPayload, payload) {
|
|
t.Fatalf("payloads are not equal")
|
|
}
|
|
|
|
// Test deleting manifests
|
|
err = ms.Delete(ctx, dgst)
|
|
if err != nil {
|
|
t.Fatalf("unexpected an error deleting manifest by digest: %v", err)
|
|
}
|
|
|
|
exists, err = ms.Exists(ctx, dgst)
|
|
if err != nil {
|
|
t.Fatalf("Error querying manifest existence")
|
|
}
|
|
if exists {
|
|
t.Errorf("Deleted manifest should not exist")
|
|
}
|
|
|
|
deletedManifest, err := ms.Get(ctx, dgst)
|
|
if err == nil {
|
|
t.Errorf("Unexpected success getting deleted manifest")
|
|
}
|
|
switch err.(type) {
|
|
case distribution.ErrManifestUnknownRevision:
|
|
break
|
|
default:
|
|
t.Errorf("Unexpected error getting deleted manifest: %s", reflect.ValueOf(err).Type())
|
|
}
|
|
|
|
if deletedManifest != nil {
|
|
t.Errorf("Deleted manifest get returned non-nil")
|
|
}
|
|
|
|
// Re-upload should restore manifest to a good state
|
|
_, err = ms.Put(ctx, sm)
|
|
if err != nil {
|
|
t.Errorf("Error re-uploading deleted manifest")
|
|
}
|
|
|
|
exists, err = ms.Exists(ctx, dgst)
|
|
if err != nil {
|
|
t.Fatalf("Error querying manifest existence")
|
|
}
|
|
if !exists {
|
|
t.Errorf("Restored manifest should exist")
|
|
}
|
|
|
|
deletedManifest, err = ms.Get(ctx, dgst)
|
|
if err != nil {
|
|
t.Errorf("Unexpected error getting manifest")
|
|
}
|
|
if deletedManifest == nil {
|
|
t.Errorf("Deleted manifest get returned non-nil")
|
|
}
|
|
|
|
r, err := NewRegistry(ctx, env.driver, BlobDescriptorCacheProvider(memory.NewInMemoryBlobDescriptorCacheProvider(memory.UnlimitedSize)), EnableRedirect)
|
|
if err != nil {
|
|
t.Fatalf("error creating registry: %v", err)
|
|
}
|
|
repo, err := r.Repository(ctx, env.name)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error getting repo: %v", err)
|
|
}
|
|
ms, err = repo.Manifests(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = ms.Delete(ctx, dgst)
|
|
if err == nil {
|
|
t.Errorf("Unexpected success deleting while disabled")
|
|
}
|
|
}
|
|
|
|
func TestOCIManifestStorage(t *testing.T) {
|
|
testOCIManifestStorage(t, "includeMediaTypes=true", true)
|
|
testOCIManifestStorage(t, "includeMediaTypes=false", false)
|
|
}
|
|
|
|
func testOCIManifestStorage(t *testing.T, testname string, includeMediaTypes bool) {
|
|
var imageMediaType string
|
|
var indexMediaType string
|
|
if includeMediaTypes {
|
|
imageMediaType = v1.MediaTypeImageManifest
|
|
indexMediaType = v1.MediaTypeImageIndex
|
|
} else {
|
|
imageMediaType = ""
|
|
indexMediaType = ""
|
|
}
|
|
|
|
repoName, _ := reference.WithName("foo/bar")
|
|
env := newManifestStoreTestEnv(t, repoName, "thetag",
|
|
BlobDescriptorCacheProvider(memory.NewInMemoryBlobDescriptorCacheProvider(memory.UnlimitedSize)),
|
|
EnableDelete, EnableRedirect)
|
|
|
|
ctx := context.Background()
|
|
ms, err := env.repository.Manifests(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Build a manifest and store it and its layers in the registry
|
|
|
|
blobStore := env.repository.Blobs(ctx)
|
|
builder := ocischema.NewManifestBuilder(blobStore, []byte{}, map[string]string{})
|
|
err = builder.(*ocischema.Builder).SetMediaType(imageMediaType)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Add some layers
|
|
for i := 0; i < 2; i++ {
|
|
rs, dgst, err := testutil.CreateRandomTarFile()
|
|
if err != nil {
|
|
t.Fatalf("%s: unexpected error generating test layer file", testname)
|
|
}
|
|
|
|
wr, err := env.repository.Blobs(env.ctx).Create(env.ctx)
|
|
if err != nil {
|
|
t.Fatalf("%s: unexpected error creating test upload: %v", testname, err)
|
|
}
|
|
|
|
if _, err := io.Copy(wr, rs); err != nil {
|
|
t.Fatalf("%s: unexpected error copying to upload: %v", testname, err)
|
|
}
|
|
|
|
if _, err := wr.Commit(env.ctx, distribution.Descriptor{Digest: dgst}); err != nil {
|
|
t.Fatalf("%s: unexpected error finishing upload: %v", testname, err)
|
|
}
|
|
|
|
builder.AppendReference(distribution.Descriptor{Digest: dgst})
|
|
}
|
|
|
|
mfst, err := builder.Build(ctx)
|
|
if err != nil {
|
|
t.Fatalf("%s: unexpected error generating manifest: %v", testname, err)
|
|
}
|
|
|
|
// before putting the manifest test for proper handling of SchemaVersion
|
|
|
|
if mfst.(*ocischema.DeserializedManifest).Manifest.SchemaVersion != 2 {
|
|
t.Fatalf("%s: unexpected error generating default version for oci manifest", testname)
|
|
}
|
|
mfst.(*ocischema.DeserializedManifest).Manifest.SchemaVersion = 0
|
|
|
|
var manifestDigest digest.Digest
|
|
if manifestDigest, err = ms.Put(ctx, mfst); err != nil {
|
|
if err.Error() != "unrecognized manifest schema version 0" {
|
|
t.Fatalf("%s: unexpected error putting manifest: %v", testname, err)
|
|
}
|
|
mfst.(*ocischema.DeserializedManifest).Manifest.SchemaVersion = 2
|
|
if manifestDigest, err = ms.Put(ctx, mfst); err != nil {
|
|
t.Fatalf("%s: unexpected error putting manifest: %v", testname, err)
|
|
}
|
|
}
|
|
|
|
// Also create an image index that contains the manifest
|
|
|
|
descriptor, err := env.registry.BlobStatter().Stat(ctx, manifestDigest)
|
|
if err != nil {
|
|
t.Fatalf("%s: unexpected error getting manifest descriptor", testname)
|
|
}
|
|
descriptor.MediaType = v1.MediaTypeImageManifest
|
|
|
|
platformSpec := v1.Platform{
|
|
Architecture: "atari2600",
|
|
OS: "CP/M",
|
|
}
|
|
|
|
manifestDescriptors := []ocischema.ManifestDescriptor{
|
|
{
|
|
Descriptor: descriptor,
|
|
Platform: &platformSpec,
|
|
},
|
|
}
|
|
|
|
imageIndex, err := ociIndexFromDesriptorsWithMediaType(manifestDescriptors, indexMediaType)
|
|
if err != nil {
|
|
t.Fatalf("%s: unexpected error creating image index: %v", testname, err)
|
|
}
|
|
|
|
var indexDigest digest.Digest
|
|
if indexDigest, err = ms.Put(ctx, imageIndex); err != nil {
|
|
t.Fatalf("%s: unexpected error putting image index: %v", testname, err)
|
|
}
|
|
|
|
// Now check that we can retrieve the manifest
|
|
|
|
fromStore, err := ms.Get(ctx, manifestDigest)
|
|
if err != nil {
|
|
t.Fatalf("%s: unexpected error fetching manifest: %v", testname, err)
|
|
}
|
|
|
|
fetchedManifest, ok := fromStore.(*ocischema.DeserializedManifest)
|
|
if !ok {
|
|
t.Fatalf("%s: unexpected type for fetched manifest", testname)
|
|
}
|
|
|
|
if fetchedManifest.MediaType != imageMediaType {
|
|
t.Fatalf("%s: unexpected MediaType for result, %s", testname, fetchedManifest.MediaType)
|
|
}
|
|
|
|
if fetchedManifest.SchemaVersion != ocischema.SchemaVersion.SchemaVersion {
|
|
t.Fatalf("%s: unexpected schema version for result, %d", testname, fetchedManifest.SchemaVersion)
|
|
}
|
|
|
|
payloadMediaType, _, err := fromStore.Payload()
|
|
if err != nil {
|
|
t.Fatalf("%s: error getting payload %v", testname, err)
|
|
}
|
|
|
|
if payloadMediaType != v1.MediaTypeImageManifest {
|
|
t.Fatalf("%s: unexpected MediaType for manifest payload, %s", testname, payloadMediaType)
|
|
}
|
|
|
|
// and the image index
|
|
|
|
fromStore, err = ms.Get(ctx, indexDigest)
|
|
if err != nil {
|
|
t.Fatalf("%s: unexpected error fetching image index: %v", testname, err)
|
|
}
|
|
|
|
fetchedIndex, ok := fromStore.(*ocischema.DeserializedImageIndex)
|
|
if !ok {
|
|
t.Fatalf("%s: unexpected type for fetched manifest", testname)
|
|
}
|
|
|
|
if fetchedIndex.MediaType != indexMediaType {
|
|
t.Fatalf("%s: unexpected MediaType for result, %s", testname, fetchedIndex.MediaType)
|
|
}
|
|
|
|
payloadMediaType, _, err = fromStore.Payload()
|
|
if err != nil {
|
|
t.Fatalf("%s: error getting payload %v", testname, err)
|
|
}
|
|
|
|
if payloadMediaType != v1.MediaTypeImageIndex {
|
|
t.Fatalf("%s: unexpected MediaType for index payload, %s", testname, payloadMediaType)
|
|
}
|
|
}
|
|
|
|
// TestLinkPathFuncs ensures that the link path functions behavior are locked
|
|
// down and implemented as expected.
|
|
func TestLinkPathFuncs(t *testing.T) {
|
|
for _, testcase := range []struct {
|
|
repo string
|
|
digest digest.Digest
|
|
linkPathFn linkPathFunc
|
|
expected string
|
|
}{
|
|
{
|
|
repo: "foo/bar",
|
|
digest: "sha256:deadbeaf98fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
linkPathFn: blobLinkPath,
|
|
expected: "/docker/registry/v2/repositories/foo/bar/_layers/sha256/deadbeaf98fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/link",
|
|
},
|
|
{
|
|
repo: "foo/bar",
|
|
digest: "sha256:deadbeaf98fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
linkPathFn: manifestRevisionLinkPath,
|
|
expected: "/docker/registry/v2/repositories/foo/bar/_manifests/revisions/sha256/deadbeaf98fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/link",
|
|
},
|
|
} {
|
|
p, err := testcase.linkPathFn(testcase.repo, testcase.digest)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error calling linkPathFn(pm, %q, %q): %v", testcase.repo, testcase.digest, err)
|
|
}
|
|
|
|
if p != testcase.expected {
|
|
t.Fatalf("incorrect path returned: %q != %q", p, testcase.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func ociIndexFromDesriptorsWithMediaType(descriptors []ocischema.ManifestDescriptor, mediaType string) (*ocischema.DeserializedImageIndex, error) {
|
|
manifest, err := ocischema.FromDescriptors(descriptors)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
manifest.ImageIndex.MediaType = mediaType
|
|
|
|
rawManifest, err := json.Marshal(manifest.ImageIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var d ocischema.DeserializedImageIndex
|
|
if err := d.UnmarshalJSON(rawManifest); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &d, nil
|
|
}
|