master
cpp 478 lines 19.8 KB
Raw
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 WSLCE2EImageListTests.cpp
8
9 Abstract:
10
11 This file contains end-to-end tests for WSLC.
12 --*/
13
14 #include "precomp.h"
15 #include "windows/Common.h"
16 #include "ImageModel.h"
17 #include "WSLCExecutor.h"
18 #include "WSLCE2EHelpers.h"
19 #include "TestImageRegistry.h"
20
21 namespace WSLCE2ETests {
22 using namespace wsl::shared;
23 using namespace wsl::windows::common::string;
24 using namespace wsl::windows::wslc::models;
25
26 class WSLCE2EImageListTests
27 {
28 WSLC_TEST_CLASS(WSLCE2EImageListTests)
29
30 TEST_CLASS_SETUP(ClassSetup)
31 {
32 TestImageRegistry::Instance().EnsureLoaded(DebianImage);
33 TestImageRegistry::Instance().EnsureLoaded(AlpineImage);
34 return true;
35 }
36
37 TEST_CLASS_CLEANUP(ClassCleanup)
38 {
39 return true;
40 }
41
42 WSLC_TEST_METHOD(WSLCE2E_Image_List_HelpCommand)
43 {
44 const auto result = RunWslc(L"image list --help");
45 result.Verify({.Stderr = L"", .ExitCode = 0});
46 VERIFY_IS_FALSE(result.Stdout.value().empty());
47 }
48
49 WSLC_TEST_METHOD(WSLCE2E_Image_List_DisplayLoadedImage)
50 {
51 const auto result = RunWslc(L"image list");
52 result.Verify({.Stderr = L"", .ExitCode = 0});
53 for (const auto& line : result.GetStdoutLines())
54 {
55 if (line.find(DebianImage.Name) != std::wstring::npos && line.find(DebianImage.Tag) != std::wstring::npos)
56 {
57 return;
58 }
59 }
60
61 VERIFY_FAIL(L"Failed to find the loaded image in the output");
62 }
63
64 WSLC_TEST_METHOD(WSLCE2E_Image_List_QuietOption_OutputsIdsOnly)
65 {
66 // Get the expected image ID from JSON output. --no-trunc is required because json output
67 // truncates the id by default.
68 auto jsonResult = RunWslc(L"image list --format json --no-trunc");
69 jsonResult.Verify({.Stderr = L"", .ExitCode = 0});
70 const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(jsonResult);
71
72 std::string debianId;
73 for (const auto& image : images)
74 {
75 if (image.Repository == wsl::shared::string::WideToMultiByte(DebianImage.Name) &&
76 image.Tag == wsl::shared::string::WideToMultiByte(DebianImage.Tag))
77 {
78 debianId = image.ID;
79 break;
80 }
81 }
82 VERIFY_ARE_NOT_EQUAL(std::string{}, debianId, L"Debian image was not present in `image list --format json` output");
83
84 const auto truncatedDebianId = wsl::shared::string::MultiByteToWide(TruncateId(debianId, true));
85 const auto fullDebianIdW = wsl::shared::string::MultiByteToWide(debianId);
86
87 // Default --quiet truncates to 12 chars.
88 auto truncResult = RunWslc(L"image list --quiet");
89 truncResult.Verify({.Stderr = L"", .ExitCode = 0});
90
91 bool truncatedFound = false;
92 for (const auto& line : truncResult.GetStdoutLines())
93 {
94 if (line == truncatedDebianId)
95 {
96 truncatedFound = true;
97 break;
98 }
99 }
100 VERIFY_IS_TRUE(truncatedFound, L"Truncated image ID not found in --quiet output");
101
102 // --quiet --no-trunc shows the full id with sha256: prefix.
103 auto noTruncResult = RunWslc(L"image list --quiet --no-trunc");
104 noTruncResult.Verify({.Stderr = L"", .ExitCode = 0});
105
106 bool fullFound = false;
107 for (const auto& line : noTruncResult.GetStdoutLines())
108 {
109 if (line == fullDebianIdW)
110 {
111 fullFound = true;
112 break;
113 }
114 }
115 VERIFY_IS_TRUE(fullFound, L"Full image ID not found in --quiet --no-trunc output");
116 }
117
118 WSLC_TEST_METHOD(WSLCE2E_Image_List_InvalidFormatOption)
119 {
120 const auto result = RunWslc(L"image list --format invalid");
121 result.Verify({.Stdout = L"", .ExitCode = 1});
122 VERIFY_IS_TRUE(result.StderrContainsSubstring(
123 L"Invalid format value: invalid is not a recognized format type. Supported format types are: json, table."));
124 }
125
126 WSLC_TEST_METHOD(WSLCE2E_Image_List_JsonFormat)
127 {
128 const auto result = RunWslc(L"image list --format json");
129 result.Verify({.Stderr = L"", .ExitCode = 0});
130
131 const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(result);
132
133 VERIFY_IS_GREATER_THAN_OR_EQUAL(images.size(), 2u);
134
135 std::vector<std::wstring> imageNames;
136 for (const auto& image : images)
137 {
138 auto nameAndTag = std::format(
139 L"{}:{}", wsl::shared::string::MultiByteToWide(image.Repository), wsl::shared::string::MultiByteToWide(image.Tag));
140 imageNames.push_back(nameAndTag);
141 }
142
143 VERIFY_ARE_NOT_EQUAL(imageNames.end(), std::find(imageNames.begin(), imageNames.end(), DebianImage.NameAndTag()));
144 VERIFY_ARE_NOT_EQUAL(imageNames.end(), std::find(imageNames.begin(), imageNames.end(), AlpineImage.NameAndTag()));
145 }
146
147 WSLC_TEST_METHOD(WSLCE2E_Image_List_JsonFormat_MatchesDockerShape)
148 {
149 const std::set<std::string> expectedKeys = {
150 "Containers", "CreatedAt", "CreatedSince", "Digest", "ID", "Repository", "SharedSize", "Size", "Tag", "UniqueSize"};
151
152 const auto result = RunWslc(L"image list --format json");
153 result.Verify({.Stderr = L"", .ExitCode = 0});
154
155 const auto entries = ParseNdjsonOutput(result);
156 VERIFY_IS_GREATER_THAN_OR_EQUAL(entries.size(), 2u);
157
158 for (const auto& entry : entries)
159 {
160 std::set<std::string> keys;
161 for (const auto& [key, value] : entry.items())
162 {
163 keys.insert(key);
164 VERIFY_IS_TRUE(value.is_string(), wsl::shared::string::MultiByteToWide(std::format("'{}' must be a string", key)).c_str());
165 }
166
167 VERIFY_ARE_EQUAL(expectedKeys, keys, L"json output must contain exactly docker's image fields");
168
169 VERIFY_ARE_NOT_EQUAL(std::string{}, entry["Repository"].get<std::string>());
170 VERIFY_ARE_NOT_EQUAL(std::string{}, entry["Tag"].get<std::string>());
171 VERIFY_ARE_EQUAL(std::string{c_none}, entry["Digest"].get<std::string>());
172
173 const auto containers = entry["Containers"].get<std::string>();
174 VERIFY_IS_FALSE(containers.empty());
175 VERIFY_IS_TRUE(
176 std::ranges::all_of(containers, [](char value) { return std::isdigit(static_cast<unsigned char>(value)) != 0; }),
177 L"'Containers' must be a container count");
178 }
179 }
180
181 WSLC_TEST_METHOD(WSLCE2E_Image_List_JsonFormat_ReportsContainerCount)
182 {
183 // Every container created from an image is counted, including containers that were never
184 // started, and the count is reported against every tag of that image.
185 constexpr auto containerName = L"wslc-image-list-container-count";
186 EnsureContainerDoesNotExist(containerName);
187
188 auto containerCount = [](const TestImage& image) {
189 const auto result = RunWslc(L"image list --format json");
190 result.Verify({.Stderr = L"", .ExitCode = 0});
191
192 for (const auto& entry : ParseNdjsonOutputAs<ImageOutputInformation>(result))
193 {
194 if (entry.Repository == wsl::shared::string::WideToMultiByte(image.Name) &&
195 entry.Tag == wsl::shared::string::WideToMultiByte(image.Tag))
196 {
197 return std::stoi(entry.Containers);
198 }
199 }
200
201 VERIFY_FAIL(std::format(L"Image '{}' not found in image list output", image.NameAndTag()).c_str());
202 return -1;
203 };
204
205 const auto alpineBaseline = containerCount(AlpineImage);
206 const auto debianBaseline = containerCount(DebianImage);
207
208 auto createResult = RunWslc(std::format(L"container create --name {} {}", containerName, AlpineImage.NameAndTag()));
209 createResult.Verify({.Stderr = L"", .ExitCode = 0});
210 auto cleanup = wil::scope_exit([&]() { EnsureContainerDoesNotExist(containerName); });
211
212 VERIFY_ARE_EQUAL(alpineBaseline + 1, containerCount(AlpineImage), L"a created container must be counted");
213 VERIFY_ARE_EQUAL(debianBaseline, containerCount(DebianImage), L"only the image the container was created from is counted");
214
215 cleanup.reset();
216
217 VERIFY_ARE_EQUAL(alpineBaseline, containerCount(AlpineImage), L"a removed container must no longer be counted");
218 }
219
220 WSLC_TEST_METHOD(WSLCE2E_Image_List_JsonFormat_TruncatesIdByDefault)
221 {
222 // The id is truncated to 12 hex characters unless --no-trunc is passed, in which case it
223 // keeps the sha256: prefix.
224 auto truncResult = RunWslc(L"image list --format json");
225 truncResult.Verify({.Stderr = L"", .ExitCode = 0});
226
227 for (const auto& image : ParseNdjsonOutputAs<ImageOutputInformation>(truncResult))
228 {
229 VERIFY_ARE_EQUAL(12u, image.ID.size(), L"json ids must be truncated to 12 characters by default");
230 VERIFY_IS_FALSE(image.ID.starts_with("sha256:"));
231 }
232
233 auto noTruncResult = RunWslc(L"image list --format json --no-trunc");
234 noTruncResult.Verify({.Stderr = L"", .ExitCode = 0});
235
236 for (const auto& image : ParseNdjsonOutputAs<ImageOutputInformation>(noTruncResult))
237 {
238 VERIFY_IS_TRUE(image.ID.starts_with("sha256:"), L"--no-trunc ids must keep the algorithm prefix");
239 VERIFY_IS_GREATER_THAN(image.ID.size(), 12u);
240 }
241 }
242
243 WSLC_TEST_METHOD(WSLCE2E_Image_List_TableFormat_HasExpectedColumns)
244 {
245 const auto result = RunWslc(L"image list");
246 result.Verify({.Stderr = L"", .ExitCode = 0});
247
248 bool foundHeader = false;
249 for (const auto& line : result.GetStdoutLines())
250 {
251 if (line.find(L"REPOSITORY") != std::wstring::npos && line.find(L"TAG") != std::wstring::npos &&
252 line.find(L"IMAGE ID") != std::wstring::npos && line.find(L"CREATED") != std::wstring::npos &&
253 line.find(L"SIZE") != std::wstring::npos)
254 {
255 foundHeader = true;
256 break;
257 }
258 }
259
260 VERIFY_IS_TRUE(foundHeader, L"Expected table header with REPOSITORY, TAG, IMAGE ID, CREATED, SIZE columns");
261 }
262
263 WSLC_TEST_METHOD(WSLCE2E_Image_List_TableFormat_MatchesJsonValues)
264 {
265 // The table and json output must report the same values, formatted the way docker formats
266 // them: SI sizes ("120MB", not "119.86 MB") and "<none>" for missing repository/tag data.
267 const auto jsonResult = RunWslc(L"image list --format json");
268 jsonResult.Verify({.Stderr = L"", .ExitCode = 0});
269
270 const auto tableResult = RunWslc(L"image list");
271 tableResult.Verify({.Stderr = L"", .ExitCode = 0});
272 const auto tableLines = tableResult.GetStdoutLines();
273
274 const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(jsonResult);
275 VERIFY_IS_GREATER_THAN_OR_EQUAL(images.size(), 2u);
276
277 for (const auto& image : images)
278 {
279 const auto row = std::format(
280 L"{} {} {} {}",
281 wsl::shared::string::MultiByteToWide(image.Repository),
282 wsl::shared::string::MultiByteToWide(image.Tag),
283 wsl::shared::string::MultiByteToWide(image.ID),
284 wsl::shared::string::MultiByteToWide(image.Size));
285
286 const bool found = std::ranges::any_of(tableLines, [&](const auto& line) {
287 // Columns are padded, so match on the individual values rather than the row text.
288 return line.find(wsl::shared::string::MultiByteToWide(image.ID)) != std::wstring::npos &&
289 line.find(wsl::shared::string::MultiByteToWide(image.Repository)) != std::wstring::npos &&
290 line.find(wsl::shared::string::MultiByteToWide(image.Tag)) != std::wstring::npos &&
291 line.find(wsl::shared::string::MultiByteToWide(image.Size)) != std::wstring::npos &&
292 line.find(wsl::shared::string::MultiByteToWide(image.CreatedSince)) != std::wstring::npos;
293 });
294
295 VERIFY_IS_TRUE(found, std::format(L"Table output has no row matching json values: {}", row).c_str());
296 }
297
298 // An untagged image is never labeled "<untagged>" in either format.
299 for (const auto& line : tableLines)
300 {
301 VERIFY_ARE_EQUAL(std::wstring::npos, line.find(L"<untagged>"), L"table must use the '<none>' placeholder");
302 }
303 }
304
305 WSLC_TEST_METHOD(WSLCE2E_Image_List_TableFormat_NoTruncKeepsAlgorithmPrefix)
306 {
307 // The --no-trunc table keeps the "sha256:" prefix on the image id.
308 const auto result = RunWslc(L"image list --no-trunc");
309 result.Verify({.Stderr = L"", .ExitCode = 0});
310
311 auto lines = result.GetStdoutLines();
312 VERIFY_IS_GREATER_THAN_OR_EQUAL(lines.size(), 2u);
313
314 for (size_t i = 1; i < lines.size(); i++)
315 {
316 if (!lines[i].empty())
317 {
318 VERIFY_ARE_NOT_EQUAL(
319 std::wstring::npos, lines[i].find(L"sha256:"), L"--no-trunc table ids must keep the algorithm prefix");
320 }
321 }
322 }
323
324 WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_MalformedValue)
325 {
326 // Filter values must be of the form key=value; bare keys are rejected by the CLI.
327 const auto result = RunWslc(L"image list --filter dangling");
328 result.Verify({.Stdout = L"", .ExitCode = 1});
329 VERIFY_IS_TRUE(result.StderrContainsSubstring(Localization::WSLCCLI_InvalidFilterError(L"dangling")));
330 }
331
332 WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_InvalidKey)
333 {
334 // Filter keys are validated by the Docker daemon, which rejects unknown keys.
335 const auto result = RunWslc(L"image list --filter color=blue");
336 VERIFY_ARE_EQUAL(1, result.ExitCode);
337 VERIFY_IS_TRUE(result.Stderr.has_value());
338 VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.Stderr->find(L"invalid filter"));
339 }
340
341 WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_Reference)
342 {
343 auto listNames = [&](const std::wstring& filterArgs) {
344 auto r = RunWslc(std::format(L"image list --format json {}", filterArgs));
345 r.Verify({.Stderr = L"", .ExitCode = 0});
346 const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(r);
347 std::set<std::wstring> names;
348 for (const auto& image : images)
349 {
350 names.insert(std::format(
351 L"{}:{}", wsl::shared::string::MultiByteToWide(image.Repository), wsl::shared::string::MultiByteToWide(image.Tag)));
352 }
353 return names;
354 };
355
356 // reference=<name> matches only the matching image.
357 {
358 const auto names = listNames(std::format(L"--filter reference={}", DebianImage.Name));
359 VERIFY_IS_TRUE(names.contains(DebianImage.NameAndTag()));
360 VERIFY_IS_FALSE(names.contains(AlpineImage.NameAndTag()));
361 }
362
363 {
364 const auto names = listNames(std::format(L"--filter reference={}", AlpineImage.Name));
365 VERIFY_IS_FALSE(names.contains(DebianImage.NameAndTag()));
366 VERIFY_IS_TRUE(names.contains(AlpineImage.NameAndTag()));
367 }
368
369 // Multiple --filter reference= values are OR'd: both images should be returned.
370 {
371 const auto names = listNames(std::format(L"--filter reference={} --filter reference={}", DebianImage.Name, AlpineImage.Name));
372 VERIFY_IS_TRUE(names.contains(DebianImage.NameAndTag()));
373 VERIFY_IS_TRUE(names.contains(AlpineImage.NameAndTag()));
374 }
375
376 // A reference that matches nothing returns neither image.
377 {
378 const auto names = listNames(L"--filter reference=wslc-no-such-image-zzz");
379 VERIFY_IS_FALSE(names.contains(DebianImage.NameAndTag()));
380 VERIFY_IS_FALSE(names.contains(AlpineImage.NameAndTag()));
381 }
382 }
383
384 WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_Dangling)
385 {
386 // dangling=false should include normal (tagged) images.
387 auto result = RunWslc(L"image list --format json --filter dangling=false");
388 result.Verify({.Stderr = L"", .ExitCode = 0});
389
390 auto images = ParseNdjsonOutputAs<ImageOutputInformation>(result);
391 bool foundDebian = false;
392 for (const auto& image : images)
393 {
394 if (image.Repository == wsl::shared::string::WideToMultiByte(DebianImage.Name))
395 {
396 foundDebian = true;
397 break;
398 }
399 }
400 VERIFY_IS_TRUE(foundDebian, L"Expected debian image to appear in dangling=false image list");
401
402 // dangling=true should exclude all tagged images.
403 result = RunWslc(L"image list --format json --filter dangling=true");
404 result.Verify({.Stderr = L"", .ExitCode = 0});
405
406 images = ParseNdjsonOutputAs<ImageOutputInformation>(result);
407 for (const auto& image : images)
408 {
409 VERIFY_ARE_EQUAL(
410 std::string{wsl::windows::wslc::models::c_none},
411 image.Repository,
412 L"dangling=true list should not contain tagged images");
413 }
414 }
415
416 WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_MultipleKinds)
417 {
418 // Mixing different filter kinds should AND: reference=<debian> AND dangling=false
419 // narrows to just debian (alpine is excluded by the reference filter).
420 const auto result =
421 RunWslc(std::format(L"image list --format json --filter reference={} --filter dangling=false", DebianImage.Name));
422 result.Verify({.Stderr = L"", .ExitCode = 0});
423
424 const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(result);
425 bool foundDebian = false;
426 for (const auto& image : images)
427 {
428 const auto repo = wsl::shared::string::MultiByteToWide(image.Repository);
429 VERIFY_ARE_NOT_EQUAL(AlpineImage.Name, repo, L"alpine should not appear when filtering by reference=debian");
430 if (repo == DebianImage.Name)
431 {
432 foundDebian = true;
433 }
434 }
435 VERIFY_IS_TRUE(foundDebian, L"Expected debian image when combining reference and dangling filters");
436 }
437
438 WSLC_TEST_METHOD(WSLCE2E_Image_List_NoTrunc_ShowsFullImageId)
439 {
440 // Pull the full image id from JSON output. --no-trunc is required because json output
441 // truncates the id by default.
442 auto jsonResult = RunWslc(L"image list --format json --no-trunc");
443 jsonResult.Verify({.Stderr = L"", .ExitCode = 0});
444 const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(jsonResult);
445
446 std::string fullDebianId;
447 for (const auto& image : images)
448 {
449 if (image.Repository == wsl::shared::string::WideToMultiByte(DebianImage.Name))
450 {
451 fullDebianId = image.ID;
452 break;
453 }
454 }
455 VERIFY_ARE_NOT_EQUAL(std::string{}, fullDebianId, L"Debian image was not present in `image list --format json` output");
456
457 fullDebianId = GetHashId(fullDebianId, true);
458 VERIFY_IS_GREATER_THAN(fullDebianId.size(), 12u);
459 const auto fullDebianIdW = wsl::shared::string::MultiByteToWide(fullDebianId);
460 const auto truncatedDebianIdW = fullDebianIdW.substr(0, 12);
461
462 // Default table truncates IMAGE ID to 12 chars.
463 auto truncResult = RunWslc(L"image list");
464 truncResult.Verify({.Stderr = L"", .ExitCode = 0});
465 VERIFY_IS_TRUE(truncResult.StdoutContainsSubstring(truncatedDebianIdW));
466 VERIFY_IS_FALSE(truncResult.StdoutContainsSubstring(fullDebianIdW));
467
468 // --no-trunc must show the full id.
469 auto noTruncResult = RunWslc(L"image list --no-trunc");
470 noTruncResult.Verify({.Stderr = L"", .ExitCode = 0});
471 VERIFY_IS_TRUE(noTruncResult.StdoutContainsSubstring(fullDebianIdW));
472 }
473
474 private:
475 const TestImage& DebianImage = DebianTestImage();
476 const TestImage& AlpineImage = AlpineTestImage();
477 };
478 } // namespace WSLCE2ETests