From c467bd139adc2515cca927f3b0cbf06c5d23ba97 Mon Sep 17 00:00:00 2001 From: Nalin Dahyabhai Date: Fri, 12 Jul 2024 10:16:33 -0400 Subject: [PATCH] Add PrependedLinkedLayers/AppendedLinkedLayers to CommitOptions Add API for adding arbitrary layers at commit-time via CommitOptions, and via methods of the Builder type. Signed-off-by: Nalin Dahyabhai --- buildah.go | 7 ++ commit.go | 28 +++++- commit_test.go | 232 +++++++++++++++++++++++++++++++++++++++++++ config.go | 59 +++++++++++ image.go | 265 +++++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 548 insertions(+), 43 deletions(-) create mode 100644 commit_test.go diff --git a/buildah.go b/buildah.go index 1aa6e173de0..0b2b655dd7f 100644 --- a/buildah.go +++ b/buildah.go @@ -191,6 +191,13 @@ type Builder struct { // CDIConfigDir is the location of CDI configuration files, if the files in // the default configuration locations shouldn't be used. CDIConfigDir string + // PrependedLinkedLayers and AppendedLinkedLayers are combinations of + // history entries and locations of either directory trees (if + // directories, per os.Stat()) or uncompressed layer blobs which should + // be added to the image at commit-time. The order of these relative + // to PrependedEmptyLayers and AppendedEmptyLayers in the committed + // image is not guaranteed. + PrependedLinkedLayers, AppendedLinkedLayers []LinkedLayer } // BuilderInfo are used as objects to display container information diff --git a/commit.go b/commit.go index e43f1b0a848..192709428a5 100644 --- a/commit.go +++ b/commit.go @@ -24,6 +24,7 @@ import ( "github.com/containers/storage/pkg/archive" "github.com/containers/storage/pkg/stringid" digest "github.com/opencontainers/go-digest" + v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sirupsen/logrus" "golang.org/x/exp/maps" ) @@ -120,10 +121,11 @@ type CommitOptions struct { // OverrideConfig is applied. OverrideChanges []string // ExtraImageContent is a map which describes additional content to add - // to the committed image. The map's keys are filesystem paths in the - // image and the corresponding values are the paths of files whose - // contents will be used in their place. The contents will be owned by - // 0:0 and have mode 0644. Currently only accepts regular files. + // to the new layer in the committed image. The map's keys are + // filesystem paths in the image and the corresponding values are the + // paths of files whose contents will be used in their place. The + // contents will be owned by 0:0 and have mode 0o644. Currently only + // accepts regular files. ExtraImageContent map[string]string // SBOMScanOptions encapsulates options which control whether or not we // run scanners on the rootfs that we're about to commit, and how. @@ -132,6 +134,23 @@ type CommitOptions struct { // the image in Docker format. Newer BuildKit-based builds don't set // this field. CompatSetParent types.OptionalBool + // PrependedLinkedLayers and AppendedLinkedLayers are combinations of + // history entries and locations of either directory trees (if + // directories, per os.Stat()) or uncompressed layer blobs which should + // be added to the image at commit-time. The order of these relative + // to PrependedEmptyLayers and AppendedEmptyLayers, and relative to the + // corresponding members in the Builder object, in the committed image + // is not guaranteed. + PrependedLinkedLayers, AppendedLinkedLayers []LinkedLayer +} + +// LinkedLayer combines a history entry with the location of either a directory +// tree (if it's a directory, per os.Stat()) or an uncompressed layer blob +// which should be added to the image at commit-time. The BlobPath and +// History.EmptyLayer fields should be considered mutually-exclusive. +type LinkedLayer struct { + History v1.History // history entry to add + BlobPath string // corresponding uncompressed blob file (layer as a tar archive), or directory tree to archive } var ( @@ -348,6 +367,7 @@ func (b *Builder) Commit(ctx context.Context, dest types.ImageReference, options if options.ExtraImageContent == nil { options.ExtraImageContent = make(map[string]string, len(extraImageContent)) } + // merge in the scanner-generated content for k, v := range extraImageContent { if _, set := options.ExtraImageContent[k]; !set { options.ExtraImageContent[k] = v diff --git a/commit_test.go b/commit_test.go new file mode 100644 index 00000000000..a3c6fa3fbbe --- /dev/null +++ b/commit_test.go @@ -0,0 +1,232 @@ +package buildah + +import ( + "archive/tar" + "context" + "crypto/rand" + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + imageStorage "github.com/containers/image/v5/storage" + "github.com/containers/image/v5/types" + "github.com/containers/storage" + storageTypes "github.com/containers/storage/types" + v1 "github.com/opencontainers/image-spec/specs-go/v1" + rspec "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommitLinkedLayers(t *testing.T) { + ctx := context.TODO() + systemContext := types.SystemContext{} + now := time.Now() + + graphDriverName := os.Getenv("STORAGE_DRIVER") + if graphDriverName == "" { + graphDriverName = "vfs" + } + store, err := storage.GetStore(storageTypes.StoreOptions{ + RunRoot: t.TempDir(), + GraphRoot: t.TempDir(), + GraphDriverName: graphDriverName, + }) + require.NoError(t, err, "initializing storage") + t.Cleanup(func() { _, err := store.Shutdown(true); assert.NoError(t, err) }) + + imageName := func(i int) string { return fmt.Sprintf("image%d", i) } + makeFile := func(base string, size int64) string { + t.Helper() + fn := filepath.Join(t.TempDir(), base) + f, err := os.Create(fn) + require.NoError(t, err) + defer f.Close() + if size == 0 { + size = 512 + } + _, err = io.CopyN(f, rand.Reader, size) + require.NoErrorf(t, err, "writing payload file %d", base) + return f.Name() + } + makeArchive := func(base string, size int64) string { + t.Helper() + file := makeFile(base, size) + archiveDir := t.TempDir() + st, err := os.Stat(file) + require.NoError(t, err) + archiveName := filepath.Join(archiveDir, filepath.Base(file)) + f, err := os.Create(archiveName) + require.NoError(t, err) + defer f.Close() + tw := tar.NewWriter(f) + defer tw.Close() + hdr, err := tar.FileInfoHeader(st, "") + require.NoErrorf(t, err, "building tar header for %s", file) + err = tw.WriteHeader(hdr) + require.NoErrorf(t, err, "writing tar header for %s", file) + f, err = os.Open(file) + require.NoError(t, err) + defer f.Close() + _, err = io.Copy(tw, f) + require.NoErrorf(t, err, "writing tar payload for %s", file) + return archiveName + } + layerNumber := 0 + + // Build a from-scratch image with one layer. + builderOptions := BuilderOptions{ + FromImage: "scratch", + NamespaceOptions: []NamespaceOption{{ + Name: string(rspec.NetworkNamespace), + Host: true, + }}, + } + b, err := NewBuilder(ctx, store, builderOptions) + require.NoError(t, err, "creating builder") + b.SetCreatedBy(imageName(layerNumber)) + firstFile := makeFile("file0", 0) + err = b.Add("/", false, AddAndCopyOptions{}, firstFile) + require.NoError(t, err, "adding", firstFile) + commitOptions := CommitOptions{} + ref, err := imageStorage.Transport.ParseStoreReference(store, imageName(layerNumber)) + require.NoError(t, err, "parsing reference for to-be-committed image", imageName(layerNumber)) + _, _, _, err = b.Commit(ctx, ref, commitOptions) + require.NoError(t, err, "committing", imageName(layerNumber)) + + // Build another image based on the first with not much in its layer. + builderOptions.FromImage = imageName(layerNumber) + layerNumber++ + b, err = NewBuilder(ctx, store, builderOptions) + require.NoError(t, err, "creating builder") + b.SetCreatedBy(imageName(layerNumber)) + secondFile := makeFile("file1", 0) + err = b.Add("/", false, AddAndCopyOptions{}, secondFile) + require.NoError(t, err, "adding", secondFile) + commitOptions = CommitOptions{} + ref, err = imageStorage.Transport.ParseStoreReference(store, imageName(layerNumber)) + require.NoError(t, err, "parsing reference for to-be-committed image", imageName(layerNumber)) + _, _, _, err = b.Commit(ctx, ref, commitOptions) + require.NoError(t, err, "committing", imageName(layerNumber)) + + // Build a third image with two layers on either side of its read-write layer. + builderOptions.FromImage = imageName(layerNumber) + layerNumber++ + b, err = NewBuilder(ctx, store, builderOptions) + require.NoError(t, err, "creating builder") + thirdFile := makeFile("file2", 0) + fourthArchiveFile := makeArchive("file3", 0) + fifthFile := makeFile("file4", 0) + sixthFile := makeFile("file5", 0) + seventhArchiveFile := makeArchive("file6", 0) + eighthFile := makeFile("file7", 0) + ninthArchiveFile := makeArchive("file8", 0) + err = b.Add("/", false, AddAndCopyOptions{}, sixthFile) + require.NoError(t, err, "adding", sixthFile) + b.SetCreatedBy(imageName(layerNumber + 3)) + b.AddPrependedLinkedLayer(nil, imageName(layerNumber), "", "", filepath.Dir(thirdFile)) + commitOptions = CommitOptions{ + PrependedLinkedLayers: []LinkedLayer{ + { + BlobPath: fourthArchiveFile, + History: v1.History{ + Created: &now, + CreatedBy: imageName(layerNumber + 1), + }, + }, + { + BlobPath: filepath.Dir(fifthFile), + History: v1.History{ + Created: &now, + CreatedBy: imageName(layerNumber + 2), + }, + }, + }, + AppendedLinkedLayers: []LinkedLayer{ + { + BlobPath: seventhArchiveFile, + History: v1.History{ + Created: &now, + CreatedBy: imageName(layerNumber + 4), + }, + }, + { + BlobPath: filepath.Dir(eighthFile), + History: v1.History{ + Created: &now, + CreatedBy: imageName(layerNumber + 5), + }, + }, + }, + } + b.AddAppendedLinkedLayer(nil, imageName(layerNumber+6), "", "", ninthArchiveFile) + ref, err = imageStorage.Transport.ParseStoreReference(store, imageName(layerNumber)) + require.NoError(t, err, "parsing reference for to-be-committed image", imageName(layerNumber)) + _, _, _, err = b.Commit(ctx, ref, commitOptions) + require.NoError(t, err, "committing", imageName(layerNumber)) + + // Build one last image based on the previous one. + builderOptions.FromImage = imageName(layerNumber) + layerNumber += 7 + b, err = NewBuilder(ctx, store, builderOptions) + require.NoError(t, err, "creating builder") + b.SetCreatedBy(imageName(layerNumber)) + tenthFile := makeFile("file9", 0) + err = b.Add("/", false, AddAndCopyOptions{}, tenthFile) + require.NoError(t, err, "adding", tenthFile) + commitOptions = CommitOptions{} + ref, err = imageStorage.Transport.ParseStoreReference(store, imageName(layerNumber)) + require.NoError(t, err, "parsing reference for to-be-committed image", imageName(layerNumber)) + _, _, _, err = b.Commit(ctx, ref, commitOptions) + require.NoError(t, err, "committing", imageName(layerNumber)) + + // Get set to examine this image. At this point, each history entry + // should just have "image%d" as its CreatedBy field, and each layer + // should have the corresponding file (and nothing else) in it. + src, err := ref.NewImageSource(ctx, &systemContext) + require.NoError(t, err, "opening image source") + defer src.Close() + img, err := ref.NewImage(ctx, &systemContext) + require.NoError(t, err, "opening image") + defer img.Close() + config, err := img.OCIConfig(ctx) + require.NoError(t, err, "reading config in OCI format") + require.Len(t, config.History, 10, "history length") + for i := range config.History { + require.Equal(t, fmt.Sprintf("image%d", i), config.History[i].CreatedBy, "history createdBy is off") + } + require.Len(t, config.RootFS.DiffIDs, 10, "diffID list") + + layerContents := func(archive io.ReadCloser) []string { + var contents []string + defer archive.Close() + tr := tar.NewReader(archive) + entry, err := tr.Next() + for entry != nil { + contents = append(contents, entry.Name) + if err != nil { + break + } + entry, err = tr.Next() + } + require.ErrorIs(t, err, io.EOF) + return contents + } + infos, err := img.LayerInfosForCopy(ctx) + require.NoError(t, err, "getting layer infos") + require.Len(t, infos, 10) + for i, blobInfo := range infos { + func() { + t.Helper() + rc, _, err := src.GetBlob(ctx, blobInfo, nil) + require.NoError(t, err, "getting blob", i) + defer rc.Close() + contents := layerContents(rc) + require.Len(t, contents, 1) + require.Equal(t, fmt.Sprintf("file%d", i), contents[0]) + }() + } +} diff --git a/config.go b/config.go index 46120a46cb3..b817595d7a8 100644 --- a/config.go +++ b/config.go @@ -753,3 +753,62 @@ func (b *Builder) AddAppendedEmptyLayer(created *time.Time, createdBy, author, c func (b *Builder) ClearAppendedEmptyLayers() { b.AppendedEmptyLayers = nil } + +// AddPrependedLinkedLayer adds an item to the history that we'll create when +// committing the image, optionally with a layer, after any history we inherit +// from the base image, but before the history item that we'll use to describe +// the new layer that we're adding. +// The blobPath can be either the location of an uncompressed archive, or a +// directory whose contents will be archived to use as a layer blob. Leaving +// blobPath empty is functionally similar to calling AddPrependedEmptyLayer(). +func (b *Builder) AddPrependedLinkedLayer(created *time.Time, createdBy, author, comment, blobPath string) { + if created != nil { + copiedTimestamp := *created + created = &copiedTimestamp + } + b.PrependedLinkedLayers = append(b.PrependedLinkedLayers, LinkedLayer{ + BlobPath: blobPath, + History: ociv1.History{ + Created: created, + CreatedBy: createdBy, + Author: author, + Comment: comment, + EmptyLayer: blobPath == "", + }, + }) +} + +// ClearPrependedLinkedLayers clears the list of history entries that we'll add +// the committed image before the layer that we're adding (if we're adding it). +func (b *Builder) ClearPrependedLinkedLayers() { + b.PrependedLinkedLayers = nil +} + +// AddAppendedLinkedLayer adds an item to the history that we'll create when +// committing the image, optionally with a layer, after the history item that +// we'll use to describe the new layer that we're adding. +// The blobPath can be either the location of an uncompressed archive, or a +// directory whose contents will be archived to use as a layer blob. Leaving +// blobPath empty is functionally similar to calling AddAppendedEmptyLayer(). +func (b *Builder) AddAppendedLinkedLayer(created *time.Time, createdBy, author, comment, blobPath string) { + if created != nil { + copiedTimestamp := *created + created = &copiedTimestamp + } + b.AppendedLinkedLayers = append(b.AppendedLinkedLayers, LinkedLayer{ + BlobPath: blobPath, + History: ociv1.History{ + Created: created, + CreatedBy: createdBy, + Author: author, + Comment: comment, + EmptyLayer: blobPath == "", + }, + }) +} + +// ClearAppendedLinkedLayers clears the list of linked layers that we'll add to +// the committed image after the layer that we're adding (if we're adding it). +func (b *Builder) ClearAppendedLinkedLayers() { + b.AppendedLinkedLayers = nil +} diff --git a/image.go b/image.go index 2b4eeb4d797..9ecd1c6dbc8 100644 --- a/image.go +++ b/image.go @@ -26,6 +26,7 @@ import ( "github.com/containers/image/v5/types" "github.com/containers/storage" "github.com/containers/storage/pkg/archive" + "github.com/containers/storage/pkg/chrootarchive" "github.com/containers/storage/pkg/idtools" "github.com/containers/storage/pkg/ioutils" digest "github.com/opencontainers/go-digest" @@ -33,6 +34,7 @@ import ( v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sirupsen/logrus" "golang.org/x/exp/maps" + "golang.org/x/exp/slices" ) const ( @@ -80,7 +82,9 @@ type containerImageRef struct { parent string blobDirectory string preEmptyLayers []v1.History + preLayers []commitLinkedLayerInfo postEmptyLayers []v1.History + postLayers []commitLinkedLayerInfo overrideChanges []string overrideConfig *manifest.Schema2Config extraImageContent map[string]string @@ -92,6 +96,13 @@ type blobLayerInfo struct { Size int64 } +type commitLinkedLayerInfo struct { + layerID string // more like layer "ID" + linkedLayer LinkedLayer + uncompressedDigest digest.Digest + size int64 +} + type containerImageSource struct { path string ref *containerImageRef @@ -406,8 +417,17 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System return nil, fmt.Errorf("no supported manifest types (attempted to use %q, only know %q and %q)", manifestType, v1.MediaTypeImageManifest, manifest.DockerV2Schema2MediaType) } - // Start building the list of layers using the read-write layer. + // These maps will let us check if a layer ID is part of one group or another. + parentLayerIDs := make(map[string]bool) + apiLayerIDs := make(map[string]bool) + // Start building the list of layers with any prepended layers. layers := []string{} + for _, preLayer := range i.preLayers { + layers = append(layers, preLayer.layerID) + apiLayerIDs[preLayer.layerID] = true + } + // Now look at the read-write layer, and prepare to work our way back + // through all of its parent layers. layerID := i.layerID layer, err := i.store.Layer(layerID) if err != nil { @@ -417,7 +437,15 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System // or making a confidential workload, we're only producing one layer, so stop at // the layer ID of the top layer, which we won't really be using anyway. for layer != nil { - layers = append(append([]string{}, layerID), layers...) + if layerID == i.layerID { + // append the layer for this container to the list, + // whether it's first or after some prepended layers + layers = append(layers, layerID) + } else { + // prepend this parent layer to the list + layers = append(append([]string{}, layerID), layers...) + parentLayerIDs[layerID] = true + } layerID = layer.Parent if layerID == "" || i.confidentialWorkload.Convert || i.squash { err = nil @@ -430,14 +458,24 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System } layer = nil - // If we're slipping in a synthesized layer, we need to add a placeholder for it - // to the list. + // If we're slipping in a synthesized layer to hold some files, we need + // to add a placeholder for it to the list just after the read-write + // layer. Confidential workloads and squashed images will just inline + // the files, so we don't need to create a layer in those cases. const synthesizedLayerID = "(synthesized layer)" if len(i.extraImageContent) > 0 && !i.confidentialWorkload.Convert && !i.squash { layers = append(layers, synthesizedLayerID) } + // Now add any API-supplied layers we have to append. + for _, postLayer := range i.postLayers { + layers = append(layers, postLayer.layerID) + apiLayerIDs[postLayer.layerID] = true + } logrus.Debugf("layer list: %q", layers) + // It's simpler from here on to keep track of these as a group. + apiLayers := append(slices.Clone(i.preLayers), slices.Clone(i.postLayers)...) + // Make a temporary directory to hold blobs. path, err := os.MkdirTemp(tmpdir.GetTempDir(), define.Package) if err != nil { @@ -469,21 +507,26 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System if i.confidentialWorkload.Convert || i.squash { what = fmt.Sprintf("container %q", i.containerID) } + if layerID == synthesizedLayerID { + what = synthesizedLayerID + } + if apiLayerIDs[layerID] { + what = layerID + } // The default layer media type assumes no compression. omediaType := v1.MediaTypeImageLayer dmediaType := docker.V2S2MediaTypeUncompressedLayer // Look up this layer. var layerUncompressedDigest digest.Digest var layerUncompressedSize int64 - if layerID != synthesizedLayerID { - layer, err := i.store.Layer(layerID) - if err != nil { - return nil, fmt.Errorf("unable to locate layer %q: %w", layerID, err) - } - layerID = layer.ID - layerUncompressedDigest = layer.UncompressedDigest - layerUncompressedSize = layer.UncompressedSize - } else { + linkedLayerHasLayerID := func(l commitLinkedLayerInfo) bool { return l.layerID == layerID } + if apiLayerIDs[layerID] { + // API-provided prepended or appended layer + apiLayerIndex := slices.IndexFunc(apiLayers, linkedLayerHasLayerID) + layerUncompressedDigest = apiLayers[apiLayerIndex].uncompressedDigest + layerUncompressedSize = apiLayers[apiLayerIndex].size + } else if layerID == synthesizedLayerID { + // layer diff consisting of extra files to synthesize into a layer diffFilename, digest, size, err := i.makeExtraImageContentDiff(true) if err != nil { return nil, fmt.Errorf("unable to generate layer for additional content: %w", err) @@ -492,10 +535,20 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System extraImageContentDiffDigest = digest layerUncompressedDigest = digest layerUncompressedSize = size + } else { + // "normal" layer + layer, err := i.store.Layer(layerID) + if err != nil { + return nil, fmt.Errorf("unable to locate layer %q: %w", layerID, err) + } + layerID = layer.ID + layerUncompressedDigest = layer.UncompressedDigest + layerUncompressedSize = layer.UncompressedSize } - // If we already know the digest of the contents of parent - // layers, reuse their blobsums, diff IDs, and sizes. - if !i.confidentialWorkload.Convert && !i.squash && layerID != i.layerID && layerID != synthesizedLayerID && layerUncompressedDigest != "" { + // We already know the digest of the contents of parent layers, + // so if this is a parent layer, and we know its digest, reuse + // its blobsum, diff ID, and size. + if !i.confidentialWorkload.Convert && !i.squash && parentLayerIDs[layerID] && layerUncompressedDigest != "" { layerBlobSum := layerUncompressedDigest layerBlobSize := layerUncompressedSize diffID := layerUncompressedDigest @@ -546,7 +599,20 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System return nil, err } } else { - if layerID != synthesizedLayerID { + if apiLayerIDs[layerID] { + // We're reading an API-supplied blob. + apiLayerIndex := slices.IndexFunc(apiLayers, linkedLayerHasLayerID) + f, err := os.Open(apiLayers[apiLayerIndex].linkedLayer.BlobPath) + if err != nil { + return nil, fmt.Errorf("opening layer blob for %s: %w", layerID, err) + } + rc = f + } else if layerID == synthesizedLayerID { + // Slip in additional content as an additional layer. + if rc, err = os.Open(extraImageContentDiff); err != nil { + return nil, err + } + } else { // If we're up to the final layer, but we don't want to // include a diff for it, we're done. if i.emptyLayer && layerID == i.layerID { @@ -557,11 +623,6 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System if err != nil { return nil, fmt.Errorf("extracting %s: %w", what, err) } - } else { - // Slip in additional content as an additional layer. - if rc, err = os.Open(extraImageContentDiff); err != nil { - return nil, err - } } } srcHasher := digest.Canonical.Digester() @@ -678,7 +739,7 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System } // Build history notes in the image configurations. - appendHistory := func(history []v1.History) { + appendHistory := func(history []v1.History, empty bool) { for i := range history { var created *time.Time if history[i].Created != nil { @@ -690,7 +751,7 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System CreatedBy: history[i].CreatedBy, Author: history[i].Author, Comment: history[i].Comment, - EmptyLayer: true, + EmptyLayer: empty, } oimage.History = append(oimage.History, onews) if created == nil { @@ -701,7 +762,7 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System CreatedBy: history[i].CreatedBy, Author: history[i].Author, Comment: history[i].Comment, - EmptyLayer: true, + EmptyLayer: empty, } dimage.History = append(dimage.History, dnews) } @@ -712,36 +773,38 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System // Keep track of how many entries the base image's history had // before we started adding to it. baseImageHistoryLen := len(oimage.History) - appendHistory(i.preEmptyLayers) + + // Add history entries for prepended empty layers. + appendHistory(i.preEmptyLayers, true) + // Add history entries for prepended API-supplied layers. + for _, h := range i.preLayers { + appendHistory([]v1.History{h.linkedLayer.History}, h.linkedLayer.History.EmptyLayer) + } + // Add a history entry for this layer, empty or not. created := time.Now().UTC() if i.created != nil { created = (*i.created).UTC() } - comment := i.historyComment - // Add a comment indicating which base image was used, if it wasn't - // just an image ID. - if strings.Contains(i.parent, i.fromImageID) && i.fromImageName != i.fromImageID { - comment += "FROM " + i.fromImageName - } onews := v1.History{ Created: &created, CreatedBy: i.createdBy, Author: oimage.Author, EmptyLayer: i.emptyLayer, + Comment: i.historyComment, } oimage.History = append(oimage.History, onews) - oimage.History[baseImageHistoryLen].Comment = comment dnews := docker.V2S2History{ Created: created, CreatedBy: i.createdBy, Author: dimage.Author, EmptyLayer: i.emptyLayer, + Comment: i.historyComment, } dimage.History = append(dimage.History, dnews) - dimage.History[baseImageHistoryLen].Comment = comment - appendHistory(i.postEmptyLayers) - // Add a history entry for the extra image content if we added a layer for it. + // This diff was added to the list of layers before API-supplied layers that + // needed to be appended, and we need to keep the order of history entries for + // not-empty layers consistent with that. if extraImageContentDiff != "" { createdBy := fmt.Sprintf(`/bin/sh -c #(nop) ADD dir:%s in /",`, extraImageContentDiffDigest.Encoded()) onews := v1.History{ @@ -755,6 +818,24 @@ func (i *containerImageRef) NewImageSource(ctx context.Context, sc *types.System } dimage.History = append(dimage.History, dnews) } + // Add history entries for appended empty layers. + appendHistory(i.postEmptyLayers, true) + // Add history entries for appended API-supplied layers. + for _, h := range i.postLayers { + appendHistory([]v1.History{h.linkedLayer.History}, h.linkedLayer.History.EmptyLayer) + } + + // Assemble a comment indicating which base image was used, if it wasn't + // just an image ID, and add it to the first history entry we added. + var fromComment string + if strings.Contains(i.parent, i.fromImageID) && i.fromImageName != "" && !strings.HasPrefix(i.fromImageID, i.fromImageName) { + if oimage.History[baseImageHistoryLen].Comment != "" { + fromComment = " " + } + fromComment += "FROM " + i.fromImageName + } + oimage.History[baseImageHistoryLen].Comment += fromComment + dimage.History[baseImageHistoryLen].Comment += fromComment // Confidence check that we didn't just create a mismatch between non-empty layers in the // history and the number of diffIDs. Only applicable if the base image (if there was @@ -1018,11 +1099,92 @@ func (i *containerImageRef) makeExtraImageContentDiff(includeFooter bool) (_ str return diff.Name(), digester.Digest(), counter.Count, nil } +// makeLinkedLayerInfos calculates the size and digest information for a layer +// we intend to add to the image that we're committing. +func (b *Builder) makeLinkedLayerInfos(layers []LinkedLayer, layerType string) ([]commitLinkedLayerInfo, error) { + if layers == nil { + return nil, nil + } + infos := make([]commitLinkedLayerInfo, 0, len(layers)) + for i, layer := range layers { + // complain if EmptyLayer and "is the BlobPath empty" don't agree + if layer.History.EmptyLayer != (layer.BlobPath == "") { + return nil, fmt.Errorf("internal error: layer-is-empty = %v, but content path is %q", layer.History.EmptyLayer, layer.BlobPath) + } + // if there's no layer contents, we're done with this one + if layer.History.EmptyLayer { + continue + } + // check if it's a directory or a non-directory + st, err := os.Stat(layer.BlobPath) + if err != nil { + return nil, fmt.Errorf("checking if layer content %s is a directory: %w", layer.BlobPath, err) + } + info := commitLinkedLayerInfo{ + layerID: fmt.Sprintf("(%s %d)", layerType, i+1), + linkedLayer: layer, + } + if err = func() error { + if st.IsDir() { + // if it's a directory, archive it and digest the archive while we're storing a copy somewhere + cdir, err := b.store.ContainerDirectory(b.ContainerID) + if err != nil { + return fmt.Errorf("determining directory for working container: %w", err) + } + f, err := os.CreateTemp(cdir, "") + if err != nil { + return fmt.Errorf("creating temporary file to hold blob for %q: %w", info.linkedLayer.BlobPath, err) + } + defer f.Close() + rc, err := chrootarchive.Tar(info.linkedLayer.BlobPath, nil, info.linkedLayer.BlobPath) + if err != nil { + return fmt.Errorf("generating a layer blob from %q: %w", info.linkedLayer.BlobPath, err) + } + digester := digest.Canonical.Digester() + sizeCounter := ioutils.NewWriteCounter(digester.Hash()) + _, copyErr := io.Copy(f, io.TeeReader(rc, sizeCounter)) + if err := rc.Close(); err != nil { + return fmt.Errorf("storing a copy of %q: %w", info.linkedLayer.BlobPath, err) + } + if copyErr != nil { + return fmt.Errorf("storing a copy of %q: %w", info.linkedLayer.BlobPath, copyErr) + } + info.uncompressedDigest = digester.Digest() + info.size = sizeCounter.Count + info.linkedLayer.BlobPath = f.Name() + } else { + // if it's not a directory, just digest it + f, err := os.Open(info.linkedLayer.BlobPath) + if err != nil { + return err + } + defer f.Close() + sizeCounter := ioutils.NewWriteCounter(io.Discard) + uncompressedDigest, err := digest.Canonical.FromReader(io.TeeReader(f, sizeCounter)) + if err != nil { + return err + } + info.uncompressedDigest = uncompressedDigest + info.size = sizeCounter.Count + } + return nil + }(); err != nil { + return nil, err + } + infos = append(infos, info) + } + return infos, nil +} + // makeContainerImageRef creates a containers/image/v5/types.ImageReference // which is mainly used for representing the working container as a source -// image that can be copied, which is how we commit container to create the +// image that can be copied, which is how we commit the container to create the // image. func (b *Builder) makeContainerImageRef(options CommitOptions) (*containerImageRef, error) { + if (len(options.PrependedLinkedLayers) > 0 || len(options.AppendedLinkedLayers) > 0) && + (options.ConfidentialWorkloadOptions.Convert || options.Squash) { + return nil, errors.New("can't add prebuilt layers and produce an image with only one layer, at the same time") + } var name reference.Named container, err := b.store.Container(b.ContainerID) if err != nil { @@ -1080,6 +1242,15 @@ func (b *Builder) makeContainerImageRef(options CommitOptions) (*containerImageR } } + preLayerInfos, err := b.makeLinkedLayerInfos(append(slices.Clone(b.PrependedLinkedLayers), slices.Clone(options.PrependedLinkedLayers)...), "prepended layer") + if err != nil { + return nil, err + } + postLayerInfos, err := b.makeLinkedLayerInfos(append(slices.Clone(options.AppendedLinkedLayers), slices.Clone(b.AppendedLinkedLayers)...), "appended layer") + if err != nil { + return nil, err + } + ref := &containerImageRef{ fromImageName: b.FromImage, fromImageID: b.FromImageID, @@ -1104,13 +1275,29 @@ func (b *Builder) makeContainerImageRef(options CommitOptions) (*containerImageR idMappingOptions: &b.IDMappingOptions, parent: parent, blobDirectory: options.BlobDirectory, - preEmptyLayers: b.PrependedEmptyLayers, - postEmptyLayers: b.AppendedEmptyLayers, + preEmptyLayers: slices.Clone(b.PrependedEmptyLayers), + preLayers: preLayerInfos, + postEmptyLayers: slices.Clone(b.AppendedEmptyLayers), + postLayers: postLayerInfos, overrideChanges: options.OverrideChanges, overrideConfig: options.OverrideConfig, extraImageContent: maps.Clone(options.ExtraImageContent), compatSetParent: options.CompatSetParent, } + if ref.created != nil { + for i := range ref.preEmptyLayers { + ref.preEmptyLayers[i].Created = ref.created + } + for i := range ref.preLayers { + ref.preLayers[i].linkedLayer.History.Created = ref.created + } + for i := range ref.postEmptyLayers { + ref.postEmptyLayers[i].Created = ref.created + } + for i := range ref.postLayers { + ref.postLayers[i].linkedLayer.History.Created = ref.created + } + } return ref, nil }