From 2159e7f802e4c600c81c862ecbe74c8c547dd983 Mon Sep 17 00:00:00 2001 From: Austin Vazquez Date: Mon, 14 Oct 2024 16:20:27 +0000 Subject: [PATCH] Add container run from oci-archive Signed-off-by: Austin Vazquez --- .../container/container_create_linux_test.go | 32 +++++++++++++++++++ cmd/nerdctl/container/container_run_test.go | 28 ++++++++++++++++ docs/command-reference.md | 2 ++ pkg/cmd/container/create.go | 32 +++++++++++++++++++ pkg/imgutil/load/load.go | 20 ++++++++++++ 5 files changed, 114 insertions(+) diff --git a/cmd/nerdctl/container/container_create_linux_test.go b/cmd/nerdctl/container/container_create_linux_test.go index 56b71ce854a..c63472baaee 100644 --- a/cmd/nerdctl/container/container_create_linux_test.go +++ b/cmd/nerdctl/container/container_create_linux_test.go @@ -29,6 +29,7 @@ import ( "github.com/containerd/containerd/v2/defaults" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" @@ -304,3 +305,34 @@ func TestIssue2993(t *testing.T) { testCase.Run(t) } + +func TestCreateFromOCIArchive(t *testing.T) { + testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) + + // Docker does not support creating containers from OCI archive. + testutil.DockerIncompatible(t) + + base := testutil.NewBase(t) + imageName := testutil.Identifier(t) + containerName := testutil.Identifier(t) + + teardown := func() { + base.Cmd("rm", containerName).Run() + base.Cmd("rmi", imageName).Run() + } + defer teardown() + teardown() + + const sentinel = "test-nerdctl-create-from-oci-archive" + dockerfile := fmt.Sprintf(`FROM %s + CMD ["echo", "%s"]`, testutil.CommonImage, sentinel) + + buildCtx := helpers.CreateBuildContext(t, dockerfile) + tag := fmt.Sprintf("%s:latest", imageName) + tarPath := fmt.Sprintf("%s/%s.tar", buildCtx, imageName) + + base.Cmd("build", "--tag", tag, fmt.Sprintf("--output=type=oci,dest=%s", tarPath), buildCtx).AssertOK() + base.Cmd("create", "--rm", "--name", containerName, fmt.Sprintf("oci-archive://%s", tarPath)).AssertOK() + base.Cmd("start", containerName).AssertOutContains("test-nerdctl-create-from-oci-archive") +} diff --git a/cmd/nerdctl/container/container_run_test.go b/cmd/nerdctl/container/container_run_test.go index 4d796fa750e..f44f6f6e0ff 100644 --- a/cmd/nerdctl/container/container_run_test.go +++ b/cmd/nerdctl/container/container_run_test.go @@ -658,3 +658,31 @@ func TestRunQuiet(t *testing.T) { assert.Assert(t, wasQuiet(result.Combined(), sentinel), "Found %s in container run output", sentinel) } + +func TestRunFromOCIArchive(t *testing.T) { + testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) + + // Docker does not support running container images from OCI archive. + testutil.DockerIncompatible(t) + + base := testutil.NewBase(t) + imageName := testutil.Identifier(t) + + teardown := func() { + base.Cmd("rmi", imageName).Run() + } + defer teardown() + teardown() + + const sentinel = "test-nerdctl-run-from-oci-archive" + dockerfile := fmt.Sprintf(`FROM %s + CMD ["echo", "%s"]`, testutil.CommonImage, sentinel) + + buildCtx := helpers.CreateBuildContext(t, dockerfile) + tag := fmt.Sprintf("%s:latest", imageName) + tarPath := fmt.Sprintf("%s/%s.tar", buildCtx, imageName) + + base.Cmd("build", "--tag", tag, fmt.Sprintf("--output=type=oci,dest=%s", tarPath), buildCtx).AssertOK() + base.Cmd("run", "--rm", fmt.Sprintf("oci-archive://%s", tarPath)).AssertOutContainsAll(fmt.Sprintf("Loaded image: %s", tag), sentinel) +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 2fbd0dc6479..8818aebc9a5 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -134,6 +134,7 @@ Run a command in a new container. Usage: `nerdctl run [OPTIONS] IMAGE [COMMAND] [ARG...]` :nerd_face: `ipfs://` prefix can be used for `IMAGE` to pull it from IPFS. See [`ipfs.md`](./ipfs.md) for details. +:nerd_face: `oci-archive://` prefix can be used for `IMAGE` to specify a local file system path to an OCI formatted tarball. Basic flags: @@ -422,6 +423,7 @@ Create a new container. Usage: `nerdctl create [OPTIONS] IMAGE [COMMAND] [ARG...]` :nerd_face: `ipfs://` prefix can be used for `IMAGE` to pull it from IPFS. See [`ipfs.md`](./ipfs.md) for details. +:nerd_face: `oci-archive://` prefix can be used for `IMAGE` to specify a local file system path to an OCI formatted tarball. The `nerdctl create` command similar to `nerdctl run -d` except the container is never started. You can then use the `nerdctl start ` command to start the container at any point. diff --git a/pkg/cmd/container/create.go b/pkg/cmd/container/create.go index 6bf6022f70b..800491434dd 100644 --- a/pkg/cmd/container/create.go +++ b/pkg/cmd/container/create.go @@ -50,6 +50,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/flagutil" "github.com/containerd/nerdctl/v2/pkg/idgen" "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/imgutil/load" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/ipcutil" "github.com/containerd/nerdctl/v2/pkg/labels" @@ -123,6 +124,37 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa } opts = append(opts, platformOpts...) + if imageRef := args[0]; strings.HasPrefix(imageRef, "oci-archive://") { + // Load and create the platform specified by the user. + // If none specified, fallback to the default platform. + platform := []string{} + if options.Platform != "" { + platform = append(platform, options.Platform) + } + + images, err := load.FromOCIArchive(ctx, client, imageRef, types.ImageLoadOptions{ + Stdout: options.Stdout, + GOptions: options.GOptions, + Platform: platform, + AllPlatforms: false, + Quiet: options.ImagePullOpt.Quiet, + }) + if err != nil { + return nil, nil, err + } else if len(images) == 0 { + // This is a regression and should not occur. + return nil, nil, errors.New("OCI archive did not contain any images") + } + + imageRef = images[0].Name + // Multi-image archive provided, default to first image found. + if len(images) != 1 { + log.L.Warnf("multi-image OCI archive provided, defaulting to image %s...", imageRef) + } + + args[0] = imageRef + } + var ensuredImage *imgutil.EnsuredImage if !options.Rootfs { var platformSS []string // len: 0 or 1 diff --git a/pkg/imgutil/load/load.go b/pkg/imgutil/load/load.go index c1d8c239b0c..fa43ea79c60 100644 --- a/pkg/imgutil/load/load.go +++ b/pkg/imgutil/load/load.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "os" + "strings" containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/core/images" @@ -76,6 +77,25 @@ func FromArchive(ctx context.Context, client *containerd.Client, options types.I return unpackedImages, nil } +// FromOCIArchive loads and unpacks images from the on-disk OCI archive. +func FromOCIArchive(ctx context.Context, client *containerd.Client, pathToOCIArchive string, options types.ImageLoadOptions) ([]images.Image, error) { + const ociArchivePrefix = "oci-archive://" + pathToOCIArchive = strings.TrimPrefix(pathToOCIArchive, ociArchivePrefix) + + const separator = ":" + if strings.Contains(pathToOCIArchive, separator) { + subs := strings.Split(pathToOCIArchive, separator) + if len(subs) != 2 { + return []images.Image{}, errors.New("too many seperators found in oci-archive path") + } + pathToOCIArchive = subs[0] + } + + options.Input = pathToOCIArchive + + return FromArchive(ctx, client, options) +} + type readCounter struct { io.Reader N int