diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index a35146ca8f..8754f300ab 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -3038,8 +3038,41 @@ On first run, creates the file with all settings commented out at their defaults Filter output based on conditions provided - Invalid value "{}" for the '-f, --filter' option: bad format of filter (expected name=value) - {FixedPlaceholder="{}"}{Locked="--filter'"}Command line arguments, file names and string inserts should not be translated + Invalid value "{}" for the '--filter' option: bad format of filter (expected name=value) + {FixedPlaceholder="{}"}{Locked="--filter'"}Command line arguments, file names and string inserts should not be translated + + + Provide filter values (e.g. "until=<timestamp>") + {Locked="until"}Command line arguments, file names and string inserts should not be translated + + + Provide filter values (e.g. "label=<label>") + {Locked="label"}Command line arguments, file names and string inserts should not be translated + + + Do not prompt for confirmation + + + Are you sure you want to continue? + + + WARNING! This will remove all stopped containers. + + + WARNING! This will remove all dangling images. + + + WARNING! This will remove all images without at least one container associated to them. + + + WARNING! This will remove anonymous local volumes not used by at least one container. + + + WARNING! This will remove all local volumes not used by at least one container. + + + WARNING! This will remove all custom networks not used by at least one container. + Invalid argument "{}" for '--mount' option: {} {FixedPlaceholder="{}"}{FixedPlaceholder="{}"}{Locked="--mount'"}Command line arguments, file names and string inserts should not be translated diff --git a/src/windows/wslc/arguments/ArgumentValidation.cpp b/src/windows/wslc/arguments/ArgumentValidation.cpp index ca6580878b..c2399308ab 100644 --- a/src/windows/wslc/arguments/ArgumentValidation.cpp +++ b/src/windows/wslc/arguments/ArgumentValidation.cpp @@ -352,16 +352,6 @@ void ValidateWSLCSignalFromString(const std::vector& values, const } } -// Validates that each --filter argument is in the form "key=value". Rejects entries without an '='; -// the runtime validates the key and value for specific objects. -void ValidateFilter(const std::vector& values) -{ - for (const auto& value : values) - { - std::ignore = ParseFilter(value); - } -} - void ValidateTimestamp(const std::vector& values, const std::wstring& argName) { for (const auto& value : values) diff --git a/src/windows/wslc/arguments/ArgumentValidation.h b/src/windows/wslc/arguments/ArgumentValidation.h index f0b84f6262..4d999b67b1 100644 --- a/src/windows/wslc/arguments/ArgumentValidation.h +++ b/src/windows/wslc/arguments/ArgumentValidation.h @@ -78,6 +78,4 @@ void ValidateFormatTypeFromString(const std::vector& values, const void ValidateGpus(const std::vector& values, const std::wstring& argName); -void ValidateFilter(const std::vector& values); - } // namespace wsl::windows::wslc::validation diff --git a/src/windows/wslc/commands/ContainerPruneCommand.cpp b/src/windows/wslc/commands/ContainerPruneCommand.cpp index a68f77d47d..68b91c6f63 100644 --- a/src/windows/wslc/commands/ContainerPruneCommand.cpp +++ b/src/windows/wslc/commands/ContainerPruneCommand.cpp @@ -26,7 +26,10 @@ namespace wsl::windows::wslc { // Container Prune Command std::vector ContainerPruneCommand::GetArguments() const { - return {}; + return { + Argument::Create(ArgType::Filter, {.Alias = NO_ALIAS, .Limit = Limit::Unlimited, .Desc = Localization::WSLCCLI_PruneFilterArgDescription()}), + Argument::Create(ArgType::Force, {.Desc = Localization::WSLCCLI_PruneForceArgDescription()}), + }; } std::wstring ContainerPruneCommand::ShortDescription() const diff --git a/src/windows/wslc/commands/ImagePruneCommand.cpp b/src/windows/wslc/commands/ImagePruneCommand.cpp index 7c63690daa..7db0b03bf9 100644 --- a/src/windows/wslc/commands/ImagePruneCommand.cpp +++ b/src/windows/wslc/commands/ImagePruneCommand.cpp @@ -29,7 +29,8 @@ std::vector ImagePruneCommand::GetArguments() const { return { Argument::Create(ArgType::All, {.Desc = Localization::WSLCCLI_ImagePruneAllArgDescription()}), - Argument::Create(ArgType::Filter, {.Limit = Limit::Unlimited}), + Argument::Create(ArgType::Filter, {.Alias = NO_ALIAS, .Limit = Limit::Unlimited, .Desc = Localization::WSLCCLI_PruneFilterArgDescription()}), + Argument::Create(ArgType::Force, {.Desc = Localization::WSLCCLI_PruneForceArgDescription()}), }; } diff --git a/src/windows/wslc/commands/NetworkPruneCommand.cpp b/src/windows/wslc/commands/NetworkPruneCommand.cpp index 4beec98a83..34d56f49b3 100644 --- a/src/windows/wslc/commands/NetworkPruneCommand.cpp +++ b/src/windows/wslc/commands/NetworkPruneCommand.cpp @@ -27,7 +27,8 @@ namespace wsl::windows::wslc { std::vector NetworkPruneCommand::GetArguments() const { return { - Argument::Create(ArgType::Filter, {.Limit = Limit::Unlimited}), + Argument::Create(ArgType::Filter, {.Alias = NO_ALIAS, .Limit = Limit::Unlimited, .Desc = Localization::WSLCCLI_PruneFilterArgDescription()}), + Argument::Create(ArgType::Force, {.Desc = Localization::WSLCCLI_PruneForceArgDescription()}), }; } diff --git a/src/windows/wslc/commands/VolumePruneCommand.cpp b/src/windows/wslc/commands/VolumePruneCommand.cpp index a7b8ed9a35..b391ddf1ce 100644 --- a/src/windows/wslc/commands/VolumePruneCommand.cpp +++ b/src/windows/wslc/commands/VolumePruneCommand.cpp @@ -28,7 +28,8 @@ std::vector VolumePruneCommand::GetArguments() const { return { Argument::Create(ArgType::All, {.Desc = Localization::WSLCCLI_VolumePruneAllArgDescription()}), - Argument::Create(ArgType::Filter, {.Limit = Limit::Unlimited}), + Argument::Create(ArgType::Filter, {.Alias = NO_ALIAS, .Limit = Limit::Unlimited, .Desc = Localization::WSLCCLI_VolumePruneFilterArgDescription()}), + Argument::Create(ArgType::Force, {.Desc = Localization::WSLCCLI_PruneForceArgDescription()}), }; } diff --git a/src/windows/wslc/core/Exceptions.h b/src/windows/wslc/core/Exceptions.h index 1b341474c7..5bf100fbf9 100644 --- a/src/windows/wslc/core/Exceptions.h +++ b/src/windows/wslc/core/Exceptions.h @@ -76,4 +76,13 @@ struct ExecutionException : CLIException { } }; + +// Specific exception for a user declining a confirmation prompt. The requested action is abandoned +// and the program exits successfully. +struct TerminateException : CLIException +{ + TerminateException() : CLIException(std::wstring_view{}) + { + } +}; } // namespace wsl::windows::wslc diff --git a/src/windows/wslc/core/ExecutionContextData.h b/src/windows/wslc/core/ExecutionContextData.h index 09d9529298..ea9f37f095 100644 --- a/src/windows/wslc/core/ExecutionContextData.h +++ b/src/windows/wslc/core/ExecutionContextData.h @@ -42,6 +42,8 @@ enum class Data : size_t Volumes, Networks, NetworkEndpointOptions, + ConfirmWarning, + ConfirmMessage, Max }; @@ -59,6 +61,8 @@ namespace details { DEFINE_DATA_MAPPING(Volumes, std::vector); DEFINE_DATA_MAPPING(Networks, std::vector); DEFINE_DATA_MAPPING(NetworkEndpointOptions, wsl::windows::wslc::models::NetworkEndpointOptions); + DEFINE_DATA_MAPPING(ConfirmWarning, std::wstring); + DEFINE_DATA_MAPPING(ConfirmMessage, std::wstring); } // namespace details struct DataMap : wsl::windows::wslc::EnumBasedVariantMap diff --git a/src/windows/wslc/core/Main.cpp b/src/windows/wslc/core/Main.cpp index d2cac7e69a..4f4c745a0a 100644 --- a/src/windows/wslc/core/Main.cpp +++ b/src/windows/wslc/core/Main.cpp @@ -149,6 +149,11 @@ try context.Terminal.Error(L"{}\n", ee.Message()); return 1; } + catch (const TerminateException&) + { + // The user declined a confirmation prompt, so the requested action is not performed. + return 0; + } catch (...) { LOG_CAUGHT_EXCEPTION(); diff --git a/src/windows/wslc/core/Terminal.cpp b/src/windows/wslc/core/Terminal.cpp index ef76d0eabb..8ab7873c9e 100644 --- a/src/windows/wslc/core/Terminal.cpp +++ b/src/windows/wslc/core/Terminal.cpp @@ -80,4 +80,11 @@ std::wstring Terminal::PromptForLine(Level level, std::wstring_view label, bool return line.value_or(std::wstring{}); } +bool Terminal::Confirm(std::wstring_view message) +{ + const auto answer = PromptForLine(std::format(L"{} [y/N] ", message)); + + return wsl::shared::string::IsEqual(wsl::shared::string::TrimAscii(std::wstring_view{answer}), L"y", true); +} + } // namespace wsl::windows::wslc diff --git a/src/windows/wslc/core/Terminal.h b/src/windows/wslc/core/Terminal.h index 8e1e742ba7..7a540c48a5 100644 --- a/src/windows/wslc/core/Terminal.h +++ b/src/windows/wslc/core/Terminal.h @@ -139,6 +139,12 @@ struct Terminal return PromptForLine(Level::Output, label, mask); } + // Prompts for confirmation by writing message followed by " [y/N] ", then reading one line. + // Returns true only when the answer is "y" (case-insensitive, surrounding ASCII whitespace + // ignored); every other answer, including end of input, declines. Declining at end of input is + // what lets a non-interactive prune abort instead of blocking. + bool Confirm(std::wstring_view message); + bool IsVTEnabled(Level level) const noexcept; bool IsColorEnabled(Level level) const noexcept; diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index a8639ca993..784a17389f 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -870,10 +870,18 @@ wsl::windows::common::docker_schema::ContainerStats ContainerService::Stats(Sess return wsl::shared::FromJson(output.get()); } -PruneContainersResult ContainerService::Prune(Session& session) +PruneContainersResult ContainerService::Prune(Session& session, const std::vector>& filters) { + std::vector filterEntries; + filterEntries.reserve(filters.size()); + for (const auto& [key, value] : filters) + { + filterEntries.push_back({.Key = key.c_str(), .Value = value.c_str()}); + } + PruneResult result; - THROW_IF_FAILED(session.Get()->PruneContainers(nullptr, 0, &result.result)); + THROW_IF_FAILED(session.Get()->PruneContainers( + filterEntries.empty() ? nullptr : filterEntries.data(), static_cast(filterEntries.size()), &result.result)); PruneContainersResult pruneResult; pruneResult.SpaceReclaimed = result.result.SpaceReclaimed; diff --git a/src/windows/wslc/services/ContainerService.h b/src/windows/wslc/services/ContainerService.h index 29b009e678..5718ffa6f8 100644 --- a/src/windows/wslc/services/ContainerService.h +++ b/src/windows/wslc/services/ContainerService.h @@ -67,6 +67,6 @@ struct ContainerService static wsl::windows::common::wslc_schema::InspectContainer Inspect(models::Session& session, const std::string& id); static void Logs(models::Session& session, const std::string& id, bool follow, bool timestamps, LONGLONG since, LONGLONG until, ULONGLONG tail = 0); static wsl::windows::common::docker_schema::ContainerStats Stats(models::Session& session, const std::string& id); - static models::PruneContainersResult Prune(models::Session& session); + static models::PruneContainersResult Prune(models::Session& session, const std::vector>& filters); }; } // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/tasks/CommonTasks.cpp b/src/windows/wslc/tasks/CommonTasks.cpp new file mode 100644 index 0000000000..17cd04c3f8 --- /dev/null +++ b/src/windows/wslc/tasks/CommonTasks.cpp @@ -0,0 +1,47 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + CommonTasks.cpp + +Abstract: + + Implementation of execution logic shared by multiple commands. + +--*/ +#include "Argument.h" +#include "CLIExecutionContext.h" +#include "CommonTasks.h" +#include "Exceptions.h" + +using namespace wsl::shared; +using namespace wsl::windows::wslc::execution; + +namespace wsl::windows::wslc::task { + +// Prompts the user to confirm the action described by Data::ConfirmMessage, preceded by the +// Data::ConfirmWarning explaining what the action affects. The prompt is skipped when --force is +// passed, and declining terminates the invocation without running the remaining tasks. +void ConfirmAction(CLIExecutionContext& context) +{ + WI_ASSERT(context.Data.Contains(Data::ConfirmMessage)); + + if (context.Args.GetValue()) + { + return; + } + + if (context.Data.Contains(Data::ConfirmWarning)) + { + context.Terminal.Warn(L"{}\n", context.Data.Get()); + } + + if (!context.Terminal.Confirm(context.Data.Get())) + { + throw TerminateException{}; + } +} + +} // namespace wsl::windows::wslc::task diff --git a/src/windows/wslc/tasks/CommonTasks.h b/src/windows/wslc/tasks/CommonTasks.h new file mode 100644 index 0000000000..b232404312 --- /dev/null +++ b/src/windows/wslc/tasks/CommonTasks.h @@ -0,0 +1,21 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + CommonTasks.h + +Abstract: + + Declaration of execution tasks shared by multiple commands. + +--*/ +#pragma once +#include "CLIExecutionContext.h" + +using wsl::windows::wslc::execution::CLIExecutionContext; + +namespace wsl::windows::wslc::task { +void ConfirmAction(CLIExecutionContext& context); +} // namespace wsl::windows::wslc::task diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index edc48b9349..7e87677083 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -15,6 +15,7 @@ Module Name: #include "ArgumentConvertedTypes.h" #include "AsyncExecution.h" #include "CLIExecutionContext.h" +#include "CommonTasks.h" #include "ContainerModel.h" #include "ContainerService.h" #include "ContainerTasks.h" @@ -1103,10 +1104,17 @@ void ViewContainerLogs(CLIExecutionContext& context) void PruneContainers(CLIExecutionContext& context) { + context.Data.Add(Localization::WSLCCLI_ContainerPruneConfirm()); + context.Data.Add(Localization::WSLCCLI_PruneConfirmPrompt()); + ConfirmAction(context); + WI_ASSERT(context.Data.Contains(Data::Session)); auto& session = context.Data.Get(); - auto result = ContainerService::Prune(session); + // Filter values are parsed and cached during argument validation. + auto filters = context.Args.GetAllValues(); + + auto result = ContainerService::Prune(session, filters); if (!result.PrunedContainers.empty()) { diff --git a/src/windows/wslc/tasks/ImageTasks.cpp b/src/windows/wslc/tasks/ImageTasks.cpp index 5f8e49ee9a..64c9ddb7da 100644 --- a/src/windows/wslc/tasks/ImageTasks.cpp +++ b/src/windows/wslc/tasks/ImageTasks.cpp @@ -15,6 +15,7 @@ Module Name: #include "ArgumentConvertedTypes.h" #include "BuildImageCallback.h" #include "CLIExecutionContext.h" +#include "CommonTasks.h" #include "ContainerService.h" #include "ImageModel.h" #include "ImageService.h" @@ -423,6 +424,11 @@ void TagImage(CLIExecutionContext& context) void PruneImages(CLIExecutionContext& context) { + context.Data.Add( + context.Args.GetValue() ? Localization::WSLCCLI_ImagePruneAllConfirm() : Localization::WSLCCLI_ImagePruneConfirm()); + context.Data.Add(Localization::WSLCCLI_PruneConfirmPrompt()); + ConfirmAction(context); + WI_ASSERT(context.Data.Contains(Data::Session)); auto& session = context.Data.Get(); diff --git a/src/windows/wslc/tasks/NetworkTasks.cpp b/src/windows/wslc/tasks/NetworkTasks.cpp index c59357409f..1c8c13607f 100644 --- a/src/windows/wslc/tasks/NetworkTasks.cpp +++ b/src/windows/wslc/tasks/NetworkTasks.cpp @@ -14,6 +14,7 @@ Module Name: #include "Argument.h" #include "ArgumentConvertedTypes.h" #include "CLIExecutionContext.h" +#include "CommonTasks.h" #include "NetworkModel.h" #include "NetworkService.h" #include "NetworkTasks.h" @@ -262,6 +263,10 @@ void ListNetworks(CLIExecutionContext& context) void PruneNetworks(CLIExecutionContext& context) { + context.Data.Add(Localization::WSLCCLI_NetworkPruneConfirm()); + context.Data.Add(Localization::WSLCCLI_PruneConfirmPrompt()); + ConfirmAction(context); + WI_ASSERT(context.Data.Contains(Data::Session)); auto& session = context.Data.Get(); diff --git a/src/windows/wslc/tasks/VolumeTasks.cpp b/src/windows/wslc/tasks/VolumeTasks.cpp index b4587f1746..35c4b39430 100644 --- a/src/windows/wslc/tasks/VolumeTasks.cpp +++ b/src/windows/wslc/tasks/VolumeTasks.cpp @@ -14,6 +14,7 @@ Module Name: #include "Argument.h" #include "ArgumentConvertedTypes.h" #include "CLIExecutionContext.h" +#include "CommonTasks.h" #include "VolumeModel.h" #include "VolumeService.h" #include "VolumeTasks.h" @@ -236,6 +237,11 @@ void ListVolumes(CLIExecutionContext& context) void PruneVolumes(CLIExecutionContext& context) { + context.Data.Add( + context.Args.GetValue() ? Localization::WSLCCLI_VolumePruneAllConfirm() : Localization::WSLCCLI_VolumePruneConfirm()); + context.Data.Add(Localization::WSLCCLI_PruneConfirmPrompt()); + ConfirmAction(context); + WI_ASSERT(context.Data.Contains(Data::Session)); auto& session = context.Data.Get(); diff --git a/test/windows/wslc/WSLCCLICommandUnitTests.cpp b/test/windows/wslc/WSLCCLICommandUnitTests.cpp index f54c9b7227..b5f141043d 100644 --- a/test/windows/wslc/WSLCCLICommandUnitTests.cpp +++ b/test/windows/wslc/WSLCCLICommandUnitTests.cpp @@ -104,6 +104,52 @@ class WSLCCLICommandUnitTests } } + // Test: Every prune command binds -f to --force and leaves --filter unaliased. Aliases resolve + // by first match with no collision detection, so an aliased --filter here would shadow -f. + TEST_METHOD(PruneCommands_BindShortFToForce) + { + const auto verifyPruneArguments = [](const std::vector& args, const std::wstring& command) { + LogComment(L"Verifying prune argument aliases for: " + command); + + const auto find = [&args](ArgType type) -> const Argument* { + const auto itr = std::find_if(args.begin(), args.end(), [type](const auto& arg) { return arg.Type() == type; }); + return itr == args.end() ? nullptr : &*itr; + }; + + const auto* force = find(ArgType::Force); + VERIFY_IS_NOT_NULL(force); + VERIFY_ARE_EQUAL(std::wstring{L"force"}, force->Name()); + VERIFY_ARE_EQUAL(std::wstring{L"f"}, force->Alias()); + + const auto* filter = find(ArgType::Filter); + VERIFY_IS_NOT_NULL(filter); + VERIFY_ARE_EQUAL(std::wstring{L"filter"}, filter->Name()); + VERIFY_ARE_EQUAL(std::wstring{L""}, filter->Alias()); + }; + + verifyPruneArguments(ContainerPruneCommand(L"container").GetArguments(), L"container prune"); + verifyPruneArguments(ImagePruneCommand(L"image").GetArguments(), L"image prune"); + verifyPruneArguments(VolumePruneCommand(L"volume").GetArguments(), L"volume prune"); + verifyPruneArguments(NetworkPruneCommand(L"network").GetArguments(), L"network prune"); + } + + // Test: List commands keep -f bound to --filter, which is why only prune commands were realigned. + TEST_METHOD(ListCommands_KeepShortFOnFilter) + { + const auto verifyFilterAlias = [](const std::vector& args, const std::wstring& command) { + LogComment(L"Verifying filter alias for: " + command); + + const auto itr = std::find_if(args.begin(), args.end(), [](const auto& arg) { return arg.Type() == ArgType::Filter; }); + VERIFY_IS_TRUE(itr != args.end()); + VERIFY_ARE_EQUAL(std::wstring{L"f"}, itr->Alias()); + }; + + verifyFilterAlias(ContainerListCommand(L"container").GetArguments(), L"container list"); + verifyFilterAlias(ImageListCommand(L"image").GetArguments(), L"image list"); + verifyFilterAlias(VolumeListCommand(L"volume").GetArguments(), L"volume list"); + verifyFilterAlias(NetworkListCommand(L"network").GetArguments(), L"network list"); + } + // Test: Verify SessionEnterCommand has the expected arguments TEST_METHOD(SessionEnterCommand_HasExpectedArguments) { diff --git a/test/windows/wslc/WSLCCLIExecutionUnitTests.cpp b/test/windows/wslc/WSLCCLIExecutionUnitTests.cpp index 09b72b8280..fa9613beca 100644 --- a/test/windows/wslc/WSLCCLIExecutionUnitTests.cpp +++ b/test/windows/wslc/WSLCCLIExecutionUnitTests.cpp @@ -160,6 +160,16 @@ class WSLCCLIExecutionUnitTests dataMap.Add(std::move(endpointOptions)); handled = true; } + else if (dataType == Data::ConfirmWarning) + { + dataMap.Add(std::wstring{L"warning"}); + handled = true; + } + else if (dataType == Data::ConfirmMessage) + { + dataMap.Add(std::wstring{L"message"}); + handled = true; + } if (!handled) { @@ -177,6 +187,9 @@ class WSLCCLIExecutionUnitTests auto& containers = dataMap.Get(); VERIFY_ARE_EQUAL(0u, containers.size()); + auto& confirmMessage = dataMap.Get(); + VERIFY_ARE_EQUAL(std::wstring{L"message"}, confirmMessage); + // Other more complex EnumVariantMap tests are in the Args unit tests. // This one will just verify all the data types in the Data Map work as expected. } diff --git a/test/windows/wslc/WSLCCLITerminalUnitTests.cpp b/test/windows/wslc/WSLCCLITerminalUnitTests.cpp index 555dbf41e9..5b35321c38 100644 --- a/test/windows/wslc/WSLCCLITerminalUnitTests.cpp +++ b/test/windows/wslc/WSLCCLITerminalUnitTests.cpp @@ -552,6 +552,44 @@ class WSLCCLITerminalUnitTests VERIFY_ARE_EQUAL(expected, result); } + TEST_METHOD(Terminal_Confirm) + { + // The prompt is written inline on stdout with the standard suffix appended, and nothing + // goes to stderr. + { + InputCaptureTerminal cap{L"y\n"}; + VERIFY_IS_TRUE(cap.terminal.Confirm(L"Remove everything?")); + VERIFY_ARE_EQUAL(std::wstring{L"Remove everything? [y/N] "}, cap.outPipe.captured()); + VERIFY_ARE_EQUAL(std::wstring{L""}, cap.errPipe.captured()); + } + + // Only a bare y accepts, in either case and with surrounding whitespace trimmed. + for (const auto* answer : {L"y\n", L"Y\n", L" y \n"}) + { + InputCaptureTerminal cap{answer}; + VERIFY_IS_TRUE(cap.terminal.Confirm(L"Remove everything?")); + } + + // Anything else declines, including a spelled-out yes, matching the container CLI ecosystem. + // A prune with no input attached must abort rather than block, so end of input declines too. + for (const auto* answer : {L"yes\n", L"n\n", L"N\n", L"no\n", L"\n", L"maybe\n", L""}) + { + InputCaptureTerminal cap{answer}; + VERIFY_IS_FALSE(cap.terminal.Confirm(L"Remove everything?")); + VERIFY_ARE_EQUAL(std::wstring{L"Remove everything? [y/N] "}, cap.outPipe.captured()); + } + + // The message is a formatting argument, not a format string, so braces must not be + // interpreted. + { + InputCaptureTerminal cap{L"y\n"}; + const std::wstring message = L"Remove {} {0} {name} 100%?"; + + VERIFY_IS_TRUE(cap.terminal.Confirm(message)); + VERIFY_ARE_EQUAL(message + L" [y/N] ", cap.outPipe.captured()); + } + } + TEST_METHOD(Terminal_ReadLineMaskDefaultsToUnmasked) { // ReadLine(bool mask = false): the default reads without masking and returns diff --git a/test/windows/wslc/e2e/WSLCE2EContainerPruneTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerPruneTests.cpp index d07d2481df..ce89023357 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerPruneTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerPruneTests.cpp @@ -33,13 +33,14 @@ class WSLCE2EContainerPruneTests EnsureContainerDoesNotExist(L"prune-running-test"); EnsureContainerDoesNotExist(L"prune-multi-1"); EnsureContainerDoesNotExist(L"prune-multi-2"); + EnsureContainerDoesNotExist(L"prune-confirm-test"); return true; } TEST_CLASS_CLEANUP(ClassCleanup) { // Clean up any leftover containers - RunWslc(L"container prune"); + RunWslc(L"container prune --force"); return true; } @@ -53,9 +54,9 @@ class WSLCE2EContainerPruneTests WSLC_TEST_METHOD(WSLCE2E_Container_Prune_NoStoppedContainers) { // Establish a clean baseline first so this does not depend on what other tests left behind. - RunWslc(L"container prune").Verify({.Stderr = L"", .ExitCode = 0}); + RunWslc(L"container prune --force").Verify({.Stderr = L"", .ExitCode = 0}); - const auto result = RunWslc(L"container prune"); + const auto result = RunWslc(L"container prune --force"); // With nothing pruned docker emits only the reclaimed-space line, with no header or leading blank line. result.Verify({.Stdout = L"Total reclaimed space: 0B\r\n", .Stderr = L"", .ExitCode = 0}); @@ -68,10 +69,10 @@ class WSLCE2EContainerPruneTests createResult.Verify({.Stderr = L"", .ExitCode = 0}); auto containerId = createResult.GetStdoutOneLine(); - auto cleanup = wil::scope_exit([&]() { RunWslc(L"container prune"); }); + auto cleanup = wil::scope_exit([&]() { RunWslc(L"container prune --force"); }); // The created container is in stopped state, so prune should remove it - const auto result = RunWslc(L"container prune"); + const auto result = RunWslc(L"container prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); // Verify pruned container ID is in output @@ -94,7 +95,7 @@ class WSLCE2EContainerPruneTests }); // Prune should not remove a running container - const auto pruneResult = RunWslc(L"container prune"); + const auto pruneResult = RunWslc(L"container prune --force"); pruneResult.Verify({.Stderr = L"", .ExitCode = 0}); // Verify the running container is still present @@ -112,9 +113,9 @@ class WSLCE2EContainerPruneTests create2.Verify({.Stderr = L"", .ExitCode = 0}); auto containerId2 = create2.GetStdoutOneLine(); - auto cleanup = wil::scope_exit([&]() { RunWslc(L"container prune"); }); + auto cleanup = wil::scope_exit([&]() { RunWslc(L"container prune --force"); }); - const auto result = RunWslc(L"container prune"); + const auto result = RunWslc(L"container prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); // Verify pruned container IDs are in output @@ -126,7 +127,55 @@ class WSLCE2EContainerPruneTests VerifyContainerIsNotListed(L"prune-multi-2"); } + WSLC_TEST_METHOD(WSLCE2E_Container_Prune_WithoutForcePromptsAndDeclines) + { + // Without --force the prune asks for confirmation. No input is attached here, so the read hits + // end of input and declines: the container survives and the command still succeeds. + RunWslc(std::format(L"container create --name {} {}", ConfirmContainerName, DebianImage.NameAndTag())).Verify({.Stderr = L"", .ExitCode = 0}); + + auto cleanup = wil::scope_exit([&]() { EnsureContainerDoesNotExist(ConfirmContainerName); }); + + // Argument validation runs before the confirmation prompt, so a bad filter fails with empty + // stdout rather than printing the warning first. + const auto malformed = RunWslc(L"container prune --filter label"); + malformed.Verify({.Stdout = L"", .ExitCode = 1}); + VERIFY_IS_TRUE(malformed.StderrContainsSubstring(Localization::WSLCCLI_InvalidFilterError(L"label"))); + + const auto result = RunWslc(L"container prune"); + result.Verify({.ExitCode = 0}); + + // The warning goes to stderr; the question goes to stdout. + VERIFY_IS_TRUE(result.StderrContainsSubstring(Localization::WSLCCLI_ContainerPruneConfirm())); + VERIFY_IS_TRUE(result.StdoutContainsSubstring(Localization::WSLCCLI_PruneConfirmPrompt())); + + // Declining prunes nothing, so no deleted header is emitted and the container is still listed. + VERIFY_IS_FALSE(result.StdoutContainsSubstring(Localization::WSLCCLI_ContainerPruneDeletedHeader())); + VerifyContainerIsListed(ConfirmContainerName, L"created"); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Prune_FilterExcludesNonMatchingContainer) + { + // container prune gained --filter here, so a label filter that matches nothing must leave the + // stopped container in place, and an unfiltered prune must still remove it. + auto createResult = RunWslc(std::format(L"container create --name {} {}", ConfirmContainerName, DebianImage.NameAndTag())); + createResult.Verify({.Stderr = L"", .ExitCode = 0}); + const auto containerId = createResult.GetStdoutOneLine(); + + auto cleanup = wil::scope_exit([&]() { EnsureContainerDoesNotExist(ConfirmContainerName); }); + + const auto filtered = RunWslc(L"container prune --force --filter label=wslc.test.never=present"); + filtered.Verify({.Stderr = L"", .ExitCode = 0}); + VERIFY_IS_FALSE(filtered.StdoutContainsSubstring(containerId)); + VerifyContainerIsListed(ConfirmContainerName, L"created"); + + const auto unfiltered = RunWslc(L"container prune --force"); + unfiltered.Verify({.Stderr = L"", .ExitCode = 0}); + VERIFY_IS_TRUE(unfiltered.StdoutContainsSubstring(containerId)); + VerifyContainerIsNotListed(ConfirmContainerName); + } + private: + const std::wstring ConfirmContainerName = L"prune-confirm-test"; const TestImage& DebianImage = DebianTestImage(); }; } // namespace WSLCE2ETests diff --git a/test/windows/wslc/e2e/WSLCE2EImagePruneTests.cpp b/test/windows/wslc/e2e/WSLCE2EImagePruneTests.cpp index 024c62cb61..eb4d9b3642 100644 --- a/test/windows/wslc/e2e/WSLCE2EImagePruneTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EImagePruneTests.cpp @@ -46,7 +46,7 @@ class WSLCE2EImagePruneTests WSLC_TEST_METHOD(WSLCE2E_Image_Prune_NoDanglingImages) { // Prune when no dangling images exist should succeed with zero reclaimed space - const auto result = RunWslc(L"image prune"); + const auto result = RunWslc(L"image prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); VerifyStdoutContains(result, L"Total reclaimed space:"); @@ -60,7 +60,7 @@ class WSLCE2EImagePruneTests // 3. Tag alpine as prune-target:v1, overwriting it — debian image is now dangling TestImageRegistry::Instance().EnsureLoaded(AlpineImage); auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { - RunWslc(L"image prune"); + RunWslc(L"image prune --force"); RunWslc(L"image delete prune-target:v1"); TestImageRegistry::Instance().Delete(AlpineImage); TestImageRegistry::Instance().Restore(DebianImage); @@ -71,7 +71,7 @@ class WSLCE2EImagePruneTests RunWslc(std::format(L"image tag {} prune-target:v1", AlpineImage.NameAndTag())).Verify({.Stderr = L"", .ExitCode = 0}); // Now prune should remove the dangling (original debian) image - const auto result = RunWslc(L"image prune"); + const auto result = RunWslc(L"image prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); bool foundDeleted = false; @@ -99,7 +99,7 @@ class WSLCE2EImagePruneTests }); // --all should prune unused images (not just dangling) - const auto result = RunWslc(L"image prune --all"); + const auto result = RunWslc(L"image prune --force --all"); result.Verify({.Stderr = L"", .ExitCode = 0}); VerifyStdoutContains(result, L"Total reclaimed space:"); @@ -126,7 +126,7 @@ class WSLCE2EImagePruneTests WSLC_TEST_METHOD(WSLCE2E_Image_Prune_Filter_InvalidKey) { // Filter keys are validated by the Docker daemon, which rejects unknown keys. - const auto result = RunWslc(L"image prune --filter color=blue"); + const auto result = RunWslc(L"image prune --force --filter color=blue"); VERIFY_ARE_EQUAL(1, result.ExitCode); VERIFY_IS_TRUE(result.Stderr.has_value()); VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.Stderr->find(L"invalid filter")); @@ -137,7 +137,7 @@ class WSLCE2EImagePruneTests // Create a dangling debian image (same trick as WSLCE2E_Image_Prune_DanglingImage). TestImageRegistry::Instance().EnsureLoaded(AlpineImage); auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { - RunWslc(L"image prune"); + RunWslc(L"image prune --force"); RunWslc(L"image delete prune-target:v1"); TestImageRegistry::Instance().Delete(AlpineImage); TestImageRegistry::Instance().Restore(DebianImage); @@ -150,7 +150,8 @@ class WSLCE2EImagePruneTests // Prune only dangling images carrying a label the dangling image does NOT have. // Multiple --filter label= values are AND'd by the daemon; the dangling image // matches neither, so it must survive this prune. - auto filteredPrune = RunWslc(L"image prune --filter label=wslc.test.never=present --filter label=wslc.test.also=missing"); + auto filteredPrune = + RunWslc(L"image prune --force --filter label=wslc.test.never=present --filter label=wslc.test.also=missing"); filteredPrune.Verify({.Stderr = L"", .ExitCode = 0}); for (const auto& line : filteredPrune.GetStdoutLines()) { @@ -163,7 +164,7 @@ class WSLCE2EImagePruneTests // A subsequent unfiltered prune should still find and remove the dangling image, // proving the filter — not the absence of dangling images — was the reason nothing // was pruned above. - auto unfilteredPrune = RunWslc(L"image prune"); + auto unfilteredPrune = RunWslc(L"image prune --force"); unfilteredPrune.Verify({.Stderr = L"", .ExitCode = 0}); bool foundDeleted = false; for (const auto& line : unfilteredPrune.GetStdoutLines()) diff --git a/test/windows/wslc/e2e/WSLCE2ENetworkPruneTests.cpp b/test/windows/wslc/e2e/WSLCE2ENetworkPruneTests.cpp index 966e92adf3..bf49eab972 100644 --- a/test/windows/wslc/e2e/WSLCE2ENetworkPruneTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2ENetworkPruneTests.cpp @@ -53,7 +53,7 @@ class WSLCE2ENetworkPruneTests WSLC_TEST_METHOD(WSLCE2E_Network_Prune_NoNetworks) { // Prune when no unused networks exist should succeed without reporting any of our test networks. - const auto result = RunWslc(L"network prune"); + const auto result = RunWslc(L"network prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_FALSE(result.StdoutContainsLine(TestNetworkName)); @@ -67,7 +67,7 @@ class WSLCE2ENetworkPruneTests auto cleanup = wil::scope_exit([&]() { EnsureNetworkDoesNotExist(TestNetworkName); }); - const auto result = RunWslc(L"network prune"); + const auto result = RunWslc(L"network prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); auto output = result.GetStdoutLines(); @@ -91,7 +91,7 @@ class WSLCE2ENetworkPruneTests EnsureNetworkDoesNotExist(TestNetworkName2); }); - const auto result = RunWslc(L"network prune"); + const auto result = RunWslc(L"network prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(result.StdoutContainsLine(TestNetworkName)); @@ -116,7 +116,7 @@ class WSLCE2ENetworkPruneTests EnsureNetworkDoesNotExist(TestNetworkName); }); - const auto result = RunWslc(L"network prune"); + const auto result = RunWslc(L"network prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_FALSE(result.StdoutContainsLine(TestNetworkName), L"Network in use by a running container must not be pruned"); @@ -132,7 +132,7 @@ class WSLCE2ENetworkPruneTests auto cleanup = wil::scope_exit([&]() { EnsureNetworkDoesNotExist(TestNetworkName); }); // A label filter that does not match the network should preserve it. - const auto filteredPrune = RunWslc(L"network prune --filter label=wslc.test.never=present"); + const auto filteredPrune = RunWslc(L"network prune --force --filter label=wslc.test.never=present"); filteredPrune.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_FALSE( filteredPrune.StdoutContainsLine(TestNetworkName), L"Filtered prune should not have deleted the non-matching network"); @@ -140,7 +140,7 @@ class WSLCE2ENetworkPruneTests // A subsequent unfiltered prune should still remove it, proving the filter // was the reason it survived. - const auto unfilteredPrune = RunWslc(L"network prune"); + const auto unfilteredPrune = RunWslc(L"network prune --force"); unfilteredPrune.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(unfilteredPrune.StdoutContainsLine(TestNetworkName)); VerifyNetworkIsNotListed(TestNetworkName); @@ -158,7 +158,7 @@ class WSLCE2ENetworkPruneTests EnsureNetworkDoesNotExist(TestNetworkName2); }); - const auto result = RunWslc(L"network prune --filter label=wslc.test.prune=keep"); + const auto result = RunWslc(L"network prune --force --filter label=wslc.test.prune=keep"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(result.StdoutContainsLine(TestNetworkName)); @@ -180,7 +180,7 @@ class WSLCE2ENetworkPruneTests EnsureNetworkDoesNotExist(TestNetworkName2); }); - const auto result = RunWslc(L"network prune --filter label!=wslc.test.keep"); + const auto result = RunWslc(L"network prune --force --filter label!=wslc.test.keep"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(result.StdoutContainsLine(TestNetworkName2)); @@ -200,7 +200,7 @@ class WSLCE2ENetworkPruneTests WSLC_TEST_METHOD(WSLCE2E_Network_Prune_Filter_InvalidKey) { - const auto result = RunWslc(L"network prune --filter color=red"); + const auto result = RunWslc(L"network prune --force --filter color=red"); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring(L"invalid filter 'color'\r\nError code: E_INVALIDARG")); } diff --git a/test/windows/wslc/e2e/WSLCE2EVolumePruneTests.cpp b/test/windows/wslc/e2e/WSLCE2EVolumePruneTests.cpp index 7dc2bee337..612172e7d6 100644 --- a/test/windows/wslc/e2e/WSLCE2EVolumePruneTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EVolumePruneTests.cpp @@ -53,7 +53,7 @@ class WSLCE2EVolumePruneTests WSLC_TEST_METHOD(WSLCE2E_Volume_Prune_NoVolumes) { // Prune when no volumes exist should succeed and report a reclaimed-space line. - const auto result = RunWslc(L"volume prune"); + const auto result = RunWslc(L"volume prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); // The deleted-volume block, and the blank line that follows it, are only written when @@ -68,7 +68,7 @@ class WSLCE2EVolumePruneTests auto cleanup = wil::scope_exit([&]() { EnsureVolumeDoesNotExist(TestVolumeName); }); - const auto result = RunWslc(L"volume prune"); + const auto result = RunWslc(L"volume prune --force"); result.Verify({.Stderr = L"", .ExitCode = 0}); auto output = result.GetStdoutLines(); @@ -85,7 +85,7 @@ class WSLCE2EVolumePruneTests auto cleanup = wil::scope_exit([&]() { EnsureVolumeDoesNotExist(TestVolumeName); }); - const auto result = RunWslc(L"volume prune --all"); + const auto result = RunWslc(L"volume prune --force --all"); result.Verify({.Stderr = L"", .ExitCode = 0}); auto output = result.GetStdoutLines(); @@ -110,7 +110,7 @@ class WSLCE2EVolumePruneTests EnsureVolumeDoesNotExist(TestVolumeName2); }); - const auto result = RunWslc(L"volume prune --all"); + const auto result = RunWslc(L"volume prune --force --all"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(result.StdoutContainsLine(L"Deleted Volumes:")); @@ -136,7 +136,7 @@ class WSLCE2EVolumePruneTests EnsureVolumeDoesNotExist(TestVolumeName); }); - const auto result = RunWslc(L"volume prune --all"); + const auto result = RunWslc(L"volume prune --force --all"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_FALSE(result.StdoutContainsLine(TestVolumeName), L"Volume in use by a running container must not be pruned"); @@ -152,7 +152,7 @@ class WSLCE2EVolumePruneTests auto cleanup = wil::scope_exit([&]() { EnsureVolumeDoesNotExist(TestVolumeName); }); // A label filter that does not match the volume should preserve it - const auto filteredPrune = RunWslc(L"volume prune --all --filter label=wslc.test.never=present"); + const auto filteredPrune = RunWslc(L"volume prune --force --all --filter label=wslc.test.never=present"); filteredPrune.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_FALSE( filteredPrune.StdoutContainsLine(TestVolumeName), L"Filtered prune should not have deleted the non-matching volume"); @@ -160,7 +160,7 @@ class WSLCE2EVolumePruneTests // Subsequent unfiltered prune --all should still remove it, proving // the filter was the reason it survived. - const auto unfilteredPrune = RunWslc(L"volume prune --all"); + const auto unfilteredPrune = RunWslc(L"volume prune --force --all"); unfilteredPrune.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(unfilteredPrune.StdoutContainsLine(TestVolumeName)); VerifyVolumeIsNotListed(TestVolumeName); @@ -178,7 +178,7 @@ class WSLCE2EVolumePruneTests EnsureVolumeDoesNotExist(TestVolumeName2); }); - const auto result = RunWslc(L"volume prune --all --filter label=wslc.test.prune=keep"); + const auto result = RunWslc(L"volume prune --force --all --filter label=wslc.test.prune=keep"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(result.StdoutContainsLine(TestVolumeName)); @@ -200,7 +200,7 @@ class WSLCE2EVolumePruneTests EnsureVolumeDoesNotExist(TestVolumeName2); }); - const auto result = RunWslc(L"volume prune --all --filter label!=wslc.test.keep"); + const auto result = RunWslc(L"volume prune --force --all --filter label!=wslc.test.keep"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(result.StdoutContainsLine(TestVolumeName2)); @@ -220,7 +220,7 @@ class WSLCE2EVolumePruneTests WSLC_TEST_METHOD(WSLCE2E_Volume_Prune_Filter_InvalidKey) { - const auto result = RunWslc(L"volume prune --filter color=red"); + const auto result = RunWslc(L"volume prune --force --filter color=red"); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring(L"invalid filter 'color'\r\nError code: E_INVALIDARG")); }