diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd84b31c..a4283681d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `umoci unpack --keep-dirlinks` (in the same vein as rsync's flag with the same name) which allows layers that contain entries which have a symlink as a path component. openSUSE/umoci#246 +- `umoci insert` now supports whiteouts in two significant ways. You can use + `--whiteout` to "insert" a deletion of a given path, while you can use + `--opaque` to replace a directory by adding an opaque whiteout (the default + behaviour causes the old and new directories to be merged). + openSUSE/umoci#257 + +## Fixed +- Docker has changed how they handle whiteouts for non-existent files. The + specification is loose on this (and in umoci we've always been liberal with + whiteout generation -- to avoid cases where someone was confused we didn't + have a whiteout for every entry). But now that they have deviated from the + spec, in the interest of playing nice, we can just follow their new + restriction (even though it is not supported by the spec). This also makes + our layers *slightly* smaller. openSUSE/umoci#254 ## [0.4.1] - 2018-08-16 ### Added diff --git a/cmd/umoci/insert.go b/cmd/umoci/insert.go index e87a78058..93cf867db 100644 --- a/cmd/umoci/insert.go +++ b/cmd/umoci/insert.go @@ -36,38 +36,75 @@ import ( var insertCommand = uxRemap(uxHistory(cli.Command{ Name: "insert", - Usage: "insert a file into an OCI image", - ArgsUsage: `--image [:] + Usage: "insert content into an OCI image", + ArgsUsage: `--image [:] [--opaque] + --image [:] [--whiteout] -Where "" is the path to the OCI image, "" is the name of the -tag that the content wil be inserted into (if not specified, defaults to -"latest"), "" is the file or folder to insert, and "" is the full -name of the path to that the file should be inserted at. Insert is -automatically recursive if the source is a directory. +Where "" is the path to the OCI image, and "" is the name of +the tag that the content wil be inserted into (if not specified, defaults to +"latest"). -For example: +The path at "" is added to the image with the given "" name. +If "--whiteout" is specified, rather than inserting content into the image, a +removal entry for "" is inserted instead. + +If "--opaque" is specified then any paths below "" (assuming it is a +directory) from previous layers will no longer be present. Only the contents +inserted by this command will be visible. This can be used to replace an entire +directory, while the default behaviour merges the old contents with the new. + +Note that this command works by creating a new layer, so this should not be +used to remove (or replace) secrets from an already-built image. See +umoci-config(1) and --config.volume for how to achieve this correctly. + +Some examples: umoci insert --image oci:foo mybinary /usr/bin/mybinary umoci insert --image oci:foo myconfigdir /etc/myconfigdir + umoci insert --image oci:foo --opaque myoptdir /opt + umoci insert --image oci:foo --whiteout /some/old/dir `, Category: "image", Action: insert, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "whiteout", + Usage: "insert a 'removal entry' for the given path", + }, + cli.BoolFlag{ + Name: "opaque", + Usage: "mask any previous entries in the target directory", + }, + }, + Before: func(ctx *cli.Context) error { - if ctx.NArg() != 2 { - return errors.Errorf("invalid number of positional arguments: expected and ") + // This command is quite weird because we need to support two different + // positional-argument numbers. Awesome. + numArgs := 2 + if ctx.IsSet("whiteout") { + numArgs = 1 + } + if ctx.NArg() != numArgs { + return errors.Errorf("invalid number of positional arguments: expected %d", numArgs) } - if ctx.Args()[0] == "" { - return errors.Errorf(" cannot be empty") + for idx, args := range ctx.Args() { + if args == "" { + return errors.Errorf("invalid positional argument %d: arguments cannot be empty", idx) + } } - ctx.App.Metadata["insertFile"] = ctx.Args()[0] - if ctx.Args()[1] == "" { - return errors.Errorf(" cannot be empty") + // Figure out the arguments. + var sourcePath, targetPath string + targetPath = ctx.Args()[0] + if !ctx.IsSet("whiteout") { + sourcePath = targetPath + targetPath = ctx.Args()[1] } - ctx.App.Metadata["insertPath"] = ctx.Args()[1] + ctx.App.Metadata["--source-path"] = sourcePath + ctx.App.Metadata["--target-path"] = targetPath return nil }, })) @@ -75,6 +112,8 @@ For example: func insert(ctx *cli.Context) error { imagePath := ctx.App.Metadata["--image-path"].(string) tagName := ctx.App.Metadata["--image-tag"].(string) + sourcePath := ctx.App.Metadata["--source-path"].(string) + targetPath := ctx.App.Metadata["--target-path"].(string) // Get a reference to the CAS. engine, err := dir.Open(imagePath) @@ -102,9 +141,6 @@ func insert(ctx *cli.Context) error { return errors.Wrap(err, "create mutator for base image") } - insertFile := ctx.App.Metadata["insertFile"].(string) - insertPath := ctx.App.Metadata["insertPath"].(string) - var meta umoci.Meta meta.Version = umoci.MetaVersion @@ -114,7 +150,7 @@ func insert(ctx *cli.Context) error { return err } - reader := layer.GenerateInsertLayer(insertFile, insertPath, &meta.MapOptions) + reader := layer.GenerateInsertLayer(sourcePath, targetPath, ctx.IsSet("opaque"), &meta.MapOptions) defer reader.Close() created := time.Now() diff --git a/cmd/umoci/repack.go b/cmd/umoci/repack.go index 5d94a66e5..e99c7cb37 100644 --- a/cmd/umoci/repack.go +++ b/cmd/umoci/repack.go @@ -184,7 +184,9 @@ func repack(ctx *cli.Context) error { maskedPaths = append(maskedPaths, v) } } - diffs = mtreefilter.FilterDeltas(diffs, mtreefilter.MaskFilter(maskedPaths)) + diffs = mtreefilter.FilterDeltas(diffs, + mtreefilter.MaskFilter(maskedPaths), + mtreefilter.SimplifyFilter(diffs)) reader, err := layer.GenerateLayer(fullRootfsPath, diffs, &meta.MapOptions) if err != nil { diff --git a/doc/man/umoci-insert.1.md b/doc/man/umoci-insert.1.md index 9b59fcd16..b2e310765 100644 --- a/doc/man/umoci-insert.1.md +++ b/doc/man/umoci-insert.1.md @@ -1,12 +1,13 @@ -% umoci-insert(1) # umoci insert - insert a file into an OCI image +% umoci-insert(1) # umoci insert - insert content into an OCI image % Aleksa Sarai % SEPTEMBER 2018 # NAME -umoci insert - insert a file into an OCI image +umoci insert - insert content into an OCI image # SYNOPSIS **umoci insert** **--image**=*image*[:*tag*] +[**--opaque**] [**--rootless**] [**--uid-map**=*value*] [**--uid-map**=*value*] @@ -14,19 +15,32 @@ umoci insert - insert a file into an OCI image [**--history.created_by**=*created_by*] [**--history.author**=*author*] [**--history-created**=*date*] -*file* -*path* +*source* +*target* -# DESCRIPTION -Creates a new OCI image layout. The new OCI image does not contain any new -references or blobs, but those can be created through the use of -**umoci-new**(1), **umoci-tag**(1), **umoci-repack**(1) and other similar -commands. +**umoci insert** +[options] +**--whiteout** +*target* -Inserts *file* into the OCI image given by **--image** (overwriting it), -creating a new layer containing just the contents of *file* at *path*. *file* -can be a file or a directory to insert (in the latter case the directory is -always recursed), and *path* is the full path where *file* will be inserted. + +# DESCRIPTION +In the first form, insert the contents of *source* into the OCI image given by +**--image** (**overwriting it** -- this is the only **umoci**(1) command which +currently does this). This is done by creating a new layer containing just the +contents of *source* with a name of *target*. *source* can be either a file or +directory, and in the latter case it will be recursed. If **--opaque** is +specified then any paths below *target* in the previous image layers (assuming +*target* is a directory) will be removed. + +In the second form, inserts a "deletion entry" into the OCI image for *target* +inside the image. This is done by inserting a layer containing just a whiteout +entry for the given path. + +Note that this command works by creating a new layer, so this should not be +used to remove (or replace) secrets from an already-built image. See +**umoci-config**(1) and **--config.volume** for how to achieve this correctly +by not creating image layers with secrets in the first place. # OPTIONS The global options are defined in **umoci**(1). @@ -37,6 +51,17 @@ The global options are defined in **umoci**(1). must be a valid tag in the image. If *tag* is not provided it defaults to "latest". +**--opaque** + (Assuming *target* is a directory.) Add an opaque whiteout entry for *target* + so that any child path of *target* in previous layers is masked by the new + entry for *target*, which will just contain the contents of *source*. This + allows for the complete replacement of a directory, as opposed to the merging + of directory entries. + +**--whiteout** + Add a deletion entry for *target*, so that it is not present in future + extractions of the image. + **--rootless** Enable rootless insertion support. This allows for **umoci-insert**(1) to be used as an unprivileged user. Use of this flag implies **--uid-map=0:$(id @@ -85,5 +110,14 @@ directories are merged (with the newer layer taking precedence). % umoci insert --image oci:foo myconfigdir /etc/myconfigdir ``` +And in these examples we delete `/usr/bin/mybinary` and replace the entirety of +`/etc` with `myetcdir` (such that none of the old `/etc` entries will be +present on **umoci-unpack**(1)). + +``` +% umoci insert --image oci:foo --whiteout /usr/bin/mybinary +% umoci insert --image oci:foo --opaque myetcdir /etc +``` + # SEE ALSO **umoci**(1), **umoci-repack**(1), **umoci-raw-add-layer**(1) diff --git a/oci/layer/generate.go b/oci/layer/generate.go index ee5068b59..2e6993639 100644 --- a/oci/layer/generate.go +++ b/oci/layer/generate.go @@ -101,9 +101,10 @@ func GenerateLayer(path string, deltas []mtree.InodeDelta, opt *MapOptions) (io. return reader, nil } -// GenerateInsertLayer generates a completely new layer from the root path to -// be inserted into the image at "target". -func GenerateInsertLayer(root string, target string, opt *MapOptions) io.ReadCloser { +// GenerateInsertLayer generates a completely new layer from "root"to be +// inserted into the image at "target". If "root" is an empty string then the +// "target" will be removed via a whiteout. +func GenerateInsertLayer(root string, target string, opaque bool, opt *MapOptions) io.ReadCloser { root = CleanPath(root) var mapOptions MapOptions @@ -113,16 +114,22 @@ func GenerateInsertLayer(root string, target string, opt *MapOptions) io.ReadClo reader, writer := io.Pipe() - go func() { - var err error - + go func() (Err error) { defer func() { - writer.CloseWithError(errors.Wrap(err, "generate layer")) + writer.CloseWithError(errors.Wrap(Err, "generate layer")) }() tg := newTarGenerator(writer, mapOptions) - err = unpriv.Walk(root, func(curPath string, info os.FileInfo, err error) error { + if opaque { + if err := tg.AddOpaqueWhiteout(target); err != nil { + return err + } + } + if root == "" { + return tg.AddWhiteout(target) + } + return unpriv.Walk(root, func(curPath string, info os.FileInfo, err error) error { if err != nil { return err } @@ -130,12 +137,6 @@ func GenerateInsertLayer(root string, target string, opt *MapOptions) io.ReadClo pathInTar := path.Join(target, curPath[len(root):]) return tg.AddFile(pathInTar, curPath) }) - if err != nil { - return - } - - err = tg.tw.Close() }() - return reader } diff --git a/oci/layer/tar_generate.go b/oci/layer/tar_generate.go index 28ebf8e57..01318210b 100644 --- a/oci/layer/tar_generate.go +++ b/oci/layer/tar_generate.go @@ -23,7 +23,6 @@ import ( "os" "path/filepath" "strings" - "time" "github.com/apex/log" "github.com/openSUSE/umoci/pkg/fseval" @@ -262,34 +261,41 @@ const whPrefix = ".wh." // siblings in a directory are to be dropped in the "lower" layer. const whOpaque = whPrefix + whPrefix + ".opq" -// AddWhiteout adds a whiteout file for the given name inside the tar archive. -// It's not recommended to add a file with AddFile and then white it out. -func (tg *tarGenerator) AddWhiteout(name string) error { +// addWhiteout adds a whiteout file for the given name inside the tar archive. +// It's not recommended to add a file with AddFile and then white it out. If +// you specify opaque, then the whiteout created is an opaque whiteout *for the +// directory path* given. +func (tg *tarGenerator) addWhiteout(name string, opaque bool) error { name, err := normalise(name, false) if err != nil { return errors.Wrap(err, "normalise path") } - // Create the explicit whiteout for the file. - dir, file := filepath.Split(name) - whiteout := filepath.Join(dir, whPrefix+file) - timestamp := time.Now() - // Disallow having a whiteout of a whiteout, purely for our own sanity. + dir, file := filepath.Split(name) if strings.HasPrefix(file, whPrefix) { return errors.Errorf("invalid path has whiteout prefix %q: %s", whPrefix, name) } - // Add a dummy header for the whiteout file. - if err := tg.tw.WriteHeader(&tar.Header{ - Name: whiteout, - Size: 0, - ModTime: timestamp, - AccessTime: timestamp, - ChangeTime: timestamp, - }); err != nil { - return errors.Wrap(err, "write whiteout header") + // Figure out the whiteout name. + whiteout := filepath.Join(dir, whPrefix+file) + if opaque { + whiteout = filepath.Join(name, whOpaque) } - return nil + // Add a dummy header for the whiteout file. + return errors.Wrap(tg.tw.WriteHeader(&tar.Header{ + Name: whiteout, + Size: 0, + }), "write whiteout header") +} + +// AddWhiteout creates a whiteout for the provided path. +func (tg *tarGenerator) AddWhiteout(name string) error { + return tg.addWhiteout(name, false) +} + +// AddOpaqueWhiteout creates a whiteout for the provided path. +func (tg *tarGenerator) AddOpaqueWhiteout(name string) error { + return tg.addWhiteout(name, true) } diff --git a/pkg/mtreefilter/mask.go b/pkg/mtreefilter/mask.go index 2998a8e7d..7f3491c4b 100644 --- a/pkg/mtreefilter/mask.go +++ b/pkg/mtreefilter/mask.go @@ -27,48 +27,74 @@ import ( // FilterFunc is a function used when filtering deltas with FilterDeltas. type FilterFunc func(path string) bool -// isParent returns whether the path a is lexically an ancestor of the path b. -func isParent(a, b string) bool { - a = filepath.Clean(a) - b = filepath.Clean(b) +// makeRoot does a very simple job of converting a path to a lexical +// relative-to-root. In mtree we don't deal with any symlink components. +func makeRoot(path string) string { + return filepath.Join(string(filepath.Separator), path) +} - for a != b && b != filepath.Dir(b) { - b = filepath.Dir(b) +func maskFilter(maskedPaths map[string]struct{}, includeSelf bool) FilterFunc { + return func(path string) bool { + // Convert the path to be cleaned and relative-to-root. + path = makeRoot(path) + + // Check that no ancestor of the path is a masked path. + for parent := path; parent != filepath.Dir(parent); parent = filepath.Dir(parent) { + if _, ok := maskedPaths[parent]; !ok { + continue + } + if parent == path && !includeSelf { + continue + } + log.Debugf("maskfilter: ignoring path %q matched by mask %q", path, parent) + return false + } + return true } - return a == b } // MaskFilter is a factory for FilterFuncs that will mask all InodeDelta paths // that are lexical children of any path in the mask slice. All paths are // considered to be relative to '/'. func MaskFilter(masks []string) FilterFunc { - return func(path string) bool { - // Convert the path to be cleaned and relative-to-root. - path = filepath.Join("/", path) - - // Check that no masks are matched. - for _, mask := range masks { - // Mask also needs to be cleaned and relative-to-root. - mask = filepath.Join("/", mask) + maskedPaths := map[string]struct{}{} + for _, mask := range masks { + maskedPaths[makeRoot(mask)] = struct{}{} + } + return maskFilter(maskedPaths, true) +} - // Is it a parent? - if isParent(mask, path) { - log.Debugf("maskfilter: ignoring path %q matched by mask %q", path, mask) - return false - } +// SimplifyFilter is a factory that takes a list of InodeDelta and creates a +// filter to filter out all deletion entries that have a parent which also has +// a deletion entry. This is necessary to both reduce our image sizes and +// remain compatible with Docker's now-incompatible image format (the OCI spec +// doesn't require this behaviour but it's now needed because of course Docker +// won't fix their own bugs). +func SimplifyFilter(deltas []mtree.InodeDelta) FilterFunc { + deletedPaths := make(map[string]struct{}) + for _, delta := range deltas { + if delta.Type() != mtree.Missing { + continue } - - return true + deletedPaths[makeRoot(delta.Path())] = struct{}{} } + return maskFilter(deletedPaths, false) } // FilterDeltas is a helper function to easily filter []mtree.InodeDelta with a // filter function. Only entries which have `filter(delta.Path()) == true` will // be included in the returned slice. -func FilterDeltas(deltas []mtree.InodeDelta, filter FilterFunc) []mtree.InodeDelta { +func FilterDeltas(deltas []mtree.InodeDelta, filters ...FilterFunc) []mtree.InodeDelta { var filtered []mtree.InodeDelta for _, delta := range deltas { - if filter(delta.Path()) { + var blocked bool + for _, filter := range filters { + if !filter(delta.Path()) { + blocked = true + break + } + } + if !blocked { filtered = append(filtered, delta) } } diff --git a/pkg/mtreefilter/mask_test.go b/pkg/mtreefilter/mask_test.go index db658faa3..276ba4ee7 100644 --- a/pkg/mtreefilter/mask_test.go +++ b/pkg/mtreefilter/mask_test.go @@ -26,33 +26,14 @@ import ( "github.com/vbatts/go-mtree" ) -func TestIsParent(t *testing.T) { - for _, test := range []struct { - parent, path string - expected bool - }{ - {"/", "/a", true}, - {"/", "/a/b/c", true}, - {"/", "/", true}, - {"/a path/", "/a path", true}, - {"/a nother path", "/a nother path/test", true}, - {"/a nother path", "/a nother path/test/1 2/ 33 /", true}, - {"/path1", "/path2", false}, - {"/pathA", "/PATHA", false}, - {"/pathC", "/path", false}, - {"/path9", "/", false}, - // Make sure it's not the same as filepath.HasPrefix. - {"/a/b/c", "/a/b/c/d", true}, - {"/a/b/c", "/a/b/cd", false}, - {"/a/b/c", "/a/bcd", false}, - {"/a/bc", "/a/bcd", false}, - {"/abc", "/abcd", false}, - } { - got := isParent(test.parent, test.path) - if got != test.expected { - t.Errorf("isParent(%q, %q) got %v expected %v", test.parent, test.path, got, test.expected) - } +func isParent(a, b string) bool { + a = filepath.Clean(a) + b = filepath.Clean(b) + + for a != b && b != filepath.Dir(b) { + b = filepath.Dir(b) } + return a == b } func TestMaskDeltas(t *testing.T) { @@ -122,11 +103,11 @@ func TestMaskDeltas(t *testing.T) { {[]string{"/", "file2"}}, {[]string{"file2", filepath.Join("dir", "child2")}}, } { - newDiff := FilterDeltas(diff, MaskFilter(test.paths)) - for _, delta := range newDiff { + simpleDiff := FilterDeltas(diff, MaskFilter(test.paths)) + for _, delta := range simpleDiff { if len(test.paths) == 0 { - if len(newDiff) != len(diff) { - t.Errorf("expected diff={} to give %d got %d", len(diff), len(newDiff)) + if len(simpleDiff) != len(diff) { + t.Errorf("expected diff={} to give %d got %d", len(diff), len(simpleDiff)) } } else { found := false @@ -142,3 +123,68 @@ func TestMaskDeltas(t *testing.T) { } } } + +func TestSimplifyFilter(t *testing.T) { + dir, err := ioutil.TempDir("", "TestSimplifyFilter-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + mtreeKeywords := append(mtree.DefaultKeywords, "sha256digest") + + // Create some nested directories we can remove. + if err != os.MkdirAll(filepath.Join(dir, "some", "path", "to", "remove"), 0755) { + t.Fatal(err) + } + if err != ioutil.WriteFile(filepath.Join(dir, "some", "path", "to", "remove", "child"), []byte("very content"), 0644) { + t.Fatal(err) + } + + // Generate a diff. + originalDh, err := mtree.Walk(dir, nil, mtreeKeywords, nil) + if err != nil { + t.Fatal(err) + } + + // Modify the root. + if err := os.RemoveAll(filepath.Join(dir, "some")); err != nil { + t.Fatal(err) + } + + // Generate the set of diffs. + newDh, err := mtree.Walk(dir, nil, mtreeKeywords, nil) + if err != nil { + t.Fatal(err) + } + diff, err := mtree.Compare(originalDh, newDh, mtreeKeywords) + if err != nil { + t.Fatal(err) + } + + // We expect to see a deletion for each entry. + var sawDeletions int + for _, delta := range diff { + if delta.Type() == mtree.Missing { + sawDeletions++ + } + } + if sawDeletions != 5 { + t.Errorf("expected to see 5 deletions with stock Compare, saw %v", sawDeletions) + } + + // Simplify the diffs. + simpleDiff := FilterDeltas(diff, SimplifyFilter(diff)) + if len(simpleDiff) >= len(diff) { + t.Errorf("expected simplified diff to be shorter (%v >= %v)", len(simpleDiff), len(diff)) + } + var sawSimpleDeletions int + for _, delta := range simpleDiff { + if delta.Type() == mtree.Missing { + sawSimpleDeletions++ + } + } + if sawSimpleDeletions != 1 { + t.Errorf("expected to see 1 deletion with simplified filter, saw %v", sawSimpleDeletions) + } +} diff --git a/test/insert.bats b/test/insert.bats index 8490f3fcf..5475fa9fa 100644 --- a/test/insert.bats +++ b/test/insert.bats @@ -83,4 +83,111 @@ function teardown() { [[ "$(stat -c '%f' "${INSERTDIR}/some/path")" == "$(stat -c '%f' "$ROOTFS/rootless/path")" ]] chmod a+rwx "$ROOTFS/rootless/path" [ -f "$ROOTFS/rootless/path/hidden" ] + + image-verify "${IMAGE}" +} + +@test "umoci insert --opaque" { + # Some things to insert. + INSERTDIR="$(setup_tmpdir)" + mkdir -p "${INSERTDIR}/etc" + touch "${INSERTDIR}/etc/foo" + + # Insert our /etc. + umoci insert --image "${IMAGE}:${TAG}" "${INSERTDIR}/etc" /etc + [ "$status" -eq 0 ] + image-verify "${IMAGE}" + + # Make sure that the /etc/foo is there. + new_bundle_rootfs + umoci unpack --image "${IMAGE}:${TAG}" "$BUNDLE" + [ "$status" -eq 0 ] + bundle-verify "$BUNDLE" + + # Make sure that it's merged! + [ -f "$ROOTFS/etc/shadow" ] + [ -f "$ROOTFS/etc/foo" ] + + # Now make it opaque to make sure it isn't included. + INSERTDIR="$(setup_tmpdir)" + mkdir -p "${INSERTDIR}/etc" + touch "${INSERTDIR}/etc/bar" + touch "${INSERTDIR}/should_be_fine" + + # Insert our /etc. + umoci insert --image "${IMAGE}:${TAG}" --opaque "${INSERTDIR}/etc" /etc + [ "$status" -eq 0 ] + image-verify "${IMAGE}" + # And try to make a file opaque just to see what happens (should be nothing). + umoci insert --image "${IMAGE}:${TAG}" --opaque "${INSERTDIR}/should_be_fine" /should_be_fine + [ "$status" -eq 0 ] + image-verify "${IMAGE}" + + # Make sure that now only /etc/bar is around. + new_bundle_rootfs + umoci unpack --image "${IMAGE}:${TAG}" "$BUNDLE" + [ "$status" -eq 0 ] + bundle-verify "$BUNDLE" + + # Make sure that it's _not_ merged! + ! [ -f "$ROOTFS/etc/shadow" ] + ! [ -f "$ROOTFS/etc/foo" ] + # And that bar is there. + [ -f "$ROOTFS/etc/bar" ] + # And that should_be_fine is around. + [ -f "$ROOTFS/should_be_fine" ] + + image-verify "${IMAGE}" +} + +@test "umoci insert --whiteout" { + # Some things to insert. + INSERTDIR="$(setup_tmpdir)" + touch "${INSERTDIR}/rm_file" + mkdir "${INSERTDIR}/rm_dir" + + # Add our things. + umoci insert --image "${IMAGE}:${TAG}" "${INSERTDIR}/rm_file" /rm_file + [ "$status" -eq 0 ] + image-verify "${IMAGE}" + umoci insert --image "${IMAGE}:${TAG}" "${INSERTDIR}/rm_dir" /rm_dir + [ "$status" -eq 0 ] + image-verify "${IMAGE}" + + # Unpack after the inserts. + new_bundle_rootfs + umoci unpack --image "${IMAGE}:${TAG}" "$BUNDLE" + [ "$status" -eq 0 ] + bundle-verify "$BUNDLE" + + [ -d "$ROOTFS/etc" ] + [ -d "$ROOTFS/rm_dir" ] + [ -f "$ROOTFS/rm_file" ] + + # Directory whiteout. + umoci insert --image "${IMAGE}:${TAG}" --whiteout /rm_dir + [ "$status" -eq 0 ] + image-verify "${IMAGE}" + + # (Another) directory whiteout. + umoci insert --image "${IMAGE}:${TAG}" --whiteout /etc + [ "$status" -eq 0 ] + image-verify "${IMAGE}" + + # File whiteout. + umoci insert --image "${IMAGE}:${TAG}" --whiteout /rm_file + [ "$status" -eq 0 ] + image-verify "${IMAGE}" + + # Unpack after the inserts. + new_bundle_rootfs + umoci unpack --image "${IMAGE}:${TAG}" "$BUNDLE" + [ "$status" -eq 0 ] + bundle-verify "$BUNDLE" + + ! [ -d "$ROOTFS/etc" ] + ! [ -d "$ROOTFS/rm_dir" ] + ! [ -f "$ROOTFS/rm_file" ] + + image-verify "${IMAGE}" }