Skip to content

Commit 360423f

Browse files
committed
Add image prune --filter support
Signed-off-by: Austin Vazquez <[email protected]>
1 parent ec7c395 commit 360423f

File tree

8 files changed

+299
-28
lines changed

8 files changed

+299
-28
lines changed

cmd/nerdctl/image_prune.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func newImagePruneCommand() *cobra.Command {
3838
}
3939

4040
imagePruneCommand.Flags().BoolP("all", "a", false, "Remove all unused images, not just dangling ones")
41+
imagePruneCommand.Flags().StringSlice("filter", []string{}, "Filter output based on conditions provided")
4142
imagePruneCommand.Flags().BoolP("force", "f", false, "Do not prompt for confirmation")
4243
return imagePruneCommand
4344
}
@@ -52,6 +53,14 @@ func processImagePruneOptions(cmd *cobra.Command) (types.ImagePruneOptions, erro
5253
return types.ImagePruneOptions{}, err
5354
}
5455

56+
var filters []string
57+
if cmd.Flags().Changed("filter") {
58+
filters, err = cmd.Flags().GetStringSlice("filter")
59+
if err != nil {
60+
return types.ImagePruneOptions{}, err
61+
}
62+
}
63+
5564
force, err := cmd.Flags().GetBool("force")
5665
if err != nil {
5766
return types.ImagePruneOptions{}, err
@@ -61,6 +70,7 @@ func processImagePruneOptions(cmd *cobra.Command) (types.ImagePruneOptions, erro
6170
Stdout: cmd.OutOrStdout(),
6271
GOptions: globalOptions,
6372
All: all,
73+
Filters: filters,
6474
Force: force,
6575
}, err
6676
}

cmd/nerdctl/image_prune_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package main
1919
import (
2020
"fmt"
2121
"testing"
22+
"time"
2223

2324
"github.com/containerd/nerdctl/v2/pkg/testutil"
2425
)
@@ -71,3 +72,57 @@ func TestImagePruneAll(t *testing.T) {
7172
base.Cmd("image", "prune", "--force", "--all").AssertOutContains(imageName)
7273
base.Cmd("images").AssertOutNotContains(imageName)
7374
}
75+
76+
func TestImagePruneFilterLabel(t *testing.T) {
77+
testutil.RequiresBuild(t)
78+
testutil.RegisterBuildCacheCleanup(t)
79+
80+
base := testutil.NewBase(t)
81+
imageName := testutil.Identifier(t)
82+
t.Cleanup(func() { base.Cmd("rmi", "--force", imageName) })
83+
84+
dockerfile := fmt.Sprintf(`FROM %s
85+
CMD ["echo", "nerdctl-test-image-prune-filter-label"]
86+
LABEL foo=bar
87+
LABEL version=0.1`, testutil.CommonImage)
88+
89+
buildCtx := createBuildContext(t, dockerfile)
90+
91+
base.Cmd("build", "-t", imageName, buildCtx).AssertOK()
92+
base.Cmd("images", "--all").AssertOutContains(imageName)
93+
94+
base.Cmd("image", "prune", "--force", "--all", "--filter", "label=foo=baz").AssertOK()
95+
base.Cmd("images", "--all").AssertOutContains(imageName)
96+
97+
base.Cmd("image", "prune", "--force", "--all", "--filter", "label=foo=bar").AssertOK()
98+
base.Cmd("images", "--all").AssertOutNotContains(imageName)
99+
}
100+
101+
func TestImagePruneFilterUntil(t *testing.T) {
102+
testutil.RequiresBuild(t)
103+
testutil.RegisterBuildCacheCleanup(t)
104+
105+
// Docker image's created timestamp is set based on base image creation time.
106+
testutil.DockerIncompatible(t)
107+
108+
base := testutil.NewBase(t)
109+
imageName := testutil.Identifier(t)
110+
t.Cleanup(func() { base.Cmd("rmi", "--force", imageName) })
111+
112+
dockerfile := fmt.Sprintf(`FROM %s
113+
CMD ["echo", "nerdctl-test-image-prune-filter-until"]`, testutil.CommonImage)
114+
115+
buildCtx := createBuildContext(t, dockerfile)
116+
117+
base.Cmd("build", "-t", imageName, buildCtx).AssertOK()
118+
base.Cmd("images", "--all").AssertOutContains(imageName)
119+
120+
base.Cmd("image", "prune", "--force", "--all", "--filter", "until=12h").AssertOK()
121+
base.Cmd("images", "--all").AssertOutContains(imageName)
122+
123+
// Pause to ensure enough time has passed for the image to be clean on next prune.
124+
time.Sleep(1 * time.Second)
125+
126+
base.Cmd("image", "prune", "--force", "--all", "--filter", "until=100ms").AssertOK()
127+
base.Cmd("images", "--all").AssertOutNotContains(imageName)
128+
}

docs/command-reference.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -886,10 +886,11 @@ Usage: `nerdctl image prune [OPTIONS]`
886886
Flags:
887887

888888
- :whale: `-a, --all`: Remove all unused images, not just dangling ones
889+
- :whale: `-f, --filter`: Filter the images.
890+
- :whale: `--filter=until=<timestamp>`: Images created before given date formatted timestamps or Go duration strings. Currently does not support Unix timestamps.
891+
- :whale: `--filter=label<key>=<value>`: Matches images based on the presence of a label alone or a label and a value
889892
- :whale: `-f, --force`: Do not prompt for confirmation
890893

891-
Unimplemented `docker image prune` flags: `--filter`
892-
893894
### :nerd_face: nerdctl image convert
894895

895896
Convert an image format.

pkg/api/types/image_types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ type ImagePruneOptions struct {
236236
GOptions GlobalCommandOptions
237237
// All Remove all unused images, not just dangling ones.
238238
All bool
239+
// Filters output based on conditions provided for the --filter argument
240+
Filters []string
239241
// Force will not prompt for confirmation.
240242
Force bool
241243
}

pkg/cmd/image/prune.go

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,45 +34,43 @@ import (
3434
// Prune will remove all dangling images. If all is specified, will also remove all images not referenced by any container.
3535
func Prune(ctx context.Context, client *containerd.Client, options types.ImagePruneOptions) error {
3636
var (
37-
imageStore = client.ImageService()
38-
contentStore = client.ContentStore()
39-
containerStore = client.ContainerService()
37+
imageStore = client.ImageService()
38+
contentStore = client.ContentStore()
4039
)
4140

42-
imageList, err := imageStore.List(ctx)
43-
if err != nil {
44-
return err
45-
}
46-
47-
var filteredImages []images.Image
41+
var (
42+
imagesToBeRemoved []images.Image
43+
err error
44+
)
4845

49-
if options.All {
50-
containerList, err := containerStore.List(ctx)
46+
filters := []imgutil.Filter{}
47+
if len(options.Filters) > 0 {
48+
parsedFilters, err := imgutil.ParseFilters(options.Filters)
5149
if err != nil {
5250
return err
5351
}
54-
usedImages := make(map[string]struct{})
55-
for _, container := range containerList {
56-
usedImages[container.Image] = struct{}{}
52+
if len(parsedFilters.Labels) > 0 {
53+
filters = append(filters, imgutil.FilterByLabel(ctx, client, parsedFilters.Labels))
5754
}
58-
59-
for _, image := range imageList {
60-
if _, ok := usedImages[image.Name]; ok {
61-
continue
62-
}
63-
64-
filteredImages = append(filteredImages, image)
55+
if len(parsedFilters.Until) > 0 {
56+
filters = append(filters, imgutil.FilterUntil(parsedFilters.Until))
6557
}
58+
}
59+
60+
if options.All {
61+
// Remove all unused images; not just dangling ones
62+
imagesToBeRemoved, err = imgutil.GetUnusedImages(ctx, client, filters...)
6663
} else {
67-
filteredImages, err = imgutil.FilterDanglingImages()(imageList)
68-
if err != nil {
69-
return err
70-
}
64+
// Remove dangling images only
65+
imagesToBeRemoved, err = imgutil.GetDanglingImages(ctx, client, filters...)
66+
}
67+
if err != nil {
68+
return err
7169
}
7270

7371
delOpts := []images.DeleteOpt{images.SynchronousDelete()}
7472
removedImages := make(map[string][]digest.Digest)
75-
for _, image := range filteredImages {
73+
for _, image := range imagesToBeRemoved {
7674
digests, err := image.RootFS(ctx, contentStore, platforms.DefaultStrict())
7775
if err != nil {
7876
log.G(ctx).WithError(err).Warnf("failed to enumerate rootfs")

pkg/imgutil/filtering.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package imgutil
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"regexp"
2324
"strings"
@@ -35,15 +36,23 @@ import (
3536
const (
3637
FilterBeforeType = "before"
3738
FilterSinceType = "since"
39+
FilterUntilType = "until"
3840
FilterLabelType = "label"
3941
FilterReferenceType = "reference"
4042
FilterDanglingType = "dangling"
4143
)
4244

45+
var (
46+
errMultipleUntilFilters = errors.New("more than one until filter provided")
47+
errNoUntilTimestamp = errors.New("no until timestamp provided")
48+
errUnparsableUntilTimestamp = errors.New("unable to parse until timestamp")
49+
)
50+
4351
// Filters contains all types of filters to filter images.
4452
type Filters struct {
4553
Before []string
4654
Since []string
55+
Until string
4756
Labels map[string]string
4857
Reference []string
4958
Dangling *bool
@@ -85,6 +94,13 @@ func ParseFilters(filters []string) (*Filters, error) {
8594
}
8695
f.Since = append(f.Since, fmt.Sprintf("name==%s", canonicalRef.String()))
8796
f.Since = append(f.Since, fmt.Sprintf("name==%s", tempFilterToken[1]))
97+
} else if tempFilterToken[0] == FilterUntilType {
98+
if len(tempFilterToken[0]) == 0 {
99+
return nil, errNoUntilTimestamp
100+
} else if len(f.Until) > 0 {
101+
return nil, errMultipleUntilFilters
102+
}
103+
f.Until = tempFilterToken[1]
88104
} else if tempFilterToken[0] == FilterLabelType {
89105
// To support filtering labels by keys.
90106
f.Labels[tempFilterToken[1]] = ""
@@ -161,6 +177,57 @@ func FilterByCreatedAt(ctx context.Context, client *containerd.Client, before []
161177
}
162178
}
163179

180+
// FilterUntil filters images created before the provided timestamp.
181+
func FilterUntil(until string) Filter {
182+
return func(imageList []images.Image) ([]images.Image, error) {
183+
if len(until) == 0 {
184+
return []images.Image{}, errNoUntilTimestamp
185+
}
186+
187+
var (
188+
parsedTime time.Time
189+
err error
190+
)
191+
192+
type parseUntilFunc func(string) (time.Time, error)
193+
parsingFuncs := []parseUntilFunc{
194+
func(until string) (time.Time, error) {
195+
return time.Parse(time.RFC3339, until)
196+
},
197+
func(until string) (time.Time, error) {
198+
return time.Parse(time.RFC3339Nano, until)
199+
},
200+
func(until string) (time.Time, error) {
201+
return time.Parse(time.DateOnly, until)
202+
},
203+
func(until string) (time.Time, error) {
204+
// Go duration strings
205+
d, err := time.ParseDuration(until)
206+
if err != nil {
207+
return time.Time{}, err
208+
}
209+
return time.Now().Add(-d), nil
210+
},
211+
}
212+
213+
for _, parse := range parsingFuncs {
214+
parsedTime, err = parse(until)
215+
if err != nil {
216+
continue
217+
}
218+
break
219+
}
220+
221+
if err != nil {
222+
return []images.Image{}, errUnparsableUntilTimestamp
223+
}
224+
225+
return filter(imageList, func(i images.Image) (bool, error) {
226+
return imageCreatedBefore(i, parsedTime), nil
227+
})
228+
}
229+
}
230+
164231
// FilterByLabel filters an image list based on labels applied to the image's config specification for the platform.
165232
// Any matching label will include the image in the list.
166233
func FilterByLabel(ctx context.Context, client *containerd.Client, labels map[string]string) Filter {
@@ -221,6 +288,10 @@ func imageCreatedBetween(image images.Image, min time.Time, max time.Time) bool
221288
return image.CreatedAt.After(min) && image.CreatedAt.Before(max)
222289
}
223290

291+
func imageCreatedBefore(image images.Image, max time.Time) bool {
292+
return image.CreatedAt.Before(max)
293+
}
294+
224295
func matchesAllLabels(imageCfgLabels map[string]string, filterLabels map[string]string) bool {
225296
var matches int
226297
for lk, lv := range filterLabels {

0 commit comments

Comments
 (0)