Align container list format with Docker specifications (#41375)

wslc: match column order, status text, and json shape for container list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

ggarzia-MSFT committed Aug 31, 2026 at 15:33 UTC 46be5bb59bcf2a012a5cd3d3114cf509d26af42e
21 files changed +1126 -91
localization/strings/en-US/Resources.resw
+26
@@ -3689,6 +3689,12 @@ On first run, creates the file with all settings commented out at their defaults
3689 <data name="WSLCCLI_TableHeaderName" xml:space="preserve">
3690 <value>NAME</value>
3691 </data>
3692 + <data name="WSLCCLI_TableHeaderNames" xml:space="preserve">
3693 + <value>NAMES</value>
3694 + </data>
3695 + <data name="WSLCCLI_TableHeaderCommand" xml:space="preserve">
3696 + <value>COMMAND</value>
3697 + </data>
3698 <data name="WSLCCLI_TableHeaderImage" xml:space="preserve">
3699 <value>IMAGE</value>
3700 </data>
@@ -3719,6 +3725,26 @@ On first run, creates the file with all settings commented out at their defaults
3725 <data name="WSLCCLI_TableHeaderPids" xml:space="preserve">
3726 <value>PIDS</value>
3727 </data>
3728 + <data name="WSLCCLI_ContainerStateCreated" xml:space="preserve">
3729 + <value>created</value>
3730 + <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3731 + </data>
3732 + <data name="WSLCCLI_ContainerStateRunning" xml:space="preserve">
3733 + <value>running</value>
3734 + <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3735 + </data>
3736 + <data name="WSLCCLI_ContainerStateStopped" xml:space="preserve">
3737 + <value>stopped</value>
3738 + <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3739 + </data>
3740 + <data name="WSLCCLI_ContainerStateExited" xml:space="preserve">
3741 + <value>exited</value>
3742 + <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3743 + </data>
3744 + <data name="WSLCCLI_ContainerStateInvalid" xml:space="preserve">
3745 + <value>invalid</value>
3746 + <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3747 + </data>
3748 <data name="WSLCCLI_RelativeTimeLessThanASecond" xml:space="preserve">
3749 <value>Less than a second ago</value>
3750 <comment>Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
src/windows/common/CMakeLists.txt
+1
@@ -130,6 +130,7 @@ set(HEADERS
130 VTSupport.h
131 WindowsUpdateIntegration.h
132 WSLCContainerLauncher.h
133 + WSLCContainerEntry.h
134 ConsommeNetworking.h
135 WSLCProcessLauncher.h
136 WslClient.h
src/windows/common/WSLCContainerEntry.h new
+29
@@ -0,0 +1,29 @@
1 +// Copyright (C) Microsoft Corporation. All rights reserved.
2 +
3 +#pragma once
4 +
5 +#include <wil/resource.h>
6 +#include "wslc.h"
7 +
8 +namespace wsl::windows::common::wslc {
9 +
10 +// IWSLCSession::ListContainers allocates a string for every unbounded field on an entry, so each
11 +// element owns memory that releasing the array alone would miss. Safe to call on a partially
12 +// populated entry because unset fields are null.
13 +inline void FreeContainerEntryStrings(_Inout_ WSLCContainerEntry* Entry)
14 +{
15 + CoTaskMemFree(Entry->Command);
16 + CoTaskMemFree(Entry->Status);
17 + CoTaskMemFree(Entry->Labels);
18 + CoTaskMemFree(Entry->Networks);
19 + CoTaskMemFree(Entry->Mounts);
20 +}
21 +
22 +// Owns both the entry array and the per-entry strings. Callers of ListContainers should use this
23 +// rather than a plain cotaskmem array so the strings cannot be leaked. The array still holds raw
24 +// entries, so it can be passed to the interface as-is.
25 +using unique_container_entry = wil::unique_struct<WSLCContainerEntry, decltype(&FreeContainerEntryStrings), FreeContainerEntryStrings>;
26 +
27 +using unique_container_entry_array = wil::unique_cotaskmem_array_ptr<unique_container_entry>;
28 +
29 +} // namespace wsl::windows::common::wslc
src/windows/common/string.cpp
+51
@@ -406,3 +406,54 @@ std::string wsl::windows::common::string::TruncateId(_In_ std::string_view id, b
406 {
407 return TruncateIdImpl(id, shortenLength);
408 }
409 +
410 +// Returns the number of terminal columns a code point occupies. This mirrors docker's charWidth, which treats
411 +// East Asian wide and fullwidth code points as two columns and everything else as one.
412 +static size_t CharacterWidth(UChar32 CodePoint)
413 +{
414 + const auto width = u_getIntPropertyValue(CodePoint, UCHAR_EAST_ASIAN_WIDTH);
415 + return (width == U_EA_WIDE || width == U_EA_FULLWIDTH) ? 2 : 1;
416 +}
417 +
418 +std::wstring wsl::windows::common::string::Ellipsis(_In_ std::wstring_view Value, _In_ size_t MaxDisplayWidth)
419 +{
420 + if (MaxDisplayWidth == 0 || Value.empty())
421 + {
422 + return {};
423 + }
424 +
425 + const auto length = gsl::narrow_cast<int32_t>(Value.size());
426 + if (MaxDisplayWidth == 1)
427 + {
428 + // There is no room for both content and an ellipsis, so the leading code point is kept as-is even
429 + // if it is wider than the limit.
430 + int32_t index = 0;
431 + UChar32 codePoint{};
432 + U16_NEXT(Value.data(), index, length, codePoint);
433 + return std::wstring{Value.substr(0, index)};
434 + }
435 +
436 + // The ellipsis occupies one column, so the retained content has one column less to work with.
437 + const auto budget = MaxDisplayWidth - 1;
438 + size_t totalWidth = 0;
439 + size_t cutoff = 0;
440 + for (int32_t index = 0; index < length;)
441 + {
442 + UChar32 codePoint{};
443 + U16_NEXT(Value.data(), index, length, codePoint);
444 + totalWidth += CharacterWidth(codePoint);
445 + if (totalWidth <= budget)
446 + {
447 + cutoff = index;
448 + }
449 + }
450 +
451 + // A cutoff of zero means the first code point alone leaves no room for the ellipsis, in which case docker
452 + // returns the value untouched.
453 + if (totalWidth <= MaxDisplayWidth || cutoff == 0)
454 + {
455 + return std::wstring{Value};
456 + }
457 +
458 + return std::wstring{Value.substr(0, cutoff)} + L'\u2026';
459 +}
src/windows/common/string.hpp
+6
@@ -68,6 +68,12 @@ std::string WideToMultiByte(_In_ std::wstring_view Source);
68 std::wstring TruncateId(_In_ std::wstring_view id, bool shortenLength = true);
69 std::string TruncateId(_In_ std::string_view id, bool shortenLength = true);
70
71 +// Shortens a value so it occupies at most MaxDisplayWidth terminal columns, appending an ellipsis when
72 +// characters are dropped. East Asian wide and fullwidth code points occupy two columns, so fewer of them
73 +// fit than narrow ones, and a code point is never split. This matches docker's formatter.Ellipsis
74 +// (cli/command/formatter/displayutils.go), including its handling of widths of one and below.
75 +std::wstring Ellipsis(_In_ std::wstring_view Value, _In_ size_t MaxDisplayWidth);
76 +
77 // Template implementation for TruncateId to avoid code duplication.
78 // Algorithm inspired from Moby for consistency in presentation of shortened IDs.
79 // Always strips the algorithm prefix (e.g., "sha256:") if present, and optionally shortens to 12 characters.
src/windows/common/timestamp.cpp
+115 -13
@@ -311,10 +311,36 @@ std::string wsl::windows::common::timestamp::Rfc3339ToUtcDisplayTime(std::string
311 return std::format("{:%F %T}{} +0000 UTC", parsed, fraction);
312 }
313
314 -std::wstring wsl::windows::common::timestamp::FormatElapsedSeconds(LONGLONG elapsedSeconds)
314 +namespace {
315 +
316 +enum class ElapsedUnit
317 +{
318 + LessThanASecond,
319 + OneSecond,
320 + Seconds,
321 + AboutAMinute,
322 + Minutes,
323 + AboutAnHour,
324 + Hours,
325 + Days,
326 + Weeks,
327 + Months,
328 + Years,
329 +};
330 +
331 +struct ElapsedDuration
332 +{
333 + ElapsedUnit Unit;
334 + LONGLONG Count;
335 +};
336 +
337 +} // namespace
338 +
339 +// Buckets an elapsed duration using the thresholds docker applies in go-units HumanDuration. The
340 +// localized and invariant renderings share this so the two can only differ in wording.
341 +static ElapsedDuration ClassifyElapsedSeconds(LONGLONG elapsedSeconds)
342 {
343 using namespace std::chrono_literals;
317 - using wsl::shared::Localization;
344
345 constexpr LONGLONG SecondsPerMinute = std::chrono::duration_cast<std::chrono::seconds>(1min).count();
346 constexpr LONGLONG SecondsPerHour = std::chrono::duration_cast<std::chrono::seconds>(1h).count();
@@ -325,51 +351,117 @@ std::wstring wsl::windows::common::timestamp::FormatElapsedSeconds(LONGLONG elap
351
352 if (elapsed < 1)
353 {
328 - return Localization::WSLCCLI_RelativeTimeLessThanASecond();
354 + return {ElapsedUnit::LessThanASecond, 0};
355 }
356 else if (elapsed == 1)
357 {
332 - return Localization::WSLCCLI_RelativeTimeOneSecond();
358 + return {ElapsedUnit::OneSecond, 1};
359 }
360 else if (elapsed < SecondsPerMinute)
361 {
336 - return Localization::WSLCCLI_RelativeTimeSeconds(elapsed);
362 + return {ElapsedUnit::Seconds, elapsed};
363 }
364
365 const auto minutes = elapsed / SecondsPerMinute;
366 if (minutes == 1)
367 {
342 - return Localization::WSLCCLI_RelativeTimeAboutAMinute();
368 + return {ElapsedUnit::AboutAMinute, 1};
369 }
370 else if (minutes < MinutesPerHour)
371 {
346 - return Localization::WSLCCLI_RelativeTimeMinutes(minutes);
372 + return {ElapsedUnit::Minutes, minutes};
373 }
374
375 // Rounded to the nearest hour rather than truncated.
376 const auto hours = (elapsed + (SecondsPerHour / 2)) / SecondsPerHour;
377 if (hours == 1)
378 {
353 - return Localization::WSLCCLI_RelativeTimeAboutAnHour();
379 + return {ElapsedUnit::AboutAnHour, 1};
380 }
381 else if (hours < HoursPerDay * 2)
382 {
357 - return Localization::WSLCCLI_RelativeTimeHours(hours);
383 + return {ElapsedUnit::Hours, hours};
384 }
385 else if (hours < HoursPerDay * 7 * 2)
386 {
361 - return Localization::WSLCCLI_RelativeTimeDays(hours / HoursPerDay);
387 + return {ElapsedUnit::Days, hours / HoursPerDay};
388 }
389 else if (hours < HoursPerDay * 30 * 2)
390 {
365 - return Localization::WSLCCLI_RelativeTimeWeeks(hours / HoursPerDay / 7);
391 + return {ElapsedUnit::Weeks, hours / HoursPerDay / 7};
392 }
393 else if (hours < HoursPerDay * 365 * 2)
394 {
369 - return Localization::WSLCCLI_RelativeTimeMonths(hours / HoursPerDay / 30);
395 + return {ElapsedUnit::Months, hours / HoursPerDay / 30};
396 }
397
372 - return Localization::WSLCCLI_RelativeTimeYears(elapsed / SecondsPerHour / HoursPerDay / 365);
398 + return {ElapsedUnit::Years, elapsed / SecondsPerHour / HoursPerDay / 365};
399 +}
400 +
401 +std::wstring wsl::windows::common::timestamp::FormatElapsedSeconds(LONGLONG elapsedSeconds)
402 +{
403 + using wsl::shared::Localization;
404 +
405 + const auto [unit, count] = ClassifyElapsedSeconds(elapsedSeconds);
406 + switch (unit)
407 + {
408 + case ElapsedUnit::LessThanASecond:
409 + return Localization::WSLCCLI_RelativeTimeLessThanASecond();
410 + case ElapsedUnit::OneSecond:
411 + return Localization::WSLCCLI_RelativeTimeOneSecond();
412 + case ElapsedUnit::Seconds:
413 + return Localization::WSLCCLI_RelativeTimeSeconds(count);
414 + case ElapsedUnit::AboutAMinute:
415 + return Localization::WSLCCLI_RelativeTimeAboutAMinute();
416 + case ElapsedUnit::Minutes:
417 + return Localization::WSLCCLI_RelativeTimeMinutes(count);
418 + case ElapsedUnit::AboutAnHour:
419 + return Localization::WSLCCLI_RelativeTimeAboutAnHour();
420 + case ElapsedUnit::Hours:
421 + return Localization::WSLCCLI_RelativeTimeHours(count);
422 + case ElapsedUnit::Days:
423 + return Localization::WSLCCLI_RelativeTimeDays(count);
424 + case ElapsedUnit::Weeks:
425 + return Localization::WSLCCLI_RelativeTimeWeeks(count);
426 + case ElapsedUnit::Months:
427 + return Localization::WSLCCLI_RelativeTimeMonths(count);
428 + case ElapsedUnit::Years:
429 + return Localization::WSLCCLI_RelativeTimeYears(count);
430 + default:
431 + THROW_HR(E_UNEXPECTED);
432 + }
433 +}
434 +
435 +std::wstring wsl::windows::common::timestamp::FormatInvariantElapsedSeconds(LONGLONG elapsedSeconds)
436 +{
437 + const auto [unit, count] = ClassifyElapsedSeconds(elapsedSeconds);
438 + switch (unit)
439 + {
440 + case ElapsedUnit::LessThanASecond:
441 + return L"Less than a second ago";
442 + case ElapsedUnit::OneSecond:
443 + return L"1 second ago";
444 + case ElapsedUnit::Seconds:
445 + return std::format(L"{} seconds ago", count);
446 + case ElapsedUnit::AboutAMinute:
447 + return L"About a minute ago";
448 + case ElapsedUnit::Minutes:
449 + return std::format(L"{} minutes ago", count);
450 + case ElapsedUnit::AboutAnHour:
451 + return L"About an hour ago";
452 + case ElapsedUnit::Hours:
453 + return std::format(L"{} hours ago", count);
454 + case ElapsedUnit::Days:
455 + return std::format(L"{} days ago", count);
456 + case ElapsedUnit::Weeks:
457 + return std::format(L"{} weeks ago", count);
458 + case ElapsedUnit::Months:
459 + return std::format(L"{} months ago", count);
460 + case ElapsedUnit::Years:
461 + return std::format(L"{} years ago", count);
462 + default:
463 + THROW_HR(E_UNEXPECTED);
464 + }
465 }
466
467 std::wstring wsl::windows::common::timestamp::FormatRelativeTime(LONGLONG timestamp)
@@ -381,3 +473,13 @@ std::wstring wsl::windows::common::timestamp::FormatRelativeTime(LONGLONG timest
473
474 return FormatElapsedSeconds(static_cast<LONGLONG>(std::time(nullptr)) - timestamp);
475 }
476 +
477 +std::wstring wsl::windows::common::timestamp::FormatInvariantRelativeTime(LONGLONG timestamp)
478 +{
479 + if (timestamp == 0)
480 + {
481 + return {};
482 + }
483 +
484 + return FormatInvariantElapsedSeconds(static_cast<LONGLONG>(std::time(nullptr)) - timestamp);
485 +}
src/windows/common/timestamp.hpp
+8
@@ -49,8 +49,16 @@ std::string Rfc3339ToUtcDisplayTime(std::string_view timestamp);
49 // or "3 weeks". Negative values are treated as zero.
50 std::wstring FormatElapsedSeconds(LONGLONG elapsedSeconds);
51
52 +// The invariant English form of FormatElapsedSeconds, matching the strings docker produces through
53 +// go-units HumanDuration. Machine readable output uses this so its values do not vary by display
54 +// language.
55 +std::wstring FormatInvariantElapsedSeconds(LONGLONG elapsedSeconds);
56 +
57 // Renders how long ago a timestamp given in seconds since the unix epoch occurred. A timestamp of
58 // zero means "unset" and returns an empty string.
59 std::wstring FormatRelativeTime(LONGLONG timestamp);
60
61 +// The invariant English form of FormatRelativeTime.
62 +std::wstring FormatInvariantRelativeTime(LONGLONG timestamp);
63 +
64 } // namespace wsl::windows::common::timestamp
src/windows/inc/docker_schema.h
+4 -1
@@ -775,6 +775,8 @@ struct ContainerInfo
775 std::vector<std::string> Names;
776 std::string Image;
777 std::string ImageID;
778 + std::string Command;
779 + std::string Status;
780 std::map<std::string, std::string> Labels;
781 std::vector<Port> Ports;
782 std::vector<Mount> Mounts;
@@ -783,7 +785,8 @@ struct ContainerInfo
785 HostConfig HostConfig;
786 NetworkSettings NetworkSettings;
787
786 - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInfo, Id, Names, Image, ImageID, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings);
788 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(
789 + ContainerInfo, Id, Names, Image, ImageID, Command, Status, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings);
790 };
791
792 struct BuildKitVertex
src/windows/service/inc/wslc.idl
+8
@@ -372,9 +372,17 @@ typedef struct _WSLCContainerEntry
372 {
373 char Name[WSLC_MAX_CONTAINER_NAME_LENGTH + 1];
374 char Image[WSLC_MAX_IMAGE_NAME_LENGTH + 1];
375 + // The runtime imposes no bound on these values, so they are allocated by the callee and freed
376 + // by the caller. Any of them may be null when the container reports no value.
377 + [string] LPSTR Command;
378 + [string] LPSTR Status;
379 + [string] LPSTR Labels;
380 + [string] LPSTR Networks;
381 + [string] LPSTR Mounts;
382 WSLCContainerId Id;
383 LONGLONG StateChangedAt;
384 LONGLONG CreatedAt;
385 + ULONG LocalVolumes;
386 WSLCContainerState State;
387 } WSLCContainerEntry;
388
src/windows/wslc/services/ContainerModel.h
+39 -1
@@ -134,12 +134,50 @@ struct ContainerInformation
134 std::string Id;
135 std::string Name;
136 std::string Image;
137 + // Command and runtime supplied status description.
138 + std::string Command;
139 + std::string Status;
140 + std::string Labels;
141 + std::string Networks;
142 + std::string Mounts;
143 + ULONG LocalVolumes{};
144 WSLCContainerState State;
145 LONGLONG StateChangedAt{};
146 LONGLONG CreatedAt{};
147 std::vector<PortInformation> Ports;
148 +};
149 +
150 +// The platform a container runs on. Emitted as a nested object to match docker.
151 +struct ContainerPlatform
152 +{
153 + std::string architecture;
154 + std::string os;
155
142 - NLOHMANN_DEFINE_TYPE_INTRUSIVE(ContainerInformation, Id, Name, Image, State, StateChangedAt, CreatedAt, Ports);
156 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerPlatform, architecture, os);
157 +};
158 +
159 +// The shape emitted by "container list --format json".
160 +struct ContainerOutputInformation
161 +{
162 + std::string Command;
163 + std::string CreatedAt;
164 + std::string HealthStatus;
165 + std::string ID;
166 + std::string Image;
167 + std::string Labels;
168 + std::string LocalVolumes;
169 + std::string Mounts;
170 + std::string Names;
171 + std::string Networks;
172 + ContainerPlatform Platform;
173 + std::string Ports;
174 + std::string RunningFor;
175 + std::string Size;
176 + std::string State;
177 + std::string Status;
178 +
179 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(
180 + ContainerOutputInformation, Command, CreatedAt, HealthStatus, ID, Image, Labels, LocalVolumes, Mounts, Names, Networks, Platform, Ports, RunningFor, Size, State, Status);
181 };
182
183 struct EnvironmentVariable
src/windows/wslc/services/ContainerService.cpp
+191 -13
@@ -21,6 +21,7 @@ Abstract:
21 #include <wslutil.h>
22 #include <HandleConsoleProgressBar.h>
23 #include <WSLCProcessLauncher.h>
24 +#include <WSLCContainerEntry.h>
25 #include <ConsoleState.h>
26 #include <CommandLine.h>
27 #include <WSLCUserSettings.h>
@@ -340,35 +341,206 @@ int ContainerService::Attach(Terminal& terminal, Session& session, const std::st
341 return runningProcess.Wait();
342 }
343
343 -std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, LONGLONG stateChangedAt)
344 +// The invariant state name. This is what "container list --format json" reports.
345 +std::wstring ContainerService::ContainerStateName(WSLCContainerState state)
346 {
345 - std::wstring stateString;
347 switch (state)
348 {
349 case WSLCContainerState::WslcContainerStateCreated:
349 - stateString = L"created";
350 - break;
350 + return L"created";
351 case WSLCContainerState::WslcContainerStateRunning:
352 - stateString = L"running";
353 - break;
352 + return L"running";
353 case WSLCContainerState::WslcContainerStateDeleted:
355 - stateString = L"stopped";
356 - break;
354 + return L"stopped";
355 case WSLCContainerState::WslcContainerStateExited:
358 - stateString = L"exited";
359 - break;
356 + return L"exited";
357 case WSLCContainerState::WslcContainerStateInvalid:
358 return L"invalid";
359 default:
360 THROW_HR(E_UNEXPECTED);
361 }
362 +}
363
366 - if (stateChangedAt == 0)
364 +std::wstring ContainerService::LocalizedContainerStateName(WSLCContainerState state)
365 +{
366 + switch (state)
367 + {
368 + case WSLCContainerState::WslcContainerStateCreated:
369 + return Localization::WSLCCLI_ContainerStateCreated();
370 + case WSLCContainerState::WslcContainerStateRunning:
371 + return Localization::WSLCCLI_ContainerStateRunning();
372 + case WSLCContainerState::WslcContainerStateDeleted:
373 + return Localization::WSLCCLI_ContainerStateStopped();
374 + case WSLCContainerState::WslcContainerStateExited:
375 + return Localization::WSLCCLI_ContainerStateExited();
376 + case WSLCContainerState::WslcContainerStateInvalid:
377 + return Localization::WSLCCLI_ContainerStateInvalid();
378 + default:
379 + THROW_HR(E_UNEXPECTED);
380 + }
381 +}
382 +
383 +std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, LONGLONG stateChangedAt, FormatType format)
384 +{
385 + const auto invariant = format == FormatType::Json;
386 + auto stateString = invariant ? ContainerStateName(state) : LocalizedContainerStateName(state);
387 + if (stateChangedAt == 0 || state == WSLCContainerState::WslcContainerStateInvalid)
388 {
389 return stateString;
390 }
391
371 - return std::format(L"{} {}", stateString, wsl::windows::common::timestamp::FormatRelativeTime(stateChangedAt));
392 + const auto relative = invariant ? wsl::windows::common::timestamp::FormatInvariantRelativeTime(stateChangedAt)
393 + : wsl::windows::common::timestamp::FormatRelativeTime(stateChangedAt);
394 +
395 + return std::format(L"{} {}", stateString, relative);
396 +}
397 +
398 +// Reports whether a code point is printable using the same rule as Go's unicode.IsPrint, which docker relies on when
399 +// quoting: letters, marks, numbers, punctuation, symbols and the ASCII space.
400 +static bool IsPrintable(UChar32 codePoint)
401 +{
402 + constexpr auto printableMask = U_GC_L_MASK | U_GC_M_MASK | U_GC_N_MASK | U_GC_P_MASK | U_GC_S_MASK;
403 + return codePoint == U' ' || (U_GET_GC_MASK(codePoint) & printableMask) != 0;
404 +}
405 +
406 +// Appends a code point that has no printable representation, mirroring the escapes Go's strconv.Quote emits.
407 +static void AppendEscape(std::wstring& quoted, UChar32 codePoint)
408 +{
409 + switch (codePoint)
410 + {
411 + case L'\a':
412 + quoted += L"\\a";
413 + return;
414 + case L'\b':
415 + quoted += L"\\b";
416 + return;
417 + case L'\f':
418 + quoted += L"\\f";
419 + return;
420 + case L'\n':
421 + quoted += L"\\n";
422 + return;
423 + case L'\r':
424 + quoted += L"\\r";
425 + return;
426 + case L'\t':
427 + quoted += L"\\t";
428 + return;
429 + case L'\v':
430 + quoted += L"\\v";
431 + return;
432 + default:
433 + break;
434 + }
435 +
436 + if (codePoint < L' ' || codePoint == 0x7F)
437 + {
438 + quoted += std::format(L"\\x{:02x}", static_cast<unsigned int>(codePoint));
439 + }
440 + else if (U_IS_SURROGATE(codePoint))
441 + {
442 + // An unpaired surrogate is not a valid code point, and Go substitutes the replacement character.
443 + quoted += L"\\ufffd";
444 + }
445 + else if (codePoint < 0x10000)
446 + {
447 + quoted += std::format(L"\\u{:04x}", static_cast<unsigned int>(codePoint));
448 + }
449 + else
450 + {
451 + quoted += std::format(L"\\U{:08x}", static_cast<unsigned int>(codePoint));
452 + }
453 +}
454 +
455 +std::wstring ContainerService::FormatCommand(const std::string& command, bool truncate)
456 +{
457 + constexpr size_t c_maxDisplayWidth = 20;
458 +
459 + auto wide = wsl::shared::string::MultiByteToWide(command);
460 + if (truncate)
461 + {
462 + wide = wsl::windows::common::string::Ellipsis(wide, c_maxDisplayWidth);
463 + }
464 +
465 + // Quoting happens after truncation, so the result can exceed c_maxDisplayWidth. This matches docker, which truncates
466 + // the command first and quotes the truncated value.
467 + const auto length = static_cast<int32_t>(wide.size());
468 + std::wstring quoted{L'"'};
469 + for (int32_t index = 0; index < length;)
470 + {
471 + const auto start = index;
472 + UChar32 codePoint{};
473 + U16_NEXT(wide.data(), index, length, codePoint);
474 +
475 + if (codePoint == L'"' || codePoint == L'\\')
476 + {
477 + quoted += L'\\';
478 + quoted += static_cast<wchar_t>(codePoint);
479 + }
480 + else if (IsPrintable(codePoint))
481 + {
482 + quoted.append(wide, start, static_cast<size_t>(index - start));
483 + }
484 + else
485 + {
486 + AppendEscape(quoted, codePoint);
487 + }
488 + }
489 +
490 + quoted += L'"';
491 + return quoted;
492 +}
493 +
494 +std::wstring ContainerService::FormatMounts(const std::string& mounts, bool truncate)
495 +{
496 + constexpr size_t c_maxDisplayWidth = 15;
497 +
498 + auto wide = wsl::shared::string::MultiByteToWide(mounts);
499 + if (!truncate || wide.empty())
500 + {
501 + return wide;
502 + }
503 +
504 + std::vector<std::wstring> shortened;
505 + for (const auto& mount : wsl::shared::string::SplitPreserveEmpty(std::wstring_view{wide}, L','))
506 + {
507 + shortened.emplace_back(wsl::windows::common::string::Ellipsis(mount, c_maxDisplayWidth));
508 + }
509 +
510 + return wsl::shared::string::Join(shortened, L',');
511 +}
512 +
513 +std::wstring ContainerService::FormatStatus(const std::string& status, WSLCContainerState state, LONGLONG stateChangedAt, FormatType format)
514 +{
515 + if (!status.empty())
516 + {
517 + return wsl::shared::string::MultiByteToWide(status);
518 + }
519 +
520 + return ContainerStateToString(state, stateChangedAt, format);
521 +}
522 +
523 +std::string ContainerService::FormatHealthStatus(const std::string& status)
524 +{
525 + const auto open = status.find('(');
526 + if (open == std::string::npos || status.back() != ')')
527 + {
528 + return {};
529 + }
530 +
531 + constexpr std::string_view c_healthPrefix = "health: ";
532 + auto health = std::string_view{status}.substr(open + 1, status.size() - open - 2);
533 + if (health.starts_with(c_healthPrefix))
534 + {
535 + health.remove_prefix(c_healthPrefix.size());
536 + }
537 +
538 + if (health == "healthy" || health == "unhealthy" || health == "starting")
539 + {
540 + return std::string{health};
541 + }
542 +
543 + return {};
544 }
545
546 std::wstring ContainerService::FormatPorts(WSLCContainerState state, const std::vector<PortInformation>& ports)
@@ -535,7 +707,7 @@ std::vector<ContainerInformation> ContainerService::List(
707 options.Filters = filterEntries.data();
708 options.FiltersCount = static_cast<ULONG>(filterEntries.size());
709
538 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
710 + wsl::windows::common::wslc::unique_container_entry_array containers;
711 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
712 THROW_IF_FAILED(
713 session.Get()->ListContainers(&options, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -547,6 +719,12 @@ std::vector<ContainerInformation> ContainerService::List(
719 ContainerInformation entry;
720 entry.Name = current.Name;
721 entry.Image = current.Image;
722 + entry.Command = current.Command == nullptr ? "" : current.Command;
723 + entry.Status = current.Status == nullptr ? "" : current.Status;
724 + entry.Labels = current.Labels == nullptr ? "" : current.Labels;
725 + entry.Networks = current.Networks == nullptr ? "" : current.Networks;
726 + entry.Mounts = current.Mounts == nullptr ? "" : current.Mounts;
727 + entry.LocalVolumes = current.LocalVolumes;
728 entry.State = current.State;
729 entry.Id = current.Id;
730 entry.StateChangedAt = current.StateChangedAt;
src/windows/wslc/services/ContainerService.h
+26 -1
@@ -22,8 +22,33 @@ Abstract:
22 namespace wsl::windows::wslc::services {
23 struct ContainerService
24 {
25 - static std::wstring ContainerStateToString(WSLCContainerState state, LONGLONG stateChangedAt = 0);
25 + // Renders a container state with the time it last changed appended. Table output is localized;
26 + // json output is invariant so its values do not vary by display language.
27 + static std::wstring ContainerStateToString(WSLCContainerState state, LONGLONG stateChangedAt = 0, models::FormatType format = models::FormatType::Table);
28 +
29 + // The bare state name, e.g. "running", without the relative time ContainerStateToString appends.
30 + static std::wstring ContainerStateName(WSLCContainerState state);
31 +
32 + // The display form of ContainerStateName, used for the table output.
33 + static std::wstring LocalizedContainerStateName(WSLCContainerState state);
34 +
35 static std::wstring FormatPorts(WSLCContainerState state, const std::vector<models::PortInformation>& ports);
36 +
37 + static std::wstring FormatCommand(const std::string& command, bool truncate);
38 +
39 + // Renders the comma separated mount list. Docker shortens each name independently, so a long path
40 + // never crowds out the mounts that follow it.
41 + static std::wstring FormatMounts(const std::string& mounts, bool truncate);
42 +
43 + // Renders a container status, preferring the description supplied by the runtime and falling back
44 + // to a locally built one when it is unavailable. Only the fallback varies with the format, since
45 + // the runtime supplied description is already invariant.
46 + static std::wstring FormatStatus(
47 + const std::string& status, WSLCContainerState state, LONGLONG stateChangedAt, models::FormatType format = models::FormatType::Table);
48 +
49 + // Extracts the health status from a runtime supplied status description, which carries it as a
50 + // parenthesized suffix. Containers without a health check report an empty string.
51 + static std::string FormatHealthStatus(const std::string& status);
52 static int Attach(Terminal& terminal, models::Session& session, const std::string& id);
53 static int Run(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options);
54 static models::CreateContainerResult Create(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options);
src/windows/wslc/tasks/ContainerTasks.cpp
+52 -15
@@ -125,6 +125,38 @@ nlohmann::json ComputeContainerStatsJson(const wsl::windows::common::docker_sche
125 };
126 }
127
128 +// Builds the representation of a container, shared by the table and json output so the two cannot
129 +// drift. Every value is emitted as a string apart from the platform object, and the id is truncated
130 +// unless --no-trunc is passed. RunningFor and Status are the only fields that vary with the format:
131 +// docker renders them in invariant English, so json keeps that while the table is localized.
132 +ContainerOutputInformation ToContainerOutput(const ContainerInformation& container, bool truncate, FormatType format)
133 +{
134 + ContainerOutputInformation entry;
135 + entry.Command = WideToMultiByte(ContainerService::FormatCommand(container.Command, truncate));
136 + entry.CreatedAt = EpochToLocalDisplayTime(container.CreatedAt);
137 + // The runtime reports health as a suffix on the status description, which is the only place it is
138 + // exposed by the listing API.
139 + entry.HealthStatus = ContainerService::FormatHealthStatus(container.Status);
140 + entry.ID = truncate ? TruncateId(container.Id) : container.Id;
141 + entry.Image = container.Image;
142 + entry.Labels = container.Labels;
143 + entry.LocalVolumes = std::to_string(container.LocalVolumes);
144 + entry.Mounts = WideToMultiByte(ContainerService::FormatMounts(container.Mounts, truncate));
145 + entry.Names = container.Name;
146 + entry.Networks = container.Networks;
147 + entry.Platform.architecture = wsl::shared::Arm64 ? "arm64" : "amd64";
148 + entry.Platform.os = "linux";
149 + entry.Ports = WideToMultiByte(ContainerService::FormatPorts(container.State, container.Ports));
150 + entry.RunningFor = WideToMultiByte(
151 + format == FormatType::Json ? FormatInvariantRelativeTime(container.CreatedAt) : FormatRelativeTime(container.CreatedAt));
152 + // Container sizes are only computed when docker is passed --size, which wslc does not support.
153 + entry.Size = WideToMultiByte(FormatHumanReadableSize(0));
154 + entry.State = WideToMultiByte(ContainerService::ContainerStateName(container.State));
155 + entry.Status = WideToMultiByte(ContainerService::FormatStatus(container.Status, container.State, container.StateChangedAt, format));
156 +
157 + return entry;
158 +}
159 +
160 } // namespace
161
162 namespace wsl::windows::wslc::task {
@@ -535,15 +567,17 @@ void ListContainers(CLIExecutionContext& context)
567 if (context.Args.GetValue<ArgType::Quiet>())
568 {
569 // Print only the container ids
570 + bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
571 for (const auto& container : containers)
572 {
540 - context.Terminal.Output(L"{}\n", MultiByteToWide(container.Id));
573 + context.Terminal.Output(L"{}\n", MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id));
574 }
575
576 return;
577 }
578
579 const auto format = context.Args.GetValue<ArgType::Format>(FormatType::Table);
580 + bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
581
582 switch (format)
583 {
@@ -551,45 +585,48 @@ void ListContainers(CLIExecutionContext& context)
585 {
586 for (const auto& container : containers)
587 {
554 - context.Terminal.Output(L"{}\n", ToJsonW(container, c_jsonCompactIndent));
588 + context.Terminal.Output(L"{}\n", ToJsonW(ToContainerOutput(container, trunc, FormatType::Json), c_jsonCompactIndent));
589 }
590
591 break;
592 }
593 case FormatType::Table:
594 {
561 - bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
595 using enum ColumnOverflow;
596
597 // Create table with or without column limits based on --no-trunc flag
565 - auto table = trunc ? wsl::windows::wslc::TableOutput<6>(
598 + auto table = trunc ? wsl::windows::wslc::TableOutput<7>(
599 context.Terminal,
600 {{{Localization::WSLCCLI_TableHeaderContainerId(), {.MaxWidth = 12, .Overflow = Shrink}},
568 - {Localization::WSLCCLI_TableHeaderName(), {.MaxWidth = 20, .Overflow = Shrink}},
601 {Localization::WSLCCLI_TableHeaderImage(), {.MaxWidth = 20, .Overflow = Shrink}},
602 + {Localization::WSLCCLI_TableHeaderCommand(), {.Overflow = Shrink}},
603 {Localization::WSLCCLI_TableHeaderCreated(), {.Overflow = Shrink}},
604 {Localization::WSLCCLI_TableHeaderStatus(), {.Overflow = Shrink}},
572 - {Localization::WSLCCLI_TableHeaderPorts(), {.Overflow = Shrink}}}},
605 + {Localization::WSLCCLI_TableHeaderPorts(), {.Overflow = Shrink}},
606 + {Localization::WSLCCLI_TableHeaderNames(), {.MaxWidth = 20, .Overflow = Shrink}}}},
607 containers.size())
574 - : wsl::windows::wslc::TableOutput<6>(
608 + : wsl::windows::wslc::TableOutput<7>(
609 context.Terminal,
610 {Localization::WSLCCLI_TableHeaderContainerId(),
577 - Localization::WSLCCLI_TableHeaderName(),
611 Localization::WSLCCLI_TableHeaderImage(),
612 + Localization::WSLCCLI_TableHeaderCommand(),
613 Localization::WSLCCLI_TableHeaderCreated(),
614 Localization::WSLCCLI_TableHeaderStatus(),
581 - Localization::WSLCCLI_TableHeaderPorts()});
615 + Localization::WSLCCLI_TableHeaderPorts(),
616 + Localization::WSLCCLI_TableHeaderNames()});
617
618 // Add each container as a row
619 for (const auto& container : containers)
620 {
621 + const auto entry = ToContainerOutput(container, trunc, FormatType::Table);
622 table.WriteRow({
587 - MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id),
588 - MultiByteToWide(container.Name),
589 - MultiByteToWide(container.Image),
590 - FormatRelativeTime(container.CreatedAt),
591 - ContainerService::ContainerStateToString(container.State, container.StateChangedAt),
592 - ContainerService::FormatPorts(container.State, container.Ports),
623 + MultiByteToWide(entry.ID),
624 + MultiByteToWide(entry.Image),
625 + MultiByteToWide(entry.Command),
626 + MultiByteToWide(entry.RunningFor),
627 + MultiByteToWide(entry.Status),
628 + MultiByteToWide(entry.Ports),
629 + MultiByteToWide(entry.Names),
630 });
631 }
632
src/windows/wslcsession/WSLCSession.cpp
+60 -3
@@ -24,6 +24,7 @@ Abstract:
24 #include "WSLCSessionDefaults.h"
25 #include "wslpolicies.h"
26 #include "APICompat.h"
27 +#include "WSLCContainerEntry.h"
28
29 using namespace wsl::windows::common;
30 using io::MultiHandleWait;
@@ -2548,6 +2549,13 @@ try
2549 // if some IDs returned by Docker aren't in m_containers (e.g. created externally), but in the
2550 // common case the two should match.
2551 auto output = wil::make_unique_cotaskmem<WSLCContainerEntry[]>(dockerContainers.size());
2552 + auto freeStrings = wil::scope_exit([&] {
2553 + for (size_t i = 0; i < dockerContainers.size(); ++i)
2554 + {
2555 + wsl::windows::common::wslc::FreeContainerEntryStrings(&output[i]);
2556 + }
2557 + });
2558 +
2559 std::vector<WSLCContainerPortMapping> allPorts;
2560
2561 size_t index = 0;
@@ -2563,6 +2571,47 @@ try
2571 THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Image, e->Image().c_str()) != 0);
2572 THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Name, e->Name().c_str()) != 0);
2573 THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Id, e->ID().c_str()) != 0);
2574 +
2575 + // Commands and status descriptions have no bound imposed by the runtime, so they are
2576 + // allocated rather than copied into a fixed buffer.
2577 + output[index].Command = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(dockerContainer.Command.c_str()).release();
2578 + output[index].Status = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(dockerContainer.Status.c_str()).release();
2579 +
2580 + // Labels, networks and mounts are reported the way the docker CLI renders them: a comma
2581 + // separated list. Like the command and status above they are unbounded.
2582 + std::vector<std::string> labels;
2583 + for (const auto& [key, value] : dockerContainer.Labels)
2584 + {
2585 + labels.push_back(std::format("{}={}", key, value));
2586 + }
2587 +
2588 + std::vector<std::string> networks;
2589 + for (const auto& [name, _] : dockerContainer.NetworkSettings.Networks)
2590 + {
2591 + networks.push_back(name);
2592 + }
2593 +
2594 + std::vector<std::string> mounts;
2595 + ULONG localVolumes = 0;
2596 + for (const auto& mount : dockerContainer.Mounts)
2597 + {
2598 + // Named volumes report a name, bind mounts only report the host path.
2599 + mounts.push_back(mount.Name.empty() ? mount.Source : mount.Name);
2600 + if (mount.Type == "volume")
2601 + {
2602 + localVolumes++;
2603 + }
2604 + }
2605 +
2606 + const auto joinedLabels = wsl::shared::string::Join(labels, ',');
2607 + const auto joinedNetworks = wsl::shared::string::Join(networks, ',');
2608 + const auto joinedMounts = wsl::shared::string::Join(mounts, ',');
2609 +
2610 + output[index].Labels = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(joinedLabels.c_str()).release();
2611 + output[index].Networks = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(joinedNetworks.c_str()).release();
2612 + output[index].Mounts = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(joinedMounts.c_str()).release();
2613 + output[index].LocalVolumes = localVolumes;
2614 +
2615 e->GetState(&output[index].State);
2616 e->GetStateChangedAt(&output[index].StateChangedAt);
2617 e->GetCreatedAt(&output[index].CreatedAt);
@@ -2583,13 +2632,21 @@ try
2632 index++;
2633 }
2634
2635 + // Finish every allocation before transferring ownership so nothing can throw once the caller
2636 + // owns the results.
2637 + wil::unique_cotaskmem_ptr<WSLCContainerPortMapping[]> portsOutput;
2638 + if (!allPorts.empty())
2639 + {
2640 + portsOutput = wil::make_unique_cotaskmem<WSLCContainerPortMapping[]>(allPorts.size());
2641 + memcpy(portsOutput.get(), allPorts.data(), allPorts.size() * sizeof(WSLCContainerPortMapping));
2642 + }
2643 +
2644 + freeStrings.release();
2645 *Count = static_cast<ULONG>(index);
2646 *Containers = output.release();
2647
2589 - if (!allPorts.empty())
2648 + if (portsOutput)
2649 {
2591 - auto portsOutput = wil::make_unique_cotaskmem<WSLCContainerPortMapping[]>(allPorts.size());
2592 - memcpy(portsOutput.get(), allPorts.data(), allPorts.size() * sizeof(WSLCContainerPortMapping));
2650 *PortsCount = static_cast<ULONG>(allPorts.size());
2651 *Ports = portsOutput.release();
2652 }
test/windows/StringUnitTests.cpp
+54
@@ -5,6 +5,7 @@
5 #include "string.hpp"
6
7 using wsl::windows::common::string::c_reclaimedSpacePrecision;
8 +using wsl::windows::common::string::Ellipsis;
9 using wsl::windows::common::string::FormatHumanReadableSize;
10 using wsl::windows::common::string::ParseStorageSize;
11 using wsl::windows::common::string::StorageSizeUnit;
@@ -268,6 +269,59 @@ class StringUnitTests
269 }
270 }
271
272 + // Docker shortens display values with formatter.Ellipsis, which measures terminal columns rather than
273 + // characters so East Asian wide and fullwidth code points count double.
274 + TEST_METHOD(Ellipsis_NarrowCharacters_AreCountedAsOneColumn)
275 + {
276 + VERIFY_ARE_EQUAL(std::wstring{L""}, Ellipsis(L"", 20));
277 + VERIFY_ARE_EQUAL(std::wstring{L"sleep 3600"}, Ellipsis(L"sleep 3600", 20));
278 + VERIFY_ARE_EQUAL(std::wstring{L"12345678901234567890"}, Ellipsis(L"12345678901234567890", 20));
279 + VERIFY_ARE_EQUAL(std::wstring{L"1234567890123456789\u2026"}, Ellipsis(L"123456789012345678901", 20));
280 + VERIFY_ARE_EQUAL(std::wstring(19, L'\u00E0') + L"\u2026", Ellipsis(std::wstring(21, L'\u00E0'), 20));
281 + }
282 +
283 + TEST_METHOD(Ellipsis_WideCharacters_AreCountedAsTwoColumns)
284 + {
285 + // Ten wide characters fill the twenty columns exactly, so an eleventh forces the value to be shortened
286 + // to the nine characters that leave room for the ellipsis.
287 + VERIFY_ARE_EQUAL(std::wstring(10, L'\u65E5'), Ellipsis(std::wstring(10, L'\u65E5'), 20));
288 + VERIFY_ARE_EQUAL(std::wstring(9, L'\u65E5') + L"\u2026", Ellipsis(std::wstring(11, L'\u65E5'), 20));
289 + VERIFY_ARE_EQUAL(std::wstring(9, L'\uFF21') + L"\u2026", Ellipsis(std::wstring(11, L'\uFF21'), 20));
290 + VERIFY_ARE_EQUAL(std::wstring{L"ab"} + std::wstring(8, L'\u65E5') + L"\u2026", Ellipsis(L"ab" + std::wstring(10, L'\u65E5'), 20));
291 + }
292 +
293 + TEST_METHOD(Ellipsis_SurrogatePairs_AreNotSplit)
294 + {
295 + // Emoji are wide and encoded as surrogate pairs, so both the column count and the code unit boundary
296 + // have to be honored.
297 + const std::wstring emoji{L"\U0001F600"};
298 + std::wstring ten;
299 + for (size_t index = 0; index < 10; ++index)
300 + {
301 + ten += emoji;
302 + }
303 +
304 + VERIFY_ARE_EQUAL(ten, Ellipsis(ten, 20));
305 + VERIFY_ARE_EQUAL(ten.substr(0, 18) + L"\u2026", Ellipsis(ten + emoji, 20));
306 + }
307 +
308 + TEST_METHOD(Ellipsis_SmallWidths_MatchDocker)
309 + {
310 + VERIFY_ARE_EQUAL(std::wstring{L""}, Ellipsis(L"abc", 0));
311 +
312 + // A width of one has no room for both content and an ellipsis, so the leading code point is kept even
313 + // when it is wider than the limit.
314 + VERIFY_ARE_EQUAL(std::wstring{L"a"}, Ellipsis(L"abc", 1));
315 + VERIFY_ARE_EQUAL(std::wstring{L"\u65E5"}, Ellipsis(L"\u65E5\u65E5", 1));
316 + VERIFY_ARE_EQUAL(std::wstring{L"\U0001F600"}, Ellipsis(L"\U0001F600\U0001F600", 1));
317 +
318 + // A leading wide character leaves no room for the ellipsis at a width of two, and docker returns the
319 + // value untouched in that case.
320 + VERIFY_ARE_EQUAL(std::wstring{L"\u65E5\u65E5"}, Ellipsis(L"\u65E5\u65E5", 2));
321 + VERIFY_ARE_EQUAL(std::wstring{L"a\u2026"}, Ellipsis(L"abc", 2));
322 + VERIFY_ARE_EQUAL(std::wstring{L"\u65E5\u2026"}, Ellipsis(L"\u65E5\u65E5", 3));
323 + }
324 +
325 TEST_METHOD(StorageSize_BytesToTextRoundTrips)
326 {
327 // The parser accepts suffixes up to peta, matching docker's unit map, so the round trip is
test/windows/WSLCTests.cpp
+11 -10
@@ -18,6 +18,7 @@ Abstract:
18 #include "wslccompat.h"
19 #include "WSLCProcessLauncher.h"
20 #include "WSLCContainerLauncher.h"
21 +#include "WSLCContainerEntry.h"
22 #include "WslCoreFilesystem.h"
23 #include "hcs.hpp"
24 #include "ContainerNameGenerator.h"
@@ -222,7 +223,7 @@ class WSLCTests
223
224 struct ListContainersResult
225 {
225 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> Containers;
226 + wsl::windows::common::wslc::unique_container_entry_array Containers;
227 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> Ports;
228 };
229
@@ -7666,7 +7667,7 @@ class WSLCTests
7667 options.Filters = filters.data();
7668 options.FiltersCount = static_cast<ULONG>(filters.size());
7669
7669 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
7670 + wsl::windows::common::wslc::unique_container_entry_array containers;
7671 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
7672 VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
7673 &options, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -7751,7 +7752,7 @@ class WSLCTests
7752 options.Flags = WSLCListContainersFlagsAll;
7753 options.Limit = 1;
7754
7754 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
7755 + wsl::windows::common::wslc::unique_container_entry_array containers;
7756 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
7757 VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
7758 &options, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -7822,7 +7823,7 @@ class WSLCTests
7823 WSLCListContainersOptions options{};
7824 options.Flags = WSLCListContainersFlagsAll;
7825
7825 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
7826 + wsl::windows::common::wslc::unique_container_entry_array containers;
7827 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
7828 HRESULT hrList = m_defaultSession->ListContainers(
7829 &options, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>());
@@ -9575,7 +9576,7 @@ class WSLCTests
9576
9577 // Verify that ListContainers returns the port data for a running container.
9578 {
9578 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
9579 + wsl::windows::common::wslc::unique_container_entry_array containers;
9580 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
9581 VERIFY_SUCCEEDED(session.ListContainers(
9582 nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -9620,7 +9621,7 @@ class WSLCTests
9621
9622 auto createdContainer = createdLauncher.Create(session);
9623
9623 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
9624 + wsl::windows::common::wslc::unique_container_entry_array containers;
9625 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
9626 VERIFY_SUCCEEDED(session.ListContainers(
9627 nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -9647,7 +9648,7 @@ class WSLCTests
9648 // Verify that a stopped container returns no ports.
9649 VERIFY_SUCCEEDED(container.Get().Stop(WSLCSignalSIGKILL, 0));
9650 {
9650 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
9651 + wsl::windows::common::wslc::unique_container_entry_array containers;
9652 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
9653 VERIFY_SUCCEEDED(session.ListContainers(
9654 nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -12327,7 +12328,7 @@ class WSLCTests
12328 VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer("test-auto-remove", &notFound), WSLC_E_CONTAINER_NOT_FOUND);
12329 VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer(id.c_str(), &notFound), WSLC_E_CONTAINER_NOT_FOUND);
12330
12330 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
12331 + wsl::windows::common::wslc::unique_container_entry_array containers;
12332 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
12333 VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
12334 nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -12362,7 +12363,7 @@ class WSLCTests
12363 wil::com_ptr<IWSLCContainer> notFound;
12364 VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer("test-auto-remove-stdout", &notFound), WSLC_E_CONTAINER_NOT_FOUND);
12365
12365 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
12366 + wsl::windows::common::wslc::unique_container_entry_array containers;
12367 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
12368 VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
12369 nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -12493,7 +12494,7 @@ class WSLCTests
12494 // Validate that various operations can be done while the export is in progress.
12495
12496 {
12496 - wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
12497 + wsl::windows::common::wslc::unique_container_entry_array containers;
12498 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
12499 VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
12500 nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp new
+271
@@ -0,0 +1,271 @@
1 +// Copyright (C) Microsoft Corporation. All rights reserved.
2 +
3 +#include "precomp.h"
4 +#include "windows/Common.h"
5 +#include "WSLCCLITestHelpers.h"
6 +
7 +#include "ContainerService.h"
8 +
9 +using namespace wsl::windows::wslc;
10 +using namespace wsl::windows::wslc::services;
11 +using namespace WSLCTestHelpers;
12 +using namespace WEX::Logging;
13 +using namespace WEX::Common;
14 +using namespace WEX::TestExecution;
15 +
16 +namespace WSLCCLIContainerCommandUnitTests {
17 +
18 +class WSLCCLIContainerCommandUnitTests
19 +{
20 + WSLC_TEST_CLASS(WSLCCLIContainerCommandUnitTests)
21 +
22 + TEST_CLASS_SETUP(TestClassSetup)
23 + {
24 + return true;
25 + }
26 +
27 + TEST_CLASS_CLEANUP(TestClassCleanup)
28 + {
29 + return true;
30 + }
31 +
32 + static std::wstring Truncated(const std::string& command)
33 + {
34 + return ContainerService::FormatCommand(command, true);
35 + }
36 +
37 + TEST_METHOD(FormatCommand_Empty_ReturnsEmptyQuotes)
38 + {
39 + VERIFY_ARE_EQUAL(std::wstring{LR"("")"}, Truncated(""));
40 + }
41 +
42 + TEST_METHOD(FormatCommand_ShortCommand_IsQuotedUnchanged)
43 + {
44 + VERIFY_ARE_EQUAL(std::wstring{LR"("sleep 3600")"}, Truncated("sleep 3600"));
45 + }
46 +
47 + TEST_METHOD(FormatCommand_ExactlyTwentyCharacters_IsNotShortened)
48 + {
49 + VERIFY_ARE_EQUAL(std::wstring{LR"("12345678901234567890")"}, Truncated("12345678901234567890"));
50 + }
51 +
52 + TEST_METHOD(FormatCommand_TwentyOneCharacters_KeepsNineteenAndAppendsEllipsis)
53 + {
54 + VERIFY_ARE_EQUAL(std::wstring{L"\"1234567890123456789\u2026\""}, Truncated("123456789012345678901"));
55 + }
56 +
57 + TEST_METHOD(FormatCommand_LongCommand_MatchesDockerOutput)
58 + {
59 + VERIFY_ARE_EQUAL(std::wstring{L"\"sh -c 'echo this is\u2026\""}, Truncated("sh -c 'echo this is a very long command that should be truncated by docker'"));
60 + }
61 +
62 + TEST_METHOD(FormatCommand_NoTruncate_KeepsFullCommand)
63 + {
64 + const std::string command = "sh -c 'echo this is a very long command that should be truncated by docker'";
65 + VERIFY_ARE_EQUAL(
66 + std::wstring{L"\"" + wsl::shared::string::MultiByteToWide(command) + L"\""}, ContainerService::FormatCommand(command, false));
67 + }
68 +
69 + TEST_METHOD(FormatCommand_EmbeddedQuotesAndBackslashes_AreEscaped)
70 + {
71 + // Raw string literals are avoided here: the compiler mangles them when the verify macro
72 + // stringizes its arguments.
73 + VERIFY_ARE_EQUAL(std::wstring{L"\"say \\\"hi\\\"\""}, Truncated("say \"hi\""));
74 + VERIFY_ARE_EQUAL(std::wstring{L"\"c:\\\\temp\""}, Truncated("c:\\temp"));
75 + }
76 +
77 + TEST_METHOD(FormatCommand_NarrowMultiByteCharacters_CountedAsOneColumn)
78 + {
79 + const std::string accented = "\xC3\xA0";
80 + std::string twenty;
81 + for (int i = 0; i < 20; ++i)
82 + {
83 + twenty += accented;
84 + }
85 +
86 + VERIFY_ARE_EQUAL(std::wstring(20, L'\u00E0').insert(0, L"\"") + L"\"", Truncated(twenty));
87 + VERIFY_ARE_EQUAL(std::wstring(19, L'\u00E0').insert(0, L"\"") + L"\u2026\"", Truncated(twenty + accented));
88 + }
89 +
90 + TEST_METHOD(FormatCommand_WideCharacters_CountedAsTwoColumns)
91 + {
92 + // Docker measures display columns, so an East Asian wide character consumes two of the twenty
93 + // available columns and only ten of them fit.
94 + const std::string wide = "\xE6\x97\xA5";
95 + std::string ten;
96 + for (int i = 0; i < 10; ++i)
97 + {
98 + ten += wide;
99 + }
100 +
101 + VERIFY_ARE_EQUAL(std::wstring(10, L'\u65E5').insert(0, L"\"") + L"\"", Truncated(ten));
102 + VERIFY_ARE_EQUAL(std::wstring(9, L'\u65E5').insert(0, L"\"") + L"\u2026\"", Truncated(ten + wide));
103 + }
104 +
105 + TEST_METHOD(FormatCommand_MixedWidthCharacters_AreShortenedByColumn)
106 + {
107 + // Two narrow characters leave eighteen columns, so eight wide characters fit before the ellipsis.
108 + const std::string wide = "\xE6\x97\xA5";
109 + std::string command = "ab";
110 + for (int i = 0; i < 10; ++i)
111 + {
112 + command += wide;
113 + }
114 +
115 + VERIFY_ARE_EQUAL(std::wstring{L"\"ab"} + std::wstring(8, L'\u65E5') + L"\u2026\"", Truncated(command));
116 + }
117 +
118 + TEST_METHOD(FormatCommand_SurrogatePairs_AreNotSplit)
119 + {
120 + // Emoji are wide and encoded as surrogate pairs, so a shortened value has to stop on a code point
121 + // boundary as well as a column boundary.
122 + const std::string emoji = "\xF0\x9F\x98\x80";
123 + std::string ten;
124 + std::wstring expected;
125 + for (int i = 0; i < 10; ++i)
126 + {
127 + ten += emoji;
128 + expected += L"\U0001F600";
129 + }
130 +
131 + VERIFY_ARE_EQUAL(L"\"" + expected + L"\"", Truncated(ten));
132 + VERIFY_ARE_EQUAL(L"\"" + expected.substr(0, 18) + L"\u2026\"", Truncated(ten + emoji));
133 + }
134 +
135 + TEST_METHOD(FormatCommand_ControlCharacters_AreEscaped)
136 + {
137 + // Docker quotes this field with Go's strconv.Quote, which renders control characters as escape sequences
138 + // rather than emitting them raw and breaking the table row.
139 + VERIFY_ARE_EQUAL(std::wstring{L"\"line1\\nline2\""}, Truncated("line1\nline2"));
140 + VERIFY_ARE_EQUAL(std::wstring{L"\"col1\\tcol2\""}, Truncated("col1\tcol2"));
141 + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\rb\""}, Truncated("a\rb"));
142 + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\vb\""}, Truncated("a\vb"));
143 + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\fb\""}, Truncated("a\fb"));
144 + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\bb\""}, Truncated("a\bb"));
145 + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\ab\""}, Truncated("a\ab"));
146 + }
147 +
148 + TEST_METHOD(FormatCommand_NonPrintableCharacters_AreEscapedAsHex)
149 + {
150 + // Control characters without a dedicated escape use \x, matching strconv.Quote.
151 + VERIFY_ARE_EQUAL(
152 + std::wstring{L"\"a\\x1bb\""},
153 + Truncated("a\x1b"
154 + "b"));
155 + VERIFY_ARE_EQUAL(
156 + std::wstring{L"\"a\\x7fb\""},
157 + Truncated("a\x7f"
158 + "b"));
159 + }
160 +
161 + TEST_METHOD(FormatCommand_PrintableUnicode_IsNotEscaped)
162 + {
163 + // Letters and symbols stay verbatim, so only genuinely unprintable values are expanded.
164 + VERIFY_ARE_EQUAL(std::wstring{L"\"caf\u00E9\""}, Truncated("caf\xC3\xA9"));
165 + }
166 +
167 + TEST_METHOD(FormatCommand_EscapesAreCountedAfterTruncation)
168 + {
169 + // Docker truncates before quoting, so escape expansion does not consume the display budget and the
170 + // quoted result is wider than the limit.
171 + VERIFY_ARE_EQUAL(std::wstring{L"\"\\n\\n\\n\\n\\n\""}, Truncated("\n\n\n\n\n"));
172 + }
173 +
174 + TEST_METHOD(FormatMounts_LongNames_AreShortenedIndependently)
175 + {
176 + // Docker shortens every mount name to fifteen columns rather than the list as a whole
177 + // (ContainerContext.Mounts in cli/command/formatter/container.go).
178 + const auto mounts = "/var/lib/docker/volumes/data,logs,/mnt/c/users/test/source";
179 + VERIFY_ARE_EQUAL(std::wstring{L"/var/lib/docke\u2026,logs,/mnt/c/users/t\u2026"}, ContainerService::FormatMounts(mounts, true));
180 + VERIFY_ARE_EQUAL(wsl::shared::string::MultiByteToWide(mounts), ContainerService::FormatMounts(mounts, false));
181 + }
182 +
183 + TEST_METHOD(FormatMounts_ShortNames_AreUnchanged)
184 + {
185 + VERIFY_ARE_EQUAL(std::wstring{L""}, ContainerService::FormatMounts("", true));
186 + VERIFY_ARE_EQUAL(std::wstring{L"data-volume"}, ContainerService::FormatMounts("data-volume", true));
187 + VERIFY_ARE_EQUAL(std::wstring{L"123456789012345"}, ContainerService::FormatMounts("123456789012345", true));
188 + VERIFY_ARE_EQUAL(std::wstring{L"12345678901234\u2026"}, ContainerService::FormatMounts("1234567890123456", true));
189 + }
190 +
191 + TEST_METHOD(FormatMounts_WideCharacters_CountedAsTwoColumns)
192 + {
193 + // Seven wide characters occupy fourteen columns, so an eighth exceeds the fifteen column budget.
194 + std::string wide;
195 + for (int i = 0; i < 8; ++i)
196 + {
197 + wide += "\xE6\x97\xA5";
198 + }
199 +
200 + VERIFY_ARE_EQUAL(std::wstring(7, L'\u65E5') + L"\u2026", ContainerService::FormatMounts(wide, true));
201 + }
202 +
203 + TEST_METHOD(FormatStatus_RuntimeStatus_IsPreferred)
204 + {
205 + VERIFY_ARE_EQUAL(std::wstring{L"Up 5 minutes"}, ContainerService::FormatStatus("Up 5 minutes", WslcContainerStateRunning, 0));
206 + }
207 +
208 + TEST_METHOD(FormatStatus_EmptyRuntimeStatus_FallsBackToState)
209 + {
210 + VERIFY_ARE_EQUAL(std::wstring{L"created"}, ContainerService::FormatStatus("", WslcContainerStateCreated, 0));
211 + }
212 +
213 + // The fallback is built locally, so json has to render it in invariant English rather than in the
214 + // machine's display language.
215 + TEST_METHOD(FormatStatus_EmptyRuntimeStatus_JsonFallbackIsInvariant)
216 + {
217 + const auto twoHoursAgo = static_cast<LONGLONG>(std::time(nullptr)) - (2 * 60 * 60);
218 +
219 + VERIFY_ARE_EQUAL(
220 + std::wstring{L"exited 2 hours ago"},
221 + ContainerService::FormatStatus("", WslcContainerStateExited, twoHoursAgo, models::FormatType::Json));
222 + VERIFY_ARE_EQUAL(std::wstring{L"created"}, ContainerService::FormatStatus("", WslcContainerStateCreated, 0, models::FormatType::Json));
223 + }
224 +
225 + // A status supplied by the runtime is already invariant, so it is passed through unchanged for
226 + // both formats.
227 + TEST_METHOD(FormatStatus_RuntimeStatus_IsFormatIndependent)
228 + {
229 + VERIFY_ARE_EQUAL(
230 + std::wstring{L"Up 5 minutes"},
231 + ContainerService::FormatStatus("Up 5 minutes", WslcContainerStateRunning, 0, models::FormatType::Json));
232 + VERIFY_ARE_EQUAL(
233 + std::wstring{L"Up 5 minutes"},
234 + ContainerService::FormatStatus("Up 5 minutes", WslcContainerStateRunning, 0, models::FormatType::Table));
235 + }
236 +
237 + TEST_METHOD(FormatHealthStatus_Healthy_IsExtracted)
238 + {
239 + VERIFY_ARE_EQUAL(std::string{"healthy"}, ContainerService::FormatHealthStatus("Up 2 minutes (healthy)"));
240 + }
241 +
242 + TEST_METHOD(FormatHealthStatus_Unhealthy_IsExtracted)
243 + {
244 + VERIFY_ARE_EQUAL(std::string{"unhealthy"}, ContainerService::FormatHealthStatus("Up 2 minutes (unhealthy)"));
245 + }
246 +
247 + TEST_METHOD(FormatHealthStatus_Starting_DropsHealthPrefix)
248 + {
249 + VERIFY_ARE_EQUAL(std::string{"starting"}, ContainerService::FormatHealthStatus("Up 2 seconds (health: starting)"));
250 + }
251 +
252 + TEST_METHOD(FormatHealthStatus_NoHealthCheck_IsEmpty)
253 + {
254 + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes"));
255 + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus(""));
256 + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Created"));
257 + }
258 +
259 + TEST_METHOD(FormatHealthStatus_NoneIsNotReported)
260 + {
261 + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes (none)"));
262 + }
263 +
264 + TEST_METHOD(FormatHealthStatus_UnrelatedParentheses_AreIgnored)
265 + {
266 + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Exited (0) 8 days ago"));
267 + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes (Paused)"));
268 + }
269 +};
270 +
271 +} // namespace WSLCCLIContainerCommandUnitTests
test/windows/wslc/WSLCCLIRelativeTimeUnitTests.cpp
+33
@@ -97,6 +97,39 @@ class WSLCCLIRelativeTimeUnitTests
97 VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeYears(2), FormatElapsed(730 * 24 * 60 * 60LL));
98 VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeYears(3), FormatElapsed(3 * 365 * 24 * 60 * 60LL));
99 }
100 +
101 + // The invariant rendering backs machine readable output, so it must stay in English and match the
102 + // strings docker produces through go-units HumanDuration.
103 + TEST_METHOD(RelativeTime_Invariant)
104 + {
105 + VERIFY_ARE_EQUAL(std::wstring{}, FormatInvariantRelativeTime(0));
106 + VERIFY_ARE_EQUAL(std::wstring{L"Less than a second ago"}, FormatInvariantElapsedSeconds(-600));
107 + VERIFY_ARE_EQUAL(std::wstring{L"Less than a second ago"}, FormatInvariantElapsedSeconds(0));
108 + VERIFY_ARE_EQUAL(std::wstring{L"1 second ago"}, FormatInvariantElapsedSeconds(1));
109 + VERIFY_ARE_EQUAL(std::wstring{L"59 seconds ago"}, FormatInvariantElapsedSeconds(59));
110 + VERIFY_ARE_EQUAL(std::wstring{L"About a minute ago"}, FormatInvariantElapsedSeconds(60));
111 + VERIFY_ARE_EQUAL(std::wstring{L"2 minutes ago"}, FormatInvariantElapsedSeconds(120));
112 + VERIFY_ARE_EQUAL(std::wstring{L"About an hour ago"}, FormatInvariantElapsedSeconds(89 * 60));
113 + VERIFY_ARE_EQUAL(std::wstring{L"2 hours ago"}, FormatInvariantElapsedSeconds(90 * 60));
114 + VERIFY_ARE_EQUAL(std::wstring{L"47 hours ago"}, FormatInvariantElapsedSeconds(47 * 60 * 60));
115 + VERIFY_ARE_EQUAL(std::wstring{L"2 days ago"}, FormatInvariantElapsedSeconds(48 * 60 * 60));
116 + VERIFY_ARE_EQUAL(std::wstring{L"2 weeks ago"}, FormatInvariantElapsedSeconds(14 * 24 * 60 * 60));
117 + VERIFY_ARE_EQUAL(std::wstring{L"2 months ago"}, FormatInvariantElapsedSeconds(60 * 24 * 60 * 60));
118 + VERIFY_ARE_EQUAL(std::wstring{L"2 years ago"}, FormatInvariantElapsedSeconds(730 * 24 * 60 * 60LL));
119 + }
120 +
121 + // Both renderings share one set of thresholds, so pin the invariant one at every boundary. This
122 + // cannot compare against the localized rendering, which varies with the machine's display language.
123 + TEST_METHOD(RelativeTime_InvariantBoundaries)
124 + {
125 + VERIFY_ARE_EQUAL(std::wstring{L"2 seconds ago"}, FormatInvariantElapsedSeconds(2));
126 + VERIFY_ARE_EQUAL(std::wstring{L"About a minute ago"}, FormatInvariantElapsedSeconds(119));
127 + VERIFY_ARE_EQUAL(std::wstring{L"59 minutes ago"}, FormatInvariantElapsedSeconds(59 * 60));
128 + VERIFY_ARE_EQUAL(std::wstring{L"13 days ago"}, FormatInvariantElapsedSeconds(13 * 24 * 60 * 60));
129 + VERIFY_ARE_EQUAL(std::wstring{L"8 weeks ago"}, FormatInvariantElapsedSeconds(59 * 24 * 60 * 60));
130 + VERIFY_ARE_EQUAL(std::wstring{L"12 months ago"}, FormatInvariantElapsedSeconds(365 * 24 * 60 * 60));
131 + VERIFY_ARE_EQUAL(std::wstring{L"3 years ago"}, FormatInvariantElapsedSeconds(3 * 365 * 24 * 60 * 60LL));
132 + }
133 };
134
135 } // namespace WSLCCLIRelativeTimeUnitTests
test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp
+115 -25
@@ -81,7 +81,7 @@ class WSLCE2EContainerListTests
81
82 // Verify we found the container in the list output
83 VERIFY_IS_TRUE(foundContainerLine.has_value());
84 - VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"created"));
84 + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"Created"));
85 }
86
87 WSLC_TEST_METHOD(WSLCE2E_Container_List_NoOptions_RunningContainers)
@@ -110,7 +110,7 @@ class WSLCE2EContainerListTests
110
111 // Verify we found the container in the list output
112 VERIFY_IS_TRUE(foundContainerLine.has_value());
113 - VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"running"));
113 + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"Up "));
114 }
115
116 WSLC_TEST_METHOD(WSLCE2E_Container_List_NoOptions_ExcludesCreatedContainers)
@@ -139,6 +139,33 @@ class WSLCE2EContainerListTests
139 VERIFY_IS_FALSE(isListed);
140 }
141
142 + // The table layout must match `docker container list` so users can rely on column order.
143 + WSLC_TEST_METHOD(WSLCE2E_Container_List_TableFormat_MatchesDockerColumnOrder)
144 + {
145 + const auto result = RunWslc(L"container list --all");
146 + result.Verify({.Stderr = L"", .ExitCode = 0});
147 +
148 + const auto outputLines = result.GetStdoutLines();
149 + VERIFY_IS_FALSE(outputLines.empty());
150 +
151 + const auto& header = outputLines.front();
152 + size_t position = 0;
153 + for (const auto& column :
154 + {Localization::WSLCCLI_TableHeaderContainerId(),
155 + Localization::WSLCCLI_TableHeaderImage(),
156 + Localization::WSLCCLI_TableHeaderCommand(),
157 + Localization::WSLCCLI_TableHeaderCreated(),
158 + Localization::WSLCCLI_TableHeaderStatus(),
159 + Localization::WSLCCLI_TableHeaderPorts(),
160 + Localization::WSLCCLI_TableHeaderNames()})
161 + {
162 + const auto found = header.find(column, position);
163 + VERIFY_ARE_NOT_EQUAL(
164 + std::wstring::npos, found, std::format(L"Column '{}' missing or out of order in '{}'", column, header).c_str());
165 + position = found + column.size();
166 + }
167 + }
168 +
169 WSLC_TEST_METHOD(WSLCE2E_Container_List_QuietOption_OutputsIdsOnly)
170 {
171 VerifyContainerIsNotListed(WslcContainerName);
@@ -151,7 +178,12 @@ class WSLCE2EContainerListTests
178 result = RunWslc(L"container list --all --quiet");
179 result.Verify({.Stderr = L"", .ExitCode = 0});
180
154 - // Verify the created container ID appears in the quiet output.
181 + const auto truncatedId = wsl::shared::string::MultiByteToWide(TruncateId(WideToMultiByte(containerId)));
182 + VERIFY_ARE_EQUAL(12u, truncatedId.size());
183 + VERIFY_IS_TRUE(result.StdoutContainsLine(truncatedId));
184 +
185 + result = RunWslc(L"container list --all --quiet --no-trunc");
186 + result.Verify({.Stderr = L"", .ExitCode = 0});
187 VERIFY_IS_TRUE(result.StdoutContainsLine(containerId));
188 }
189
@@ -174,15 +206,15 @@ class WSLCE2EContainerListTests
206 VERIFY_IS_FALSE(containerId.empty());
207
208 // List containers with json format
177 - result = RunWslc(L"container list --all --format json");
209 + result = RunWslc(L"container list --all --format json --no-trunc");
210 result.Verify({.Stderr = L"", .ExitCode = 0});
211 // Parse json and verify we got the expected container information back
180 - auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
212 + auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
213 VERIFY_IS_GREATER_THAN_OR_EQUAL(containers.size(), 1U);
214 VERIFY_ARE_EQUAL(containers.size(), result.GetStdoutLines().size());
215
184 - auto findContainer = [](const std::vector<ContainerInformation>& list, const std::wstring& id) {
185 - return std::ranges::any_of(list, [&](const auto& c) { return wsl::shared::string::MultiByteToWide(c.Id) == id; });
216 + auto findContainer = [](const std::vector<ContainerOutputInformation>& list, const std::wstring& id) {
217 + return std::ranges::any_of(list, [&](const auto& c) { return wsl::shared::string::MultiByteToWide(c.ID) == id; });
218 };
219
220 VERIFY_IS_TRUE(findContainer(containers, containerId));
@@ -194,16 +226,74 @@ class WSLCE2EContainerListTests
226 VERIFY_IS_FALSE(containerId2.empty());
227
228 // List containers with json format again
197 - result = RunWslc(L"container list --all --format json");
229 + result = RunWslc(L"container list --all --format json --no-trunc");
230 result.Verify({.Stderr = L"", .ExitCode = 0});
231 // Parse json and verify we got both containers back
200 - containers = ParseNdjsonOutputAs<ContainerInformation>(result);
232 + containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
233 VERIFY_IS_GREATER_THAN_OR_EQUAL(containers.size(), 2U);
234
235 VERIFY_IS_TRUE(findContainer(containers, containerId));
236 VERIFY_IS_TRUE(findContainer(containers, containerId2));
237 }
238
239 + WSLC_TEST_METHOD(WSLCE2E_Container_List_JsonFormat_MatchesDockerShape)
240 + {
241 + const std::set<std::string> expectedKeys = {
242 + "Command",
243 + "CreatedAt",
244 + "HealthStatus",
245 + "ID",
246 + "Image",
247 + "Labels",
248 + "LocalVolumes",
249 + "Mounts",
250 + "Names",
251 + "Networks",
252 + "Platform",
253 + "Ports",
254 + "RunningFor",
255 + "Size",
256 + "State",
257 + "Status"};
258 +
259 + VerifyContainerIsNotListed(WslcContainerName);
260 +
261 + auto result = RunWslc(std::format(L"container create --name {} {}", WslcContainerName, DebianImage.NameAndTag()));
262 + result.Verify({.Stderr = L"", .ExitCode = 0});
263 +
264 + result = RunWslc(L"container list --all --format json");
265 + result.Verify({.Stderr = L"", .ExitCode = 0});
266 +
267 + const auto entries = ParseNdjsonOutput(result);
268 + VERIFY_IS_GREATER_THAN_OR_EQUAL(entries.size(), 1u);
269 +
270 + for (const auto& entry : entries)
271 + {
272 + std::set<std::string> keys;
273 + for (const auto& [key, value] : entry.items())
274 + {
275 + keys.insert(key);
276 +
277 + if (key == "Platform")
278 + {
279 + VERIFY_IS_TRUE(value.is_object());
280 + }
281 + else
282 + {
283 + VERIFY_IS_TRUE(
284 + value.is_string(), wsl::shared::string::MultiByteToWide(std::format("'{}' must be a string", key)).c_str());
285 + }
286 + }
287 +
288 + VERIFY_ARE_EQUAL(expectedKeys, keys, L"json output must contain exactly docker's container fields");
289 +
290 + VERIFY_ARE_EQUAL(12u, entry["ID"].get<std::string>().size());
291 + VERIFY_ARE_NOT_EQUAL(std::string{}, entry["Names"].get<std::string>());
292 + VERIFY_ARE_NOT_EQUAL(std::string{}, entry["State"].get<std::string>());
293 + VERIFY_ARE_EQUAL(std::string{"linux"}, entry["Platform"]["os"].get<std::string>());
294 + }
295 + }
296 +
297 WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_InvalidKey)
298 {
299 // Filter keys are validated by the Docker daemon, which rejects unknown keys.
@@ -246,9 +336,9 @@ class WSLCE2EContainerListTests
336 result = RunWslc(std::format(L"container list --all --format json --filter name={}", WslcContainerName2));
337 result.Verify({.Stderr = L"", .ExitCode = 0});
338
249 - const auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
339 + const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
340 VERIFY_ARE_EQUAL(1U, containers.size());
251 - VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Name));
341 + VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Names));
342 }
343
344 WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_Status)
@@ -267,11 +357,11 @@ class WSLCE2EContainerListTests
357 auto listNames = [&](const std::wstring& filterArgs) {
358 auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs));
359 r.Verify({.Stderr = L"", .ExitCode = 0});
270 - const auto containers = ParseNdjsonOutputAs<ContainerInformation>(r);
360 + const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(r);
361 std::set<std::string> names;
362 for (const auto& c : containers)
363 {
274 - names.insert(c.Name);
364 + names.insert(c.Names);
365 }
366 return names;
367 };
@@ -314,11 +404,11 @@ class WSLCE2EContainerListTests
404 auto listNames = [&](const std::wstring& filterArgs) {
405 auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs));
406 r.Verify({.Stderr = L"", .ExitCode = 0});
317 - const auto containers = ParseNdjsonOutputAs<ContainerInformation>(r);
407 + const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(r);
408 std::set<std::string> names;
409 for (const auto& c : containers)
410 {
321 - names.insert(c.Name);
411 + names.insert(c.Names);
412 }
413 return names;
414 };
@@ -358,12 +448,12 @@ class WSLCE2EContainerListTests
448 result.Verify({.Stderr = L"", .ExitCode = 0});
449
450 // Filter by id (full id) should return exactly one container.
361 - result = RunWslc(std::format(L"container list --all --format json --filter id={}", containerId));
451 + result = RunWslc(std::format(L"container list --all --format json --no-trunc --filter id={}", containerId));
452 result.Verify({.Stderr = L"", .ExitCode = 0});
453
364 - const auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
454 + const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
455 VERIFY_ARE_EQUAL(1U, containers.size());
366 - VERIFY_ARE_EQUAL(WideToMultiByte(containerId), std::string(containers[0].Id));
456 + VERIFY_ARE_EQUAL(WideToMultiByte(containerId), std::string(containers[0].ID));
457 }
458
459 WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_Exited)
@@ -382,11 +472,11 @@ class WSLCE2EContainerListTests
472 auto listNames = [&](const std::wstring& filterArgs) {
473 auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs));
474 r.Verify({.Stderr = L"", .ExitCode = 0});
385 - const auto containers = ParseNdjsonOutputAs<ContainerInformation>(r);
475 + const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(r);
476 std::set<std::string> names;
477 for (const auto& c : containers)
478 {
389 - names.insert(c.Name);
479 + names.insert(c.Names);
480 }
481 return names;
482 };
@@ -421,11 +511,11 @@ class WSLCE2EContainerListTests
511 auto listNames = [&](const std::wstring& filterArgs) {
512 auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs));
513 r.Verify({.Stderr = L"", .ExitCode = 0});
424 - const auto containers = ParseNdjsonOutputAs<ContainerInformation>(r);
514 + const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(r);
515 std::set<std::string> names;
516 for (const auto& c : containers)
517 {
428 - names.insert(c.Name);
518 + names.insert(c.Names);
519 }
520 return names;
521 };
@@ -463,9 +553,9 @@ class WSLCE2EContainerListTests
553 result = RunWslc(L"container list --latest --format json");
554 result.Verify({.Stderr = L"", .ExitCode = 0});
555
466 - const auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
556 + const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
557 VERIFY_ARE_EQUAL(1U, containers.size());
468 - VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Name));
558 + VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Names));
559 }
560
561 // --last 2 should cap output at 2 containers.
@@ -473,7 +563,7 @@ class WSLCE2EContainerListTests
563 result = RunWslc(L"container list --last 2 --format json");
564 result.Verify({.Stderr = L"", .ExitCode = 0});
565
476 - const auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
566 + const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
567 VERIFY_IS_TRUE(containers.size() <= 2u);
568 }
569
test/windows/wslc/e2e/WSLCE2EHelpers.cpp
+25 -8
@@ -152,6 +152,22 @@ TestSession::~TestSession()
152
153 void VerifyContainerIsListed(const std::wstring& containerNameOrId, const std::wstring& status, const std::wstring& sessionName)
154 {
155 + // The status column reports the runtime's description, e.g. "Up 5 seconds", so map the logical
156 + // state callers pass in onto the text that description starts with.
157 + std::wstring expectedStatus = status;
158 + if (status == L"created")
159 + {
160 + expectedStatus = L"Created";
161 + }
162 + else if (status == L"running")
163 + {
164 + expectedStatus = L"Up ";
165 + }
166 + else if (status == L"exited")
167 + {
168 + expectedStatus = L"Exited (";
169 + }
170 +
171 std::wstring command = L"container list --no-trunc --all";
172 if (!sessionName.empty())
173 {
@@ -167,8 +183,8 @@ void VerifyContainerIsListed(const std::wstring& containerNameOrId, const std::w
183 if (line.find(containerNameOrId) != std::wstring::npos)
184 {
185 const std::wstring message = L"Container '" + containerNameOrId + L"' found in container list output but status '" +
170 - status + L"' was not found in the same line";
171 - VERIFY_ARE_NOT_EQUAL(std::wstring::npos, line.find(status), message.c_str());
186 + expectedStatus + L"' was not found in the same line";
187 + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, line.find(expectedStatus), message.c_str());
188 return;
189 }
190 }
@@ -348,7 +364,7 @@ void EnsureContainerDoesNotExist(const std::wstring& containerName)
364 {
365 const auto name = wsl::shared::string::WideToMultiByte(containerName);
366 const auto containers = ListAllContainers();
351 - auto it = std::ranges::find_if(containers, [&](const auto& c) { return c.Name == name; });
367 + auto it = std::ranges::find_if(containers, [&](const auto& c) { return c.Names == name; });
368 if (it == containers.end())
369 {
370 return;
@@ -362,11 +378,12 @@ void EnsureContainerDoesNotExist(const std::wstring& containerName)
378 }
379 }
380
365 -std::vector<wsl::windows::wslc::models::ContainerInformation> ListAllContainers()
381 +std::vector<wsl::windows::wslc::models::ContainerOutputInformation> ListAllContainers()
382 {
367 - auto result = RunWslc(L"container list --all --format json");
383 + // --no-trunc keeps the full ids, which callers use to address containers.
384 + auto result = RunWslc(L"container list --all --format json --no-trunc");
385 result.Verify({.Stderr = L"", .ExitCode = 0});
369 - return ParseNdjsonOutputAs<wsl::windows::wslc::models::ContainerInformation>(result);
386 + return ParseNdjsonOutputAs<wsl::windows::wslc::models::ContainerOutputInformation>(result);
387 }
388
389 void EnsureImageContainersAreDeleted(const TestImage& image)
@@ -377,8 +394,8 @@ void EnsureImageContainersAreDeleted(const TestImage& image)
394 auto nameAndTag = wsl::shared::string::WideToMultiByte(image.NameAndTag());
395 if (container.Image.find(nameAndTag) != std::string::npos)
396 {
380 - auto result = RunWslc(std::format(L"container remove --force {}", container.Id));
381 - result.Verify({.Stdout = std::format(L"{}\r\n", container.Id), .Stderr = L"", .ExitCode = 0});
397 + auto result = RunWslc(std::format(L"container remove --force {}", container.ID));
398 + result.Verify({.Stdout = std::format(L"{}\r\n", container.ID), .Stderr = L"", .ExitCode = 0});
399 }
400 }
401 }
test/windows/wslc/e2e/WSLCE2EHelpers.h
+1 -1
@@ -155,7 +155,7 @@ wsl::windows::common::wslc_schema::InspectContainer InspectContainer(const std::
155 wsl::windows::common::wslc_schema::InspectImage InspectImage(const std::wstring& imageName);
156 wsl::windows::common::wslc_schema::InspectVolume InspectVolume(const std::wstring& volumeName);
157 wsl::windows::common::wslc_schema::Network InspectNetwork(const std::wstring& networkName);
158 -std::vector<wsl::windows::wslc::models::ContainerInformation> ListAllContainers();
158 +std::vector<wsl::windows::wslc::models::ContainerOutputInformation> ListAllContainers();
159
160 void EnsureContainerDoesNotExist(const std::wstring& containerName);
161 void DeleteImagesWithRepositoryPrefix(const std::wstring& repositoryPrefix);