CLI: Add quiet support to pull and align with Docker (#41121)
David Bennett committed
Jul 23, 2026 at 10:28 UTC
5f0dae1062382103496cec42d1704a11927a335e
8 files changed
+130
-3
localization/strings/en-US/Resources.resw
+7
@@ -3051,6 +3051,13 @@ On first run, creates the file with all settings commented out at their defaults
3051
<data name="WSLCCLI_PublishAllArgDescription" xml:space="preserve">
3052
<value>Publish all exposed ports to random host ports</value>
3053
</data>
3054
+ <data name="WSLCCLI_PullQuietArgDescription" xml:space="preserve">
3055
+ <value>Suppress verbose output</value>
3056
+ </data>
3057
+ <data name="WSLCCLI_PullUsingDefaultTag" xml:space="preserve">
3058
+ <value>Using default tag: {}</value>
3059
+ <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
3060
+ </data>
3061
<data name="WSLCCLI_QuietArgDescription" xml:space="preserve">
3062
<value>Outputs the container IDs only</value>
3063
</data>
src/windows/common/wslutil.cpp
+23
@@ -1433,6 +1433,29 @@ std::pair<std::string, std::optional<std::string>> wsl::windows::common::wslutil
1433
return {repo.str(), std::move(tagOrDigest)};
1434
}
1435
1436
+std::string wsl::windows::common::wslutil::GetCanonicalImageReference(const std::string& input)
1437
+{
1438
+ // Mirror the Docker CLI's client-side reference normalization so the final line matches `docker pull` exactly.
1439
+ // See github.com/distribution/reference (normalize.go, reference.go) and github.com/docker/cli
1440
+ // (cli/command/image/pull.go). Unlike ParseImage -- which collapses to a single tag-or-digest field with digest
1441
+ // precedence -- Docker's canonical string keeps both a tag and a digest when both are present, so compose the
1442
+ // reference directly from the parsed name, tag and digest groups.
1443
+ static const auto regex = BuildImageReferenceRegex();
1444
+ std::smatch match;
1445
+ if (!std::regex_match(input, match, regex))
1446
+ {
1447
+ THROW_HR_WITH_USER_ERROR(E_INVALIDARG, wsl::shared::Localization::MessageWslcInvalidImage(input.c_str()));
1448
+ }
1449
+
1450
+ auto [domain, path] = NormalizeRepo(match[1].str());
1451
+
1452
+ // A tag joins with ':' and a digest with '@'. A name-only reference (no tag and no digest) defaults to ":latest";
1453
+ // a digest-only reference is not name-only, so it keeps no tag (matching Docker's TagNameOnly).
1454
+ const std::string tag = match[2].matched ? std::format(":{}", match[2].str()) : (match[3].matched ? "" : ":latest");
1455
+ const std::string digest = match[3].matched ? std::format("@{}", match[3].str()) : "";
1456
+ return std::format("{}/{}{}{}", domain, path, tag, digest);
1457
+}
1458
+
1459
void wsl::windows::common::wslutil::PrintSystemError(_In_ HRESULT result, _Inout_ FILE* const stream)
1460
{
1461
fwprintf(stream, L"%ls\n", GetSystemErrorString(result).c_str());
src/windows/common/wslutil.h
+4
@@ -222,6 +222,10 @@ ErrorStrings ErrorToString(const Error& error);
222
223
std::filesystem::path GetBasePath();
224
225
+// Returns the fully-qualified canonical image reference for the given input, matching the string
226
+// printed by `docker pull` (e.g. "ubuntu" -> "docker.io/library/ubuntu:latest").
227
+std::string GetCanonicalImageReference(const std::string& input);
228
+
229
std::optional<COMErrorInfo> GetCOMErrorInfo();
230
231
DWORD GetDefaultVersion(void);
src/windows/wslc/commands/ImagePullCommand.cpp
+2
-1
@@ -28,6 +28,7 @@ std::vector<Argument> ImagePullCommand::GetArguments() const
28
{
29
return {
30
Argument::Create(ArgType::ImageId, true),
31
+ Argument::Create(ArgType::Quiet, std::nullopt, std::nullopt, Localization::WSLCCLI_PullQuietArgDescription()),
32
// Argument::Create(ArgType::Scheme),
33
// Argument::Create(ArgType::Progress),
34
};
@@ -49,4 +50,4 @@ void ImagePullCommand::ExecuteInternal(CLIExecutionContext& context) const
50
<< ResolveSession //
51
<< PullImage;
52
}
52
-} // namespace wsl::windows::wslc
\ No newline at end of file
53
+} // namespace wsl::windows::wslc
src/windows/wslc/tasks/ImageTasks.cpp
+18
-2
@@ -223,10 +223,26 @@ void PullImage(CLIExecutionContext& context)
223
WI_ASSERT(context.Data.Contains(Data::Session));
224
WI_ASSERT(context.Args.Contains(ArgType::ImageId));
225
auto& session = context.Data.Get<Data::Session>();
226
- auto& imageId = context.Args.Get<ArgType::ImageId>();
226
+ const auto image = WideToMultiByte(context.Args.Get<ArgType::ImageId>());
227
+ const bool quiet = context.Args.Contains(ArgType::Quiet);
228
+
229
+ // Match `docker pull`: for a name-only reference (no tag or digest) the tag defaults to "latest". Unless quiet,
230
+ // the client reports this on stdout before contacting the registry.
231
+ EnumReferenceFormat format = EnumReferenceFormatNone;
232
+ ParseImage(image, &format);
233
+ if (!quiet && format == EnumReferenceFormatNone)
234
+ {
235
+ context.Reporter.Output(L"{}\n", Localization::WSLCCLI_PullUsingDefaultTag(L"latest"));
236
+ }
237
238
+ // Match `docker pull`: in quiet mode, suppress progress output by passing no progress callback. Warnings are
239
+ // unaffected because the warning callback is built internally by ImageService::Pull from the Reporter.
240
ImageProgressCallback callback(context.Reporter, Reporter::Level::Output);
229
- services::ImageService::Pull(context.Reporter, session, WideToMultiByte(imageId), &callback);
241
+ IProgressCallback* progress = quiet ? nullptr : &callback;
242
+ services::ImageService::Pull(context.Reporter, session, image, progress);
243
+
244
+ // Match `docker pull`: always print the resolved canonical image reference as the final line.
245
+ context.Reporter.Output(L"{}\n", MultiByteToWide(GetCanonicalImageReference(image)));
246
}
247
248
void PushImage(CLIExecutionContext& context)
test/windows/WSLCTests.cpp
+35
@@ -11873,6 +11873,41 @@ class WSLCTests
11873
"2001:0db8:85a3:0000:0000:8a2e:0370:7334:80/path", "2001:0db8:85a3:0000:0000:8a2e:0370:7334:80", "path");
11874
}
11875
11876
+ TEST_METHOD(CanonicalImageReference)
11877
+ {
11878
+ using wsl::windows::common::wslutil::GetCanonicalImageReference;
11879
+
11880
+ auto Validate = [](const std::string& input, const std::string& expected) {
11881
+ VERIFY_ARE_EQUAL(GetCanonicalImageReference(input), expected);
11882
+ };
11883
+
11884
+ // Name-only references default to ":latest" and the docker.io/library prefix (matches `docker pull` output).
11885
+ Validate("ubuntu", "docker.io/library/ubuntu:latest");
11886
+ Validate("ubuntu:22.04", "docker.io/library/ubuntu:22.04");
11887
+ Validate("library/ubuntu", "docker.io/library/ubuntu:latest");
11888
+ Validate("pytorch/pytorch", "docker.io/pytorch/pytorch:latest");
11889
+ Validate("docker.io/ubuntu", "docker.io/library/ubuntu:latest");
11890
+ Validate("index.docker.io/library/ubuntu:latest", "docker.io/library/ubuntu:latest");
11891
+
11892
+ // Custom registries keep their domain and path.
11893
+ Validate("ghcr.io/owner/repo:sha-abc123", "ghcr.io/owner/repo:sha-abc123");
11894
+ Validate("myregistry.io:5000/myimage", "myregistry.io:5000/myimage:latest");
11895
+ Validate("localhost:5000/myimage:latest", "localhost:5000/myimage:latest");
11896
+
11897
+ // A mixed-case registry domain is preserved verbatim, matching Docker (which never lowercases the domain).
11898
+ Validate("Example.COM/owner/repo", "Example.COM/owner/repo:latest");
11899
+
11900
+ // Digest references are preserved.
11901
+ Validate(
11902
+ "ubuntu@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30",
11903
+ "docker.io/library/ubuntu@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30");
11904
+
11905
+ // A tag and digest are both preserved when both are present, matching Docker's canonical reference.
11906
+ Validate(
11907
+ "ubuntu:22.04@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30",
11908
+ "docker.io/library/ubuntu:22.04@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30");
11909
+ }
11910
+
11911
WSLC_TEST_METHOD(ElevatedTokenCanOpenNonElevatedHandles)
11912
{
11913
wil::com_ptr<IWSLCSession> nonElevatedSession;
test/windows/wslc/CommandLineTestCases.h
+2
@@ -294,6 +294,8 @@ COMMAND_LINE_TEST_CASE(L"image list --verbose", L"list", true)
294
COMMAND_LINE_TEST_CASE(L"image list -q", L"list", true)
295
COMMAND_LINE_TEST_CASE(L"image pull ubuntu", L"pull", true)
296
COMMAND_LINE_TEST_CASE(L"pull ubuntu", L"pull", true)
297
+COMMAND_LINE_TEST_CASE(L"pull ubuntu --quiet", L"pull", true)
298
+COMMAND_LINE_TEST_CASE(L"pull ubuntu -q", L"pull", true)
299
COMMAND_LINE_TEST_CASE(L"image rm cont1 --force --no-prune", L"remove", true)
300
COMMAND_LINE_TEST_CASE(L"image rm cont1 cont2 cont3 --force --no-prune", L"remove", true)
301
test/windows/wslc/e2e/WSLCE2EPushPullTests.cpp
+39
@@ -93,6 +93,35 @@ class WSLCE2EPushPullTests
93
}
94
}
95
96
+ WSLC_TEST_METHOD(WSLCE2E_Image_Pull_QuietOption)
97
+ {
98
+ const auto& debianImage = DebianTestImage();
99
+ EnsureImageIsLoaded(debianImage);
100
+
101
+ auto session = OpenDefaultElevatedSession();
102
+
103
+ {
104
+ auto [registryContainer, registryAddress] = StartLocalRegistry(*session, "", "", 15004);
105
+ auto registryAddressW = string::MultiByteToWide(registryAddress);
106
+
107
+ // Tag and push the image so it can be pulled back from the registry.
108
+ auto registryImage = TagImageForRegistry(debianImage.NameAndTag(), registryAddressW);
109
+ auto tagCleanup = wil::scope_exit([&]() { RunWslc(std::format(L"image delete --force {}", registryImage)); });
110
+
111
+ RunWslcAndVerify(std::format(L"push {}", registryImage), {.Stderr = L"", .ExitCode = 0});
112
+
113
+ // Delete the local copy so the pull actually fetches from the registry.
114
+ RunWslcAndVerify(std::format(L"image delete --force {}", registryImage), {.ExitCode = 0});
115
+
116
+ // Quiet pull (Docker parity): progress is suppressed and stdout is exactly the resolved canonical
117
+ // reference. The registry image is already fully-qualified, so it equals the printed reference.
118
+ // GetStdoutOneLine() also asserts there is exactly one output line, proving progress was suppressed.
119
+ auto result = RunWslc(std::format(L"pull --quiet {}", registryImage));
120
+ result.Verify({.Stderr = L"", .ExitCode = 0});
121
+ VERIFY_ARE_EQUAL(registryImage, result.GetStdoutOneLine());
122
+ }
123
+ }
124
+
125
WSLC_TEST_METHOD(WSLCE2E_Image_Push_NonExistentImage)
126
{
127
auto result = RunWslc(L"push does-not-exist:latest");
@@ -108,5 +137,15 @@ class WSLCE2EPushPullTests
137
L"access to the resource is denied\r\nError code: WSLC_E_IMAGE_NOT_FOUND\r\n";
138
result.Verify({.Stdout = L"", .Stderr = errorMessage, .ExitCode = 1});
139
}
140
+
141
+ WSLC_TEST_METHOD(WSLCE2E_Image_Pull_NameOnlyDefaultsTag)
142
+ {
143
+ auto result = RunWslc(L"pull does-not-exist");
144
+ result.Verify({.ExitCode = 1});
145
+ VERIFY_IS_TRUE(result.StdoutContainsLine(L"Using default tag: latest"));
146
+
147
+ // Quiet mode suppresses the "Using default tag" line, leaving stdout empty on failure.
148
+ RunWslcAndVerify(L"pull -q does-not-exist", {.Stdout = L"", .ExitCode = 1});
149
+ }
150
};
151
} // namespace WSLCE2ETests