Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions cmd/nerdctl/image/image_remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,40 @@ func TestRemove(t *testing.T) {
}
},
},
{
Description: "Issue #4109 - force-removing an in-use image does not collide with a dangling ref left by an earlier force-remove",
NoParallel: true,
Require: require.All(
// Dangling-ref naming on force-remove of an in-use image is a nerdctl-specific
// implementation detail; Docker doesn't use this scheme, so the test doesn't apply.
require.Not(nerdtest.Docker),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ogulcanaydogan Please add a comment line to explain why this doesn't work on Docker, and squash the commits

),
Setup: func(data test.Data, helpers test.Helpers) {
helpers.Ensure("run", "--quiet", "--pull", "always", "-d", "--name", data.Identifier()+"-1", testutil.CommonImage, "sleep", nerdtest.Infinity)
helpers.Ensure("run", "--quiet", "--pull", "always", "-d", "--name", data.Identifier()+"-2", testutil.BusyboxImage, "sleep", nerdtest.Infinity)
// Force-remove the first in-use image now: this creates a dangling ref to keep
// its layers alive. Before the fix, that ref was unconditionally named ":", so
// the second force-remove below (the command under test) would fail creating
// its own dangling ref with "image \":\": already exists".
helpers.Ensure("rmi", "-f", testutil.CommonImage)
},
Cleanup: func(data test.Data, helpers test.Helpers) {
helpers.Anyhow("rm", "-f", data.Identifier()+"-1")
helpers.Anyhow("rm", "-f", data.Identifier()+"-2")
},
Command: test.Command("rmi", "-f", testutil.BusyboxImage),
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: 0,
Errors: []error{},
Output: func(stdout string, t tig.T) {
helpers.Command("images").Run(&test.Expected{
Output: expect.Contains("<untagged>"),
})
},
}
},
},
{
Description: "Remove image with created container - without -f",
NoParallel: true,
Expand Down
28 changes: 22 additions & 6 deletions pkg/cmd/image/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ import (
"fmt"
"strings"

"github.com/opencontainers/go-digest"

containerd "github.com/containerd/containerd/v2/client"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/errdefs"
"github.com/containerd/log"

"github.com/containerd/nerdctl/v2/pkg/api/types"
Expand All @@ -32,6 +35,15 @@ import (
"github.com/containerd/nerdctl/v2/pkg/platformutil"
)

// danglingRefName builds a dangling-image name unique to the given digest, so that
// force-removing more than one running image's image in the same invocation does not
// collide on image creation: containerd's image store requires unique names, and a
// fixed ":" name is shared by every dangling ref. See:
// https://github.com/containerd/nerdctl/issues/4109
func danglingRefName(dgst digest.Digest) string {
return ":" + dgst.String()
}

// Remove removes a list of `images`.
func Remove(ctx context.Context, client *containerd.Client, args []string, options types.ImageRemoveOptions) error {
var delOpts []images.DeleteOpt
Expand Down Expand Up @@ -84,10 +96,12 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio
if cid, ok := runningImages[found.Image.Name]; ok {
if options.Force {
// This is a running image, so, we need to keep a ref on it so that containerd does not GC the layers
// First create the new image with an empty name
// First create the new image with a dangling name unique to its digest: a fixed ":" name
// collides ("image \":\": already exists") when force-removing more than one running
// image's image in the same invocation.
originalName := found.Image.Name
found.Image.Name = ":"
if _, err = is.Create(ctx, found.Image); err != nil {
found.Image.Name = danglingRefName(found.Image.Target.Digest)
if _, err = is.Create(ctx, found.Image); err != nil && !errdefs.IsAlreadyExists(err) {
return err
}

Expand Down Expand Up @@ -137,10 +151,12 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio
if cid, ok := runningImages[found.Image.Name]; ok {
if options.Force {
// This is a running image, so, we need to keep a ref on it so that containerd does not GC the layers
// First create the new image with an empty name
// First create the new image with a dangling name unique to its digest: a fixed ":" name
// collides ("image \":\": already exists") when force-removing more than one running
// image's image in the same invocation.
originalName := found.Image.Name
found.Image.Name = ":"
if _, err = is.Create(ctx, found.Image); err != nil {
found.Image.Name = danglingRefName(found.Image.Target.Digest)
if _, err = is.Create(ctx, found.Image); err != nil && !errdefs.IsAlreadyExists(err) {
return false, err
}

Expand Down
7 changes: 5 additions & 2 deletions pkg/imgutil/filtering.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,11 @@ func matchesAllLabels(imageCfgLabels map[string]string, filterLabels map[string]
func matchesReferences(image images.Image, referencePatterns []string) (bool, error) {
var matches int

// Containerd returns ":" for dangling untagged images - see https://github.com/containerd/nerdctl/issues/3852
if image.Name == ":" {
// Dangling untagged images are named ":" or ":<digest>" (see
// https://github.com/containerd/nerdctl/issues/3852 and
// https://github.com/containerd/nerdctl/issues/4109), neither of which is a
// parsable reference.
if strings.HasPrefix(image.Name, ":") {
return false, nil
}

Expand Down
23 changes: 23 additions & 0 deletions pkg/imgutil/filtering_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,29 @@ func TestFilterByReference(t *testing.T) {
referencePatterns: []string{"foobar"},
expectedImages: []images.Image{},
},
{
// Dangling refs kept alive by `rmi -f` on a running image are named ":" or, since
// #4109, ":<digest>". Neither is a parsable reference, so they must be skipped
// rather than erroring out the whole filter. See issues #3852 and #4109.
name: "SkipsDanglingRefsWithoutErroring",
images: []images.Image{
{
Name: "foo:latest",
},
{
Name: ":",
},
{
Name: ":sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
referencePatterns: []string{"foo"},
expectedImages: []images.Image{
{
Name: "foo:latest",
},
},
},
}

for _, test := range tests {
Expand Down
Loading