Skip to content

Commit 7d9b1cc

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

File tree

8 files changed

+294
-28
lines changed

8 files changed

+294
-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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,53 @@ func TestImagePruneAll(t *testing.T) {
7171
base.Cmd("image", "prune", "--force", "--all").AssertOutContains(imageName)
7272
base.Cmd("images").AssertOutNotContains(imageName)
7373
}
74+
75+
func TestImagePruneFilterLabel(t *testing.T) {
76+
testutil.RequiresBuild(t)
77+
testutil.RegisterBuildCacheCleanup(t)
78+
79+
base := testutil.NewBase(t)
80+
imageName := testutil.Identifier(t)
81+
t.Cleanup(func() { base.Cmd("rmi", "--force", imageName) })
82+
83+
dockerfile := fmt.Sprintf(`FROM %s
84+
CMD ["echo", "nerdctl-test-image-prune-filter-label"]
85+
LABEL foo=bar
86+
LABEL version=0.1`, testutil.CommonImage)
87+
88+
buildCtx := createBuildContext(t, dockerfile)
89+
90+
base.Cmd("build", "-t", imageName, buildCtx).AssertOK()
91+
base.Cmd("images").AssertOutContains(imageName)
92+
93+
base.Cmd("image", "prune", "--force", "--all", "--filter", "label=foo=baz").AssertOK()
94+
base.Cmd("images").AssertOutContains(imageName)
95+
96+
base.Cmd("image", "prune", "--force", "--all", "--filter", "label=foo=bar").AssertOK()
97+
base.Cmd("images").AssertOutNotContains(imageName)
98+
}
99+
100+
func TestImagePruneFilterUntil(t *testing.T) {
101+
testutil.RequiresBuild(t)
102+
testutil.RegisterBuildCacheCleanup(t)
103+
104+
base := testutil.NewBase(t)
105+
imageName := testutil.Identifier(t)
106+
t.Cleanup(func() { base.Cmd("rmi", "--force", imageName) })
107+
108+
dockerfile := fmt.Sprintf(`FROM %s
109+
CMD ["echo", "nerdctl-test-image-prune-filter-label"]
110+
LABEL foo=bar
111+
LABEL version=0.1`, testutil.CommonImage)
112+
113+
buildCtx := createBuildContext(t, dockerfile)
114+
115+
base.Cmd("build", "-t", imageName, buildCtx).AssertOK()
116+
base.Cmd("images").AssertOutContains(imageName)
117+
118+
base.Cmd("image", "prune", "--force", "--all", "--filter", "until=12h").AssertOK()
119+
base.Cmd("images").AssertOutContains(imageName)
120+
121+
base.Cmd("image", "prune", "--force", "--all", "--filter", "until=100ms").AssertOK()
122+
base.Cmd("images").AssertOutNotContains(imageName)
123+
}

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 {

pkg/imgutil/filtering_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,23 @@ func TestApplyFilters(t *testing.T) {
108108
},
109109
},
110110
},
111+
{
112+
name: "ReturnErrorAndEmptyListOnFilterError",
113+
images: []images.Image{
114+
{
115+
Name: "<none>:<none>",
116+
},
117+
{
118+
Name: "docker.io/library/hello-world:latest",
119+
},
120+
},
121+
filters: []Filter{
122+
FilterDanglingImages(),
123+
FilterUntil(""),
124+
},
125+
expectedImages: []images.Image{},
126+
expectedErr: errNoUntilTimestamp,
127+
},
111128
}
112129

113130
for _, test := range tests {
@@ -124,6 +141,74 @@ func TestApplyFilters(t *testing.T) {
124141
}
125142
}
126143

144+
func TestFilterUntil(t *testing.T) {
145+
now := time.Now().UTC()
146+
147+
tests := []struct {
148+
name string
149+
until string
150+
images []images.Image
151+
expectedImages []images.Image
152+
expectedErr error
153+
}{
154+
{
155+
name: "EmptyTimestampReturnsError",
156+
until: "",
157+
images: []images.Image{},
158+
expectedImages: []images.Image{},
159+
expectedErr: errNoUntilTimestamp,
160+
},
161+
{
162+
name: "UnparseableTimestampReturnsError",
163+
until: "-2006-01-02T15:04:05Z07:00",
164+
images: []images.Image{},
165+
expectedImages: []images.Image{},
166+
expectedErr: errUnparsableUntilTimestamp,
167+
},
168+
{
169+
name: "ImagesOlderThan3Hours(Go duration)",
170+
until: "3h",
171+
images: []images.Image{
172+
{
173+
Name: "image:yesterday",
174+
CreatedAt: now.Add(-24 * time.Hour),
175+
},
176+
{
177+
Name: "image:today",
178+
CreatedAt: now.Add(-12 * time.Hour),
179+
},
180+
{
181+
Name: "image:latest",
182+
CreatedAt: now,
183+
},
184+
},
185+
expectedImages: []images.Image{
186+
{
187+
Name: "image:yesterday",
188+
CreatedAt: now.Add(-24 * time.Hour),
189+
},
190+
{
191+
Name: "image:today",
192+
CreatedAt: now.Add(-12 * time.Hour),
193+
},
194+
},
195+
},
196+
}
197+
198+
for _, test := range tests {
199+
t.Run(test.name, func(t *testing.T) {
200+
actualImages, err := FilterUntil(test.until)(test.images)
201+
if test.expectedErr == nil {
202+
assert.NilError(t, err)
203+
} else {
204+
assert.ErrorIs(t, err, test.expectedErr)
205+
}
206+
assert.Equal(t, len(actualImages), len(test.expectedImages))
207+
assert.DeepEqual(t, actualImages, test.expectedImages)
208+
})
209+
}
210+
}
211+
127212
func TestFilterByReference(t *testing.T) {
128213
tests := []struct {
129214
name string

0 commit comments

Comments
 (0)