diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index e75120b62..00bc92dee 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -3674,6 +3674,12 @@ On first run, creates the file with all settings commented out at their defaults NAME + + NAMES + + + COMMAND + IMAGE @@ -3704,6 +3710,26 @@ On first run, creates the file with all settings commented out at their defaults PIDS + + created + Container state shown in the STATUS column of `wslc container list`. + + + running + Container state shown in the STATUS column of `wslc container list`. + + + stopped + Container state shown in the STATUS column of `wslc container list`. + + + exited + Container state shown in the STATUS column of `wslc container list`. + + + invalid + Container state shown in the STATUS column of `wslc container list`. + Less than a second ago Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'. diff --git a/src/windows/common/CMakeLists.txt b/src/windows/common/CMakeLists.txt index 97fb2dfd8..57d4e7307 100644 --- a/src/windows/common/CMakeLists.txt +++ b/src/windows/common/CMakeLists.txt @@ -128,6 +128,7 @@ set(HEADERS VTSupport.h WindowsUpdateIntegration.h WSLCContainerLauncher.h + WSLCContainerEntry.h ConsommeNetworking.h WSLCProcessLauncher.h WslClient.h diff --git a/src/windows/common/WSLCContainerEntry.h b/src/windows/common/WSLCContainerEntry.h new file mode 100644 index 000000000..5bee0fc4b --- /dev/null +++ b/src/windows/common/WSLCContainerEntry.h @@ -0,0 +1,28 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +#pragma once + +#include +#include "wslc.h" + +namespace wsl::windows::common::wslc { + +// IWSLCSession::ListContainers allocates a string for every unbounded field on an entry, so each +// element owns memory that releasing the array alone would miss. +struct ContainerEntryDeleter +{ + void operator()(WSLCContainerEntry& Entry) const + { + CoTaskMemFree(Entry.Command); + CoTaskMemFree(Entry.Status); + CoTaskMemFree(Entry.Labels); + CoTaskMemFree(Entry.Networks); + CoTaskMemFree(Entry.Mounts); + } +}; + +// Owns both the entry array and the per-entry strings. Callers of ListContainers should use this +// rather than a plain cotaskmem array so the strings cannot be leaked. +using unique_container_entry_array = wil::unique_any_array_ptr; + +} // namespace wsl::windows::common::wslc diff --git a/src/windows/common/string.cpp b/src/windows/common/string.cpp index c3b8f954a..ab9881d93 100644 --- a/src/windows/common/string.cpp +++ b/src/windows/common/string.cpp @@ -426,6 +426,57 @@ std::string wsl::windows::common::string::TruncateId(_In_ std::string_view id, b return TruncateIdImpl(id, shortenLength); } +// Returns the number of terminal columns a code point occupies. This mirrors docker's charWidth, which treats +// East Asian wide and fullwidth code points as two columns and everything else as one. +static size_t CharacterWidth(UChar32 CodePoint) +{ + const auto width = u_getIntPropertyValue(CodePoint, UCHAR_EAST_ASIAN_WIDTH); + return (width == U_EA_WIDE || width == U_EA_FULLWIDTH) ? 2 : 1; +} + +std::wstring wsl::windows::common::string::Ellipsis(_In_ std::wstring_view Value, _In_ size_t MaxDisplayWidth) +{ + if (MaxDisplayWidth == 0 || Value.empty()) + { + return {}; + } + + const auto length = gsl::narrow_cast(Value.size()); + if (MaxDisplayWidth == 1) + { + // There is no room for both content and an ellipsis, so the leading code point is kept as-is even + // if it is wider than the limit. + int32_t index = 0; + UChar32 codePoint{}; + U16_NEXT(Value.data(), index, length, codePoint); + return std::wstring{Value.substr(0, index)}; + } + + // The ellipsis occupies one column, so the retained content has one column less to work with. + const auto budget = MaxDisplayWidth - 1; + size_t totalWidth = 0; + size_t cutoff = 0; + for (int32_t index = 0; index < length;) + { + UChar32 codePoint{}; + U16_NEXT(Value.data(), index, length, codePoint); + totalWidth += CharacterWidth(codePoint); + if (totalWidth <= budget) + { + cutoff = index; + } + } + + // A cutoff of zero means the first code point alone leaves no room for the ellipsis, in which case docker + // returns the value untouched. + if (totalWidth <= MaxDisplayWidth || cutoff == 0) + { + return std::wstring{Value}; + } + + return std::wstring{Value.substr(0, cutoff)} + L'\u2026'; +} + std::uint64_t wsl::windows::common::string::Rfc3339ToEpoch(const std::string& timestamp) { std::chrono::sys_seconds utcSeconds; diff --git a/src/windows/common/string.hpp b/src/windows/common/string.hpp index 2a5c7403c..0a665892d 100644 --- a/src/windows/common/string.hpp +++ b/src/windows/common/string.hpp @@ -67,6 +67,12 @@ std::string WideToMultiByte(_In_ std::wstring_view Source); std::wstring TruncateId(_In_ std::wstring_view id, bool shortenLength = true); std::string TruncateId(_In_ std::string_view id, bool shortenLength = true); +// Shortens a value so it occupies at most MaxDisplayWidth terminal columns, appending an ellipsis when +// characters are dropped. East Asian wide and fullwidth code points occupy two columns, so fewer of them +// fit than narrow ones, and a code point is never split. This matches docker's formatter.Ellipsis +// (cli/command/formatter/displayutils.go), including its handling of widths of one and below. +std::wstring Ellipsis(_In_ std::wstring_view Value, _In_ size_t MaxDisplayWidth); + // Converts an RFC 3339 timestamp to seconds since the unix epoch. Only the 'Z' zone designator is // accepted; numeric offsets are not. std::uint64_t Rfc3339ToEpoch(const std::string& timestamp); diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index 256fe0646..57911166e 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -771,6 +771,8 @@ struct ContainerInfo std::vector Names; std::string Image; std::string ImageID; + std::string Command; + std::string Status; std::map Labels; std::vector Ports; std::vector Mounts; @@ -779,7 +781,8 @@ struct ContainerInfo HostConfig HostConfig; NetworkSettings NetworkSettings; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInfo, Id, Names, Image, ImageID, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT( + ContainerInfo, Id, Names, Image, ImageID, Command, Status, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings); }; struct BuildKitVertex diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 1bd1fce68..b1b599c37 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -373,9 +373,17 @@ typedef struct _WSLCContainerEntry { char Name[WSLC_MAX_CONTAINER_NAME_LENGTH + 1]; char Image[WSLC_MAX_IMAGE_NAME_LENGTH + 1]; + // The runtime imposes no bound on these values, so they are allocated by the callee and freed + // by the caller. Any of them may be null when the container reports no value. + [string] LPSTR Command; + [string] LPSTR Status; + [string] LPSTR Labels; + [string] LPSTR Networks; + [string] LPSTR Mounts; WSLCContainerId Id; ULONGLONG StateChangedAt; ULONGLONG CreatedAt; + ULONG LocalVolumes; WSLCContainerState State; } WSLCContainerEntry; diff --git a/src/windows/wslc/services/ContainerModel.h b/src/windows/wslc/services/ContainerModel.h index 86c27e6ce..7defbfbb2 100644 --- a/src/windows/wslc/services/ContainerModel.h +++ b/src/windows/wslc/services/ContainerModel.h @@ -134,12 +134,50 @@ struct ContainerInformation std::string Id; std::string Name; std::string Image; + // Command and runtime supplied status description. + std::string Command; + std::string Status; + std::string Labels; + std::string Networks; + std::string Mounts; + ULONG LocalVolumes{}; WSLCContainerState State; ULONGLONG StateChangedAt{}; ULONGLONG CreatedAt{}; std::vector Ports; +}; + +// The platform a container runs on. Emitted as a nested object to match docker. +struct ContainerPlatform +{ + std::string architecture; + std::string os; - NLOHMANN_DEFINE_TYPE_INTRUSIVE(ContainerInformation, Id, Name, Image, State, StateChangedAt, CreatedAt, Ports); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerPlatform, architecture, os); +}; + +// The shape emitted by "container list --format json". +struct ContainerOutputInformation +{ + std::string Command; + std::string CreatedAt; + std::string HealthStatus; + std::string ID; + std::string Image; + std::string Labels; + std::string LocalVolumes; + std::string Mounts; + std::string Names; + std::string Networks; + ContainerPlatform Platform; + std::string Ports; + std::string RunningFor; + std::string Size; + std::string State; + std::string Status; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT( + ContainerOutputInformation, Command, CreatedAt, HealthStatus, ID, Image, Labels, LocalVolumes, Mounts, Names, Networks, Platform, Ports, RunningFor, Size, State, Status); }; struct EnvironmentVariable diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index 06d30c655..2d2dfda9e 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -21,6 +21,7 @@ Module Name: #include #include #include +#include #include #include #include @@ -408,30 +409,49 @@ int ContainerService::Attach(Terminal& terminal, Session& session, const std::st return runningProcess.Wait(); } -std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, ULONGLONG stateChangedAt) +// The invariant state name. This is what "container list --format json" reports. +std::wstring ContainerService::ContainerStateName(WSLCContainerState state) { - std::wstring stateString; switch (state) { case WSLCContainerState::WslcContainerStateCreated: - stateString = L"created"; - break; + return L"created"; case WSLCContainerState::WslcContainerStateRunning: - stateString = L"running"; - break; + return L"running"; case WSLCContainerState::WslcContainerStateDeleted: - stateString = L"stopped"; - break; + return L"stopped"; case WSLCContainerState::WslcContainerStateExited: - stateString = L"exited"; - break; + return L"exited"; case WSLCContainerState::WslcContainerStateInvalid: return L"invalid"; default: THROW_HR(E_UNEXPECTED); } +} + +std::wstring ContainerService::LocalizedContainerStateName(WSLCContainerState state) +{ + switch (state) + { + case WSLCContainerState::WslcContainerStateCreated: + return Localization::WSLCCLI_ContainerStateCreated(); + case WSLCContainerState::WslcContainerStateRunning: + return Localization::WSLCCLI_ContainerStateRunning(); + case WSLCContainerState::WslcContainerStateDeleted: + return Localization::WSLCCLI_ContainerStateStopped(); + case WSLCContainerState::WslcContainerStateExited: + return Localization::WSLCCLI_ContainerStateExited(); + case WSLCContainerState::WslcContainerStateInvalid: + return Localization::WSLCCLI_ContainerStateInvalid(); + default: + THROW_HR(E_UNEXPECTED); + } +} - if (stateChangedAt == 0) +std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, ULONGLONG stateChangedAt) +{ + auto stateString = LocalizedContainerStateName(state); + if (stateChangedAt == 0 || state == WSLCContainerState::WslcContainerStateInvalid) { return stateString; } @@ -439,6 +459,154 @@ std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, return std::format(L"{} {}", stateString, FormatRelativeTime(stateChangedAt)); } +// Reports whether a code point is printable using the same rule as Go's unicode.IsPrint, which docker relies on when +// quoting: letters, marks, numbers, punctuation, symbols and the ASCII space. +static bool IsPrintable(UChar32 codePoint) +{ + constexpr auto printableMask = U_GC_L_MASK | U_GC_M_MASK | U_GC_N_MASK | U_GC_P_MASK | U_GC_S_MASK; + return codePoint == U' ' || (U_GET_GC_MASK(codePoint) & printableMask) != 0; +} + +// Appends a code point that has no printable representation, mirroring the escapes Go's strconv.Quote emits. +static void AppendEscape(std::wstring& quoted, UChar32 codePoint) +{ + switch (codePoint) + { + case L'\a': + quoted += L"\\a"; + return; + case L'\b': + quoted += L"\\b"; + return; + case L'\f': + quoted += L"\\f"; + return; + case L'\n': + quoted += L"\\n"; + return; + case L'\r': + quoted += L"\\r"; + return; + case L'\t': + quoted += L"\\t"; + return; + case L'\v': + quoted += L"\\v"; + return; + default: + break; + } + + if (codePoint < L' ' || codePoint == 0x7F) + { + quoted += std::format(L"\\x{:02x}", static_cast(codePoint)); + } + else if (U_IS_SURROGATE(codePoint)) + { + // An unpaired surrogate is not a valid code point, and Go substitutes the replacement character. + quoted += L"\\ufffd"; + } + else if (codePoint < 0x10000) + { + quoted += std::format(L"\\u{:04x}", static_cast(codePoint)); + } + else + { + quoted += std::format(L"\\U{:08x}", static_cast(codePoint)); + } +} + +std::wstring ContainerService::FormatCommand(const std::string& command, bool truncate) +{ + constexpr size_t c_maxDisplayWidth = 20; + + auto wide = wsl::shared::string::MultiByteToWide(command); + if (truncate) + { + wide = wsl::windows::common::string::Ellipsis(wide, c_maxDisplayWidth); + } + + // Quoting happens after truncation, so the result can exceed c_maxDisplayWidth. This matches docker, which truncates + // the command first and quotes the truncated value. + const auto length = static_cast(wide.size()); + std::wstring quoted{L'"'}; + for (int32_t index = 0; index < length;) + { + const auto start = index; + UChar32 codePoint{}; + U16_NEXT(wide.data(), index, length, codePoint); + + if (codePoint == L'"' || codePoint == L'\\') + { + quoted += L'\\'; + quoted += static_cast(codePoint); + } + else if (IsPrintable(codePoint)) + { + quoted.append(wide, start, static_cast(index - start)); + } + else + { + AppendEscape(quoted, codePoint); + } + } + + quoted += L'"'; + return quoted; +} + +std::wstring ContainerService::FormatMounts(const std::string& mounts, bool truncate) +{ + constexpr size_t c_maxDisplayWidth = 15; + + auto wide = wsl::shared::string::MultiByteToWide(mounts); + if (!truncate || wide.empty()) + { + return wide; + } + + std::vector shortened; + for (const auto& mount : wsl::shared::string::SplitPreserveEmpty(std::wstring_view{wide}, L',')) + { + shortened.emplace_back(wsl::windows::common::string::Ellipsis(mount, c_maxDisplayWidth)); + } + + return wsl::shared::string::Join(shortened, L','); +} + +std::wstring ContainerService::FormatStatus(const std::string& status, WSLCContainerState state, ULONGLONG stateChangedAt) +{ + if (!status.empty()) + { + return wsl::shared::string::MultiByteToWide(status); + } + + return ContainerStateToString(state, stateChangedAt); +} + +std::string ContainerService::FormatHealthStatus(const std::string& status) +{ + const auto open = status.find('('); + if (open == std::string::npos || status.back() != ')') + { + return {}; + } + + constexpr std::string_view c_healthPrefix = "health: "; + auto health = std::string_view{status}.substr(open + 1, status.size() - open - 2); + if (health.starts_with(c_healthPrefix)) + { + health.remove_prefix(c_healthPrefix.size()); + } + + if (health == "healthy" || health == "unhealthy" || health == "starting") + { + return std::string{health}; + } + + return {}; +} + std::wstring ContainerService::FormatPorts(WSLCContainerState state, const std::vector& ports) { if (state != WslcContainerStateRunning || ports.empty()) @@ -603,7 +771,7 @@ std::vector ContainerService::List( options.Filters = filterEntries.data(); options.FiltersCount = static_cast(filterEntries.size()); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; THROW_IF_FAILED( session.Get()->ListContainers(&options, &containers, containers.size_address(), &ports, ports.size_address())); @@ -615,6 +783,12 @@ std::vector ContainerService::List( ContainerInformation entry; entry.Name = current.Name; entry.Image = current.Image; + entry.Command = current.Command == nullptr ? "" : current.Command; + entry.Status = current.Status == nullptr ? "" : current.Status; + entry.Labels = current.Labels == nullptr ? "" : current.Labels; + entry.Networks = current.Networks == nullptr ? "" : current.Networks; + entry.Mounts = current.Mounts == nullptr ? "" : current.Mounts; + entry.LocalVolumes = current.LocalVolumes; entry.State = current.State; entry.Id = current.Id; entry.StateChangedAt = current.StateChangedAt; diff --git a/src/windows/wslc/services/ContainerService.h b/src/windows/wslc/services/ContainerService.h index 09203056d..73bbc4838 100644 --- a/src/windows/wslc/services/ContainerService.h +++ b/src/windows/wslc/services/ContainerService.h @@ -23,9 +23,29 @@ namespace wsl::windows::wslc::services { struct ContainerService { static std::wstring ContainerStateToString(WSLCContainerState state, ULONGLONG stateChangedAt = 0); + + // The bare state name, e.g. "running", without the relative time ContainerStateToString appends. + static std::wstring ContainerStateName(WSLCContainerState state); + + // The display form of ContainerStateName, used for the table output. + static std::wstring LocalizedContainerStateName(WSLCContainerState state); static std::wstring FormatRelativeTime(ULONGLONG timestamp); static std::wstring FormatElapsedSeconds(LONGLONG elapsedSeconds); static std::wstring FormatPorts(WSLCContainerState state, const std::vector& ports); + + static std::wstring FormatCommand(const std::string& command, bool truncate); + + // Renders the comma separated mount list. Docker shortens each name independently, so a long path + // never crowds out the mounts that follow it. + static std::wstring FormatMounts(const std::string& mounts, bool truncate); + + // Renders a container status, preferring the description supplied by the runtime and falling back + // to a locally built one when it is unavailable. + static std::wstring FormatStatus(const std::string& status, WSLCContainerState state, ULONGLONG stateChangedAt); + + // Extracts the health status from a runtime supplied status description, which carries it as a + // parenthesized suffix. Containers without a health check report an empty string. + static std::string FormatHealthStatus(const std::string& status); static int Attach(Terminal& terminal, models::Session& session, const std::string& id); static int Run(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options); static models::CreateContainerResult Create(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options); diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index 695fb61c5..d3b1f517b 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -114,6 +114,36 @@ nlohmann::json ComputeContainerStatsJson(const wsl::windows::common::docker_sche }; } +// Builds the representation of a container, shared by the table and json output so the two cannot +// drift. Every value is emitted as a string apart from the platform object, and the id is truncated +// unless --no-trunc is passed. +ContainerOutputInformation ToContainerOutput(const ContainerInformation& container, bool truncate) +{ + ContainerOutputInformation entry; + entry.Command = WideToMultiByte(ContainerService::FormatCommand(container.Command, truncate)); + entry.CreatedAt = EpochToLocalDisplayTime(static_cast(container.CreatedAt)); + // The runtime reports health as a suffix on the status description, which is the only place it is + // exposed by the listing API. + entry.HealthStatus = ContainerService::FormatHealthStatus(container.Status); + entry.ID = truncate ? TruncateId(container.Id) : container.Id; + entry.Image = container.Image; + entry.Labels = container.Labels; + entry.LocalVolumes = std::to_string(container.LocalVolumes); + entry.Mounts = WideToMultiByte(ContainerService::FormatMounts(container.Mounts, truncate)); + entry.Names = container.Name; + entry.Networks = container.Networks; + entry.Platform.architecture = wsl::shared::Arm64 ? "arm64" : "amd64"; + entry.Platform.os = "linux"; + entry.Ports = WideToMultiByte(ContainerService::FormatPorts(container.State, container.Ports)); + entry.RunningFor = WideToMultiByte(ContainerService::FormatRelativeTime(container.CreatedAt)); + // Container sizes are only computed when docker is passed --size, which wslc does not support. + entry.Size = WideToMultiByte(FormatHumanReadableSize(0)); + entry.State = WideToMultiByte(ContainerService::ContainerStateName(container.State)); + entry.Status = WideToMultiByte(ContainerService::FormatStatus(container.Status, container.State, container.StateChangedAt)); + + return entry; +} + } // namespace namespace wsl::windows::wslc::task { @@ -524,15 +554,17 @@ void ListContainers(CLIExecutionContext& context) if (context.Args.GetValue()) { // Print only the container ids + bool trunc = !context.Args.GetValue(); for (const auto& container : containers) { - context.Terminal.Output(L"{}\n", MultiByteToWide(container.Id)); + context.Terminal.Output(L"{}\n", MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id)); } return; } const auto format = context.Args.GetValue(FormatType::Table); + bool trunc = !context.Args.GetValue(); switch (format) { @@ -540,45 +572,48 @@ void ListContainers(CLIExecutionContext& context) { for (const auto& container : containers) { - context.Terminal.Output(L"{}\n", ToJsonW(container, c_jsonCompactIndent)); + context.Terminal.Output(L"{}\n", ToJsonW(ToContainerOutput(container, trunc), c_jsonCompactIndent)); } break; } case FormatType::Table: { - bool trunc = !context.Args.GetValue(); using enum ColumnOverflow; // Create table with or without column limits based on --no-trunc flag - auto table = trunc ? wsl::windows::wslc::TableOutput<6>( + auto table = trunc ? wsl::windows::wslc::TableOutput<7>( context.Terminal, {{{Localization::WSLCCLI_TableHeaderContainerId(), {.MaxWidth = 12, .Overflow = Shrink}}, - {Localization::WSLCCLI_TableHeaderName(), {.MaxWidth = 20, .Overflow = Shrink}}, {Localization::WSLCCLI_TableHeaderImage(), {.MaxWidth = 20, .Overflow = Shrink}}, + {Localization::WSLCCLI_TableHeaderCommand(), {.Overflow = Shrink}}, {Localization::WSLCCLI_TableHeaderCreated(), {.Overflow = Shrink}}, {Localization::WSLCCLI_TableHeaderStatus(), {.Overflow = Shrink}}, - {Localization::WSLCCLI_TableHeaderPorts(), {.Overflow = Shrink}}}}, + {Localization::WSLCCLI_TableHeaderPorts(), {.Overflow = Shrink}}, + {Localization::WSLCCLI_TableHeaderNames(), {.MaxWidth = 20, .Overflow = Shrink}}}}, containers.size()) - : wsl::windows::wslc::TableOutput<6>( + : wsl::windows::wslc::TableOutput<7>( context.Terminal, {Localization::WSLCCLI_TableHeaderContainerId(), - Localization::WSLCCLI_TableHeaderName(), Localization::WSLCCLI_TableHeaderImage(), + Localization::WSLCCLI_TableHeaderCommand(), Localization::WSLCCLI_TableHeaderCreated(), Localization::WSLCCLI_TableHeaderStatus(), - Localization::WSLCCLI_TableHeaderPorts()}); + Localization::WSLCCLI_TableHeaderPorts(), + Localization::WSLCCLI_TableHeaderNames()}); // Add each container as a row for (const auto& container : containers) { + const auto entry = ToContainerOutput(container, trunc); table.WriteRow({ - MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id), - MultiByteToWide(container.Name), - MultiByteToWide(container.Image), - ContainerService::FormatRelativeTime(container.CreatedAt), - ContainerService::ContainerStateToString(container.State, container.StateChangedAt), - ContainerService::FormatPorts(container.State, container.Ports), + MultiByteToWide(entry.ID), + MultiByteToWide(entry.Image), + MultiByteToWide(entry.Command), + MultiByteToWide(entry.RunningFor), + MultiByteToWide(entry.Status), + MultiByteToWide(entry.Ports), + MultiByteToWide(entry.Names), }); } diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index 31fff7997..fc0b61aeb 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -24,6 +24,7 @@ Module Name: #include "WSLCSessionDefaults.h" #include "wslpolicies.h" #include "APICompat.h" +#include "WSLCContainerEntry.h" using namespace wsl::windows::common; using io::MultiHandleWait; @@ -79,6 +80,13 @@ void ValidateNewSessionStorageDirectory(const std::filesystem::path& StoragePath THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessageWslcSessionStorageMustBeEmpty(StoragePath.c_str()), !empty); } +// Frees the caller-owned strings on a container entry. Safe to call on a partially populated entry +// because unset fields are null. +void FreeContainerEntryStrings(WSLCContainerEntry& Entry) +{ + wsl::windows::common::wslc::ContainerEntryDeleter{}(Entry); +} + // Group policy: WSLContainerRegistryAllowlist restricts which container-image // registries can be pulled from or pushed to. The check is enforced here at the // service boundary so it covers ALL callers (wslc.exe CLI, the WslcSDK C API, and @@ -2547,6 +2555,13 @@ try // if some IDs returned by Docker aren't in m_containers (e.g. created externally), but in the // common case the two should match. auto output = wil::make_unique_cotaskmem(dockerContainers.size()); + auto freeStrings = wil::scope_exit([&] { + for (size_t i = 0; i < dockerContainers.size(); ++i) + { + FreeContainerEntryStrings(output[i]); + } + }); + std::vector allPorts; size_t index = 0; @@ -2562,6 +2577,47 @@ try THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Image, e->Image().c_str()) != 0); THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Name, e->Name().c_str()) != 0); THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Id, e->ID().c_str()) != 0); + + // Commands and status descriptions have no bound imposed by the runtime, so they are + // allocated rather than copied into a fixed buffer. + output[index].Command = wil::make_unique_ansistring(dockerContainer.Command.c_str()).release(); + output[index].Status = wil::make_unique_ansistring(dockerContainer.Status.c_str()).release(); + + // Labels, networks and mounts are reported the way the docker CLI renders them: a comma + // separated list. Like the command and status above they are unbounded. + std::vector labels; + for (const auto& [key, value] : dockerContainer.Labels) + { + labels.push_back(std::format("{}={}", key, value)); + } + + std::vector networks; + for (const auto& [name, _] : dockerContainer.NetworkSettings.Networks) + { + networks.push_back(name); + } + + std::vector mounts; + ULONG localVolumes = 0; + for (const auto& mount : dockerContainer.Mounts) + { + // Named volumes report a name, bind mounts only report the host path. + mounts.push_back(mount.Name.empty() ? mount.Source : mount.Name); + if (mount.Type == "volume") + { + localVolumes++; + } + } + + const auto joinedLabels = wsl::shared::string::Join(labels, ','); + const auto joinedNetworks = wsl::shared::string::Join(networks, ','); + const auto joinedMounts = wsl::shared::string::Join(mounts, ','); + + output[index].Labels = wil::make_unique_ansistring(joinedLabels.c_str()).release(); + output[index].Networks = wil::make_unique_ansistring(joinedNetworks.c_str()).release(); + output[index].Mounts = wil::make_unique_ansistring(joinedMounts.c_str()).release(); + output[index].LocalVolumes = localVolumes; + e->GetState(&output[index].State); e->GetStateChangedAt(&output[index].StateChangedAt); e->GetCreatedAt(&output[index].CreatedAt); @@ -2582,13 +2638,21 @@ try index++; } + // Finish every allocation before transferring ownership so nothing can throw once the caller + // owns the results. + wil::unique_cotaskmem_ptr portsOutput; + if (!allPorts.empty()) + { + portsOutput = wil::make_unique_cotaskmem(allPorts.size()); + memcpy(portsOutput.get(), allPorts.data(), allPorts.size() * sizeof(WSLCContainerPortMapping)); + } + + freeStrings.release(); *Count = static_cast(index); *Containers = output.release(); - if (!allPorts.empty()) + if (portsOutput) { - auto portsOutput = wil::make_unique_cotaskmem(allPorts.size()); - memcpy(portsOutput.get(), allPorts.data(), allPorts.size() * sizeof(WSLCContainerPortMapping)); *PortsCount = static_cast(allPorts.size()); *Ports = portsOutput.release(); } diff --git a/test/windows/StringUnitTests.cpp b/test/windows/StringUnitTests.cpp index 1d97a3bf0..6a7953af7 100644 --- a/test/windows/StringUnitTests.cpp +++ b/test/windows/StringUnitTests.cpp @@ -4,6 +4,7 @@ #include "Common.h" #include "string.hpp" +using wsl::windows::common::string::Ellipsis; using wsl::windows::common::string::FormatBytes; using wsl::windows::common::string::FormatHumanReadableSize; using wsl::windows::common::string::FormatStorageSize; @@ -268,6 +269,59 @@ class StringUnitTests } } + // Docker shortens display values with formatter.Ellipsis, which measures terminal columns rather than + // characters so East Asian wide and fullwidth code points count double. + TEST_METHOD(Ellipsis_NarrowCharacters_AreCountedAsOneColumn) + { + VERIFY_ARE_EQUAL(std::wstring{L""}, Ellipsis(L"", 20)); + VERIFY_ARE_EQUAL(std::wstring{L"sleep 3600"}, Ellipsis(L"sleep 3600", 20)); + VERIFY_ARE_EQUAL(std::wstring{L"12345678901234567890"}, Ellipsis(L"12345678901234567890", 20)); + VERIFY_ARE_EQUAL(std::wstring{L"1234567890123456789\u2026"}, Ellipsis(L"123456789012345678901", 20)); + VERIFY_ARE_EQUAL(std::wstring(19, L'\u00E0') + L"\u2026", Ellipsis(std::wstring(21, L'\u00E0'), 20)); + } + + TEST_METHOD(Ellipsis_WideCharacters_AreCountedAsTwoColumns) + { + // Ten wide characters fill the twenty columns exactly, so an eleventh forces the value to be shortened + // to the nine characters that leave room for the ellipsis. + VERIFY_ARE_EQUAL(std::wstring(10, L'\u65E5'), Ellipsis(std::wstring(10, L'\u65E5'), 20)); + VERIFY_ARE_EQUAL(std::wstring(9, L'\u65E5') + L"\u2026", Ellipsis(std::wstring(11, L'\u65E5'), 20)); + VERIFY_ARE_EQUAL(std::wstring(9, L'\uFF21') + L"\u2026", Ellipsis(std::wstring(11, L'\uFF21'), 20)); + VERIFY_ARE_EQUAL(std::wstring{L"ab"} + std::wstring(8, L'\u65E5') + L"\u2026", Ellipsis(L"ab" + std::wstring(10, L'\u65E5'), 20)); + } + + TEST_METHOD(Ellipsis_SurrogatePairs_AreNotSplit) + { + // Emoji are wide and encoded as surrogate pairs, so both the column count and the code unit boundary + // have to be honored. + const std::wstring emoji{L"\U0001F600"}; + std::wstring ten; + for (size_t index = 0; index < 10; ++index) + { + ten += emoji; + } + + VERIFY_ARE_EQUAL(ten, Ellipsis(ten, 20)); + VERIFY_ARE_EQUAL(ten.substr(0, 18) + L"\u2026", Ellipsis(ten + emoji, 20)); + } + + TEST_METHOD(Ellipsis_SmallWidths_MatchDocker) + { + VERIFY_ARE_EQUAL(std::wstring{L""}, Ellipsis(L"abc", 0)); + + // A width of one has no room for both content and an ellipsis, so the leading code point is kept even + // when it is wider than the limit. + VERIFY_ARE_EQUAL(std::wstring{L"a"}, Ellipsis(L"abc", 1)); + VERIFY_ARE_EQUAL(std::wstring{L"\u65E5"}, Ellipsis(L"\u65E5\u65E5", 1)); + VERIFY_ARE_EQUAL(std::wstring{L"\U0001F600"}, Ellipsis(L"\U0001F600\U0001F600", 1)); + + // A leading wide character leaves no room for the ellipsis at a width of two, and docker returns the + // value untouched in that case. + VERIFY_ARE_EQUAL(std::wstring{L"\u65E5\u65E5"}, Ellipsis(L"\u65E5\u65E5", 2)); + VERIFY_ARE_EQUAL(std::wstring{L"a\u2026"}, Ellipsis(L"abc", 2)); + VERIFY_ARE_EQUAL(std::wstring{L"\u65E5\u2026"}, Ellipsis(L"\u65E5\u65E5", 3)); + } + TEST_METHOD(StorageSize_BytesToTextRoundTrips) { const auto VerifyRoundTrip = [](uint64_t Bytes, StorageSizeUnit Unit, uint32_t DecimalPlaces, bool IncludeSpace = false) { diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 4e0ea24d1..e997d7d35 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -18,6 +18,7 @@ Module Name: #include "wslccompat.h" #include "WSLCProcessLauncher.h" #include "WSLCContainerLauncher.h" +#include "WSLCContainerEntry.h" #include "WslCoreFilesystem.h" #include "hcs.hpp" #include "ContainerNameGenerator.h" @@ -208,7 +209,7 @@ class WSLCTests struct ListContainersResult { - wil::unique_cotaskmem_array_ptr Containers; + wsl::windows::common::wslc::unique_container_entry_array Containers; wil::unique_cotaskmem_array_ptr Ports; }; @@ -7161,7 +7162,7 @@ class WSLCTests options.Filters = filters.data(); options.FiltersCount = static_cast(filters.size()); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( &options, &containers, containers.size_address(), &ports, ports.size_address())); @@ -7246,7 +7247,7 @@ class WSLCTests options.Flags = WSLCListContainersFlagsAll; options.Limit = 1; - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( &options, &containers, containers.size_address(), &ports, ports.size_address())); @@ -7317,7 +7318,7 @@ class WSLCTests WSLCListContainersOptions options{}; options.Flags = WSLCListContainersFlagsAll; - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; HRESULT hrList = m_defaultSession->ListContainers( &options, &containers, containers.size_address(), &ports, ports.size_address()); @@ -8988,7 +8989,7 @@ class WSLCTests // Verify that ListContainers returns the port data for a running container. { - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(session.ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -9033,7 +9034,7 @@ class WSLCTests auto createdContainer = createdLauncher.Create(session); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(session.ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -9060,7 +9061,7 @@ class WSLCTests // Verify that a stopped container returns no ports. VERIFY_SUCCEEDED(container.Get().Stop(WSLCSignalSIGKILL, 0)); { - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(session.ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -11740,7 +11741,7 @@ class WSLCTests VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer("test-auto-remove", ¬Found), WSLC_E_CONTAINER_NOT_FOUND); VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer(id.c_str(), ¬Found), WSLC_E_CONTAINER_NOT_FOUND); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -11775,7 +11776,7 @@ class WSLCTests wil::com_ptr notFound; VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer("test-auto-remove-stdout", ¬Found), WSLC_E_CONTAINER_NOT_FOUND); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -11906,7 +11907,7 @@ class WSLCTests // Validate that various operations can be done while the export is in progress. { - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); diff --git a/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp new file mode 100644 index 000000000..eaf6f6177 --- /dev/null +++ b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp @@ -0,0 +1,247 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +#include "precomp.h" +#include "windows/Common.h" +#include "WSLCCLITestHelpers.h" + +#include "ContainerService.h" + +using namespace wsl::windows::wslc; +using namespace wsl::windows::wslc::services; +using namespace WSLCTestHelpers; +using namespace WEX::Logging; +using namespace WEX::Common; +using namespace WEX::TestExecution; + +namespace WSLCCLIContainerCommandUnitTests { + +class WSLCCLIContainerCommandUnitTests +{ + WSLC_TEST_CLASS(WSLCCLIContainerCommandUnitTests) + + TEST_CLASS_SETUP(TestClassSetup) + { + return true; + } + + TEST_CLASS_CLEANUP(TestClassCleanup) + { + return true; + } + + static std::wstring Truncated(const std::string& command) + { + return ContainerService::FormatCommand(command, true); + } + + TEST_METHOD(FormatCommand_Empty_ReturnsEmptyQuotes) + { + VERIFY_ARE_EQUAL(std::wstring{LR"("")"}, Truncated("")); + } + + TEST_METHOD(FormatCommand_ShortCommand_IsQuotedUnchanged) + { + VERIFY_ARE_EQUAL(std::wstring{LR"("sleep 3600")"}, Truncated("sleep 3600")); + } + + TEST_METHOD(FormatCommand_ExactlyTwentyCharacters_IsNotShortened) + { + VERIFY_ARE_EQUAL(std::wstring{LR"("12345678901234567890")"}, Truncated("12345678901234567890")); + } + + TEST_METHOD(FormatCommand_TwentyOneCharacters_KeepsNineteenAndAppendsEllipsis) + { + VERIFY_ARE_EQUAL(std::wstring{L"\"1234567890123456789\u2026\""}, Truncated("123456789012345678901")); + } + + TEST_METHOD(FormatCommand_LongCommand_MatchesDockerOutput) + { + 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'")); + } + + TEST_METHOD(FormatCommand_NoTruncate_KeepsFullCommand) + { + const std::string command = "sh -c 'echo this is a very long command that should be truncated by docker'"; + VERIFY_ARE_EQUAL( + std::wstring{L"\"" + wsl::shared::string::MultiByteToWide(command) + L"\""}, ContainerService::FormatCommand(command, false)); + } + + TEST_METHOD(FormatCommand_EmbeddedQuotesAndBackslashes_AreEscaped) + { + // Raw string literals are avoided here: the compiler mangles them when the verify macro + // stringizes its arguments. + VERIFY_ARE_EQUAL(std::wstring{L"\"say \\\"hi\\\"\""}, Truncated("say \"hi\"")); + VERIFY_ARE_EQUAL(std::wstring{L"\"c:\\\\temp\""}, Truncated("c:\\temp")); + } + + TEST_METHOD(FormatCommand_NarrowMultiByteCharacters_CountedAsOneColumn) + { + const std::string accented = "\xC3\xA0"; + std::string twenty; + for (int i = 0; i < 20; ++i) + { + twenty += accented; + } + + VERIFY_ARE_EQUAL(std::wstring(20, L'\u00E0').insert(0, L"\"") + L"\"", Truncated(twenty)); + VERIFY_ARE_EQUAL(std::wstring(19, L'\u00E0').insert(0, L"\"") + L"\u2026\"", Truncated(twenty + accented)); + } + + TEST_METHOD(FormatCommand_WideCharacters_CountedAsTwoColumns) + { + // Docker measures display columns, so an East Asian wide character consumes two of the twenty + // available columns and only ten of them fit. + const std::string wide = "\xE6\x97\xA5"; + std::string ten; + for (int i = 0; i < 10; ++i) + { + ten += wide; + } + + VERIFY_ARE_EQUAL(std::wstring(10, L'\u65E5').insert(0, L"\"") + L"\"", Truncated(ten)); + VERIFY_ARE_EQUAL(std::wstring(9, L'\u65E5').insert(0, L"\"") + L"\u2026\"", Truncated(ten + wide)); + } + + TEST_METHOD(FormatCommand_MixedWidthCharacters_AreShortenedByColumn) + { + // Two narrow characters leave eighteen columns, so eight wide characters fit before the ellipsis. + const std::string wide = "\xE6\x97\xA5"; + std::string command = "ab"; + for (int i = 0; i < 10; ++i) + { + command += wide; + } + + VERIFY_ARE_EQUAL(std::wstring{L"\"ab"} + std::wstring(8, L'\u65E5') + L"\u2026\"", Truncated(command)); + } + + TEST_METHOD(FormatCommand_SurrogatePairs_AreNotSplit) + { + // Emoji are wide and encoded as surrogate pairs, so a shortened value has to stop on a code point + // boundary as well as a column boundary. + const std::string emoji = "\xF0\x9F\x98\x80"; + std::string ten; + std::wstring expected; + for (int i = 0; i < 10; ++i) + { + ten += emoji; + expected += L"\U0001F600"; + } + + VERIFY_ARE_EQUAL(L"\"" + expected + L"\"", Truncated(ten)); + VERIFY_ARE_EQUAL(L"\"" + expected.substr(0, 18) + L"\u2026\"", Truncated(ten + emoji)); + } + + TEST_METHOD(FormatCommand_ControlCharacters_AreEscaped) + { + // Docker quotes this field with Go's strconv.Quote, which renders control characters as escape sequences + // rather than emitting them raw and breaking the table row. + VERIFY_ARE_EQUAL(std::wstring{L"\"line1\\nline2\""}, Truncated("line1\nline2")); + VERIFY_ARE_EQUAL(std::wstring{L"\"col1\\tcol2\""}, Truncated("col1\tcol2")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\rb\""}, Truncated("a\rb")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\vb\""}, Truncated("a\vb")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\fb\""}, Truncated("a\fb")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\bb\""}, Truncated("a\bb")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\ab\""}, Truncated("a\ab")); + } + + TEST_METHOD(FormatCommand_NonPrintableCharacters_AreEscapedAsHex) + { + // Control characters without a dedicated escape use \x, matching strconv.Quote. + VERIFY_ARE_EQUAL( + std::wstring{L"\"a\\x1bb\""}, + Truncated("a\x1b" + "b")); + VERIFY_ARE_EQUAL( + std::wstring{L"\"a\\x7fb\""}, + Truncated("a\x7f" + "b")); + } + + TEST_METHOD(FormatCommand_PrintableUnicode_IsNotEscaped) + { + // Letters and symbols stay verbatim, so only genuinely unprintable values are expanded. + VERIFY_ARE_EQUAL(std::wstring{L"\"caf\u00E9\""}, Truncated("caf\xC3\xA9")); + } + + TEST_METHOD(FormatCommand_EscapesAreCountedAfterTruncation) + { + // Docker truncates before quoting, so escape expansion does not consume the display budget and the + // quoted result is wider than the limit. + VERIFY_ARE_EQUAL(std::wstring{L"\"\\n\\n\\n\\n\\n\""}, Truncated("\n\n\n\n\n")); + } + + TEST_METHOD(FormatMounts_LongNames_AreShortenedIndependently) + { + // Docker shortens every mount name to fifteen columns rather than the list as a whole + // (ContainerContext.Mounts in cli/command/formatter/container.go). + const auto mounts = "/var/lib/docker/volumes/data,logs,/mnt/c/users/test/source"; + VERIFY_ARE_EQUAL(std::wstring{L"/var/lib/docke\u2026,logs,/mnt/c/users/t\u2026"}, ContainerService::FormatMounts(mounts, true)); + VERIFY_ARE_EQUAL(wsl::shared::string::MultiByteToWide(mounts), ContainerService::FormatMounts(mounts, false)); + } + + TEST_METHOD(FormatMounts_ShortNames_AreUnchanged) + { + VERIFY_ARE_EQUAL(std::wstring{L""}, ContainerService::FormatMounts("", true)); + VERIFY_ARE_EQUAL(std::wstring{L"data-volume"}, ContainerService::FormatMounts("data-volume", true)); + VERIFY_ARE_EQUAL(std::wstring{L"123456789012345"}, ContainerService::FormatMounts("123456789012345", true)); + VERIFY_ARE_EQUAL(std::wstring{L"12345678901234\u2026"}, ContainerService::FormatMounts("1234567890123456", true)); + } + + TEST_METHOD(FormatMounts_WideCharacters_CountedAsTwoColumns) + { + // Seven wide characters occupy fourteen columns, so an eighth exceeds the fifteen column budget. + std::string wide; + for (int i = 0; i < 8; ++i) + { + wide += "\xE6\x97\xA5"; + } + + VERIFY_ARE_EQUAL(std::wstring(7, L'\u65E5') + L"\u2026", ContainerService::FormatMounts(wide, true)); + } + + TEST_METHOD(FormatStatus_RuntimeStatus_IsPreferred) + { + VERIFY_ARE_EQUAL(std::wstring{L"Up 5 minutes"}, ContainerService::FormatStatus("Up 5 minutes", WslcContainerStateRunning, 0)); + } + + TEST_METHOD(FormatStatus_EmptyRuntimeStatus_FallsBackToState) + { + VERIFY_ARE_EQUAL(std::wstring{L"created"}, ContainerService::FormatStatus("", WslcContainerStateCreated, 0)); + } + + TEST_METHOD(FormatHealthStatus_Healthy_IsExtracted) + { + VERIFY_ARE_EQUAL(std::string{"healthy"}, ContainerService::FormatHealthStatus("Up 2 minutes (healthy)")); + } + + TEST_METHOD(FormatHealthStatus_Unhealthy_IsExtracted) + { + VERIFY_ARE_EQUAL(std::string{"unhealthy"}, ContainerService::FormatHealthStatus("Up 2 minutes (unhealthy)")); + } + + TEST_METHOD(FormatHealthStatus_Starting_DropsHealthPrefix) + { + VERIFY_ARE_EQUAL(std::string{"starting"}, ContainerService::FormatHealthStatus("Up 2 seconds (health: starting)")); + } + + TEST_METHOD(FormatHealthStatus_NoHealthCheck_IsEmpty) + { + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes")); + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("")); + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Created")); + } + + TEST_METHOD(FormatHealthStatus_NoneIsNotReported) + { + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes (none)")); + } + + TEST_METHOD(FormatHealthStatus_UnrelatedParentheses_AreIgnored) + { + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Exited (0) 8 days ago")); + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes (Paused)")); + } +}; + +} // namespace WSLCCLIContainerCommandUnitTests diff --git a/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp index 6e9c7c880..3d521e6ff 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp @@ -81,7 +81,7 @@ class WSLCE2EContainerListTests // Verify we found the container in the list output VERIFY_IS_TRUE(foundContainerLine.has_value()); - VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"created")); + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"Created")); } WSLC_TEST_METHOD(WSLCE2E_Container_List_NoOptions_RunningContainers) @@ -110,7 +110,7 @@ class WSLCE2EContainerListTests // Verify we found the container in the list output VERIFY_IS_TRUE(foundContainerLine.has_value()); - VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"running")); + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"Up ")); } WSLC_TEST_METHOD(WSLCE2E_Container_List_NoOptions_ExcludesCreatedContainers) @@ -139,6 +139,33 @@ class WSLCE2EContainerListTests VERIFY_IS_FALSE(isListed); } + // The table layout must match `docker container list` so users can rely on column order. + WSLC_TEST_METHOD(WSLCE2E_Container_List_TableFormat_MatchesDockerColumnOrder) + { + const auto result = RunWslc(L"container list --all"); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto outputLines = result.GetStdoutLines(); + VERIFY_IS_FALSE(outputLines.empty()); + + const auto& header = outputLines.front(); + size_t position = 0; + for (const auto& column : + {Localization::WSLCCLI_TableHeaderContainerId(), + Localization::WSLCCLI_TableHeaderImage(), + Localization::WSLCCLI_TableHeaderCommand(), + Localization::WSLCCLI_TableHeaderCreated(), + Localization::WSLCCLI_TableHeaderStatus(), + Localization::WSLCCLI_TableHeaderPorts(), + Localization::WSLCCLI_TableHeaderNames()}) + { + const auto found = header.find(column, position); + VERIFY_ARE_NOT_EQUAL( + std::wstring::npos, found, std::format(L"Column '{}' missing or out of order in '{}'", column, header).c_str()); + position = found + column.size(); + } + } + WSLC_TEST_METHOD(WSLCE2E_Container_List_QuietOption_OutputsIdsOnly) { VerifyContainerIsNotListed(WslcContainerName); @@ -151,7 +178,12 @@ class WSLCE2EContainerListTests result = RunWslc(L"container list --all --quiet"); result.Verify({.Stderr = L"", .ExitCode = 0}); - // Verify the created container ID appears in the quiet output. + const auto truncatedId = wsl::shared::string::MultiByteToWide(TruncateId(WideToMultiByte(containerId))); + VERIFY_ARE_EQUAL(12u, truncatedId.size()); + VERIFY_IS_TRUE(result.StdoutContainsLine(truncatedId)); + + result = RunWslc(L"container list --all --quiet --no-trunc"); + result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(result.StdoutContainsLine(containerId)); } @@ -174,15 +206,15 @@ class WSLCE2EContainerListTests VERIFY_IS_FALSE(containerId.empty()); // List containers with json format - result = RunWslc(L"container list --all --format json"); + result = RunWslc(L"container list --all --format json --no-trunc"); result.Verify({.Stderr = L"", .ExitCode = 0}); // Parse json and verify we got the expected container information back - auto containers = ParseNdjsonOutputAs(result); + auto containers = ParseNdjsonOutputAs(result); VERIFY_IS_GREATER_THAN_OR_EQUAL(containers.size(), 1U); VERIFY_ARE_EQUAL(containers.size(), result.GetStdoutLines().size()); - auto findContainer = [](const std::vector& list, const std::wstring& id) { - return std::ranges::any_of(list, [&](const auto& c) { return wsl::shared::string::MultiByteToWide(c.Id) == id; }); + auto findContainer = [](const std::vector& list, const std::wstring& id) { + return std::ranges::any_of(list, [&](const auto& c) { return wsl::shared::string::MultiByteToWide(c.ID) == id; }); }; VERIFY_IS_TRUE(findContainer(containers, containerId)); @@ -194,16 +226,74 @@ class WSLCE2EContainerListTests VERIFY_IS_FALSE(containerId2.empty()); // List containers with json format again - result = RunWslc(L"container list --all --format json"); + result = RunWslc(L"container list --all --format json --no-trunc"); result.Verify({.Stderr = L"", .ExitCode = 0}); // Parse json and verify we got both containers back - containers = ParseNdjsonOutputAs(result); + containers = ParseNdjsonOutputAs(result); VERIFY_IS_GREATER_THAN_OR_EQUAL(containers.size(), 2U); VERIFY_IS_TRUE(findContainer(containers, containerId)); VERIFY_IS_TRUE(findContainer(containers, containerId2)); } + WSLC_TEST_METHOD(WSLCE2E_Container_List_JsonFormat_MatchesDockerShape) + { + const std::set expectedKeys = { + "Command", + "CreatedAt", + "HealthStatus", + "ID", + "Image", + "Labels", + "LocalVolumes", + "Mounts", + "Names", + "Networks", + "Platform", + "Ports", + "RunningFor", + "Size", + "State", + "Status"}; + + VerifyContainerIsNotListed(WslcContainerName); + + auto result = RunWslc(std::format(L"container create --name {} {}", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(L"container list --all --format json"); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto entries = ParseNdjsonOutput(result); + VERIFY_IS_GREATER_THAN_OR_EQUAL(entries.size(), 1u); + + for (const auto& entry : entries) + { + std::set keys; + for (const auto& [key, value] : entry.items()) + { + keys.insert(key); + + if (key == "Platform") + { + VERIFY_IS_TRUE(value.is_object()); + } + else + { + VERIFY_IS_TRUE( + value.is_string(), wsl::shared::string::MultiByteToWide(std::format("'{}' must be a string", key)).c_str()); + } + } + + VERIFY_ARE_EQUAL(expectedKeys, keys, L"json output must contain exactly docker's container fields"); + + VERIFY_ARE_EQUAL(12u, entry["ID"].get().size()); + VERIFY_ARE_NOT_EQUAL(std::string{}, entry["Names"].get()); + VERIFY_ARE_NOT_EQUAL(std::string{}, entry["State"].get()); + VERIFY_ARE_EQUAL(std::string{"linux"}, entry["Platform"]["os"].get()); + } + } + WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_InvalidKey) { // Filter keys are validated by the Docker daemon, which rejects unknown keys. @@ -246,9 +336,9 @@ class WSLCE2EContainerListTests result = RunWslc(std::format(L"container list --all --format json --filter name={}", WslcContainerName2)); result.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(result); + const auto containers = ParseNdjsonOutputAs(result); VERIFY_ARE_EQUAL(1U, containers.size()); - VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Name)); + VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Names)); } WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_Status) @@ -267,11 +357,11 @@ class WSLCE2EContainerListTests auto listNames = [&](const std::wstring& filterArgs) { auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs)); r.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(r); + const auto containers = ParseNdjsonOutputAs(r); std::set names; for (const auto& c : containers) { - names.insert(c.Name); + names.insert(c.Names); } return names; }; @@ -314,11 +404,11 @@ class WSLCE2EContainerListTests auto listNames = [&](const std::wstring& filterArgs) { auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs)); r.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(r); + const auto containers = ParseNdjsonOutputAs(r); std::set names; for (const auto& c : containers) { - names.insert(c.Name); + names.insert(c.Names); } return names; }; @@ -358,12 +448,12 @@ class WSLCE2EContainerListTests result.Verify({.Stderr = L"", .ExitCode = 0}); // Filter by id (full id) should return exactly one container. - result = RunWslc(std::format(L"container list --all --format json --filter id={}", containerId)); + result = RunWslc(std::format(L"container list --all --format json --no-trunc --filter id={}", containerId)); result.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(result); + const auto containers = ParseNdjsonOutputAs(result); VERIFY_ARE_EQUAL(1U, containers.size()); - VERIFY_ARE_EQUAL(WideToMultiByte(containerId), std::string(containers[0].Id)); + VERIFY_ARE_EQUAL(WideToMultiByte(containerId), std::string(containers[0].ID)); } WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_Exited) @@ -382,11 +472,11 @@ class WSLCE2EContainerListTests auto listNames = [&](const std::wstring& filterArgs) { auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs)); r.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(r); + const auto containers = ParseNdjsonOutputAs(r); std::set names; for (const auto& c : containers) { - names.insert(c.Name); + names.insert(c.Names); } return names; }; @@ -421,11 +511,11 @@ class WSLCE2EContainerListTests auto listNames = [&](const std::wstring& filterArgs) { auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs)); r.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(r); + const auto containers = ParseNdjsonOutputAs(r); std::set names; for (const auto& c : containers) { - names.insert(c.Name); + names.insert(c.Names); } return names; }; @@ -463,9 +553,9 @@ class WSLCE2EContainerListTests result = RunWslc(L"container list --latest --format json"); result.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(result); + const auto containers = ParseNdjsonOutputAs(result); VERIFY_ARE_EQUAL(1U, containers.size()); - VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Name)); + VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Names)); } // --last 2 should cap output at 2 containers. @@ -473,7 +563,7 @@ class WSLCE2EContainerListTests result = RunWslc(L"container list --last 2 --format json"); result.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(result); + const auto containers = ParseNdjsonOutputAs(result); VERIFY_IS_TRUE(containers.size() <= 2u); } diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp index 8cd2d4daf..cc5ff705e 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp @@ -152,6 +152,22 @@ TestSession::~TestSession() void VerifyContainerIsListed(const std::wstring& containerNameOrId, const std::wstring& status, const std::wstring& sessionName) { + // The status column reports the runtime's description, e.g. "Up 5 seconds", so map the logical + // state callers pass in onto the text that description starts with. + std::wstring expectedStatus = status; + if (status == L"created") + { + expectedStatus = L"Created"; + } + else if (status == L"running") + { + expectedStatus = L"Up "; + } + else if (status == L"exited") + { + expectedStatus = L"Exited ("; + } + std::wstring command = L"container list --no-trunc --all"; if (!sessionName.empty()) { @@ -167,8 +183,8 @@ void VerifyContainerIsListed(const std::wstring& containerNameOrId, const std::w if (line.find(containerNameOrId) != std::wstring::npos) { const std::wstring message = L"Container '" + containerNameOrId + L"' found in container list output but status '" + - status + L"' was not found in the same line"; - VERIFY_ARE_NOT_EQUAL(std::wstring::npos, line.find(status), message.c_str()); + expectedStatus + L"' was not found in the same line"; + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, line.find(expectedStatus), message.c_str()); return; } } @@ -348,7 +364,7 @@ void EnsureContainerDoesNotExist(const std::wstring& containerName) { const auto name = wsl::shared::string::WideToMultiByte(containerName); const auto containers = ListAllContainers(); - auto it = std::ranges::find_if(containers, [&](const auto& c) { return c.Name == name; }); + auto it = std::ranges::find_if(containers, [&](const auto& c) { return c.Names == name; }); if (it == containers.end()) { return; @@ -362,11 +378,12 @@ void EnsureContainerDoesNotExist(const std::wstring& containerName) } } -std::vector ListAllContainers() +std::vector ListAllContainers() { - auto result = RunWslc(L"container list --all --format json"); + // --no-trunc keeps the full ids, which callers use to address containers. + auto result = RunWslc(L"container list --all --format json --no-trunc"); result.Verify({.Stderr = L"", .ExitCode = 0}); - return ParseNdjsonOutputAs(result); + return ParseNdjsonOutputAs(result); } void EnsureImageContainersAreDeleted(const TestImage& image) @@ -377,8 +394,8 @@ void EnsureImageContainersAreDeleted(const TestImage& image) auto nameAndTag = wsl::shared::string::WideToMultiByte(image.NameAndTag()); if (container.Image.find(nameAndTag) != std::string::npos) { - auto result = RunWslc(std::format(L"container remove --force {}", container.Id)); - result.Verify({.Stdout = std::format(L"{}\r\n", container.Id), .Stderr = L"", .ExitCode = 0}); + auto result = RunWslc(std::format(L"container remove --force {}", container.ID)); + result.Verify({.Stdout = std::format(L"{}\r\n", container.ID), .Stderr = L"", .ExitCode = 0}); } } } diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.h b/test/windows/wslc/e2e/WSLCE2EHelpers.h index 421cb3491..1dcc09f51 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.h +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.h @@ -144,7 +144,7 @@ wsl::windows::common::wslc_schema::InspectContainer InspectContainer(const std:: wsl::windows::common::wslc_schema::InspectImage InspectImage(const std::wstring& imageName); wsl::windows::common::wslc_schema::InspectVolume InspectVolume(const std::wstring& volumeName); wsl::windows::common::wslc_schema::Network InspectNetwork(const std::wstring& networkName); -std::vector ListAllContainers(); +std::vector ListAllContainers(); void EnsureContainerDoesNotExist(const std::wstring& containerName); void DeleteImagesWithRepositoryPrefix(const std::wstring& repositoryPrefix);