Image list and prune filters
Kevin Vega committed
May 29, 2026 at 15:37 UTC
b86c73cfe996a99db88e633ec48ac0a35276579e
7 files changed
+260
-18
src/windows/wslc/commands/ImageListCommand.cpp
+1
@@ -28,6 +28,7 @@ namespace wsl::windows::wslc {
28
std::vector<Argument> ImageListCommand::GetArguments() const
29
{
30
return {
31
+ Argument::Create(ArgType::Filter, false, NO_LIMIT),
32
Argument::Create(ArgType::Format),
33
Argument::Create(ArgType::NoTrunc),
34
Argument::Create(ArgType::Quiet),
src/windows/wslc/commands/ImagePruneCommand.cpp
+1
@@ -29,6 +29,7 @@ std::vector<Argument> ImagePruneCommand::GetArguments() const
29
{
30
return {
31
Argument::Create(ArgType::All, std::nullopt, std::nullopt, Localization::WSLCCLI_ImagePruneAllArgDescription()),
32
+ Argument::Create(ArgType::Filter, false, NO_LIMIT),
33
Argument::Create(ArgType::Session),
34
};
35
}
src/windows/wslc/services/ImageService.cpp
+36
-5
@@ -181,11 +181,24 @@ void ImageService::Build(
181
THROW_IF_FAILED(session.Get()->BuildImage(&options, callback, cancelEvent));
182
}
183
184
-std::vector<ImageInformation> ImageService::List(wsl::windows::wslc::models::Session& session)
184
+std::vector<ImageInformation> ImageService::List(
185
+ wsl::windows::wslc::models::Session& session, const std::vector<std::pair<std::string, std::string>>& filters)
186
{
187
+ std::vector<WSLCFilter> filterEntries;
188
+ filterEntries.reserve(filters.size());
189
+ for (const auto& [key, value] : filters)
190
+ {
191
+ filterEntries.push_back({.Key = key.c_str(), .Value = value.c_str()});
192
+ }
193
+
194
+ WSLCListImagesOptions options{};
195
+ options.Flags = WSLCListImagesFlagsNone;
196
+ options.Filters = filterEntries.empty() ? nullptr : filterEntries.data();
197
+ options.FiltersCount = static_cast<ULONG>(filterEntries.size());
198
+
199
wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
200
ULONG count = 0;
188
- THROW_IF_FAILED(session.Get()->ListImages(nullptr, &images, &count));
201
+ THROW_IF_FAILED(session.Get()->ListImages(&options, &images, &count));
202
203
std::vector<ImageInformation> result;
204
for (auto ptr = images.get(), end = images.get() + count; ptr != end; ++ptr)
@@ -297,13 +310,31 @@ void ImageService::Save(wsl::windows::wslc::models::Session& session, const std:
310
THROW_IF_FAILED(session.Get()->SaveImage(ToCOMInputHandle(outputHandle), image.c_str(), nullptr, cancelEvent));
311
}
312
300
-wsl::windows::wslc::models::PruneImagesResult ImageService::Prune(wsl::windows::wslc::models::Session& session, bool all)
313
+wsl::windows::wslc::models::PruneImagesResult ImageService::Prune(
314
+ wsl::windows::wslc::models::Session& session, bool all, const std::vector<std::pair<std::string, std::string>>& filters)
315
{
302
- WSLCFilter filter{.Key = "dangling", .Value = all ? "false" : "true"};
316
+ // The --all flag is translated into a `dangling` filter. Skip the implicit
317
+ // filter if the caller already supplied an explicit `dangling` filter so the
318
+ // user's value wins (matching docker's behavior).
319
+ const bool hasExplicitDangling =
320
+ std::any_of(filters.begin(), filters.end(), [](const auto& f) { return f.first == "dangling"; });
321
+
322
+ std::vector<WSLCFilter> filterEntries;
323
+ filterEntries.reserve(filters.size() + (hasExplicitDangling ? 0 : 1));
324
+ if (!hasExplicitDangling)
325
+ {
326
+ filterEntries.push_back({.Key = "dangling", .Value = all ? "false" : "true"});
327
+ }
328
+
329
+ for (const auto& [key, value] : filters)
330
+ {
331
+ filterEntries.push_back({.Key = key.c_str(), .Value = value.c_str()});
332
+ }
333
334
wil::unique_cotaskmem_array_ptr<WSLCDeletedImageInformation> deletedImages;
335
ULONGLONG spaceReclaimed = 0;
306
- THROW_IF_FAILED(session.Get()->PruneImages(&filter, 1, &deletedImages, deletedImages.size_address<ULONG>(), &spaceReclaimed));
336
+ THROW_IF_FAILED(session.Get()->PruneImages(
337
+ filterEntries.data(), static_cast<ULONG>(filterEntries.size()), &deletedImages, deletedImages.size_address<ULONG>(), &spaceReclaimed));
338
339
wsl::windows::wslc::models::PruneImagesResult result;
340
result.SpaceReclaimed = spaceReclaimed;
src/windows/wslc/services/ImageService.h
+4
-2
@@ -32,7 +32,8 @@ public:
32
IProgressCallback* callback,
33
HANDLE cancelEvent = nullptr);
34
35
- static std::vector<wsl::windows::wslc::models::ImageInformation> List(wsl::windows::wslc::models::Session& session);
35
+ static std::vector<wsl::windows::wslc::models::ImageInformation> List(
36
+ wsl::windows::wslc::models::Session& session, const std::vector<std::pair<std::string, std::string>>& filters = {});
37
static void Load(wsl::windows::wslc::models::Session& session, const std::wstring& input);
38
static void Import(wsl::windows::wslc::models::Session& session, const std::wstring& input, const std::string& imageName);
39
static void Delete(wsl::windows::wslc::models::Session& session, const std::string& image, bool force, bool noPrune);
@@ -42,6 +43,7 @@ public:
43
static void Save(wsl::windows::wslc::models::Session& session, const std::string& image, const std::wstring& output, HANDLE cancelEvent = nullptr);
44
static void Save(wsl::windows::wslc::models::Session& session, const std::string& image, HANDLE outputHandle, HANDLE cancelEvent = nullptr);
45
static void Tag(wsl::windows::wslc::models::Session& session, const std::string& sourceImage, const std::string& targetImage);
45
- static wsl::windows::wslc::models::PruneImagesResult Prune(wsl::windows::wslc::models::Session& session, bool all);
46
+ static wsl::windows::wslc::models::PruneImagesResult Prune(
47
+ wsl::windows::wslc::models::Session& session, bool all, const std::vector<std::pair<std::string, std::string>>& filters = {});
48
};
49
} // namespace wsl::windows::wslc::services
src/windows/wslc/tasks/ImageTasks.cpp
+32
-2
@@ -92,7 +92,22 @@ void GetImages(CLIExecutionContext& context)
92
{
93
WI_ASSERT(context.Data.Contains(Data::Session));
94
auto& session = context.Data.Get<Data::Session>();
95
- auto images = ImageService::List(session);
95
+
96
+ // Filter syntax (`key=value`) is enforced upstream; here we just split on the first '='.
97
+ std::vector<std::pair<std::string, std::string>> filters;
98
+ if (context.Args.Contains(ArgType::Filter))
99
+ {
100
+ for (const auto& wideValue : context.Args.GetAll<ArgType::Filter>())
101
+ {
102
+ std::string raw = WideToMultiByte(wideValue);
103
+ const auto eq = raw.find('=');
104
+ WI_ASSERT(eq != std::string::npos);
105
+
106
+ filters.emplace_back(raw.substr(0, eq), raw.substr(eq + 1));
107
+ }
108
+ }
109
+
110
+ auto images = ImageService::List(session, filters);
111
context.Data.Add<Data::Images>(std::move(images));
112
}
113
@@ -290,7 +305,22 @@ void PruneImages(CLIExecutionContext& context)
305
auto& session = context.Data.Get<Data::Session>();
306
307
bool all = context.Args.Contains(ArgType::All);
293
- auto result = ImageService::Prune(session, all);
308
+
309
+ // Filter syntax (`key=value`) is enforced upstream; here we just split on the first '='.
310
+ std::vector<std::pair<std::string, std::string>> filters;
311
+ if (context.Args.Contains(ArgType::Filter))
312
+ {
313
+ for (const auto& wideValue : context.Args.GetAll<ArgType::Filter>())
314
+ {
315
+ std::string raw = WideToMultiByte(wideValue);
316
+ const auto eq = raw.find('=');
317
+ WI_ASSERT(eq != std::string::npos);
318
+
319
+ filters.emplace_back(raw.substr(0, eq), raw.substr(eq + 1));
320
+ }
321
+ }
322
+
323
+ auto result = ImageService::Prune(session, all, filters);
324
325
for (const auto& image : result.UntaggedImages)
326
{
test/windows/wslc/e2e/WSLCE2EImageListTests.cpp
+121
-6
@@ -128,6 +128,120 @@ class WSLCE2EImageListTests
128
VERIFY_IS_TRUE(foundHeader, L"Expected table header with REPOSITORY, TAG, IMAGE ID, CREATED, SIZE columns");
129
}
130
131
+ WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_MalformedValue)
132
+ {
133
+ // Filter values must be of the form key=value; bare keys are rejected by the CLI.
134
+ const auto result = RunWslc(L"image list --filter dangling");
135
+ result.Verify({.Stdout = GetHelpMessage(), .Stderr = Localization::WSLCCLI_InvalidFilterError(L"dangling") + L"\r\n", .ExitCode = 1});
136
+ }
137
+
138
+ WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_InvalidKey)
139
+ {
140
+ // Filter keys are validated by the Docker daemon, which rejects unknown keys.
141
+ const auto result = RunWslc(L"image list --filter color=blue");
142
+ VERIFY_ARE_EQUAL(1, result.ExitCode);
143
+ VERIFY_IS_TRUE(result.Stderr.has_value());
144
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.Stderr->find(L"invalid filter"));
145
+ }
146
+
147
+ WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_Reference)
148
+ {
149
+ auto listNames = [&](const std::wstring& filterArgs) {
150
+ auto r = RunWslc(std::format(L"image list --format json {}", filterArgs));
151
+ r.Verify({.Stderr = L"", .ExitCode = 0});
152
+ const auto images = wsl::shared::FromJson<std::vector<ImageInformation>>(r.Stdout.value().c_str());
153
+ std::set<std::wstring> names;
154
+ for (const auto& image : images)
155
+ {
156
+ names.insert(std::format(
157
+ L"{}:{}",
158
+ wsl::shared::string::MultiByteToWide(image.Repository.value_or("<untagged>")),
159
+ wsl::shared::string::MultiByteToWide(image.Tag.value_or("<untagged>"))));
160
+ }
161
+ return names;
162
+ };
163
+
164
+ // reference=<name> matches only the matching image.
165
+ {
166
+ const auto names = listNames(std::format(L"--filter reference={}", DebianImage.Name));
167
+ VERIFY_IS_TRUE(names.contains(DebianImage.NameAndTag()));
168
+ VERIFY_IS_FALSE(names.contains(AlpineImage.NameAndTag()));
169
+ }
170
+
171
+ {
172
+ const auto names = listNames(std::format(L"--filter reference={}", AlpineImage.Name));
173
+ VERIFY_IS_FALSE(names.contains(DebianImage.NameAndTag()));
174
+ VERIFY_IS_TRUE(names.contains(AlpineImage.NameAndTag()));
175
+ }
176
+
177
+ // Multiple --filter reference= values are OR'd: both images should be returned.
178
+ {
179
+ const auto names = listNames(std::format(L"--filter reference={} --filter reference={}", DebianImage.Name, AlpineImage.Name));
180
+ VERIFY_IS_TRUE(names.contains(DebianImage.NameAndTag()));
181
+ VERIFY_IS_TRUE(names.contains(AlpineImage.NameAndTag()));
182
+ }
183
+
184
+ // A reference that matches nothing returns neither image.
185
+ {
186
+ const auto names = listNames(L"--filter reference=wslc-no-such-image-zzz");
187
+ VERIFY_IS_FALSE(names.contains(DebianImage.NameAndTag()));
188
+ VERIFY_IS_FALSE(names.contains(AlpineImage.NameAndTag()));
189
+ }
190
+ }
191
+
192
+ WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_Dangling)
193
+ {
194
+ // dangling=false should include normal (tagged) images.
195
+ auto result = RunWslc(L"image list --format json --filter dangling=false");
196
+ result.Verify({.Stderr = L"", .ExitCode = 0});
197
+
198
+ auto images = wsl::shared::FromJson<std::vector<ImageInformation>>(result.Stdout.value().c_str());
199
+ bool foundDebian = false;
200
+ for (const auto& image : images)
201
+ {
202
+ if (image.Repository == wsl::shared::string::WideToMultiByte(DebianImage.Name))
203
+ {
204
+ foundDebian = true;
205
+ break;
206
+ }
207
+ }
208
+ VERIFY_IS_TRUE(foundDebian, L"Expected debian image to appear in dangling=false image list");
209
+
210
+ // dangling=true should exclude all tagged images.
211
+ result = RunWslc(L"image list --format json --filter dangling=true");
212
+ result.Verify({.Stderr = L"", .ExitCode = 0});
213
+
214
+ images = wsl::shared::FromJson<std::vector<ImageInformation>>(result.Stdout.value().c_str());
215
+ for (const auto& image : images)
216
+ {
217
+ VERIFY_IS_FALSE(
218
+ image.Repository.has_value() && image.Repository.value() != "<none>",
219
+ L"dangling=true list should not contain tagged images");
220
+ }
221
+ }
222
+
223
+ WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_MultipleKinds)
224
+ {
225
+ // Mixing different filter kinds should AND: reference=<debian> AND dangling=false
226
+ // narrows to just debian (alpine is excluded by the reference filter).
227
+ const auto result =
228
+ RunWslc(std::format(L"image list --format json --filter reference={} --filter dangling=false", DebianImage.Name));
229
+ result.Verify({.Stderr = L"", .ExitCode = 0});
230
+
231
+ const auto images = wsl::shared::FromJson<std::vector<ImageInformation>>(result.Stdout.value().c_str());
232
+ bool foundDebian = false;
233
+ for (const auto& image : images)
234
+ {
235
+ const auto repo = wsl::shared::string::MultiByteToWide(image.Repository.value_or(""));
236
+ VERIFY_ARE_NOT_EQUAL(AlpineImage.Name, repo, L"alpine should not appear when filtering by reference=debian");
237
+ if (repo == DebianImage.Name)
238
+ {
239
+ foundDebian = true;
240
+ }
241
+ }
242
+ VERIFY_IS_TRUE(foundDebian, L"Expected debian image when combining reference and dangling filters");
243
+ }
244
+
245
private:
246
const TestImage& DebianImage = DebianTestImage();
247
const TestImage& AlpineImage = AlpineTestImage();
@@ -162,12 +276,13 @@ private:
276
{
277
std::wstringstream options;
278
options << L"The following options are available:\r\n"
165
- << L" --format " << Localization::WSLCCLI_FormatArgDescription() << L"\r\n"
166
- << L" --no-trunc Do not truncate output\r\n"
167
- << L" -q,--quiet Outputs the container IDs only\r\n"
168
- << L" --session Specify the session to use\r\n"
169
- << L" --verbose Output verbose details\r\n"
170
- << L" -?,--help Shows help about the selected command\r\n"
279
+ << L" -f,--filter " << Localization::WSLCCLI_FilterArgDescription() << L"\r\n"
280
+ << L" --format " << Localization::WSLCCLI_FormatArgDescription() << L"\r\n"
281
+ << L" --no-trunc Do not truncate output\r\n"
282
+ << L" -q,--quiet Outputs the container IDs only\r\n"
283
+ << L" --session Specify the session to use\r\n"
284
+ << L" --verbose Output verbose details\r\n"
285
+ << L" -?,--help Shows help about the selected command\r\n"
286
<< L"\r\n";
287
return options.str();
288
}
test/windows/wslc/e2e/WSLCE2EImagePruneTests.cpp
+65
-3
@@ -110,6 +110,67 @@ class WSLCE2EImagePruneTests
110
}
111
}
112
113
+ WSLC_TEST_METHOD(WSLCE2E_Image_Prune_Filter_MalformedValue)
114
+ {
115
+ // Filter values must be of the form key=value; bare keys are rejected by the CLI.
116
+ const auto result = RunWslc(L"image prune --filter label");
117
+ result.Verify({.Stdout = GetHelpMessage(), .Stderr = Localization::WSLCCLI_InvalidFilterError(L"label") + L"\r\n", .ExitCode = 1});
118
+ }
119
+
120
+ WSLC_TEST_METHOD(WSLCE2E_Image_Prune_Filter_InvalidKey)
121
+ {
122
+ // Filter keys are validated by the Docker daemon, which rejects unknown keys.
123
+ const auto result = RunWslc(L"image prune --filter color=blue");
124
+ VERIFY_ARE_EQUAL(1, result.ExitCode);
125
+ VERIFY_IS_TRUE(result.Stderr.has_value());
126
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.Stderr->find(L"invalid filter"));
127
+ }
128
+
129
+ WSLC_TEST_METHOD(WSLCE2E_Image_Prune_Filter_LabelPreservesDangling)
130
+ {
131
+ // Create a dangling debian image (same trick as WSLCE2E_Image_Prune_DanglingImage).
132
+ EnsureImageIsLoaded(AlpineImage);
133
+ auto cleanup = wil::scope_exit([&]() {
134
+ RunWslc(L"image prune");
135
+ RunWslc(L"image delete prune-target:v1");
136
+ EnsureImageIsDeleted(AlpineImage);
137
+ EnsureImageIsLoaded(DebianImage);
138
+ });
139
+
140
+ RunWslc(std::format(L"image tag {} prune-target:v1", DebianImage.NameAndTag())).Verify({.Stderr = L"", .ExitCode = 0});
141
+ RunWslc(std::format(L"image delete {}", DebianImage.NameAndTag())).Verify({.Stderr = L"", .ExitCode = 0});
142
+ RunWslc(std::format(L"image tag {} prune-target:v1", AlpineImage.NameAndTag())).Verify({.Stderr = L"", .ExitCode = 0});
143
+
144
+ // Prune only dangling images carrying a label the dangling image does NOT have.
145
+ // Multiple --filter label= values are AND'd by the daemon; the dangling image
146
+ // matches neither, so it must survive this prune.
147
+ auto filteredPrune = RunWslc(L"image prune --filter label=wslc.test.never=present --filter label=wslc.test.also=missing");
148
+ filteredPrune.Verify({.Stderr = L"", .ExitCode = 0});
149
+ for (const auto& line : filteredPrune.GetStdoutLines())
150
+ {
151
+ VERIFY_IS_FALSE(
152
+ line.find(L"Deleted:") != std::wstring::npos, L"Filtered prune should not have deleted the dangling image");
153
+ VERIFY_IS_FALSE(
154
+ line.find(L"Untagged:") != std::wstring::npos, L"Filtered prune should not have untagged the dangling image");
155
+ }
156
+
157
+ // A subsequent unfiltered prune should still find and remove the dangling image,
158
+ // proving the filter — not the absence of dangling images — was the reason nothing
159
+ // was pruned above.
160
+ auto unfilteredPrune = RunWslc(L"image prune");
161
+ unfilteredPrune.Verify({.Stderr = L"", .ExitCode = 0});
162
+ bool foundDeleted = false;
163
+ for (const auto& line : unfilteredPrune.GetStdoutLines())
164
+ {
165
+ if (line.find(L"Deleted:") != std::wstring::npos || line.find(L"Untagged:") != std::wstring::npos)
166
+ {
167
+ foundDeleted = true;
168
+ break;
169
+ }
170
+ }
171
+ VERIFY_IS_TRUE(foundDeleted, L"Expected the dangling image to be pruned by the unfiltered call");
172
+ }
173
+
174
private:
175
const TestImage& DebianImage = DebianTestImage();
176
const TestImage& AlpineImage = AlpineTestImage();
@@ -151,9 +212,10 @@ private:
212
{
213
std::wstringstream options;
214
options << L"The following options are available:\r\n"
154
- << L" -a,--all " << Localization::WSLCCLI_ImagePruneAllArgDescription() << L"\r\n"
155
- << L" --session " << Localization::WSLCCLI_SessionIdArgDescription() << L"\r\n"
156
- << L" -?,--help " << Localization::WSLCCLI_HelpArgDescription() << L"\r\n"
215
+ << L" -a,--all " << Localization::WSLCCLI_ImagePruneAllArgDescription() << L"\r\n"
216
+ << L" -f,--filter " << Localization::WSLCCLI_FilterArgDescription() << L"\r\n"
217
+ << L" --session " << Localization::WSLCCLI_SessionIdArgDescription() << L"\r\n"
218
+ << L" -?,--help " << Localization::WSLCCLI_HelpArgDescription() << L"\r\n"
219
<< L"\r\n";
220
return options.str();
221
}