From c7e6fee4ea0f3cdff2c65f43d6b4e51fd00e6a32 Mon Sep 17 00:00:00 2001 From: Esteban Rey Date: Wed, 10 Jan 2024 13:54:46 -0800 Subject: [PATCH] Userspace Convertor: Manifest Deduplication Adds manifest deduplication, which prevents re-conversion of an already converted image if its result is known and already stored within the registry. This change also includes adjustments to support cross repo mounts for whole images, improvments for the userspace db functionality, usage samples and corresponding unit tests. Signed-off-by: Esteban Rey --- .gitignore | 3 + cmd/convertor/builder/builder.go | 9 ++ cmd/convertor/builder/builder_engine.go | 33 ++++- cmd/convertor/builder/builder_test.go | 14 ++ cmd/convertor/builder/builder_utils.go | 7 +- cmd/convertor/builder/builder_utils_test.go | 2 +- cmd/convertor/builder/overlaybd_builder.go | 128 +++++++++++++++- .../builder/overlaybd_builder_test.go | 140 ++++++++++++++++-- cmd/convertor/builder/turboOCI_builder.go | 10 ++ cmd/convertor/database/database.go | 26 +++- cmd/convertor/database/mysql.go | 67 +++++++-- ...mysql-db-manifest-cache-sample-workload.sh | 21 +++ .../resources/samples/mysql-db-setup.sh | 11 ++ cmd/convertor/resources/samples/mysql.conf | 22 +++ .../run-userspace-convertor-ubuntu.Dockerfile | 63 ++++++++ cmd/convertor/testingresources/consts.go | 5 + cmd/convertor/testingresources/local_db.go | 95 +++++++++--- .../testingresources/local_registry.go | 24 ++- .../testingresources/local_remotes.go | 19 ++- cmd/convertor/testingresources/local_repo.go | 19 +-- .../testingresources/mocks/generate.sh | 25 +++- ...c3781eabf174cff608210585bf93fc83e16d33672d | Bin 0 -> 642560 bytes ...645bb392e25c9e78bce429755bd709fac598265f88 | 1 + ...b7ff0e819d7dd9d236605dd8027e5ecf2df650e65e | 1 + .../mocks/registry/hello-world/index.json | 66 +++++---- docs/USERSPACE_CONVERTOR.md | 19 ++- 26 files changed, 707 insertions(+), 123 deletions(-) create mode 100755 cmd/convertor/resources/samples/mysql-db-manifest-cache-sample-workload.sh create mode 100755 cmd/convertor/resources/samples/mysql-db-setup.sh create mode 100644 cmd/convertor/resources/samples/mysql.conf create mode 100644 cmd/convertor/resources/samples/run-userspace-convertor-ubuntu.Dockerfile create mode 100644 cmd/convertor/testingresources/mocks/registry/hello-world/blobs/sha256/022231c3f1af70e0cc42f0c3781eabf174cff608210585bf93fc83e16d33672d create mode 100644 cmd/convertor/testingresources/mocks/registry/hello-world/blobs/sha256/42caa56a19e082b872d43f645bb392e25c9e78bce429755bd709fac598265f88 create mode 100644 cmd/convertor/testingresources/mocks/registry/hello-world/blobs/sha256/b67b1f1224de620e9a932ab7ff0e819d7dd9d236605dd8027e5ecf2df650e65e diff --git a/.gitignore b/.gitignore index 6e359afa..cb4bc0e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ bin/ .vscode +vendor/ +tmp_conv/ +tmp/ \ No newline at end of file diff --git a/cmd/convertor/builder/builder.go b/cmd/convertor/builder/builder.go index 7cb651ed..1412672e 100644 --- a/cmd/convertor/builder/builder.go +++ b/cmd/convertor/builder/builder.go @@ -134,6 +134,14 @@ func (b *overlaybdBuilder) Build(ctx context.Context) error { alreadyConverted := make([]chan *v1.Descriptor, b.layers) downloaded := make([]chan error, b.layers) converted := make([]chan error, b.layers) + + // check if manifest conversion result is already present in registry, if so, we can avoid conversion. + // when errors are encountered fallback to regular conversion + if convertedDesc, err := b.engine.CheckForConvertedManifest(ctx); err == nil && convertedDesc.Digest != "" { + logrus.Infof("Image found already converted in registry with digest %s", convertedDesc.Digest) + return nil + } + // Errgroups will close the context after wait returns so the operations need their own // derived context. g, rctx := errgroup.WithContext(ctx) @@ -231,6 +239,7 @@ func (b *overlaybdBuilder) Build(ctx context.Context) error { if err := b.engine.UploadImage(ctx); err != nil { return errors.Wrap(err, "failed to upload manifest or config") } + b.engine.StoreConvertedManifestDetails(ctx) logrus.Info("convert finished") return nil } diff --git a/cmd/convertor/builder/builder_engine.go b/cmd/convertor/builder/builder_engine.go index 81f74e91..4b6c5365 100644 --- a/cmd/convertor/builder/builder_engine.go +++ b/cmd/convertor/builder/builder_engine.go @@ -51,6 +51,15 @@ type builderEngine interface { // UploadImage upload new manifest and config UploadImage(ctx context.Context) error + // Cleanup removes workdir + Cleanup() + + Deduplicateable +} + +// Deduplicateable provides a number of functions to avoid duplicating work when converting images +// It is used by the builderEngine to avoid re-converting layers and manifests +type Deduplicateable interface { // deduplication functions // finds already converted layer in db and validates presence in registry CheckForConvertedLayer(ctx context.Context, idx int) (specs.Descriptor, error) @@ -58,14 +67,18 @@ type builderEngine interface { // downloads the already converted layer DownloadConvertedLayer(ctx context.Context, idx int, desc specs.Descriptor) error - // store chainID -> converted layer mapping for deduplication + // store chainID -> converted layer mapping for layer deduplication StoreConvertedLayerDetails(ctx context.Context, idx int) error - // Cleanup removes workdir - Cleanup() + // store manifest digest -> converted manifest to avoid re-conversion + CheckForConvertedManifest(ctx context.Context) (specs.Descriptor, error) + + // store manifest digest -> converted manifest to avoid re-conversion + StoreConvertedManifestDetails(ctx context.Context) error } type builderEngineBase struct { + resolver remotes.Resolver fetcher remotes.Fetcher pusher remotes.Pusher manifest specs.Manifest @@ -77,6 +90,8 @@ type builderEngineBase struct { db database.ConversionDatabase host string repository string + inputDesc specs.Descriptor // original manifest descriptor + outputDesc specs.Descriptor // converted manifest descriptor reserve bool noUpload bool dumpManifest bool @@ -172,6 +187,7 @@ func (e *builderEngineBase) uploadManifestAndConfig(ctx context.Context) error { if err = uploadBytes(ctx, e.pusher, manifestDesc, cbuf); err != nil { return errors.Wrapf(err, "failed to upload manifest") } + e.outputDesc = manifestDesc logrus.Infof("manifest uploaded") } if e.dumpManifest { @@ -181,7 +197,6 @@ func (e *builderEngineBase) uploadManifestAndConfig(ctx context.Context) error { } logrus.Infof("manifest dumped") } - return nil } @@ -203,9 +218,11 @@ func getBuilderEngineBase(ctx context.Context, resolver remotes.Resolver, ref, t return nil, errors.Wrap(err, "failed to fetch manifest and config") } return &builderEngineBase{ - fetcher: fetcher, - pusher: pusher, - manifest: *manifest, - config: *config, + resolver: resolver, + fetcher: fetcher, + pusher: pusher, + manifest: *manifest, + config: *config, + inputDesc: desc, }, nil } diff --git a/cmd/convertor/builder/builder_test.go b/cmd/convertor/builder/builder_test.go index 3d93e8cd..6118dba6 100644 --- a/cmd/convertor/builder/builder_test.go +++ b/cmd/convertor/builder/builder_test.go @@ -118,6 +118,20 @@ func (e *mockFuzzBuilderEngine) StoreConvertedLayerDetails(ctx context.Context, return nil } +func (e *mockFuzzBuilderEngine) CheckForConvertedManifest(ctx context.Context) (specs.Descriptor, error) { + if e.fixedRand.Float64() < failRate { + return specs.Descriptor{}, fmt.Errorf("random error on CheckForConvertedManifest") + } + return specs.Descriptor{}, nil +} + +func (e *mockFuzzBuilderEngine) StoreConvertedManifestDetails(ctx context.Context) error { + if e.fixedRand.Float64() < failRate { + return fmt.Errorf("random error on StoreConvertedManifestDetails") + } + return nil +} + func (e *mockFuzzBuilderEngine) DownloadConvertedLayer(ctx context.Context, idx int, desc specs.Descriptor) error { if e.fixedRand.Float64() < failRate { return fmt.Errorf("random error on DownloadConvertedLayer") diff --git a/cmd/convertor/builder/builder_utils.go b/cmd/convertor/builder/builder_utils.go index 3010d9b8..6ff77684 100644 --- a/cmd/convertor/builder/builder_utils.go +++ b/cmd/convertor/builder/builder_utils.go @@ -217,12 +217,7 @@ func uploadBytes(ctx context.Context, pusher remotes.Pusher, desc specs.Descript return err } defer cw.Close() - - err = content.Copy(ctx, cw, bytes.NewReader(data), desc.Size, desc.Digest) - if err != nil { - return err - } - return nil + return content.Copy(ctx, cw, bytes.NewReader(data), desc.Size, desc.Digest) } func buildArchiveFromFiles(ctx context.Context, target string, compress compression.Compression, files ...string) error { diff --git a/cmd/convertor/builder/builder_utils_test.go b/cmd/convertor/builder/builder_utils_test.go index bf8ee9ac..3d38ba31 100644 --- a/cmd/convertor/builder/builder_utils_test.go +++ b/cmd/convertor/builder/builder_utils_test.go @@ -291,7 +291,7 @@ func Test_uploadBlob(t *testing.T) { ctx := context.Background() // Create a new inmemory registry to push to reg := testingresources.GetTestRegistry(t, ctx, testingresources.RegistryOptions{ - InmemoryOnly: true, + InmemoryRegistryOnly: true, ManifestPushIgnoresLayers: false, }) diff --git a/cmd/convertor/builder/overlaybd_builder.go b/cmd/convertor/builder/overlaybd_builder.go index 28aa35e5..192f69e2 100644 --- a/cmd/convertor/builder/overlaybd_builder.go +++ b/cmd/convertor/builder/overlaybd_builder.go @@ -20,6 +20,7 @@ import ( "archive/tar" "bufio" "context" + "encoding/json" "fmt" "io" "os" @@ -180,8 +181,8 @@ func (e *overlaybdBuilderEngine) CheckForConvertedLayer(ctx context.Context, idx } chainID := e.overlaybdLayers[idx].chainID - // try to find in the same repo then check existence on registry - entry := e.db.GetEntryForRepo(ctx, e.host, e.repository, chainID) + // try to find the layer in the target repo + entry := e.db.GetLayerEntryForRepo(ctx, e.host, e.repository, chainID) if entry != nil && entry.ChainID != "" { desc := specs.Descriptor{ MediaType: e.mediaTypeImageLayer(), @@ -197,15 +198,16 @@ func (e *overlaybdBuilderEngine) CheckForConvertedLayer(ctx context.Context, idx } if errdefs.IsNotFound(err) { // invalid record in db, which is not found in registry, remove it - err := e.db.DeleteEntry(ctx, e.host, e.repository, chainID) + err := e.db.DeleteLayerEntry(ctx, e.host, e.repository, chainID) if err != nil { return specs.Descriptor{}, err } } } + // fallback to a registry wide search // found record in other repos, try mounting it to the target repo - entries := e.db.GetCrossRepoEntries(ctx, e.host, chainID) + entries := e.db.GetCrossRepoLayerEntries(ctx, e.host, chainID) for _, entry := range entries { desc := specs.Descriptor{ MediaType: e.mediaTypeImageLayer(), @@ -220,7 +222,7 @@ func (e *overlaybdBuilderEngine) CheckForConvertedLayer(ctx context.Context, idx if errdefs.IsAlreadyExists(err) { desc.Annotations = nil - if err := e.db.CreateEntry(ctx, e.host, e.repository, entry.ConvertedDigest, chainID, entry.DataSize); err != nil { + if err := e.db.CreateLayerEntry(ctx, e.host, e.repository, entry.ConvertedDigest, chainID, entry.DataSize); err != nil { continue // try a different repo if available } @@ -234,11 +236,125 @@ func (e *overlaybdBuilderEngine) CheckForConvertedLayer(ctx context.Context, idx return specs.Descriptor{}, errdefs.ErrNotFound } +// If manifest is already converted, avoid conversion. (e.g During tag reuse or cross repo mounts) +// Note: This is output mediatype sensitive, if the manifest is converted to a different mediatype, +// we will still convert it normally. +func (e *overlaybdBuilderEngine) CheckForConvertedManifest(ctx context.Context) (specs.Descriptor, error) { + if e.db == nil { + return specs.Descriptor{}, errdefs.ErrNotFound + } + + // try to find the manifest in the target repo + entry := e.db.GetManifestEntryForRepo(ctx, e.host, e.repository, e.mediaTypeManifest(), e.inputDesc.Digest) + if entry != nil && entry.ConvertedDigest != "" { + convertedDesc := specs.Descriptor{ + MediaType: e.mediaTypeImageLayer(), + Digest: entry.ConvertedDigest, + Size: entry.DataSize, + } + rc, err := e.fetcher.Fetch(ctx, convertedDesc) + if err == nil { + rc.Close() + logrus.Infof("manifest %s found in remote with resulting digest %s", e.inputDesc.Digest, convertedDesc.Digest) + return convertedDesc, nil + } + if errdefs.IsNotFound(err) { + // invalid record in db, which is not found in registry, remove it + err := e.db.DeleteManifestEntry(ctx, e.host, e.repository, e.mediaTypeManifest(), e.inputDesc.Digest) + if err != nil { + return specs.Descriptor{}, err + } + } + } + // fallback to a registry wide search + entries := e.db.GetCrossRepoManifestEntries(ctx, e.host, e.mediaTypeManifest(), e.inputDesc.Digest) + for _, entry := range entries { + convertedDesc := specs.Descriptor{ + MediaType: e.mediaTypeManifest(), + Digest: entry.ConvertedDigest, + Size: entry.DataSize, + } + fetcher, err := e.resolver.Fetcher(ctx, fmt.Sprintf("%s/%s@%s", entry.Host, entry.Repository, convertedDesc.Digest.String())) + if err != nil { + return specs.Descriptor{}, err + } + manifest, err := fetchManifest(ctx, fetcher, convertedDesc) + if err != nil { + if errdefs.IsNotFound(err) { + // invalid record in db, which is not found in registry, remove it + err := e.db.DeleteManifestEntry(ctx, entry.Host, entry.Repository, e.mediaTypeManifest(), e.inputDesc.Digest) + if err != nil { + return specs.Descriptor{}, err + } + } + continue + } + if err := e.mountImage(ctx, *manifest, convertedDesc, entry.Repository); err != nil { + continue // try a different repo if available + } + if err := e.db.CreateManifestEntry(ctx, e.host, e.repository, e.mediaTypeManifest(), e.inputDesc.Digest, convertedDesc.Digest, entry.DataSize); err != nil { + continue // try a different repo if available + } + logrus.Infof("manifest %s mount from %s was successful", convertedDesc.Digest, entry.Repository) + return convertedDesc, nil + } + + logrus.Infof("manifest %s not found already converted in remote", e.inputDesc.Digest) + return specs.Descriptor{}, errdefs.ErrNotFound +} + +// mountImage is responsible for mounting a specific manifest from a source repository, this includes +// mounting all layers + config and then pushing the manifest. +func (e *overlaybdBuilderEngine) mountImage(ctx context.Context, manifest specs.Manifest, desc specs.Descriptor, mountRepository string) error { + // Mount Config Blobs + config := manifest.Config + config.Annotations = map[string]string{ + fmt.Sprintf("%s.%s", labelDistributionSource, e.host): mountRepository, + } + _, err := e.pusher.Push(ctx, config) + if errdefs.IsAlreadyExists(err) { + logrus.Infof("config blob mount from %s was successful", mountRepository) + } else if err != nil { + return fmt.Errorf("Failed to mount config blob from %s repository : %w", mountRepository, err) + } + + // Mount Layer Blobs + for idx, layer := range manifest.Layers { + desc := layer + desc.Annotations = map[string]string{ + fmt.Sprintf("%s.%s", labelDistributionSource, e.host): mountRepository, + } + _, err := e.pusher.Push(ctx, desc) + if errdefs.IsAlreadyExists(err) { + logrus.Infof("layer %d mount from %s was successful", idx, mountRepository) + } else if err != nil { + return fmt.Errorf("failed to mount all layers from %s repository : %w", mountRepository, err) + } + } + + // Push Manifest + cbuf, err := json.Marshal(manifest) + if err != nil { + return err + } + return uploadBytes(ctx, e.pusher, desc, cbuf) +} + +func (e *overlaybdBuilderEngine) StoreConvertedManifestDetails(ctx context.Context) error { + if e.db == nil { + return nil + } + if e.outputDesc.Digest == "" { + return errors.New("manifest is not yet converted") + } + return e.db.CreateManifestEntry(ctx, e.host, e.repository, e.mediaTypeManifest(), e.inputDesc.Digest, e.outputDesc.Digest, e.outputDesc.Size) +} + func (e *overlaybdBuilderEngine) StoreConvertedLayerDetails(ctx context.Context, idx int) error { if e.db == nil { return nil } - return e.db.CreateEntry(ctx, e.host, e.repository, e.overlaybdLayers[idx].desc.Digest, e.overlaybdLayers[idx].chainID, e.overlaybdLayers[idx].desc.Size) + return e.db.CreateLayerEntry(ctx, e.host, e.repository, e.overlaybdLayers[idx].desc.Digest, e.overlaybdLayers[idx].chainID, e.overlaybdLayers[idx].desc.Size) } func (e *overlaybdBuilderEngine) DownloadConvertedLayer(ctx context.Context, idx int, desc specs.Descriptor) error { diff --git a/cmd/convertor/builder/overlaybd_builder_test.go b/cmd/convertor/builder/overlaybd_builder_test.go index 92c95682..4259e7d4 100644 --- a/cmd/convertor/builder/overlaybd_builder_test.go +++ b/cmd/convertor/builder/overlaybd_builder_test.go @@ -23,6 +23,7 @@ import ( testingresources "github.com/containerd/accelerated-container-image/cmd/convertor/testingresources" "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/images" _ "github.com/containerd/containerd/pkg/testutil" // Handle custom root flag "github.com/opencontainers/go-digest" v1 "github.com/opencontainers/image-spec/specs-go/v1" @@ -62,42 +63,163 @@ func Test_overlaybd_builder_CheckForConvertedLayer(t *testing.T) { }) base.db = db - t.Run("No Entry in DB", func(t *testing.T) { _, err := e.CheckForConvertedLayer(ctx, 0) testingresources.Assert(t, errdefs.IsNotFound(err), fmt.Sprintf("CheckForConvertedLayer() returned an unexpected Error: %v", err)) }) - err := base.db.CreateEntry(ctx, e.host, e.repository, targetDesc.Digest, fakeChainId, targetDesc.Size) + err := base.db.CreateLayerEntry(ctx, e.host, e.repository, targetDesc.Digest, fakeChainId, targetDesc.Size) if err != nil { - t.Error(err) + t.Fatal(err) } - t.Run("Entry in DB and in Registry", func(t *testing.T) { + t.Run("Layer entry in DB and in Registry", func(t *testing.T) { desc, err := e.CheckForConvertedLayer(ctx, 0) + if err != nil { + t.Fatal(err) + } + + testingresources.Assert(t, desc.Size == targetDesc.Size, "CheckForConvertedLayer() returned improper size layer") + testingresources.Assert(t, desc.Digest == targetDesc.Digest, "CheckForConvertedLayer() returned incorrect digest") + }) + // cross repo mount (change target repo) + base.repository = "hello-world2" + newImageRef := "sample.localstore.io/hello-world2:amd64" + e.resolver = testingresources.GetTestResolver(t, ctx) + e.pusher = testingresources.GetTestPusherFromResolver(t, ctx, e.resolver, newImageRef) + + t.Run("Cross repo layer entry found in DB mount", func(t *testing.T) { + desc, err := e.CheckForConvertedLayer(ctx, 0) if err != nil { - t.Error(err) + t.Fatal(err) } testingresources.Assert(t, desc.Size == targetDesc.Size, "CheckForConvertedLayer() returned improper size layer") testingresources.Assert(t, desc.Digest == targetDesc.Digest, "CheckForConvertedLayer() returned incorrect digest") + + // check that the images can be pulled from the mounted repo + fetcher := testingresources.GetTestFetcherFromResolver(t, ctx, e.resolver, newImageRef) + rc, err := fetcher.Fetch(ctx, desc) + if err != nil { + t.Fatal(err) + } + rc.Close() }) base.db = testingresources.NewLocalDB() // Reset DB digestNotInRegistry := digest.FromString("Not in reg") - err = base.db.CreateEntry(ctx, e.host, e.repository, digestNotInRegistry, fakeChainId, 10) + err = base.db.CreateLayerEntry(ctx, e.host, e.repository, digestNotInRegistry, fakeChainId, 10) if err != nil { - t.Error(err) + t.Fatal(err) } t.Run("Entry in DB but not in registry", func(t *testing.T) { _, err := e.CheckForConvertedLayer(ctx, 0) testingresources.Assert(t, errdefs.IsNotFound(err), fmt.Sprintf("CheckForConvertedLayer() returned an unexpected Error: %v", err)) - entry := base.db.GetEntryForRepo(ctx, e.host, e.repository, fakeChainId) + entry := base.db.GetLayerEntryForRepo(ctx, e.host, e.repository, fakeChainId) testingresources.Assert(t, entry == nil, "CheckForConvertedLayer() Invalid entry was not cleaned up") }) - // TODO: Cross Repo Mount Scenario +} + +func Test_overlaybd_builder_CheckForConvertedManifest(t *testing.T) { + ctx := context.Background() + db := testingresources.NewLocalDB() + resolver := testingresources.GetTestResolver(t, ctx) + fetcher := testingresources.GetTestFetcherFromResolver(t, ctx, resolver, testingresources.DockerV2_Manifest_Simple_Ref) + + // Unconverted hello world-image + inputDesc := v1.Descriptor{ + MediaType: images.MediaTypeDockerSchema2Manifest, + Digest: testingresources.DockerV2_Manifest_Simple_Digest, + Size: testingresources.DockerV2_Manifest_Simple_Size, + } + + // Converted hello world-image + outputDesc := v1.Descriptor{ + MediaType: v1.MediaTypeImageManifest, + Digest: testingresources.DockerV2_Manifest_Simple_Converted_Digest, + Size: testingresources.DockerV2_Manifest_Simple_Converted_Size, + } + + base := &builderEngineBase{ + fetcher: fetcher, + host: "sample.localstore.io", + repository: "hello-world", + inputDesc: inputDesc, + resolver: resolver, + oci: true, + } + + e := &overlaybdBuilderEngine{ + builderEngineBase: base, + } + + t.Run("No DB Present", func(t *testing.T) { + _, err := e.CheckForConvertedManifest(ctx) + testingresources.Assert(t, errdefs.IsNotFound(err), fmt.Sprintf("CheckForConvertedManifest() returned an unexpected Error: %v", err)) + }) + + base.db = db + + // Store a fake converted manifest in the DB + err := base.db.CreateManifestEntry(ctx, e.host, e.repository, outputDesc.MediaType, inputDesc.Digest, outputDesc.Digest, outputDesc.Size) + if err != nil { + t.Fatal(err) + } + + t.Run("Entry in DB and in Registry", func(t *testing.T) { + desc, err := e.CheckForConvertedManifest(ctx) + if err != nil { + t.Fatal(err) + } + + testingresources.Assert(t, desc.Size == outputDesc.Size, "CheckForConvertedManifest() returned incorrect size") + testingresources.Assert(t, desc.Digest == outputDesc.Digest, "CheckForConvertedManifest() returned incorrect digest") + }) + + // cross repo mount (change target repo) + base.repository = "hello-world2" + newImageRef := "sample.localstore.io/hello-world2:amd64" + e.resolver = testingresources.GetTestResolver(t, ctx) + e.pusher = testingresources.GetTestPusherFromResolver(t, ctx, e.resolver, newImageRef) + + t.Run("Cross Repo Entry found in DB mount", func(t *testing.T) { + _, err := e.CheckForConvertedManifest(ctx) + testingresources.Assert(t, err == nil, fmt.Sprintf("CheckForConvertedManifest() returned an unexpected Error: %v", err)) + // check that the images can be pulled from the mounted repo + fetcher := testingresources.GetTestFetcherFromResolver(t, ctx, e.resolver, newImageRef) + _, desc, err := e.resolver.Resolve(ctx, newImageRef) + if err != nil { + t.Fatal(err) + } + manifest, config, err := fetchManifestAndConfig(ctx, fetcher, desc) + if err != nil { + t.Fatal(err) + } + if manifest == nil || config == nil { + t.Fatalf("Could not pull mounted manifest or config") + } + rc, err := fetcher.Fetch(ctx, manifest.Layers[0]) + if err != nil { + t.Fatal(err) + } + rc.Close() + }) + + base.db = testingresources.NewLocalDB() // Reset DB + digestNotInRegistry := digest.FromString("Not in reg") + err = base.db.CreateManifestEntry(ctx, e.host, e.repository, outputDesc.MediaType, inputDesc.Digest, digestNotInRegistry, outputDesc.Size) + if err != nil { + t.Fatal(err) + } + + t.Run("Entry in DB but not in registry", func(t *testing.T) { + _, err := e.CheckForConvertedManifest(ctx) + testingresources.Assert(t, errdefs.IsNotFound(err), fmt.Sprintf("CheckForConvertedManifest() returned an unexpected Error: %v", err)) + entry := base.db.GetManifestEntryForRepo(ctx, e.host, e.repository, outputDesc.MediaType, inputDesc.Digest) + testingresources.Assert(t, entry == nil, "CheckForConvertedManifest() Invalid entry was not cleaned up") + }) } func Test_overlaybd_builder_StoreConvertedLayerDetails(t *testing.T) { diff --git a/cmd/convertor/builder/turboOCI_builder.go b/cmd/convertor/builder/turboOCI_builder.go index 984b442d..cbda5086 100644 --- a/cmd/convertor/builder/turboOCI_builder.go +++ b/cmd/convertor/builder/turboOCI_builder.go @@ -214,6 +214,16 @@ func (e *turboOCIBuilderEngine) DownloadConvertedLayer(ctx context.Context, idx return errdefs.ErrNotImplemented } +// DownloadConvertedLayer TODO +func (e *turboOCIBuilderEngine) CheckForConvertedManifest(ctx context.Context) (specs.Descriptor, error) { + return specs.Descriptor{}, errdefs.ErrNotImplemented +} + +// DownloadConvertedLayer TODO +func (e *turboOCIBuilderEngine) StoreConvertedManifestDetails(ctx context.Context) error { + return errdefs.ErrNotImplemented +} + func (e *turboOCIBuilderEngine) Cleanup() { os.RemoveAll(e.workDir) } diff --git a/cmd/convertor/database/database.go b/cmd/convertor/database/database.go index a9f4fa5c..045390e4 100644 --- a/cmd/convertor/database/database.go +++ b/cmd/convertor/database/database.go @@ -23,16 +23,32 @@ import ( ) type ConversionDatabase interface { - GetEntryForRepo(ctx context.Context, host string, repository string, chainID string) *Entry - GetCrossRepoEntries(ctx context.Context, host string, chainID string) []*Entry - CreateEntry(ctx context.Context, host string, repository string, convertedDigest digest.Digest, chainID string, size int64) error - DeleteEntry(ctx context.Context, host string, repository string, chainID string) error + // Layer Entries + CreateLayerEntry(ctx context.Context, host, repository string, convertedDigest digest.Digest, chainID string, size int64) error + GetLayerEntryForRepo(ctx context.Context, host, repository, chainID string) *LayerEntry + GetCrossRepoLayerEntries(ctx context.Context, host, chainID string) []*LayerEntry + DeleteLayerEntry(ctx context.Context, host, repository, chainID string) error + + // Manifest Entries + CreateManifestEntry(ctx context.Context, host, repository, mediatype string, original, convertedDigest digest.Digest, size int64) error + GetManifestEntryForRepo(ctx context.Context, host, repository, mediatype string, original digest.Digest) *ManifestEntry + GetCrossRepoManifestEntries(ctx context.Context, host, mediatype string, original digest.Digest) []*ManifestEntry + DeleteManifestEntry(ctx context.Context, host, repository, mediatype string, original digest.Digest) error } -type Entry struct { +type LayerEntry struct { ConvertedDigest digest.Digest DataSize int64 Repository string ChainID string Host string } + +type ManifestEntry struct { + ConvertedDigest digest.Digest + OriginalDigest digest.Digest + DataSize int64 + Repository string + Host string + MediaType string +} diff --git a/cmd/convertor/database/mysql.go b/cmd/convertor/database/mysql.go index 1db4375b..30663371 100644 --- a/cmd/convertor/database/mysql.go +++ b/cmd/convertor/database/mysql.go @@ -19,10 +19,9 @@ package database import ( "context" "database/sql" + "fmt" "github.com/containerd/containerd/log" - "github.com/pkg/errors" - "github.com/opencontainers/go-digest" ) @@ -36,18 +35,21 @@ func NewSqlDB(db *sql.DB) ConversionDatabase { } } -func (m *sqldb) GetEntryForRepo(ctx context.Context, host string, repository string, chainID string) *Entry { - var entry Entry +func (m *sqldb) CreateLayerEntry(ctx context.Context, host, repository string, convertedDigest digest.Digest, chainID string, size int64) error { + _, err := m.db.ExecContext(ctx, "insert into overlaybd_layers(host, repo, chain_id, data_digest, data_size) values(?, ?, ?, ?, ?)", host, repository, chainID, convertedDigest, size) + return err +} +func (m *sqldb) GetLayerEntryForRepo(ctx context.Context, host, repository, chainID string) *LayerEntry { + var entry LayerEntry row := m.db.QueryRowContext(ctx, "select host, repo, chain_id, data_digest, data_size from overlaybd_layers where host=? and repo=? and chain_id=?", host, repository, chainID) if err := row.Scan(&entry.Host, &entry.Repository, &entry.ChainID, &entry.ConvertedDigest, &entry.DataSize); err != nil { return nil } - return &entry } -func (m *sqldb) GetCrossRepoEntries(ctx context.Context, host string, chainID string) []*Entry { +func (m *sqldb) GetCrossRepoLayerEntries(ctx context.Context, host, chainID string) []*LayerEntry { rows, err := m.db.QueryContext(ctx, "select host, repo, chain_id, data_digest, data_size from overlaybd_layers where host=? and chain_id=?", host, chainID) if err != nil { if err == sql.ErrNoRows { @@ -56,9 +58,9 @@ func (m *sqldb) GetCrossRepoEntries(ctx context.Context, host string, chainID st log.G(ctx).Infof("query error %v", err) return nil } - var entries []*Entry + var entries []*LayerEntry for rows.Next() { - var entry Entry + var entry LayerEntry err = rows.Scan(&entry.Host, &entry.Repository, &entry.ChainID, &entry.ConvertedDigest, &entry.DataSize) if err != nil { continue @@ -69,15 +71,54 @@ func (m *sqldb) GetCrossRepoEntries(ctx context.Context, host string, chainID st return entries } -func (m *sqldb) CreateEntry(ctx context.Context, host string, repository string, convertedDigest digest.Digest, chainID string, size int64) error { - _, err := m.db.ExecContext(ctx, "insert into overlaybd_layers(host, repo, chain_id, data_digest, data_size) values(?, ?, ?, ?, ?)", host, repository, chainID, convertedDigest, size) +func (m *sqldb) DeleteLayerEntry(ctx context.Context, host, repository string, chainID string) error { + _, err := m.db.Exec("delete from overlaybd_layers where host=? and repo=? and chain_id=?", host, repository, chainID) + if err != nil { + return fmt.Errorf("failed to remove invalid record in db: %w", err) + } + return nil +} + +func (m *sqldb) CreateManifestEntry(ctx context.Context, host, repository, mediaType string, original, convertedDigest digest.Digest, size int64) error { + _, err := m.db.ExecContext(ctx, "insert into overlaybd_manifests(host, repo, src_digest, out_digest, data_size, mediatype) values(?, ?, ?, ?, ?, ?)", host, repository, original, convertedDigest, size, mediaType) return err } -func (m *sqldb) DeleteEntry(ctx context.Context, host string, repository string, chainID string) error { - _, err := m.db.Exec("delete from overlaybd_layers where host=? and repo=? and chain_id=?", host, repository, chainID) +func (m *sqldb) GetManifestEntryForRepo(ctx context.Context, host, repository, mediaType string, original digest.Digest) *ManifestEntry { + var entry ManifestEntry + row := m.db.QueryRowContext(ctx, "select host, repo, src_digest, out_digest, data_size, mediatype from overlaybd_manifests where host=? and repo=? and src_digest=? and mediatype=?", host, repository, original, mediaType) + if err := row.Scan(&entry.Host, &entry.Repository, &entry.OriginalDigest, &entry.ConvertedDigest, &entry.DataSize, &entry.MediaType); err != nil { + return nil + } + return &entry +} + +func (m *sqldb) GetCrossRepoManifestEntries(ctx context.Context, host, mediaType string, original digest.Digest) []*ManifestEntry { + rows, err := m.db.QueryContext(ctx, "select host, repo, src_digest, out_digest, data_size, mediatype from overlaybd_manifests where host=? and src_digest=? and mediatype=?", host, original, mediaType) + if err != nil { + if err == sql.ErrNoRows { + return nil + } + log.G(ctx).Infof("query error %v", err) + return nil + } + var entries []*ManifestEntry + for rows.Next() { + var entry ManifestEntry + err = rows.Scan(&entry.Host, &entry.Repository, &entry.OriginalDigest, &entry.ConvertedDigest, &entry.DataSize, &entry.MediaType) + if err != nil { + continue + } + entries = append(entries, &entry) + } + + return entries +} + +func (m *sqldb) DeleteManifestEntry(ctx context.Context, host, repository, mediaType string, original digest.Digest) error { + _, err := m.db.Exec("delete from overlaybd_manifests where host=? and repo=? and src_digest=? and mediatype=?", host, repository, original, mediaType) if err != nil { - return errors.Wrapf(err, "failed to remove invalid record in db") + return fmt.Errorf("failed to remove invalid record in db: %w", err) } return nil } diff --git a/cmd/convertor/resources/samples/mysql-db-manifest-cache-sample-workload.sh b/cmd/convertor/resources/samples/mysql-db-manifest-cache-sample-workload.sh new file mode 100755 index 00000000..50318507 --- /dev/null +++ b/cmd/convertor/resources/samples/mysql-db-manifest-cache-sample-workload.sh @@ -0,0 +1,21 @@ +# Validation Examples +registry=$1 # registry to push to +username=$2 # username for registry +password=$3 # password for registry +sourceImage=$4 # public image to convert +repository=$5 # repository to push to +tag=$6 # tag to push to +mysqldbuser=$7 # mysql user +mysqldbpassword=$8 # mysql password + +oras login $registry -u $username -p $password +oras cp $sourceImage $registry/$repository:$tag +# Try one conversion +./bin/convertor --repository $registry/$repository -u $username:$password --input-tag $tag --oci --overlaybd $tag-obd-cache --db-str "$mysqldbuser:mysqldbpassword@tcp(127.0.0.1:3306)/conversioncache" --db-type mysql + +# Retry, result manifest should be cached +./bin/convertor --repository $registry/$repository -u $username:$password --input-tag $tag --oci --overlaybd $tag-obd-cache-2 --db-str "$mysqldbuser:mysqldbpassword@tcp(127.0.0.1:3306)/conversioncache" --db-type mysql + +# Retry, cross repo mount +oras cp $sourceImage $registry/$repository-2:$tag +./bin/convertor --repository $registry/$repository -u $username:$password --input-tag $tag --oci --overlaybd $tag-obd-cache-2 --db-str "$mysqldbuser:mysqldbpassword@tcp(127.0.0.1:3306)/conversioncache" --db-type mysql diff --git a/cmd/convertor/resources/samples/mysql-db-setup.sh b/cmd/convertor/resources/samples/mysql-db-setup.sh new file mode 100755 index 00000000..c3d00ab0 --- /dev/null +++ b/cmd/convertor/resources/samples/mysql-db-setup.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Reference Script for getting started with mysql DB for userspace convertor +mysqldbuser=$1 +mysqldbpassword=$2 + +# Set up mysql +apt update +apt install mysql-server +service mysql start +cat ./mysql.conf | mysql +echo "CREATE USER '$mysqldbuser'@'localhost' IDENTIFIED BY '$mysqldbpassword'; GRANT ALL PRIVILEGES ON conversioncache.* TO '$mysqldbuser'@'localhost'; FLUSH PRIVILEGES;" | mysql \ No newline at end of file diff --git a/cmd/convertor/resources/samples/mysql.conf b/cmd/convertor/resources/samples/mysql.conf new file mode 100644 index 00000000..63a86648 --- /dev/null +++ b/cmd/convertor/resources/samples/mysql.conf @@ -0,0 +1,22 @@ +CREATE database conversioncache; +USE conversioncache; +CREATE TABLE `overlaybd_layers` ( + `host` varchar(255) NOT NULL, + `repo` varchar(255) NOT NULL, + `chain_id` varchar(255) NOT NULL COMMENT 'chain-id of the normal image layer', + `data_digest` varchar(255) NOT NULL COMMENT 'digest of overlaybd layer', + `data_size` bigint(20) NOT NULL COMMENT 'size of overlaybd layer', + PRIMARY KEY (`host`,`repo`,`chain_id`), + KEY `index_registry_chainId` (`host`,`chain_id`) USING BTREE +) DEFAULT CHARSET=utf8; + +CREATE TABLE `overlaybd_manifests` ( + `host` varchar(255) NOT NULL, + `repo` varchar(255) NOT NULL, + `src_digest` varchar(255) NOT NULL COMMENT 'digest of the normal image manifest', + `out_digest` varchar(255) NOT NULL COMMENT 'digest of overlaybd manifest', + `data_size` bigint(20) NOT NULL COMMENT 'size of overlaybd manifest', + `mediatype` varchar(255) NOT NULL COMMENT 'mediatype of the converted image manifest', + PRIMARY KEY (`host`,`repo`,`src_digest`, `mediatype`), + KEY `index_registry_src_digest` (`host`,`src_digest`, `mediatype`) USING BTREE +) DEFAULT CHARSET=utf8; \ No newline at end of file diff --git a/cmd/convertor/resources/samples/run-userspace-convertor-ubuntu.Dockerfile b/cmd/convertor/resources/samples/run-userspace-convertor-ubuntu.Dockerfile new file mode 100644 index 00000000..4fb6f778 --- /dev/null +++ b/cmd/convertor/resources/samples/run-userspace-convertor-ubuntu.Dockerfile @@ -0,0 +1,63 @@ +#NOTE: This Dockerfile should be executed from the root of the repository +# docker build . -f ./cmd/convertor/resources/samples/run-userspace-convertor-ubuntu.Dockerfile -t convertor + +FROM ubuntu:latest AS base +# Required Build/Run Tools Dependencies for Overlaybd tools +RUN apt-get update && \ + apt-get install -y ca-certificates && \ + update-ca-certificates + +RUN apt update && \ + apt install -y libcurl4-openssl-dev libext2fs-dev libaio-dev mysql-server + +# --- OVERLAYBD TOOLS --- +FROM base As overlaybd-build +RUN apt update && \ + apt install -y libgflags-dev libssl-dev libnl-3-dev libnl-genl-3-dev libzstd-dev && \ + apt install -y zlib1g-dev binutils make git wget sudo tar gcc cmake build-essential g++ && \ + apt install -y uuid-dev libjson-c-dev libkmod-dev libsystemd-dev autoconf automake libtool libpci-dev nasm && \ + apt install -y pkg-config + +# Download and install Golang version 1.21 +RUN wget https://go.dev/dl/go1.20.12.linux-amd64.tar.gz && \ + tar -C /usr/local -xzf go1.20.12.linux-amd64.tar.gz && \ + rm go1.20.12.linux-amd64.tar.gz + +# Set environment variables +ENV PATH="/usr/local/go/bin:${PATH}" +ENV GOPATH="/go" + +RUN git clone https://github.com/containerd/overlaybd.git && \ + cd overlaybd && \ + git submodule update --init && \ + mkdir build && \ + cd build && \ + cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=0 -DENABLE_DSA=0 -DENABLE_ISAL=0 && \ + make -j8 && \ + make install + +# --- BUILD LOCAL CONVERTER --- +FROM overlaybd-build AS convert-build +WORKDIR /home/limiteduser/accelerated-container-image +COPY . . +WORKDIR /home/limiteduser/accelerated-container-image +RUN make + +# --- FINAL --- +FROM base +WORKDIR /home/limiteduser/ + +# Copy Conversion Tools +COPY --from=overlaybd-build /opt/overlaybd/bin /opt/overlaybd/bin +COPY --from=overlaybd-build /opt/overlaybd/lib /opt/overlaybd/lib +COPY --from=overlaybd-build /opt/overlaybd/baselayers /opt/overlaybd/baselayers + +# This is necessary for overlaybd_apply to work +COPY --from=overlaybd-build /etc/overlaybd/overlaybd.json /etc/overlaybd/overlaybd.json +COPY --from=convert-build /home/limiteduser/accelerated-container-image/bin/convertor ./bin/convertor + +# Useful resources +COPY cmd/convertor/resources/samples/mysql.conf ./mysql.conf +COPY cmd/convertor/resources/samples/mysql-db-setup.sh ./mysql-db-setup.sh +COPY cmd/convertor/resources/samples/mysql-db-manifest-cache-sample-workload.sh ./mysql-db-manifest-cache-sample-workload.sh +CMD ["./bin/convertor"] \ No newline at end of file diff --git a/cmd/convertor/testingresources/consts.go b/cmd/convertor/testingresources/consts.go index 50b24618..1f2a9035 100644 --- a/cmd/convertor/testingresources/consts.go +++ b/cmd/convertor/testingresources/consts.go @@ -47,6 +47,11 @@ const ( DockerV2_Manifest_Simple_Layer_0_Digest = "sha256:719385e32844401d57ecfd3eacab360bf551a1491c05b85806ed8f1b08d792f6" DockerV2_Manifest_Simple_Layer_0_Size = 2457 + // DOCKER V2 (amd64-converted) - overlaybd + DockerV2_Manifest_Simple_Converted_Ref = "sample.localstore.io/hello-world:amd64-converted" + DockerV2_Manifest_Simple_Converted_Digest = "sha256:42caa56a19e082b872d43f645bb392e25c9e78bce429755bd709fac598265f88" + DockerV2_Manifest_Simple_Converted_Size = 641 + // DOCKER MANIFEST LIST Docker_Manifest_List_Ref = "sample.localstore.io/hello-world:docker-list" Docker_Manifest_List_Digest = "sha256:726023f73a8fc5103fa6776d48090539042cb822531c6b751b1f6dd18cb5705d" diff --git a/cmd/convertor/testingresources/local_db.go b/cmd/convertor/testingresources/local_db.go index e1cd9440..b442852a 100644 --- a/cmd/convertor/testingresources/local_db.go +++ b/cmd/convertor/testingresources/local_db.go @@ -18,14 +18,17 @@ package testingresources import ( "context" + "sync" "github.com/containerd/accelerated-container-image/cmd/convertor/database" "github.com/opencontainers/go-digest" - "github.com/pkg/errors" ) type localdb struct { - records []*database.Entry + layerRecords []*database.LayerEntry + manifestRecords []*database.ManifestEntry + layerLock sync.Mutex // Protects layerRecords + manifestLock sync.Mutex // Protects manifestRecords } // NewLocalDB returns a new local database for testing. This is a simple unoptimized in-memory database. @@ -33,8 +36,23 @@ func NewLocalDB() database.ConversionDatabase { return &localdb{} } -func (l *localdb) GetEntryForRepo(ctx context.Context, host string, repository string, chainID string) *database.Entry { - for _, entry := range l.records { +func (l *localdb) CreateLayerEntry(ctx context.Context, host string, repository string, convertedDigest digest.Digest, chainID string, size int64) error { + l.layerLock.Lock() + defer l.layerLock.Unlock() + l.layerRecords = append(l.layerRecords, &database.LayerEntry{ + Host: host, + Repository: repository, + ChainID: chainID, + ConvertedDigest: convertedDigest, + DataSize: size, + }) + return nil +} + +func (l *localdb) GetLayerEntryForRepo(ctx context.Context, host string, repository string, chainID string) *database.LayerEntry { + l.layerLock.Lock() + defer l.layerLock.Unlock() + for _, entry := range l.layerRecords { if entry.Host == host && entry.ChainID == chainID && entry.Repository == repository { return entry } @@ -42,9 +60,11 @@ func (l *localdb) GetEntryForRepo(ctx context.Context, host string, repository s return nil } -func (l *localdb) GetCrossRepoEntries(ctx context.Context, host string, chainID string) []*database.Entry { - var entries []*database.Entry - for _, entry := range l.records { +func (l *localdb) GetCrossRepoLayerEntries(ctx context.Context, host, chainID string) []*database.LayerEntry { + l.layerLock.Lock() + defer l.layerLock.Unlock() + var entries []*database.LayerEntry + for _, entry := range l.layerRecords { if entry.Host == host && entry.ChainID == chainID { entries = append(entries, entry) } @@ -52,33 +72,64 @@ func (l *localdb) GetCrossRepoEntries(ctx context.Context, host string, chainID return entries } -func (l *localdb) CreateEntry(ctx context.Context, host string, repository string, convertedDigest digest.Digest, chainID string, size int64) error { - l.records = append(l.records, &database.Entry{ +func (l *localdb) DeleteLayerEntry(ctx context.Context, host, repository, chainID string) error { + l.layerLock.Lock() + defer l.layerLock.Unlock() + // host - repo - chainID should be unique + for i, entry := range l.layerRecords { + if entry.Host == host && entry.ChainID == chainID && entry.Repository == repository { + l.layerRecords = append(l.layerRecords[:i], l.layerRecords[i+1:]...) + return nil + } + } + return nil // No error if entry not found +} + +func (l *localdb) CreateManifestEntry(ctx context.Context, host, repository, mediaType string, original, convertedDigest digest.Digest, size int64) error { + l.manifestLock.Lock() + defer l.manifestLock.Unlock() + l.manifestRecords = append(l.manifestRecords, &database.ManifestEntry{ Host: host, Repository: repository, - ChainID: chainID, + OriginalDigest: original, ConvertedDigest: convertedDigest, DataSize: size, + MediaType: mediaType, }) return nil } -func (l *localdb) DeleteEntry(ctx context.Context, host string, repository string, chainID string) error { - // Identify indices of items to be deleted. - var indicesToDelete []int - for i, entry := range l.records { - if entry.Host == host && entry.ChainID == chainID && entry.Repository == repository { - indicesToDelete = append(indicesToDelete, i) +func (l *localdb) GetManifestEntryForRepo(ctx context.Context, host, repository, mediaType string, original digest.Digest) *database.ManifestEntry { + l.manifestLock.Lock() + defer l.manifestLock.Unlock() + for _, entry := range l.manifestRecords { + if entry.Host == host && entry.OriginalDigest == original && entry.Repository == repository && entry.MediaType == mediaType { + return entry } } + return nil +} - if len(indicesToDelete) == 0 { - return errors.Errorf("failed to find entry for host %s, repository %s, chainID %s", host, repository, chainID) +func (l *localdb) GetCrossRepoManifestEntries(ctx context.Context, host, mediaType string, original digest.Digest) []*database.ManifestEntry { + l.manifestLock.Lock() + defer l.manifestLock.Unlock() + var entries []*database.ManifestEntry + for _, entry := range l.manifestRecords { + if entry.Host == host && entry.OriginalDigest == original && entry.MediaType == mediaType { + entries = append(entries, entry) + } } + return entries +} - // Delete items at identified indices. (Reverse order to avoid index shifting.) - for i := len(indicesToDelete) - 1; i >= 0; i-- { - l.records = append(l.records[:indicesToDelete[i]], l.records[indicesToDelete[i]+1:]...) +func (l *localdb) DeleteManifestEntry(ctx context.Context, host, repository, mediaType string, original digest.Digest) error { + l.manifestLock.Lock() + defer l.manifestLock.Unlock() + // Identify indices of items to be deleted. + for i, entry := range l.manifestRecords { + if entry.Host == host && entry.OriginalDigest == original && entry.Repository == repository && entry.MediaType == mediaType { + l.manifestRecords = append(l.manifestRecords[:i], l.manifestRecords[i+1:]...) + } } - return nil + return nil // No error if entry not found } diff --git a/cmd/convertor/testingresources/local_registry.go b/cmd/convertor/testingresources/local_registry.go index d607fd92..ae4ff61e 100644 --- a/cmd/convertor/testingresources/local_registry.go +++ b/cmd/convertor/testingresources/local_registry.go @@ -42,7 +42,7 @@ type TestRegistry struct { } type RegistryOptions struct { - InmemoryOnly bool // Specifies if the registry should not load any resources from storage + InmemoryRegistryOnly bool // Specifies if the registry should not load any resources from storage LocalRegistryPath string // Specifies the path to the local registry ManifestPushIgnoresLayers bool // Specifies if the registry should require layers to be pushed before manifest } @@ -54,7 +54,7 @@ func NewTestRegistry(ctx context.Context, opts RegistryOptions) (*TestRegistry, internalRegistry: make(internalRegistry), opts: opts, } - if !opts.InmemoryOnly { + if !opts.InmemoryRegistryOnly { files, err := os.ReadDir(opts.LocalRegistryPath) if err != nil { return nil, err @@ -103,8 +103,9 @@ func (r *TestRegistry) Push(ctx context.Context, repository string, tag string, return repo.Push(ctx, descriptor, tag, content) } - // If the repository does not exist we create a new one + // If the repository does not exist we create a new one inmemory repo = NewRepoStore(ctx, repository, &r.opts) + repo.inmemoryRepoOnly = true r.internalRegistry[repository] = repo repo.Push(ctx, descriptor, tag, content) @@ -137,9 +138,20 @@ func (r *TestRegistry) Exists(ctx context.Context, repository string, tag string // we don't need to check if the digest exists in the repo stores. return true, nil default: - if tag != "" { - return false, errors.New("Tag specified for non manifest") - } return repo.Exists(ctx, desc) } } + +// Mount simulates a cross repo mount by copying blobs from srcRepository to targetRepository +func (r *TestRegistry) Mount(ctx context.Context, srcRepository string, targetRepository string, desc v1.Descriptor) error { + rd, err := r.Fetch(ctx, srcRepository, desc) + if err != nil { + return err + } + defer rd.Close() + var body []byte + if body, err = io.ReadAll(rd); err != nil { + return err + } + return r.Push(ctx, targetRepository, "", desc, body) +} diff --git a/cmd/convertor/testingresources/local_remotes.go b/cmd/convertor/testingresources/local_remotes.go index 73d94f7d..25ebccb1 100644 --- a/cmd/convertor/testingresources/local_remotes.go +++ b/cmd/convertor/testingresources/local_remotes.go @@ -31,6 +31,10 @@ import ( v1 "github.com/opencontainers/image-spec/specs-go/v1" ) +const ( + labelDistributionSource = "containerd.io/distribution.source" +) + // RESOLVER type MockLocalResolver struct { testReg *TestRegistry @@ -39,7 +43,7 @@ type MockLocalResolver struct { func NewMockLocalResolver(ctx context.Context, localRegistryPath string) (*MockLocalResolver, error) { reg, err := NewTestRegistry(ctx, RegistryOptions{ LocalRegistryPath: localRegistryPath, - InmemoryOnly: false, + InmemoryRegistryOnly: false, ManifestPushIgnoresLayers: false, }) if err != nil { @@ -78,7 +82,7 @@ func (r *MockLocalResolver) Fetcher(ctx context.Context, ref string) (remotes.Fe } func (r *MockLocalResolver) Pusher(ctx context.Context, ref string) (remotes.Pusher, error) { - _, repository, tag, err := ParseRef(ctx, ref) + host, repository, tag, err := ParseRef(ctx, ref) if err != nil { return nil, err } @@ -87,6 +91,7 @@ func (r *MockLocalResolver) Pusher(ctx context.Context, ref string) (remotes.Pus testReg: r.testReg, repository: repository, tag: tag, + host: host, tracker: docker.NewInMemoryTracker(), }, nil } @@ -106,6 +111,7 @@ type MockLocalPusher struct { testReg *TestRegistry repository string tag string + host string tracker docker.StatusTracker } @@ -150,6 +156,15 @@ func (p MockLocalPusher) push(ctx context.Context, desc v1.Descriptor, ref strin return nil, errdefs.ErrAlreadyExists } + // Layer mounts + if mountRepo, ok := desc.Annotations[fmt.Sprintf("%s.%s", labelDistributionSource, p.host)]; ok { + err = p.testReg.Mount(ctx, mountRepo, p.repository, desc) + if err != nil { + return nil, err + } + return nil, errdefs.ErrAlreadyExists + } + respC := make(chan error, 1) p.tracker.SetStatus(ref, docker.Status{ diff --git a/cmd/convertor/testingresources/local_repo.go b/cmd/convertor/testingresources/local_repo.go index a1e1a45c..3244da43 100644 --- a/cmd/convertor/testingresources/local_repo.go +++ b/cmd/convertor/testingresources/local_repo.go @@ -34,10 +34,11 @@ import ( // REPOSITORY type RepoStore struct { - path string - fileStore *oci.Store - inmemoryRepo *inmemoryRepo - opts *RegistryOptions + path string + inmemoryRepoOnly bool + fileStore *oci.Store + inmemoryRepo *inmemoryRepo + opts *RegistryOptions } type inmemoryRepo struct { @@ -68,8 +69,8 @@ func (r *RepoStore) LoadStore(ctx context.Context) error { return nil } - if r.opts.InmemoryOnly { - return errors.New("LoadStore should not be invoked if registry is memory only") + if r.opts.InmemoryRegistryOnly && !r.inmemoryRepoOnly { + return errors.New("LoadStore should not be invoked if registry or repo is memory only") } if r.path == "" { @@ -100,7 +101,7 @@ func (r *RepoStore) Resolve(ctx context.Context, tag string) (v1.Descriptor, err } } - if !r.opts.InmemoryOnly { + if !r.opts.InmemoryRegistryOnly && !r.inmemoryRepoOnly { if err := r.LoadStore(ctx); err != nil { return v1.Descriptor{}, err } @@ -116,7 +117,7 @@ func (r *RepoStore) Fetch(ctx context.Context, descriptor v1.Descriptor) (io.Rea return io.NopCloser(bytes.NewReader(blob)), nil } - if !r.opts.InmemoryOnly { + if !r.opts.InmemoryRegistryOnly && !r.inmemoryRepoOnly { if err := r.LoadStore(ctx); err != nil { return nil, err } @@ -140,7 +141,7 @@ func (r *RepoStore) Exists(ctx context.Context, descriptor v1.Descriptor) (bool, return true, nil } - if !r.opts.InmemoryOnly { + if !r.opts.InmemoryRegistryOnly && !r.inmemoryRepoOnly { if err := r.LoadStore(ctx); err != nil { return false, err } diff --git a/cmd/convertor/testingresources/mocks/generate.sh b/cmd/convertor/testingresources/mocks/generate.sh index 119d1c8b..d72e85ae 100755 --- a/cmd/convertor/testingresources/mocks/generate.sh +++ b/cmd/convertor/testingresources/mocks/generate.sh @@ -1,20 +1,33 @@ -# Usage: ./generate.sh +# Usage: ./generate.sh $registry $username $password # Prerequisites: oras # Generates simple hello-world images in ./registry folder # This script serves as a way to regenerate the images in the registry folder if necessary # and to document the taken steps to generate the test registry. Add more if new images are needed. +# do note that it might be necessary to adjust the consts.go file to make sure tests don't break if +# the source hello-world images are updated. +registry=$1 +username=$2 +password=$3 srcTag="linux" srcRepo="hello-world" srcImage="docker.io/library/$srcRepo:$srcTag" srcRegistry="docker.io/library" -registry=$1 destFolder="./registry" echo "Begin image generation based on src image: $srcImage" - -# Docker oras cp --to-oci-layout $srcImage $destFolder/hello-world:docker-list -# Tag Some submanifests + +# Tag some submanifests oras cp --to-oci-layout --platform linux/arm64 $srcRegistry/hello-world:linux $destFolder/hello-world/:arm64 -oras cp --to-oci-layout --platform linux/amd64 $srcRegistry/hello-world:linux $destFolder/hello-world/:amd64 \ No newline at end of file +oras cp --to-oci-layout --platform linux/amd64 $srcRegistry/hello-world:linux $destFolder/hello-world/:amd64 + +# Add sample converted manifest +oras login $registry -u $username -p $password +oras cp --from-oci-layout $destFolder/hello-world/:amd64 $registry/hello-world:amd64 +cwd=$(pwd) +cd ../../../../ +make +sudo bin/convertor --repository $registry/hello-world --input-tag amd64 --oci --overlaybd amd64-converted -u $username:$password +cd $cwd +oras cp --to-oci-layout $registry/hello-world:amd64-converted $destFolder/hello-world/:amd64-converted \ No newline at end of file diff --git a/cmd/convertor/testingresources/mocks/registry/hello-world/blobs/sha256/022231c3f1af70e0cc42f0c3781eabf174cff608210585bf93fc83e16d33672d b/cmd/convertor/testingresources/mocks/registry/hello-world/blobs/sha256/022231c3f1af70e0cc42f0c3781eabf174cff608210585bf93fc83e16d33672d new file mode 100644 index 0000000000000000000000000000000000000000..bdd6e80852a10105db77b9ce01abe54cf78ec121 GIT binary patch literal 642560 zcmeF)byQZ3y7&7@2`1RBsMv+w3W|E8*xjOHqM%~eR4hci?C$RF#P06yF6@@`oolYW z_uB6{`#R^JcaJm1yPq+>k6!#fqTG6;qH8^2eM5SM1`lZ0p-^~mf8)RRAMbzrr${O9 zfBl-hy-O4;QPSJ^Z~6B0=^YW=%kcI6*Kff8L7y*D!n=2ufgu%27AsS{Op!$Y2fgM0 z^QKylK-j-O|J#Rk@7^Wif5)Rz#MifE@#5abKc4Qs|I4TUeBr;nEAaOA_4Y1O)VF8} zZ{xq^Ir(qT|6XBX>Kp(6w*T*V;Qkw4XY*=ZLPHEEBce~&E`1UsepgJAiRpJ^|_6Wc6a~BOS}wT%YXdyub=;qm;TS^{{Qv} z#CrxcsMXMLayCMSw!btrz$0k!r4w)JoL@g~G~b2pe4n~}%EJ+}I$+8*TVfPEMT-7f)v+ z{y+cIqV7K)hl(%XhG*rgVEo|a=j8Spu)GvB_9@lFXx@?uI%Uj`W7 z6^)FBvyq&~EzA;shxi%Z3rxOk(@DUE)@P96DEN(1r*1}F@+EgU9?y(DKz z&XU|(I7|+ENx_nWC55$cm>l+!k|iZeN^9XTIqW4BODdLB*1};q_q|flfz!pv7}>3XDu8ihrOg{NzanrS~yG&d&$6(fhB{r zaF`tSl99#wv6|6ZI7|+E$;6V0C6l#qm>l+!nI$tzW^3UvIqW41OBR+a*1})YJI58|B-!yDy|^5!Y?k0bK}lb6vyUrjbZHb6G()o_>`_E(Ud zB|A%YYvC|C>?H>a?<|ZQ*1}!eMgQOHr1hEJdw_!{o4+Vl2g2idhSX$zdl*}nx!;LX=~vyIqanjOBt3j z*1}OkPI(yy6e=2l!jB zhQs8rm&z=aSt?r#hsj|tRamO9RIwHglfzyDSOQoAtcAnmu$QVVRavT93x~;JFV$G8 zu~f4b4wJ)Ps?M#TkR{MsI7|+Eslif%rG~X|m>l*} zlcgq0O>5yWIqW5fC5R=+S~yG&d#S}zi=~#eaF`tSQk$hVOKof6Fgfg{4oe-DI@ZEr za@b2;V?Pur3p(DmL}H1VRG0@QDnzJ;w77mlcURtoUU}<43943dov}9??($ZQuOb&Z##nOtUm9=n~ z9QM+hr8P@yYvC|C?4=D$8tW)>Eo2sx7MRzdWUQnaRuSR}%~f z1_WELhQs8rmv$`eSlU?&hsj|t?OEEhw6_)xlfzy*uykPQU@aUbhrNWbgs_BI3x~;J zFCAGrvUIc-4wJ)PI0~V&CWpOrX6ek**;+VE4twds(uJjqwQ!gm_R^K5D@#{v z;V?Pur5j5(mTuOl*J&JxZNZY>-phrRS<>B-X5S~yG&d+Ei}i=~&faF`tS(wn6>OK)r8Fgffc zf+d0_!df^?4twdt(ubvwwQ!gm_R^Q7FH2u*;V?Pur5{T_mVVa4VRG0@f0q6%{jG(= z}43sFqUD~!eMf-{PQU_J*N0L>nUZTOqBUAPbpqz@-q9? z3l*piDeSYBx~U?IqYRJ%Vd_x*1}-phrP^TnZYu{S~yG&dzr~HlVzs0 zaF`tSGK*yv%Pec*FgfgHHp^_5+1A2ga@flpmN_hQtcAnmu$Q?kb6MtE3x~;JFY{RD zvCOj;4wJ)P=CjOanQtu|CWpN&U|GPjz*;y=4wipDrSiL)an@66A!;FN;lDhkc$vw| z>{qi0un4fodNmv-hrKLjS#c>u zB zi?wi=9QLx6Wh={8YvC|C>}4CvHkNJH!eMgQ%XXISEZePx!{o4+9V|Ooc32CC$zd-$ zS$4AQv=$DN!(Mi=>|)tvEgU9?z3gV$&9d8CI7|+E*~7AjWskLRm>l-9mt`-@UTfhn zIqYR0%RZKU*1} zl;UM3FSB3GLBK)4LF?6Um>l+Uh~*H=A#33U(g~Q~q zm!m94S&mu@hsj|t$5@WB9J3Y-phrOI&Il*$mS~yG&dpXH+lI5he zaF`tSa*E{?%PDK&FgfhyG|Oq0)7HXaa@flmmNP78tcAnmu$QweXIai#3x~;JFXvd! zv7ECO4wJ)P&a<3nId3f-CWpOTV7b6@!CE*>4tu%Ca*^etwQ!gm_Hv2k63ZoP;V?Pu zH6UOb&aw$#RqBrnPXG9QJaH?G zEO)Gh!{o4+yDWEE?ph0n$zd<|Snjdhvlb4M!(Q&Q+-JFOEgU8X%Riq|hrJVfT2HA5 zs0XMA|MHaLWhO7PU(G|nL%>7p)o_>`_VS435z8ZM;V?Pu7zV4wJ)PezN>z`Dra2CWpQJV)@1L%UU>0 z4tx2{@|)$iwQ!gm_VS1256d5G;V?PuukPEvtVDJWI^oD-57lv+Wrm*#0AX+UWd zl(Ay+kCGOYRzXif7I&9)pmYiWpWv1?CBHc&PN zEx4Sukz@yDS5R1Bt~kj7%Auf%DW-*q56DMBLlf;fAUQ!f6*RnFB5%nB%B7&5r;Ck} z+@RbFnlRDKAbCJ}6x4G37;ni7%B!Fy*NY#Je4u;^YEUa_2FVY~ub{PQ_K%VRpaKf2 zoNRIUO}hB^Nf@V zpb82qo_|h8sR*j5pd*_W@0UuTN(w6XDtR{X1NkXvYrB{H@gB%uK{d-X-y@Yll@+vS zN&75P1yn^r0pBkSmjFuBHT~Dflsw$|_o;v5G8mO9rmSyYPO{#;cE2#Ovqo2hA zawuqQ$3y2N5EQ7OeieGwlNz8J3L3M*@mXquYAUGz?_b>{2o$8C=})fjky@Zy3hHv8 z1Ap2Gs;!_|Ia>{vI-oiV>NxaW7O4xWtDp**8hnv@pn3{ARQ*9$sSm2JprUtgo|guo z1`0Ymx@%o&2x_RH9P_K0(g@T@L3fUR$tsOOjTKa&&6(ZO1k^-9mtBYDl%}Aj3UW%< zKdUqYHB->vDyL0p4r;ETWH&$Nlop^C3i>d@f48&*wNy~n+1P-g{od)vITbOCiy z(A4cOze!h6R|Rz_aG}0*19el-oPO=kN+>8)L4CsNWR~ur?g|=L{Pzgy0qUWkf$NXv zmM~D5f<}GmyHmnJ;R?Dp<8%Y*3F@h!Y-jqPkzSx)3cA?Xzl-z+^;S@!q#wUZ1Smp5 zv8nInl0Kk53aV7Q`!4AV>Z_phR~lxLexQB|@|EY2(jU}cL64?9*d+r%0~D0`M1x#1 z5HwIhZ|irBltG|D3QCdiW+oX78myq-$-kVDA)p}&a;Z_RfeZx=RnVsk!@kNe&@crh zi9XXsOpvLdw+AQuk>Q}>3Q94%LOU4&8lj*^hU;;O1Vt(+bBi5KWh7{%f<9H5Ggd}{ zMky#sx;$xRG-$Mfeve4LS;m0ID9GjJ;({_3G*&?uk1k6qQJ^RV6`G%Btc(MVQ_wxv z!UZK76s@3aZRT#4@u2YvI$wQbJBb0sD9AUH%O9BlnxLTA(Pf*8fJ8x+?nWJ#iJ*xJ z>TsZV0ht7vq@X!Z#%z(vpvemAHgta)nF5-kps6{NM#)srR0R#Jkgb_a15H!VsE(75 z$#l?k1@-y8roGGn%}~&|73qG~1I<&=u6DZ$$b8Uz1qFULqhtYSfr7R#DV9bSf)*+$S&cL; zWD#hQf<7c)c0?9~7AwdpdTy{R0WDF`-wTER%2Lo$1$mutDJ084%M|o}%EnEy9JE|P zSrbO3k`4WJDQy3;s$uxtcvRM42>YsbkZ&?W`- z56hZbHiI@RXzZ6<8)XYD(pwkLUQ_=sYoB^Ft(Dz*3^T}Dz zSp~TdxwlQufzB!D+wid+C)P*8(=FFVLZ&_xBU9o77oTmoHEP|K|C zTgzq8Wd$t>xNuOefUYR0R?FYX&x~ZUv!+R!|TcBGCYMarqRc?cBD`sE#pfdSSr<8}FhYC6t-haG20zFbt$yfe)8g!IbpjQe?^seh+c@284pfB5Qwv;!ZHwwzQ?#p<23wo=dCts_jly{(a z3Q83^Y`eS%y;sodQfKl?94Jmf8yodJBp*N@6jUeKp;qz{^ie?vrvCaapFp1!zOrPf^tjRIg%WdTtPRkEOnI>pcD$qQ+wVbNeN1+pzEm%Rg_eqR0=AS z)cKvH2BlWevBsNvN*Yia1(iHA_KKthrB%?$8Rcq9I#4LQ3n+_%5}jC5QL=)vD(K4; zuSMbw@>Wo-fhFEZHc&PNZO%5jw`2!pS5W0W2d+sDP!0v{dyphhd_X=5YVh5AmgEHG zRM6TbQxZrnP%Z_vEVFuvAg+YZCw4h_rYvK#? zRnQSvsUSr_MHE!7P5H%A6jW3}r;ob1OEFL}1r?vaeWnx#6<5%$(X$;=0#rglIq&AZ zE+s)F6?C<_S1&0ADy5)&nU=hj(xB1``fe<}E@ePv6y)9_twYL!$|~sF!9s7P9H^Xv zJZ8`9CFMco74&Sxro~bLR6#-MZ#q|yilB-LdRe91OsNE_q@XnE#=46i$WKADCWbqU zKgeG}9Zw&gCzU~!6*Rrx&(cx_R7F8u5(Tc108oH}#-s@8CsjdJ71TfQ+6$=$s-~c^ zm*3Zt>Y(Zh>NmFKC2@cp3R*U`#R~}p1uCfd`4|192B?ODRyVqMNos;>DyUJicC{o3 z6r`X%X==}tTA*4A3aItRS!#o7D`@NWBP*m1sE&eaj_+4m>VoPj$m`vy%Tf7)vhfaK^+y;t!cm4(h1Z_K~r6h zL`Y{)X9ZQudbqZ90d-N(u7L21(iPNIL4o%IBcvOsn}W8F`uSQyL7@t2I`3LJ=??0y zpcThLmP!v$4+S-8+0seEKw%16>HdDcgoDBr^eLo%AL$9|sh}hkAHI@apk504z4S&M z=?&_wAeZ=V7bF4{p`f?Vs~ge>)JH)n_I_RgR-sG!(|pO(oW&>#g>`sG(v27?AG=;GthS26@NL_vji-tQwr zK|>XEFZ;6#G7L0KLD>d0sv{=IR8VZOw6|qAXt;tZbzT}EBS0e*bpGSKhY|^jRFLng zLPKRFXrzMfZE{{Oqd=n+lp+7m+AX zl!9DBWxb38jZ@I4uiFz#G$>j@N!GbdmGPkQ3VO6HZ-B&rVic75-R#>k0W?8DZzGlr z6#w6%<%&T_TwTnxvrZX|ha{$)L#!sy05iuS@|=QP8gIQ#Z&|&{PFA zIlpt5Oao0*(8{TaAINmjbOkj{R165@%Xs&{~ot`pH=7HuZXzE1oL^2;VUqKxbrQaY6KnoN! zr`~E`SqNIFpd348h%5pvQqY~puSwYL(4@{RWpe+h&-ZDu-*$UdKpsfMkH)R`Wn}TX) zol;GR{Oe z+M}S3O>;k%y`a4c8ta$1nCt`XQ&7K*JJ-s7(0&Dt89qFr8~`0qQ2$#+r%Nm-RzctD zpPC{EK?fD&k#Il~IRrYSpzl-sR>@(|VFkIL_*7DkfQ~5W<%RptQX0Q5jXsczOUArCaa+`g2pgadX zSJ2OcL!ZhE&Ms>e|@&@!qK_%OS zu9ml;w+cGxdcTCc1HDsFnfcEq%6rgz1syxu$W!7#aSEEZ;>8pB0Q#VycE4K;l8>N| z3Yy%p-97mP`lO)l6)yVAXV7N_Mdti7Nxp!-C}{A|+R5ZA=&ORpKk2tdzJb0esP};* z#pOHbyMh)iIh;&>fPN^b_4n{e@)Pt^LF?NE7MEY3Uka*M=I0vu4f?I11KF+(l0Tq7 z3i2Bm@gh4~kb%ojvdG3I89ieAwu}{`*fipSx!hCy562e~V#RDAvq z;sNqd(9_2SPD?^iLIq{mxuBsW0wq$=tL)1^NMcZ81*IO4x`!kIB~j3?;4=FqDJZFe zT+5EjCdoj_6!dxFmXYEK@>EdbUq(jp0(mK@?yP-rk{py=K^qPw3zHO}6bfq7wA3j{ z2}-G;g)So-Nh(k(1=Y^Da+IV7rB=`;zl<3q4JeI*{BLDHAZbBq6|{f&ByULvN~fUE zdDAmUdQf@=O*xixlw<&9P*D4pnY<+7v!s;Eh%y~mLi}c3JMCGeo~5piYjRD$dRV<~1E~V4qM$699-ol_P=JEsT+e5c zs-UV0@@z9WQmTQfDJcGE)m%~?R9!*F{I9#j0dgql(ufa{5(o-ZP{Es(Gf53l4F%n; zGH9371l3ef_H-w5Nf0PVL1&C7U!@kPmV%14XwpS$gK8`2@WCEuqzME$y_qIEw9;lvzW-hs$Tk3=AE2wjsS0khWsDXlJv}=}G8iE=sXu!ZeXQdISk%C5N zJ62yBgBmNS@1FQ?(gf5*K~WEaI!jYfQw6p7U9-M412t37(iPv&N^?+i1vRX2u(PxP zwNTKSj=jD~OHfM%RUO(ox3mJaQqb<4Z+A*-P-_L%IB+Giv;nnI(6%R?Mo3#wTLmRe zTKT*LgMth7YAnzJqyGj@+OhNaPSN$U4pl}6^?EO89^aS-( z(4aguhf6O|F9k(!?!|xc2h>|Z5ibw!kqA(Pf@Xhs+fDj_`Y0%5b?eX47t~il(|kMC zlYXFn3hLVJ$~ox|>aU=k{Vso&0iXd2s$QUNHyH>TsGuF&o1K$Epg{_9ynR(q27?AG zX!)08!(|9)h=Q7}?~_G_f`%$+Rq>!bG7L0KL5;)WeZ&Nr3fhr#-$oe@8m=J6&}4;W z1ZaeUc0MUJP9i~(3aWl!WNH}+8mXXFD^?zrQJ_%@YWzE6a~Ta9t)S%{v&YLA&=>_Z zt1zjpj0KHVP;|EG%_RyHrJ#s`IgiRX&^QH+e2}TFM1!IgG-%I;co`2Gub^p5A`43l zC`LhDzx!^K37`oIn%yp8Y7vkqD5T8paWWA!Q9%`3xChH5&?E&NG`9Yg$)L#!Dl$8! zg-iiWQP8=A6_3bN&{PF^-%OE8rh%p@=>CXB(J~!0T|tG@%`7A{Kr<9{p-TQuG7~gY zLC$RoM9VDDECu~>U64vHOuJWDaPKf<7KiT}bAF<|-)D-7k= zT3S5Q7Fh~fs-PMLeA~-1&@u&W>lgW3mV=fnsOsC@&13~=g@Sf(Pk2mLf>tW1^Xg>3 zWff?Zf@XZ!*IrhGRx7Aew~@zW4QP#mX8M+DCTl@!71TFR#wb|_TBo3>-Ye6{deC|W z4R|?ei);XGP|)bj*$c=<&_)H_y|Cc8YyxdkQ1<8oO=UA^vw|)qPyI)>fVL>8V2$PN zWGiT^f)3Xow^_DIo zWfAwyvKzEpLGMymOe=dpdlZzscFb7W3)-uo_{NKx%0AFO1sO?G9GCr|{R)bkk-wcB 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