diff --git a/cmd/nerdctl/container/container_create_linux_test.go b/cmd/nerdctl/container/container_create_linux_test.go index 6e8dbe2b491..2da8ae92302 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" @@ -306,3 +307,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", "-f", containerName).Run() + base.Cmd("rmi", "-f", 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", "--attach", 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..b7054c77722 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", "-f", 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/cmd/nerdctl/image/image_load.go b/cmd/nerdctl/image/image_load.go index cd0698a3270..3ff8b18a892 100644 --- a/cmd/nerdctl/image/image_load.go +++ b/cmd/nerdctl/image/image_load.go @@ -23,7 +23,7 @@ import ( "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" - "github.com/containerd/nerdctl/v2/pkg/cmd/image" + "github.com/containerd/nerdctl/v2/pkg/imgutil/load" ) func NewLoadCommand() *cobra.Command { @@ -94,5 +94,6 @@ func loadAction(cmd *cobra.Command, _ []string) error { } defer cancel() - return image.Load(ctx, client, options) + _, err = load.FromArchive(ctx, client, options) + return err } diff --git a/docs/command-reference.md b/docs/command-reference.md index 070c7db395e..1bd141aecc1 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: @@ -423,6 +424,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 fe01cee7bd1..b0effcff12d 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,39 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa } opts = append(opts, platformOpts...) + if _, err := referenceutil.Parse(args[0]); errors.Is(err, referenceutil.ErrLoadOCIArchiveRequired) { + imageRef := args[0] + + // 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") + } + + image := images[0].Name + // Multiple images loaded from the provided archive. Default to the first image found. + if len(images) != 1 { + log.L.Warnf("multiple images are found for the platform, defaulting to image %s...", image) + } + + args[0] = image + } + var ensuredImage *imgutil.EnsuredImage if !options.Rootfs { var platformSS []string // len: 0 or 1 diff --git a/pkg/cmd/image/load.go b/pkg/cmd/image/load.go deleted file mode 100644 index 649a37102b9..00000000000 --- a/pkg/cmd/image/load.go +++ /dev/null @@ -1,112 +0,0 @@ -/* - Copyright The containerd Authors. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package image - -import ( - "context" - "errors" - "fmt" - "io" - "os" - - containerd "github.com/containerd/containerd/v2/client" - "github.com/containerd/containerd/v2/core/images" - "github.com/containerd/containerd/v2/core/images/archive" - "github.com/containerd/containerd/v2/pkg/archive/compression" - "github.com/containerd/platforms" - - "github.com/containerd/nerdctl/v2/pkg/api/types" - "github.com/containerd/nerdctl/v2/pkg/imgutil" - "github.com/containerd/nerdctl/v2/pkg/platformutil" -) - -type readCounter struct { - io.Reader - N int -} - -func (r *readCounter) Read(p []byte) (int, error) { - n, err := r.Reader.Read(p) - if n > 0 { - r.N += n - } - return n, err -} - -func Load(ctx context.Context, client *containerd.Client, options types.ImageLoadOptions) error { - if options.Input != "" { - f, err := os.Open(options.Input) - if err != nil { - return err - } - defer f.Close() - options.Stdin = f - } else { - // check if stdin is empty. - stdinStat, err := os.Stdin.Stat() - if err != nil { - return err - } - if stdinStat.Size() == 0 && (stdinStat.Mode()&os.ModeNamedPipe) == 0 { - return errors.New("stdin is empty and input flag is not specified") - } - } - decompressor, err := compression.DecompressStream(options.Stdin) - if err != nil { - return err - } - platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platform) - if err != nil { - return err - } - return loadImage(ctx, client, decompressor, platMC, options) -} - -func loadImage(ctx context.Context, client *containerd.Client, in io.Reader, platMC platforms.MatchComparer, options types.ImageLoadOptions) error { - // In addition to passing WithImagePlatform() to client.Import(), we also need to pass WithDefaultPlatform() to NewClient(). - // Otherwise unpacking may fail. - r := &readCounter{Reader: in} - imgs, err := client.Import(ctx, r, containerd.WithDigestRef(archive.DigestTranslator(options.GOptions.Snapshotter)), containerd.WithSkipDigestRef(func(name string) bool { return name != "" }), containerd.WithImportPlatform(platMC)) - if err != nil { - if r.N == 0 { - // Avoid confusing "unrecognized image format" - return errors.New("no image was built") - } - if errors.Is(err, images.ErrEmptyWalk) { - err = fmt.Errorf("%w (Hint: set `--platform=PLATFORM` or `--all-platforms`)", err) - } - return err - } - for _, img := range imgs { - image := containerd.NewImageWithPlatform(client, img, platMC) - - // TODO: Show unpack status - if !options.Quiet { - fmt.Fprintf(options.Stdout, "unpacking %s (%s)...\n", img.Name, img.Target.Digest) - } - err = image.Unpack(ctx, options.GOptions.Snapshotter) - if err != nil { - return err - } - - // Loaded message is shown even when quiet. - repo, tag := imgutil.ParseRepoTag(img.Name) - fmt.Fprintf(options.Stdout, "Loaded image: %s:%s\n", repo, tag) - } - - return nil -} diff --git a/pkg/imgutil/load/load.go b/pkg/imgutil/load/load.go new file mode 100644 index 00000000000..0afb322f4e4 --- /dev/null +++ b/pkg/imgutil/load/load.go @@ -0,0 +1,151 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package load + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/images/archive" + "github.com/containerd/containerd/v2/pkg/archive/compression" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/platformutil" +) + +// FromArchive loads and unpacks the images from the tar archive specified in image load options. +func FromArchive(ctx context.Context, client *containerd.Client, options types.ImageLoadOptions) ([]images.Image, error) { + if options.Input != "" { + f, err := os.Open(options.Input) + if err != nil { + return nil, err + } + defer f.Close() + options.Stdin = f + } else { + // check if stdin is empty. + stdinStat, err := os.Stdin.Stat() + if err != nil { + return nil, err + } + if stdinStat.Size() == 0 && (stdinStat.Mode()&os.ModeNamedPipe) == 0 { + return nil, errors.New("stdin is empty and input flag is not specified") + } + } + decompressor, err := compression.DecompressStream(options.Stdin) + if err != nil { + return nil, err + } + platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platform) + if err != nil { + return nil, err + } + imgs, err := importImages(ctx, client, decompressor, options.GOptions.Snapshotter, platMC) + if err != nil { + return nil, err + } + unpackedImages := make([]images.Image, 0, len(imgs)) + for _, img := range imgs { + err := unpackImage(ctx, client, img, platMC, options) + if err != nil { + return unpackedImages, fmt.Errorf("error unpacking image (%s): %w", img.Name, err) + } + unpackedImages = append(unpackedImages, img) + } + return unpackedImages, nil +} + +// FromOCIArchive loads and unpacks the images from the OCI formatted archive at the provided file system path. +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 nil, 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 +} + +func (r *readCounter) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + if n > 0 { + r.N += n + } + return n, err +} + +func importImages(ctx context.Context, client *containerd.Client, in io.Reader, snapshotter string, platformMC platforms.MatchComparer) ([]images.Image, error) { + // In addition to passing WithImagePlatform() to client.Import(), we also need to pass WithDefaultPlatform() to NewClient(). + // Otherwise unpacking may fail. + r := &readCounter{Reader: in} + imgs, err := client.Import(ctx, r, + containerd.WithDigestRef(archive.DigestTranslator(snapshotter)), + containerd.WithSkipDigestRef(func(name string) bool { return name != "" }), + containerd.WithImportPlatform(platformMC), + ) + if err != nil { + if r.N == 0 { + // Avoid confusing "unrecognized image format" + return nil, errors.New("no image was built") + } + if errors.Is(err, images.ErrEmptyWalk) { + err = fmt.Errorf("%w (Hint: set `--platform=PLATFORM` or `--all-platforms`)", err) + } + return nil, err + } + return imgs, nil +} + +func unpackImage(ctx context.Context, client *containerd.Client, model images.Image, platform platforms.MatchComparer, options types.ImageLoadOptions) error { + image := containerd.NewImageWithPlatform(client, model, platform) + + if !options.Quiet { + fmt.Fprintf(options.Stdout, "unpacking %s (%s)...\n", model.Name, model.Target.Digest) + } + + err := image.Unpack(ctx, options.GOptions.Snapshotter) + if err != nil { + return err + } + + // Loaded message is shown even when quiet. + repo, tag := imgutil.ParseRepoTag(model.Name) + fmt.Fprintf(options.Stdout, "Loaded image: %s:%s\n", repo, tag) + + return nil +} diff --git a/pkg/referenceutil/referenceutil.go b/pkg/referenceutil/referenceutil.go index 33df62f2358..4047a2ccad5 100644 --- a/pkg/referenceutil/referenceutil.go +++ b/pkg/referenceutil/referenceutil.go @@ -17,6 +17,7 @@ package referenceutil import ( + "errors" "path" "strings" @@ -31,6 +32,8 @@ const IPFSProtocol Protocol = "ipfs" const IPNSProtocol Protocol = "ipns" const shortIDLength = 5 +var ErrLoadOCIArchiveRequired = errors.New("image must be loaded from archive before parsing image reference") + type ImageReference struct { Protocol Protocol Digest digest.Digest @@ -97,6 +100,10 @@ func Parse(rawRef string) (*ImageReference, error) { } else if strings.HasPrefix(rawRef, "ipns://") { ir.Protocol = IPNSProtocol rawRef = rawRef[7:] + } else if strings.HasPrefix(rawRef, "oci-archive://") { + // The image must be loaded from the specified archive path first + // before parsing the image reference specified in its OCI image manifest. + return nil, ErrLoadOCIArchiveRequired } if decodedCID, err := cid.Decode(rawRef); err == nil { ir.Protocol = IPFSProtocol diff --git a/pkg/referenceutil/referenceutil_test.go b/pkg/referenceutil/referenceutil_test.go index 699d1c3f487..8c066434857 100644 --- a/pkg/referenceutil/referenceutil_test.go +++ b/pkg/referenceutil/referenceutil_test.go @@ -273,6 +273,9 @@ func TestReferenceUtil(t *testing.T) { Tag: "latest", ExplicitTag: "", }, + "oci-archive:///tmp/build/saved-image.tar": { + Error: "image must be loaded from archive before parsing image reference", + }, } for k, v := range needles {