@samitouri / QOSAMI-WSL / commits / b86c73cf

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 }