From e6b37ce248abf11f0e89ebdf1630068d708edbb9 Mon Sep 17 00:00:00 2001 From: David Trudgian Date: Fri, 21 Jun 2024 16:08:24 +0100 Subject: [PATCH] feat: Update SIF from ImageIndex Add `sif.Update` which accepts updates a SIF so that its content reflects the ImageIndex passed. Any blobs in the SIF that are not present in the new ImageIndex will be removed from the SIF. Any blobs that are present in the new ImageIndex, but not in the SIF, will be added to the SIF. Blobs that are present in both the SIF and the new ImageIndex are not re-written. They will remain at their current descriptor location in the SIF. Closes #48 --- pkg/sif/testdata/TestUpdate/AddImage.golden | Bin 0 -> 18089 bytes .../testdata/TestUpdate/AddImageIndex.golden | Bin 0 -> 18335 bytes pkg/sif/testdata/TestUpdate/AddLayer.golden | Bin 0 -> 18602 bytes .../testdata/TestUpdate/ReplaceLayers.golden | Bin 0 -> 61143 bytes pkg/sif/update.go | 341 ++++++++++++++++++ pkg/sif/update_test.go | 148 ++++++++ test/images.go | 4 +- 7 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 pkg/sif/testdata/TestUpdate/AddImage.golden create mode 100644 pkg/sif/testdata/TestUpdate/AddImageIndex.golden create mode 100644 pkg/sif/testdata/TestUpdate/AddLayer.golden create mode 100644 pkg/sif/testdata/TestUpdate/ReplaceLayers.golden create mode 100644 pkg/sif/update.go create mode 100644 pkg/sif/update_test.go diff --git a/pkg/sif/testdata/TestUpdate/AddImage.golden b/pkg/sif/testdata/TestUpdate/AddImage.golden new file mode 100644 index 0000000000000000000000000000000000000000..59afbd8c98927023df30f2ae4db508f33390a755 GIT binary patch literal 18089 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 literal 0 HcmV?d00001 diff --git a/pkg/sif/testdata/TestUpdate/AddImageIndex.golden b/pkg/sif/testdata/TestUpdate/AddImageIndex.golden new file mode 100644 index 0000000000000000000000000000000000000000..fafc2ae0ae5e4b9ffe77b24066d527ac98657c24 GIT binary patch literal 18335 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 TlZu%hM(Lk3`OgM5%I*1g8`+NJ literal 0 HcmV?d00001 diff --git a/pkg/sif/testdata/TestUpdate/AddLayer.golden b/pkg/sif/testdata/TestUpdate/AddLayer.golden new file mode 100644 index 0000000000000000000000000000000000000000..f9544805a9fc736a8e420eeed39b51ebf4303514 GIT binary patch literal 18602 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 K0mRJ1ru{d`^T-+i literal 0 HcmV?d00001 diff --git a/pkg/sif/update.go b/pkg/sif/update.go new file mode 100644 index 0000000..fedc198 --- /dev/null +++ b/pkg/sif/update.go @@ -0,0 +1,341 @@ +// Copyright 2024 Sylabs Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package sif + +import ( + "bytes" + "io" + "os" + "path/filepath" + "slices" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/sylabs/sif/v2/pkg/sif" +) + +// updateOpts accumulates update options. +type updateOpts struct { + tempDir string +} + +// UpdateOpt are used to specify options to apply when updating a SIF. +type UpdateOpt func(*updateOpts) error + +// OptUpdateTempDir sets the directory to use for temporary files. If not set, the +// directory returned by os.TempDir is used. +func OptUpdateTempDir(d string) UpdateOpt { + return func(c *updateOpts) error { + c.tempDir = d + return nil + } +} + +// Update modifies the SIF file associated with fi so that it holds the content +// of ImageIndex ii. Any blobs in the SIF that are not referenced in ii are +// removed from the SIF. Any blobs that are referenced in ii but not present in +// the SIF are added to the SIF. The RootIndex of the SIF is replaced with ii. +// +// Update may create one or more temporary files during the update process. By +// default, the directory returned by os.TempDir is used. To override this, +// consider using OptUpdateTmpDir. +func Update(fi *sif.FileImage, ii v1.ImageIndex, opts ...UpdateOpt) error { + uo := updateOpts{ + tempDir: os.TempDir(), + } + for _, opt := range opts { + if err := opt(&uo); err != nil { + return err + } + } + + // If the existing OCI.RootIndex in the SIF matches ii, then there is nothing to do. + sifRootIndex, err := ImageIndexFromFileImage(fi) + if err != nil { + return err + } + sifRootDigest, err := sifRootIndex.Digest() + if err != nil { + return err + } + newRootDigest, err := ii.Digest() + if err != nil { + return err + } + if sifRootDigest == newRootDigest { + return nil + } + + // Get a list of all existing OCI.Blob digests in the SIF + sifBlobs, err := sifBlobs(fi) + if err != nil { + return err + } + + // Cache all new blobs referenced by the new ImageIndex and its child + // indices / images, which aren't already in the SIF. cachedblobs are new + // things to add. keepBlobs already exist in the SIF and should be kept. + blobCache, err := os.MkdirTemp(uo.tempDir, "") + if err != nil { + return err + } + defer os.RemoveAll(blobCache) + cachedBlobs, keepBlobs, err := cacheIndexBlobs(ii, sifBlobs, blobCache) + if err != nil { + return err + } + + // Compute the new RootIndex. + ri, err := ii.RawManifest() + if err != nil { + return err + } + + // Delete existing blobs from the SIF except those we want to keep. + if err := deleteBlobsExcept(fi, keepBlobs); err != nil { + return err + } + // Delete old RootIndex. + if err := deleteRootIndex(fi); err != nil { + return err + } + + // Write new (cached) blobs from ii into the SIF. + f := fileImage{fi} + for _, b := range cachedBlobs { + rc, err := readCacheBlob(b, blobCache) + if err != nil { + return err + } + if err := f.writeBlobToFileImage(rc, false); err != nil { + return err + } + if err := rc.Close(); err != nil { + return err + } + } + + // Write the new RootIndex into the SIF. + return f.writeBlobToFileImage(bytes.NewReader(ri), true) +} + +// sifBlobs will return a list of digests for all OCI.Blob descriptors in fi. +func sifBlobs(fi *sif.FileImage) ([]v1.Hash, error) { + descrs, err := fi.GetDescriptors(sif.WithDataType(sif.DataOCIBlob)) + if err != nil { + return nil, err + } + sifBlobs := make([]v1.Hash, len(descrs)) + for i, d := range descrs { + dDigest, err := d.OCIBlobDigest() + if err != nil { + return nil, err + } + sifBlobs[i] = dDigest + } + return sifBlobs, nil +} + +// cacheIndexBlobs will cache all blobs referenced by ii, except those with +// digests specified in skip. The blobs will be cached to files in cacheDir, +// with filenames equal to their digest. The function returns two lists of blobs +// - those that were cached (in ii but not skip), and those that were skipped +// (in ii and skip). +func cacheIndexBlobs(ii v1.ImageIndex, skip []v1.Hash, cacheDir string) ([]v1.Hash, []v1.Hash, error) { + index, err := ii.IndexManifest() + if err != nil { + return nil, nil, err + } + + cached := []v1.Hash{} + skipped := []v1.Hash{} + + for _, desc := range index.Manifests { + //nolint:exhaustive + switch desc.MediaType { + case types.DockerManifestList, types.OCIImageIndex: + childIndex, err := ii.ImageIndex(desc.Digest) + if err != nil { + return nil, nil, err + } + // Cache children of this ImageIndex + childCached, childSkipped, err := cacheIndexBlobs(childIndex, skip, cacheDir) + if err != nil { + return nil, nil, err + } + cached = append(cached, childCached...) + skipped = append(skipped, childSkipped...) + // Cache the ImageIndex itself. + if slices.Contains(skip, desc.Digest) { + skipped = append(skipped, desc.Digest) + continue + } + rm, err := childIndex.RawManifest() + if err != nil { + return nil, nil, err + } + rc := io.NopCloser(bytes.NewReader(rm)) + if err := writeCacheBlob(rc, desc.Digest, cacheDir); err != nil { + return nil, nil, err + } + cached = append(cached, desc.Digest) + + case types.DockerManifestSchema2, types.OCIManifestSchema1: + childImage, err := ii.Image(desc.Digest) + if err != nil { + return nil, nil, err + } + childCached, childSkipped, err := cacheImageBlobs(childImage, skip, cacheDir) + if err != nil { + return nil, nil, err + } + cached = append(cached, childCached...) + skipped = append(skipped, childSkipped...) + + default: + return nil, nil, errUnexpectedMediaType + } + } + return cached, skipped, nil +} + +// cacheImageBlobs will cache all blobs referenced by im, except those with +// digests specified in skip. The blobs will be cached to files in cacheDir, +// with filenames equal to their digest. The function returns lists of blobs +// that were cached (in ii but not skip), and those that were skipped (in ii and +// skipDigests). +func cacheImageBlobs(im v1.Image, skip []v1.Hash, cacheDir string) ([]v1.Hash, []v1.Hash, error) { + cached := []v1.Hash{} + skipped := []v1.Hash{} + + // Cache layers first. + layers, err := im.Layers() + if err != nil { + return nil, nil, err + } + for _, l := range layers { + ld, err := l.Digest() + if err != nil { + return nil, nil, err + } + + if slices.Contains(skip, ld) { + skipped = append(skipped, ld) + continue + } + + rc, err := l.Compressed() + if err != nil { + return nil, nil, err + } + if err := writeCacheBlob(rc, ld, cacheDir); err != nil { + return nil, nil, err + } + cached = append(cached, ld) + } + + // Cache image config. + mf, err := im.Manifest() + if err != nil { + return nil, nil, err + } + if slices.Contains(skip, mf.Config.Digest) { + skipped = append(skipped, mf.Config.Digest) + } else { + c, err := im.RawConfigFile() + if err != nil { + return nil, nil, err + } + rc := io.NopCloser(bytes.NewReader(c)) + if err := writeCacheBlob(rc, mf.Config.Digest, cacheDir); err != nil { + return nil, nil, err + } + cached = append(cached, mf.Config.Digest) + } + + // Cache image manifest itself. + id, err := im.Digest() + if err != nil { + return nil, nil, err + } + if slices.Contains(skip, id) { + skipped = append(skipped, id) + return cached, skipped, nil + } + rm, err := im.RawManifest() + if err != nil { + return nil, nil, err + } + rc := io.NopCloser(bytes.NewReader(rm)) + if err := writeCacheBlob(rc, id, cacheDir); err != nil { + return nil, nil, err + } + cached = append(cached, id) + + return cached, skipped, nil +} + +// writeCacheBlob writes blob content from rc into tmpDir with filename equal to +// specified digest. +func writeCacheBlob(rc io.ReadCloser, digest v1.Hash, cacheDir string) error { + path := filepath.Join(cacheDir, digest.String()) + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, rc) + if err != nil { + return err + } + + if err := rc.Close(); err != nil { + return err + } + return nil +} + +// readCacheBlob returns a ReadCloser that will read blob content from cacheDir +// with filename equal to specified digest. +func readCacheBlob(digest v1.Hash, cacheDir string) (io.ReadCloser, error) { + path := filepath.Join(cacheDir, digest.String()) + f, err := os.Open(path) + if err != nil { + return nil, err + } + return f, nil +} + +// deleteBlobsExcept removes all OCI.Blob descriptors from fi, except those with +// digests listed in keep. +func deleteBlobsExcept(fi *sif.FileImage, keep []v1.Hash) error { + descs, err := fi.GetDescriptors(sif.WithDataType(sif.DataOCIBlob)) + if err != nil { + return err + } + for _, d := range descs { + dd, err := d.OCIBlobDigest() + if err != nil { + return err + } + if slices.Contains(keep, dd) { + continue + } + if err := fi.DeleteObject(d.ID(), sif.OptDeleteZero(true)); err != nil { + return err + } + } + return nil +} + +// deleteRootIndex removes the RootIndex from a the SIF fi. +func deleteRootIndex(fi *sif.FileImage) error { + desc, err := fi.GetDescriptor(sif.WithDataType(sif.DataOCIRootIndex)) + if err != nil { + return err + } + return fi.DeleteObject(desc.ID()) +} diff --git a/pkg/sif/update_test.go b/pkg/sif/update_test.go new file mode 100644 index 0000000..c5c7730 --- /dev/null +++ b/pkg/sif/update_test.go @@ -0,0 +1,148 @@ +// Copyright 2024 Sylabs Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package sif_test + +import ( + "math/rand" + "os" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + v1mutate "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/sebdah/goldie/v2" + "github.com/sylabs/oci-tools/pkg/mutate" + "github.com/sylabs/oci-tools/pkg/sif" + ssif "github.com/sylabs/sif/v2/pkg/sif" +) + +const randomSeed = 1719306160 + +//nolint:gocognit +func TestUpdate(t *testing.T) { + r := rand.NewSource(randomSeed) + + tests := []struct { + name string + base string + updater func(*testing.T, v1.ImageIndex) v1.ImageIndex + opts []sif.UpdateOpt + }{ + { + name: "AddLayer", + base: "hello-world-docker-v2-manifest", + updater: func(t *testing.T, ii v1.ImageIndex) v1.ImageIndex { + t.Helper() + ih, err := v1.NewHash("sha256:432f982638b3aefab73cc58ab28f5c16e96fdb504e8c134fc58dff4bae8bf338") + if err != nil { + t.Fatal(err) + } + im, err := ii.Image(ih) + if err != nil { + t.Fatal(err) + } + l, err := random.Layer(64, types.DockerLayer, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + im, err = v1mutate.AppendLayers(im, l) + if err != nil { + t.Fatal(err) + } + return v1mutate.AppendManifests(empty.Index, v1mutate.IndexAddendum{Add: im}) + }, + }, + { + name: "ReplaceLayers", // Replaces many layers with a single layer + base: "many-layers", + updater: func(t *testing.T, ii v1.ImageIndex) v1.ImageIndex { + t.Helper() + ih, err := v1.NewHash("sha256:7c000de5bc837f29d1c9a5e76bba79922d860e5c0f448df3b6fc38431a067c9a") + if err != nil { + t.Fatal(err) + } + im, err := ii.Image(ih) + if err != nil { + t.Fatal(err) + } + l, err := random.Layer(64, types.DockerLayer, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + im, err = mutate.Apply(im, mutate.ReplaceLayers(l)) + if err != nil { + t.Fatal(err) + } + return v1mutate.AppendManifests(empty.Index, v1mutate.IndexAddendum{Add: im}) + }, + }, + { + name: "AddImage", + base: "hello-world-docker-v2-manifest", + updater: func(t *testing.T, ii v1.ImageIndex) v1.ImageIndex { + t.Helper() + im, err := random.Image(64, 1, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + if err != nil { + t.Fatal(err) + } + return v1mutate.AppendManifests(ii, v1mutate.IndexAddendum{Add: im}) + }, + }, + { + name: "AddImageIndex", + base: "hello-world-docker-v2-manifest", + updater: func(t *testing.T, ii v1.ImageIndex) v1.ImageIndex { + t.Helper() + addIdx, err := random.Index(64, 1, 1, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + if err != nil { + t.Fatal(err) + } + return v1mutate.AppendManifests(ii, v1mutate.IndexAddendum{Add: addIdx}) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sifPath := corpus.SIF(t, tt.base, sif.OptWriteWithSpareDescriptorCapacity(8)) + fi, err := ssif.LoadContainerFromPath(sifPath) + if err != nil { + t.Fatal(err) + } + ii, err := sif.ImageIndexFromFileImage(fi) + if err != nil { + t.Fatal(err) + } + + ii = tt.updater(t, ii) + + if err := sif.Update(fi, ii, tt.opts...); err != nil { + t.Fatal(err) + } + + if err := fi.UnloadContainer(); err != nil { + t.Fatal(err) + } + + b, err := os.ReadFile(sifPath) + if err != nil { + t.Fatal(err) + } + + g := goldie.New(t, + goldie.WithTestNameForDir(true), + ) + + g.Assert(t, tt.name, b) + }) + } +} diff --git a/test/images.go b/test/images.go index ddef73b..c815bcd 100644 --- a/test/images.go +++ b/test/images.go @@ -63,12 +63,12 @@ func (c *Corpus) OCILayout(tb testing.TB, name string) string { // SIF returns a temporary SIF for the test to use, populated from the OCI Image Layout with the // specified name in the corpus. The SIF is automatically removed when the test and all its // subtests complete. -func (c *Corpus) SIF(tb testing.TB, name string) string { +func (c *Corpus) SIF(tb testing.TB, name string, opt ...sif.WriteOpt) string { tb.Helper() path := filepath.Join(tb.TempDir(), "image.sif") - if err := sif.Write(path, c.ImageIndex(tb, name)); err != nil { + if err := sif.Write(path, c.ImageIndex(tb, name), opt...); err != nil { tb.Fatalf("failed to write SIF: %v", err) }