master
cpp 1,159 lines 45.1 KB
Raw
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 ContainerTasks.cpp
8
9 Abstract:
10
11 Implementation of container command related execution logic.
12
13 --*/
14 #include "Argument.h"
15 #include "ArgumentConvertedTypes.h"
16 #include "AsyncExecution.h"
17 #include "CLIExecutionContext.h"
18 #include "ContainerModel.h"
19 #include "ContainerService.h"
20 #include "ContainerTasks.h"
21 #include "ImageModel.h"
22 #include "MountSpecParsing.h"
23 #include "SessionModel.h"
24 #include "SessionService.h"
25 #include "TableOutput.h"
26 #include <wil/result_macros.h>
27 #include <filesystem.hpp>
28 #include <wslc_schema.h>
29 #include <filesystem>
30
31 using namespace wsl::shared;
32 using namespace wsl::windows::common;
33 using namespace wsl::windows::common::string;
34 using namespace wsl::windows::common::timestamp;
35 using namespace wsl::windows::common::wslutil;
36 using namespace wsl::windows::wslc::execution;
37 using namespace wsl::windows::wslc::models;
38 using namespace wsl::windows::wslc::services;
39 using wsl::windows::common::string::FormatHumanReadableSize;
40 using wsl::windows::common::string::StorageSizeUnit;
41
42 namespace {
43
44 // Docker reports memory in binary units and network and block IO in decimal units.
45 constexpr uint32_t c_statsMemoryPrecision = 4;
46 constexpr uint32_t c_statsIoPrecision = 3;
47
48 std::string FormatStatsMemory(uint64_t Bytes)
49 {
50 return WideToMultiByte(FormatHumanReadableSize(Bytes, c_statsMemoryPrecision, StorageSizeUnit::Binary));
51 }
52
53 std::string FormatStatsIo(uint64_t Bytes)
54 {
55 return WideToMultiByte(FormatHumanReadableSize(Bytes, c_statsIoPrecision));
56 }
57
58 nlohmann::json ComputeContainerStatsJson(const wsl::windows::common::docker_schema::ContainerStats& stats)
59 {
60 // Calculate CPU %
61 // Formula matches Docker CLI: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go
62 double cpuPercent = 0.0;
63 const auto cpuDelta =
64 static_cast<double>(stats.cpu_stats.cpu_usage.total_usage) - static_cast<double>(stats.precpu_stats.cpu_usage.total_usage);
65 const auto systemDelta = static_cast<double>(stats.cpu_stats.system_cpu_usage) - static_cast<double>(stats.precpu_stats.system_cpu_usage);
66 if (systemDelta > 0.0 && cpuDelta > 0.0)
67 {
68 uint32_t onlineCpus = stats.cpu_stats.online_cpus;
69 if (onlineCpus == 0 && stats.cpu_stats.cpu_usage.percpu_usage.has_value())
70 {
71 onlineCpus = static_cast<uint32_t>(stats.cpu_stats.cpu_usage.percpu_usage->size());
72 }
73
74 cpuPercent = (cpuDelta / systemDelta) * static_cast<double>(onlineCpus) * 100.0;
75 }
76
77 // Calculate memory %
78 double memPercent = 0.0;
79 if (stats.memory_stats.limit > 0)
80 {
81 memPercent = (static_cast<double>(stats.memory_stats.usage) / static_cast<double>(stats.memory_stats.limit)) * 100.0;
82 }
83
84 // Aggregate network I/O
85 uint64_t netRxBytes = 0;
86 uint64_t netTxBytes = 0;
87 if (stats.networks.has_value())
88 {
89 for (const auto& [iface, netStats] : *stats.networks)
90 {
91 netRxBytes += netStats.rx_bytes;
92 netTxBytes += netStats.tx_bytes;
93 }
94 }
95
96 // Aggregate block I/O
97 uint64_t blkReadBytes = 0;
98 uint64_t blkWriteBytes = 0;
99 if (stats.blkio_stats.io_service_bytes_recursive.has_value())
100 {
101 for (const auto& entry : *stats.blkio_stats.io_service_bytes_recursive)
102 {
103 if (_stricmp(entry.op.c_str(), "read") == 0)
104 {
105 blkReadBytes += entry.value;
106 }
107 else if (_stricmp(entry.op.c_str(), "write") == 0)
108 {
109 blkWriteBytes += entry.value;
110 }
111 }
112 }
113
114 const auto& containerName = stats.name.empty() ? stats.id : stats.name;
115
116 return {
117 {"ID", stats.id},
118 {"Name", containerName},
119 {"CPUPerc", std::format("{:.2f}%", cpuPercent)},
120 {"MemUsage", std::format("{} / {}", FormatStatsMemory(stats.memory_stats.usage), FormatStatsMemory(stats.memory_stats.limit))},
121 {"MemPerc", std::format("{:.2f}%", memPercent)},
122 {"NetIO", std::format("{} / {}", FormatStatsIo(netRxBytes), FormatStatsIo(netTxBytes))},
123 {"BlockIO", std::format("{} / {}", FormatStatsIo(blkReadBytes), FormatStatsIo(blkWriteBytes))},
124 {"PIDs", stats.pids_stats.current},
125 };
126 }
127
128 // Builds the representation of a container, shared by the table and json output so the two cannot
129 // drift. Every value is emitted as a string apart from the platform object, and the id is truncated
130 // unless --no-trunc is passed. RunningFor and Status are the only fields that vary with the format:
131 // docker renders them in invariant English, so json keeps that while the table is localized.
132 ContainerOutputInformation ToContainerOutput(const ContainerInformation& container, bool truncate, FormatType format)
133 {
134 ContainerOutputInformation entry;
135 entry.Command = WideToMultiByte(ContainerService::FormatCommand(container.Command, truncate));
136 entry.CreatedAt = EpochToLocalDisplayTime(container.CreatedAt);
137 // The runtime reports health as a suffix on the status description, which is the only place it is
138 // exposed by the listing API.
139 entry.HealthStatus = ContainerService::FormatHealthStatus(container.Status);
140 entry.ID = truncate ? TruncateId(container.Id) : container.Id;
141 entry.Image = container.Image;
142 entry.Labels = container.Labels;
143 entry.LocalVolumes = std::to_string(container.LocalVolumes);
144 entry.Mounts = WideToMultiByte(ContainerService::FormatMounts(container.Mounts, truncate));
145 entry.Names = container.Name;
146 entry.Networks = container.Networks;
147 entry.Platform.architecture = wsl::shared::Arm64 ? "arm64" : "amd64";
148 entry.Platform.os = "linux";
149 entry.Ports = WideToMultiByte(ContainerService::FormatPorts(container.State, container.Ports));
150 entry.RunningFor = WideToMultiByte(
151 format == FormatType::Json ? FormatInvariantRelativeTime(container.CreatedAt) : FormatRelativeTime(container.CreatedAt));
152 // Container sizes are only computed when docker is passed --size, which wslc does not support.
153 entry.Size = WideToMultiByte(FormatHumanReadableSize(0));
154 entry.State = WideToMultiByte(ContainerService::ContainerStateName(container.State));
155 entry.Status = WideToMultiByte(ContainerService::FormatStatus(container.Status, container.State, container.StateChangedAt, format));
156
157 return entry;
158 }
159
160 } // namespace
161
162 namespace wsl::windows::wslc::task {
163
164 // Every container is attempted even if an earlier one fails; the command still exits nonzero.
165 template <typename TAction>
166 static void ForEachContainer(CLIExecutionContext& context, TAction&& action)
167 {
168 for (const auto& id : context.Args.GetAllValues<ArgType::ContainerId>())
169 {
170 try
171 {
172 action(WideToMultiByte(id));
173 context.Terminal.Output(L"{}\n", id);
174 }
175 catch (...)
176 {
177 LOG_CAUGHT_EXCEPTION();
178 context.ReportError(wil::ResultFromCaughtException());
179
180 // CollectErrorImpl keeps the first message when the next container fails with the same HRESULT.
181 context.ClearError();
182 context.ExitCode = 1;
183 }
184 }
185 }
186
187 static bool TryInspectContainer(
188 Terminal& terminal, Session& session, const std::string& containerId, std::optional<wslc_schema::InspectContainer>& inspectData, bool size = false)
189 {
190 try
191 {
192 inspectData = ContainerService::Inspect(session, containerId, size);
193 return true;
194 }
195 catch (const wil::ResultException& ex)
196 {
197 if (ex.GetErrorCode() == WSLC_E_CONTAINER_NOT_FOUND)
198 {
199 terminal.Error(L"{}\n", Localization::MessageWslcContainerNotFound(containerId.c_str()));
200 return false;
201 }
202
203 throw;
204 }
205 }
206
207 void AttachContainer::operator()(CLIExecutionContext& context) const
208 {
209 WI_ASSERT(context.Data.Contains(Data::Session));
210 context.ExitCode = ContainerService::Attach(context.Terminal, context.Data.Get<Data::Session>(), WideToMultiByte(m_containerId));
211 }
212
213 void CreateContainer(CLIExecutionContext& context)
214 {
215 WI_ASSERT(context.Data.Contains(Data::Session));
216 WI_ASSERT(context.Args.Contains(ArgType::ImageId));
217 WI_ASSERT(context.Data.Contains(Data::ContainerOptions));
218 auto result = ContainerService::Create(
219 context.Terminal,
220 context.Data.Get<Data::Session>(),
221 WideToMultiByte(context.Args.GetValue<ArgType::ImageId>()),
222 context.Data.Get<Data::ContainerOptions>());
223 context.Terminal.Output(L"{}\n", MultiByteToWide(result.Id));
224 }
225
226 void ExecContainer(CLIExecutionContext& context)
227 {
228 WI_ASSERT(context.Data.Contains(Data::Session));
229 WI_ASSERT(context.Args.Contains(ArgType::ContainerId));
230 WI_ASSERT(context.Data.Contains(Data::ContainerOptions));
231 context.ExitCode = ContainerService::Exec(
232 context.Terminal,
233 context.Data.Get<Data::Session>(),
234 WideToMultiByte(context.Args.GetValue<ArgType::ContainerId>()),
235 context.Data.Get<Data::ContainerOptions>());
236 }
237
238 void GetContainers(CLIExecutionContext& context)
239 {
240 WI_ASSERT(context.Data.Contains(Data::Session));
241 auto& session = context.Data.Get<Data::Session>();
242
243 int limit = -1;
244
245 if (context.Args.Contains(ArgType::Last))
246 {
247 limit = context.Args.GetValue<ArgType::Last>();
248 }
249 else if (context.Args.GetValue<ArgType::Latest>())
250 {
251 limit = 1;
252 }
253
254 // Filter values are parsed and cached during argument validation.
255 auto filters = context.Args.GetAllValues<ArgType::Filter>();
256
257 context.Data.Add<Data::Containers>(ContainerService::List(session, context.Args.GetValue<ArgType::All>(), limit, filters));
258 }
259
260 void InspectContainers(CLIExecutionContext& context)
261 {
262 WI_ASSERT(context.Data.Contains(Data::Session));
263 auto& session = context.Data.Get<Data::Session>();
264 auto containerIds = context.Args.GetAllValues<ArgType::ContainerId>();
265 std::vector<wsl::windows::common::wslc_schema::InspectContainer> result;
266 const bool size = context.Args.GetValue<ArgType::Size>();
267 for (const auto& id : containerIds)
268 {
269 std::optional<wslc_schema::InspectContainer> inspectData;
270 if (TryInspectContainer(context.Terminal, session, WideToMultiByte(id), inspectData, size))
271 {
272 result.push_back(*inspectData);
273 }
274 else
275 {
276 context.ExitCode = 1;
277 }
278 }
279
280 nlohmann::json array = nlohmann::json::array();
281 for (const auto& entry : result)
282 {
283 array.push_back(wslc_schema::ToInspectJson(entry));
284 }
285
286 auto json = array.dump(context.Args.GetValue<ArgType::InspectFormat>(c_jsonPrettyPrintIndent));
287 context.Terminal.Output(L"{}\n", MultiByteToWide(json));
288 }
289
290 void KillContainers(CLIExecutionContext& context)
291 {
292 WI_ASSERT(context.Data.Contains(Data::Session));
293 auto& session = context.Data.Get<Data::Session>();
294 const auto signal = context.Args.GetValue<ArgType::Signal>(WSLCSignalSIGKILL);
295
296 ForEachContainer(context, [&](const std::string& id) { ContainerService::Kill(session, id, signal); });
297 }
298
299 void ExportContainer(CLIExecutionContext& context)
300 {
301 WI_ASSERT(context.Data.Contains(Data::Session));
302 WI_ASSERT(context.Args.Contains(ArgType::ContainerId));
303 auto& session = context.Data.Get<Data::Session>();
304 auto containerId = WideToMultiByte(context.Args.GetValue<ArgType::ContainerId>());
305
306 if (context.Args.Contains(ArgType::Output))
307 {
308 auto& output = context.Args.GetValue<ArgType::Output>();
309 ContainerService::Export(session, containerId, output);
310 }
311 else
312 {
313 auto stdoutHandle = GetStdHandle(STD_OUTPUT_HANDLE);
314 if (wsl::windows::common::wslutil::IsConsoleHandle(stdoutHandle))
315 {
316 THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::WSLCCLI_ContainerExportStdoutIsTerminalError());
317 }
318
319 ContainerService::Export(session, containerId, stdoutHandle);
320 }
321 }
322
323 void ContainerCp(CLIExecutionContext& context)
324 {
325 WI_ASSERT(context.Data.Contains(Data::Session));
326 WI_ASSERT(context.Args.Contains(ArgType::Source));
327 WI_ASSERT(context.Args.Contains(ArgType::Target));
328
329 auto& session = context.Data.Get<Data::Session>();
330 const auto& source = context.Args.GetValue<ArgType::Source>();
331 const auto& target = context.Args.GetValue<ArgType::Target>();
332
333 // Determine copy direction by looking for CONTAINER:PATH patterns.
334 // A single letter before ':' is a Windows drive path (e.g. C:\path), not a container reference.
335 auto isContainerPath = [](const std::wstring& path) -> bool {
336 auto colonPos = path.find(L':');
337 if (colonPos == std::wstring::npos || colonPos == 0)
338 {
339 return false;
340 }
341
342 // Single letter before colon is a Windows drive path
343 if (colonPos == 1 && std::isalpha(static_cast<unsigned char>(path[0])))
344 {
345 return false;
346 }
347
348 return true;
349 };
350
351 auto parseContainerPath = [](const std::wstring& path) -> std::pair<std::string, std::string> {
352 auto colonPos = path.find(L':');
353 // Skip Windows drive letter if present
354 if (colonPos == 1 && std::isalpha(static_cast<unsigned char>(path[0])))
355 {
356 colonPos = path.find(L':', 2);
357 }
358
359 auto container = WideToMultiByte(path.substr(0, colonPos));
360 auto containerPath = WideToMultiByte(path.substr(colonPos + 1));
361 return {container, containerPath};
362 };
363
364 bool sourceIsStdin = (source == L"-");
365 bool sourceIsContainer = !sourceIsStdin && isContainerPath(source);
366 bool targetIsContainer = isContainerPath(target);
367
368 if ((sourceIsStdin || !sourceIsContainer) && targetIsContainer)
369 {
370 // stdin/local → container
371 auto [containerId, destPath] = parseContainerPath(target);
372 THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_CpInvalidTargetError(), containerId.empty() || destPath.empty());
373
374 if (sourceIsStdin)
375 {
376 auto inputHandle = GetStdHandle(STD_INPUT_HANDLE);
377 THROW_HR_WITH_USER_ERROR_IF(
378 E_INVALIDARG, Localization::WSLCCLI_CpStdinIsTerminalError(), wsl::windows::common::wslutil::IsConsoleHandle(inputHandle));
379
380 LARGE_INTEGER fileSize{};
381 ULONGLONG contentSize = 0;
382 if (GetFileSizeEx(inputHandle, &fileSize))
383 {
384 contentSize = static_cast<ULONGLONG>(fileSize.QuadPart);
385 }
386
387 // Note: The --archive/-a flag is accepted for CLI compatibility with docker cp, but is a
388 // no-op here. Since the tar archive contains uid/gid ownership in its headers, and Docker's
389 // PUT /archive extracts preserving that metadata.
390 ContainerService::CopyToContainer(session, containerId, destPath, inputHandle, contentSize);
391 }
392 else
393 {
394 // Local path → container: create tar from local path using tar.exe
395 std::error_code fsError;
396 bool pathExists = std::filesystem::exists(source, fsError);
397 THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_CpSourceNotFoundError(source), fsError || !pathExists);
398
399 auto absPath = std::filesystem::absolute(source);
400 auto parentDir = absPath.parent_path().wstring();
401 auto fileName = absPath.filename().wstring();
402
403 // Strip trailing separator to avoid the CRT parsing '\"' as an escaped quote
404 while (parentDir.size() > 1 && (parentDir.back() == L'\\' || parentDir.back() == L'/'))
405 {
406 parentDir.pop_back();
407 }
408
409 // Create a temp file with DELETE_ON_CLOSE and InheritHandle so tar can write to it via stdout
410 filesystem::TempFile tarFile(
411 GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, CREATE_ALWAYS, filesystem::TempFileFlags::DeleteOnClose | filesystem::TempFileFlags::InheritHandle);
412
413 // Run tar.exe writing to stdout, redirected to our temp file handle
414 auto tarCmd = std::format(L"tar.exe -cf - -C \"{}\" \"{}\"", parentDir, fileName);
415 SubProcess process(nullptr, tarCmd.c_str());
416 process.SetStdHandles(nullptr, tarFile.Handle.get(), nullptr);
417 auto exitCode = process.Run();
418 THROW_HR_IF_MSG(E_FAIL, exitCode != 0, "tar.exe exited with code %u", exitCode);
419
420 // Rewind and get size for upload
421 LARGE_INTEGER zero{};
422 THROW_LAST_ERROR_IF(!SetFilePointerEx(tarFile.Handle.get(), zero, nullptr, FILE_BEGIN));
423
424 LARGE_INTEGER fileSize{};
425 THROW_LAST_ERROR_IF(!GetFileSizeEx(tarFile.Handle.get(), &fileSize));
426
427 ContainerService::CopyToContainer(
428 session, containerId, destPath, tarFile.Handle.get(), static_cast<ULONGLONG>(fileSize.QuadPart));
429 }
430 }
431 else if (sourceIsContainer && !targetIsContainer)
432 {
433 // container → local
434 auto [containerId, srcPath] = parseContainerPath(source);
435 THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_CpInvalidSourceError(), containerId.empty() || srcPath.empty());
436
437 // Resolve any symlinks in the target path since tar.exe refuses to extract through a symlink.
438 std::error_code canonicalError;
439 auto absTarget = wsl::windows::common::filesystem::GetCanonicalPath(target, canonicalError);
440 if (canonicalError)
441 {
442 absTarget = std::filesystem::absolute(target); // Fall back to absolute if canonicalization fails.
443 }
444
445 // Determine if target is a directory or a file destination.
446 // Treat as directory if: ends with separator, or already exists as a directory.
447 bool targetIsDir = (!target.empty() && (target.back() == L'\\' || target.back() == L'/')) || std::filesystem::is_directory(absTarget);
448
449 if (targetIsDir)
450 {
451 // Extract directly into the target directory by piping the download to tar stdin.
452 std::error_code dirError;
453 std::filesystem::create_directories(absTarget, dirError);
454 THROW_HR_IF_MSG(HRESULT_FROM_WIN32(dirError.value()), !!dirError, "Failed to create directory: %ls", absTarget.c_str());
455
456 // Strip trailing separator to avoid the CRT parsing a trailing '\"' as an escaped quote.
457 auto targetDir = absTarget.wstring();
458 while (targetDir.size() > 1 && (targetDir.back() == L'\\' || targetDir.back() == L'/'))
459 {
460 targetDir.pop_back();
461 }
462
463 auto [pipeRead, pipeWrite] = OpenAnonymousPipe(0, false, false);
464 THROW_IF_WIN32_BOOL_FALSE(SetHandleInformation(pipeRead.get(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT));
465
466 auto tarCmd = std::format(L"tar.exe -xf - -C \"{}\"", targetDir);
467 SubProcess process(nullptr, tarCmd.c_str());
468 process.SetStdHandles(pipeRead.get(), nullptr, nullptr);
469 auto processHandle = process.Start();
470 pipeRead.reset();
471
472 ContainerService::CopyFromContainer(session, containerId, srcPath, pipeWrite.get());
473 pipeWrite.reset();
474
475 auto exitCode = SubProcess::GetExitCode(processHandle.get());
476 THROW_HR_IF_MSG(E_FAIL, exitCode != 0, "tar.exe exited with code %u", exitCode);
477 }
478 else
479 {
480 // Target is a file path. Download archive once to a temp file (exclusive write handle),
481 // validate it contains a single file with tar -t, then extract via tar -x -O.
482
483 // Download archive to temp file. FILE_SHARE_READ allows tar to read it while we hold
484 // the exclusive write handle, preventing other processes from tampering.
485 filesystem::TempFile tarFile(GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, CREATE_ALWAYS);
486
487 ContainerService::CopyFromContainer(session, containerId, srcPath, tarFile.Handle.get());
488
489 // Step 1: Pipe tar -t output and read just enough lines to classify the archive.
490 auto [listStdoutRead, listStdoutWrite] = OpenAnonymousPipe(0, true, false);
491 THROW_IF_WIN32_BOOL_FALSE(SetHandleInformation(listStdoutWrite.get(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT));
492
493 auto listCmd = std::format(L"tar.exe -tf \"{}\"", tarFile.Path.wstring());
494 SubProcess listProcess(nullptr, listCmd.c_str());
495 listProcess.SetStdHandles(nullptr, listStdoutWrite.get(), nullptr);
496 auto listHandle = listProcess.Start();
497 listStdoutWrite.reset();
498
499 // Read lines from tar -t output. We only need to detect:
500 // - zero entries (empty archive)
501 // - exactly one non-directory entry (single file)
502 // - anything else (directory or multi-file)
503 size_t entryCount = 0;
504 bool hasDirectory = false;
505 std::string lineBuffer;
506 char readBuf[4096];
507 DWORD bytesRead = 0;
508 bool done = false;
509 while (!done && ReadFile(listStdoutRead.get(), readBuf, sizeof(readBuf), &bytesRead, nullptr) && bytesRead > 0)
510 {
511 for (DWORD i = 0; i < bytesRead && !done; i++)
512 {
513 if (readBuf[i] == '\n')
514 {
515 if (!lineBuffer.empty())
516 {
517 entryCount++;
518 if (lineBuffer.back() == '/')
519 {
520 hasDirectory = true;
521 }
522
523 // We can stop early: directory entry or second entry means not a single file.
524 if (hasDirectory || entryCount > 1)
525 {
526 done = true;
527 }
528
529 lineBuffer.clear();
530 }
531 }
532 else if (readBuf[i] != '\r')
533 {
534 lineBuffer.append(1, readBuf[i]);
535 }
536 }
537 }
538
539 // Count trailing line without newline.
540 if (!done && !lineBuffer.empty())
541 {
542 entryCount++;
543 if (lineBuffer.back() == '/')
544 {
545 hasDirectory = true;
546 }
547 }
548
549 listStdoutRead.reset();
550
551 // Kill the tar -t process (it may still be writing lines we stopped reading) and wait for it to exit.
552 TerminateProcess(listHandle.get(), 0);
553 SubProcess::GetExitCode(listHandle.get());
554
555 THROW_HR_WITH_USER_ERROR_IF(E_FAIL, Localization::WSLCCLI_CpSourceIsDirectoryError(), hasDirectory || entryCount > 1);
556
557 THROW_HR_WITH_USER_ERROR_IF(E_FAIL, Localization::WSLCCLI_CpNoFileExtractedError(), entryCount == 0);
558
559 // Step 2: Extract the single file content directly to the target.
560 std::error_code dirError;
561 std::filesystem::create_directories(absTarget.parent_path(), dirError);
562 THROW_HR_IF_MSG(
563 HRESULT_FROM_WIN32(dirError.value()), !!dirError, "Failed to create directory: %ls", absTarget.parent_path().c_str());
564
565 wil::unique_hfile targetFile(CreateFileW(absTarget.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr));
566 THROW_LAST_ERROR_IF(!targetFile);
567 THROW_IF_WIN32_BOOL_FALSE(SetHandleInformation(targetFile.get(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT));
568
569 auto extractCmd = std::format(L"tar.exe -xf \"{}\" -O", tarFile.Path.wstring());
570 SubProcess extractProcess(nullptr, extractCmd.c_str());
571 extractProcess.SetStdHandles(nullptr, targetFile.get(), nullptr);
572 auto extractHandle = extractProcess.Start();
573 targetFile.reset();
574
575 auto extractExitCode = SubProcess::GetExitCode(extractHandle.get());
576 THROW_HR_IF_MSG(E_FAIL, extractExitCode != 0, "tar.exe -x -O exited with code %u", extractExitCode);
577 }
578 }
579 else
580 {
581 THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::WSLCCLI_CpInvalidDirectionError());
582 }
583 }
584
585 void ListContainers(CLIExecutionContext& context)
586 {
587 WI_ASSERT(context.Data.Contains(Data::Containers));
588 auto& containers = context.Data.Get<Data::Containers>();
589
590 // Note: --all and --filter status= are honored by the Docker daemon when
591 // GetContainers ran; no post-filtering needed here.
592
593 if (context.Args.GetValue<ArgType::Quiet>())
594 {
595 // Print only the container ids
596 bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
597 for (const auto& container : containers)
598 {
599 context.Terminal.Output(L"{}\n", MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id));
600 }
601
602 return;
603 }
604
605 const auto format = context.Args.GetValue<ArgType::Format>(FormatType::Table);
606 bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
607
608 switch (format)
609 {
610 case FormatType::Json:
611 {
612 for (const auto& container : containers)
613 {
614 context.Terminal.Output(L"{}\n", ToJsonW(ToContainerOutput(container, trunc, FormatType::Json), c_jsonCompactIndent));
615 }
616
617 break;
618 }
619 case FormatType::Table:
620 {
621 using enum ColumnOverflow;
622
623 // Create table with or without column limits based on --no-trunc flag
624 auto table = trunc ? wsl::windows::wslc::TableOutput<7>(
625 context.Terminal,
626 {{{Localization::WSLCCLI_TableHeaderContainerId(), {.MaxWidth = 12, .Overflow = Shrink}},
627 {Localization::WSLCCLI_TableHeaderImage(), {.MaxWidth = 20, .Overflow = Shrink}},
628 {Localization::WSLCCLI_TableHeaderCommand(), {.Overflow = Shrink}},
629 {Localization::WSLCCLI_TableHeaderCreated(), {.Overflow = Shrink}},
630 {Localization::WSLCCLI_TableHeaderStatus(), {.Overflow = Shrink}},
631 {Localization::WSLCCLI_TableHeaderPorts(), {.Overflow = Shrink}},
632 {Localization::WSLCCLI_TableHeaderNames(), {.MaxWidth = 20, .Overflow = Shrink}}}},
633 containers.size())
634 : wsl::windows::wslc::TableOutput<7>(
635 context.Terminal,
636 {Localization::WSLCCLI_TableHeaderContainerId(),
637 Localization::WSLCCLI_TableHeaderImage(),
638 Localization::WSLCCLI_TableHeaderCommand(),
639 Localization::WSLCCLI_TableHeaderCreated(),
640 Localization::WSLCCLI_TableHeaderStatus(),
641 Localization::WSLCCLI_TableHeaderPorts(),
642 Localization::WSLCCLI_TableHeaderNames()});
643
644 // Add each container as a row
645 for (const auto& container : containers)
646 {
647 const auto entry = ToContainerOutput(container, trunc, FormatType::Table);
648 table.WriteRow({
649 MultiByteToWide(entry.ID),
650 MultiByteToWide(entry.Image),
651 MultiByteToWide(entry.Command),
652 MultiByteToWide(entry.RunningFor),
653 MultiByteToWide(entry.Status),
654 MultiByteToWide(entry.Ports),
655 MultiByteToWide(entry.Names),
656 });
657 }
658
659 table.Complete();
660 break;
661 }
662 default:
663 THROW_HR(E_UNEXPECTED);
664 }
665 }
666
667 void RemoveContainers(CLIExecutionContext& context)
668 {
669 WI_ASSERT(context.Data.Contains(Data::Session));
670 auto& session = context.Data.Get<Data::Session>();
671 const bool force = context.Args.GetValue<ArgType::Force>();
672 const bool deleteVolumes = context.Args.GetValue<ArgType::Volumes>();
673
674 ForEachContainer(context, [&](const std::string& id) { ContainerService::Delete(session, id, force, deleteVolumes); });
675 }
676
677 void RunContainer(CLIExecutionContext& context)
678 {
679 WI_ASSERT(context.Data.Contains(Data::Session));
680 WI_ASSERT(context.Args.Contains(ArgType::ImageId));
681 WI_ASSERT(context.Data.Contains(Data::ContainerOptions));
682 context.ExitCode = ContainerService::Run(
683 context.Terminal,
684 context.Data.Get<Data::Session>(),
685 WideToMultiByte(context.Args.GetValue<ArgType::ImageId>()),
686 context.Data.Get<Data::ContainerOptions>());
687 }
688
689 void SetContainerOptionsFromArgs(CLIExecutionContext& context)
690 {
691 ContainerOptions options;
692
693 if (context.Args.Contains(ArgType::Pull))
694 {
695 options.Pull = context.Args.GetValue<ArgType::Pull>();
696 }
697
698 if (context.Args.Contains(ArgType::CIDFile))
699 {
700 options.CidFile = context.Args.GetValue<ArgType::CIDFile>();
701 }
702
703 if (context.Args.Contains(ArgType::Name))
704 {
705 options.Name = WideToMultiByte(context.Args.GetValue<ArgType::Name>());
706 }
707
708 options.TTY = context.Args.GetValue<ArgType::TTY>();
709 options.Detach = context.Args.GetValue<ArgType::Detach>();
710 options.Interactive = context.Args.GetValue<ArgType::Interactive>();
711
712 if (context.Args.Contains(ArgType::Publish))
713 {
714 auto ports = context.Args.GetAllValues<ArgType::Publish>();
715 options.Ports.reserve(options.Ports.size() + ports.size());
716 for (const auto& port : ports)
717 {
718 options.Ports.emplace_back(WideToMultiByte(port));
719 }
720 }
721
722 options.PublishAll = context.Args.GetValue<ArgType::PublishAll>();
723
724 if (context.Args.Contains(ArgType::Gpus))
725 {
726 options.Gpu = true;
727 }
728
729 if (context.Args.Contains(ArgType::Volume))
730 {
731 auto volumes = context.Args.GetAllValues<ArgType::Volume>();
732 options.Mounts.insert(options.Mounts.end(), std::make_move_iterator(volumes.begin()), std::make_move_iterator(volumes.end()));
733 }
734
735 if (context.Args.Contains(ArgType::Mount))
736 {
737 auto mounts = context.Args.GetAllValues<ArgType::Mount>();
738 options.Mounts.insert(options.Mounts.end(), std::make_move_iterator(mounts.begin()), std::make_move_iterator(mounts.end()));
739 }
740
741 options.Remove = context.Args.GetValue<ArgType::Remove>();
742
743 if (context.Args.Contains(ArgType::StopSignal))
744 {
745 options.StopSignal = context.Args.GetValue<ArgType::StopSignal>();
746 }
747
748 if (context.Args.Contains(ArgType::StopTimeout))
749 {
750 options.StopTimeout = context.Args.GetValue<ArgType::StopTimeout>();
751 }
752
753 if (context.Args.Contains(ArgType::ShmSize))
754 {
755 options.ShmSize = context.Args.GetValue<ArgType::ShmSize>();
756 }
757
758 if (context.Args.Contains(ArgType::HealthCmd))
759 {
760 options.HealthCmd = WideToMultiByte(context.Args.GetValue<ArgType::HealthCmd>());
761 }
762
763 if (context.Args.Contains(ArgType::HealthInterval))
764 {
765 options.HealthInterval = context.Args.GetValue<ArgType::HealthInterval>();
766 }
767
768 if (context.Args.Contains(ArgType::HealthTimeout))
769 {
770 options.HealthTimeout = context.Args.GetValue<ArgType::HealthTimeout>();
771 }
772
773 if (context.Args.Contains(ArgType::HealthStartPeriod))
774 {
775 options.HealthStartPeriod = context.Args.GetValue<ArgType::HealthStartPeriod>();
776 }
777
778 if (context.Args.Contains(ArgType::HealthRetries))
779 {
780 options.HealthRetries = context.Args.GetValue<ArgType::HealthRetries>();
781 }
782
783 options.NoHealthcheck = context.Args.GetValue<ArgType::NoHealthcheck>();
784
785 if (context.Args.Contains(ArgType::Memory))
786 {
787 options.MemoryBytes = context.Args.GetValue<ArgType::Memory>();
788 }
789
790 if (context.Args.Contains(ArgType::Cpus))
791 {
792 options.NanoCpus = context.Args.GetValue<ArgType::Cpus>();
793 }
794
795 options.Ulimits = context.Args.GetAllValues<ArgType::Ulimit>();
796
797 if (context.Args.Contains(ArgType::Command))
798 {
799 options.Arguments.emplace_back(WideToMultiByte(context.Args.GetValue<ArgType::Command>()));
800 }
801
802 if (context.Args.Contains(ArgType::EnvFile))
803 {
804 auto envFiles = context.Args.GetAllValues<ArgType::EnvFile>();
805 for (const auto& envFile : envFiles)
806 {
807 auto parsedEnvVars = EnvironmentVariable::ParseFile(envFile);
808 for (const auto& envVar : parsedEnvVars)
809 {
810 options.EnvironmentVariables.push_back(wsl::shared::string::WideToMultiByte(envVar));
811 }
812 }
813 }
814
815 if (context.Args.Contains(ArgType::Env))
816 {
817 auto envArgs = context.Args.GetAllValues<ArgType::Env>();
818 for (const auto& arg : envArgs)
819 {
820 auto envVar = EnvironmentVariable::Parse(arg);
821 if (envVar)
822 {
823 options.EnvironmentVariables.push_back(wsl::shared::string::WideToMultiByte(*envVar));
824 }
825 }
826 }
827
828 if (context.Args.Contains(ArgType::Entrypoint))
829 {
830 options.Entrypoint.push_back(WideToMultiByte(context.Args.GetValue<ArgType::Entrypoint>()));
831 }
832
833 if (context.Args.Contains(ArgType::Hostname))
834 {
835 options.Hostname = WideToMultiByte(context.Args.GetValue<ArgType::Hostname>());
836 }
837
838 if (context.Args.Contains(ArgType::Domainname))
839 {
840 options.Domainname = WideToMultiByte(context.Args.GetValue<ArgType::Domainname>());
841 }
842
843 if (context.Args.Contains(ArgType::DNS))
844 {
845 auto dnsServers = context.Args.GetAllValues<ArgType::DNS>();
846 options.DnsServers.reserve(options.DnsServers.size() + dnsServers.size());
847 for (const auto& value : dnsServers)
848 {
849 options.DnsServers.emplace_back(WideToMultiByte(value));
850 }
851 }
852
853 if (context.Args.Contains(ArgType::DNSSearch))
854 {
855 auto dnsSearch = context.Args.GetAllValues<ArgType::DNSSearch>();
856 options.DnsSearchDomains.reserve(options.DnsSearchDomains.size() + dnsSearch.size());
857 for (const auto& value : dnsSearch)
858 {
859 options.DnsSearchDomains.emplace_back(WideToMultiByte(value));
860 }
861 }
862
863 if (context.Args.Contains(ArgType::DNSOption))
864 {
865 auto dnsOptions = context.Args.GetAllValues<ArgType::DNSOption>();
866 options.DnsOptions.reserve(options.DnsOptions.size() + dnsOptions.size());
867 for (const auto& value : dnsOptions)
868 {
869 options.DnsOptions.emplace_back(WideToMultiByte(value));
870 }
871 }
872
873 if (context.Args.Contains(ArgType::Network))
874 {
875 auto networks = context.Args.GetAllValues<ArgType::Network>();
876 options.Networks.reserve(options.Networks.size() + networks.size());
877 for (auto& parsed : networks)
878 {
879 auto& network = options.Networks.emplace_back();
880 network.Name = std::move(parsed.Name);
881 network.Aliases = std::move(parsed.Aliases);
882 }
883 }
884
885 if (context.Args.Contains(ArgType::NetworkAlias))
886 {
887 auto aliases = context.Args.GetAllValues<ArgType::NetworkAlias>();
888 options.NetworkAliases.reserve(aliases.size());
889 for (const auto& value : aliases)
890 {
891 options.NetworkAliases.emplace_back(WideToMultiByte(value));
892 }
893 }
894
895 if (context.Args.Contains(ArgType::IpAddress))
896 {
897 options.IpAddress = WideToMultiByte(context.Args.GetValue<ArgType::IpAddress>());
898 }
899
900 if (context.Args.Contains(ArgType::User))
901 {
902 options.User = WideToMultiByte(context.Args.GetValue<ArgType::User>());
903 }
904
905 if (context.Args.Contains(ArgType::TMPFS))
906 {
907 auto tmpfs = context.Args.GetAllValues<ArgType::TMPFS>();
908 options.Mounts.insert(options.Mounts.end(), std::make_move_iterator(tmpfs.begin()), std::make_move_iterator(tmpfs.end()));
909 }
910
911 for (const auto& label : context.Args.GetAllValues<ArgType::Label>())
912 {
913 options.Labels.push_back(label);
914 }
915
916 if (context.Args.Contains(ArgType::ForwardArgs))
917 {
918 auto const& forwardArgs = context.Args.GetValue<ArgType::ForwardArgs>();
919 options.Arguments.reserve(options.Arguments.size() + forwardArgs.size());
920 for (const auto& arg : forwardArgs)
921 {
922 options.Arguments.emplace_back(WideToMultiByte(arg));
923 }
924 }
925
926 if (context.Args.Contains(ArgType::WorkDir))
927 {
928 options.WorkingDirectory = WideToMultiByte(context.Args.GetValue<ArgType::WorkDir>());
929 }
930
931 context.Data.Add<Data::ContainerOptions>(std::move(options));
932 }
933
934 void ShowContainerStats(CLIExecutionContext& context)
935 {
936 WI_ASSERT(context.Data.Contains(Data::Session));
937 auto& session = context.Data.Get<Data::Session>();
938
939 auto containers = context.Args.GetAllValues<ArgType::ContainerId>();
940
941 // If any are specified we use those, otherwise we show all containers.
942 const bool userSpecifiedContainers = !containers.empty();
943 if (!userSpecifiedContainers)
944 {
945 GetContainers(context);
946 const auto& allContainers = context.Data.Get<Data::Containers>();
947 for (const auto& container : allContainers)
948 {
949 // Skip non-running containers unless --all is specified.
950 if (!context.Args.GetValue<ArgType::All>() && container.State != WSLCContainerState::WslcContainerStateRunning)
951 {
952 continue;
953 }
954
955 containers.push_back(MultiByteToWide(container.Id));
956 }
957 }
958
959 // Fetch stats for all containers concurrently in batches. The Docker engine blocks for ~1s
960 // per request to collect a valid precpu_stats sample, so issuing requests in parallel keeps
961 // wall time proportional to ceil(N / batchSize) rather than N.
962 nlohmann::json statsJson = nlohmann::json::array();
963 wsl::windows::wslc::ForEachAsync<std::wstring>(
964 containers,
965 // Work to be done for each container ID on a separate thread.
966 [&session](const std::wstring& containerId) {
967 // ContainerService::Stats makes COM calls, so we must ensure COM is initialized on this thread.
968 auto comCleanup = wil::CoInitializeEx(COINIT_MULTITHREADED);
969 return ComputeContainerStatsJson(ContainerService::Stats(session, WideToMultiByte(containerId)));
970 },
971 // On Success
972 [&](const nlohmann::json& entry) { statsJson.push_back(entry); },
973 // On Error
974 [&](const std::wstring& containerId, wil::ResultException error) {
975 if (!userSpecifiedContainers)
976 {
977 switch (error.GetErrorCode())
978 {
979 case RPC_E_DISCONNECTED:
980 case WSLC_E_CONTAINER_NOT_FOUND:
981 // Container disappeared between list and stats fetch, and
982 // the user did not specify these containers, so silently skip.
983 return;
984 }
985 }
986
987 // Failure to retrieve a container should stop execution with
988 // no container information displayed.
989 LOG_HR_MSG(error.GetErrorCode(), "Failed to get stats for container %ws", containerId.c_str());
990 throw error;
991 },
992 10 // Batch Size - chosen to be around typical expected container use while protecting against extreme cases.
993 );
994
995 const auto format = context.Args.GetValue<ArgType::Format>(FormatType::Table);
996
997 switch (format)
998 {
999 case FormatType::Json:
1000 {
1001 for (const auto& entry : statsJson)
1002 {
1003 context.Terminal.Output(L"{}\n", ToJsonW(entry, c_jsonCompactIndent));
1004 }
1005
1006 break;
1007 }
1008 case FormatType::Table:
1009 {
1010 bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
1011 using enum ColumnOverflow;
1012
1013 auto table = trunc ? wsl::windows::wslc::TableOutput<8>(
1014 context.Terminal,
1015 {{{Localization::WSLCCLI_TableHeaderContainerId(), {.MaxWidth = 12, .Overflow = Shrink}},
1016 {Localization::WSLCCLI_TableHeaderName(), {.MaxWidth = 20, .Overflow = Shrink}},
1017 {Localization::WSLCCLI_TableHeaderCpuPercent(), {.Overflow = Shrink}},
1018 {Localization::WSLCCLI_TableHeaderMemUsageLimit(), {.Overflow = Shrink}},
1019 {Localization::WSLCCLI_TableHeaderMemPercent(), {.Overflow = Shrink}},
1020 {Localization::WSLCCLI_TableHeaderNetIo(), {.Overflow = Shrink}},
1021 {Localization::WSLCCLI_TableHeaderBlockIo(), {.Overflow = Shrink}},
1022 {Localization::WSLCCLI_TableHeaderPids(), {.Overflow = Shrink}}}},
1023 statsJson.size())
1024 : wsl::windows::wslc::TableOutput<8>(
1025 context.Terminal,
1026 {Localization::WSLCCLI_TableHeaderContainerId(),
1027 Localization::WSLCCLI_TableHeaderName(),
1028 Localization::WSLCCLI_TableHeaderCpuPercent(),
1029 Localization::WSLCCLI_TableHeaderMemUsageLimit(),
1030 Localization::WSLCCLI_TableHeaderMemPercent(),
1031 Localization::WSLCCLI_TableHeaderNetIo(),
1032 Localization::WSLCCLI_TableHeaderBlockIo(),
1033 Localization::WSLCCLI_TableHeaderPids()});
1034
1035 for (const auto& entry : statsJson)
1036 {
1037 const auto id = entry["ID"].get<std::string>();
1038 table.WriteRow({
1039 MultiByteToWide(trunc ? TruncateId(id) : id),
1040 MultiByteToWide(entry["Name"].get<std::string>()),
1041 MultiByteToWide(entry["CPUPerc"].get<std::string>()),
1042 MultiByteToWide(entry["MemUsage"].get<std::string>()),
1043 MultiByteToWide(entry["MemPerc"].get<std::string>()),
1044 MultiByteToWide(entry["NetIO"].get<std::string>()),
1045 MultiByteToWide(entry["BlockIO"].get<std::string>()),
1046 std::to_wstring(entry["PIDs"].get<uint64_t>()),
1047 });
1048 }
1049
1050 table.Complete();
1051 break;
1052 }
1053 default:
1054 THROW_HR(E_UNEXPECTED);
1055 }
1056 }
1057
1058 void StartContainer(CLIExecutionContext& context)
1059 {
1060 WI_ASSERT(context.Data.Contains(Data::Session));
1061 WI_ASSERT(context.Args.Contains(ArgType::ContainerId));
1062 const auto& containerId = context.Args.GetValue<ArgType::ContainerId>();
1063 const bool attach = context.Args.GetValue<ArgType::Attach>();
1064 context.ExitCode = ContainerService::Start(context.Terminal, context.Data.Get<Data::Session>(), WideToMultiByte(containerId), attach);
1065
1066 if (!attach)
1067 {
1068 context.Terminal.Output(L"{}\n", containerId);
1069 }
1070 }
1071
1072 void StopContainers(CLIExecutionContext& context)
1073 {
1074 WI_ASSERT(context.Data.Contains(Data::Session));
1075 auto& session = context.Data.Get<Data::Session>();
1076 StopContainerOptions options;
1077
1078 // WSLCSignalNone lets Docker use the container's configured STOPSIGNAL, or its default when none is configured.
1079 options.Signal = context.Args.GetValue<ArgType::Signal>(WSLCSignalNone);
1080
1081 if (context.Args.Contains(ArgType::Time))
1082 {
1083 options.Timeout = context.Args.GetValue<ArgType::Time>();
1084 }
1085
1086 ForEachContainer(context, [&](const std::string& id) { ContainerService::Stop(session, id, options); });
1087 }
1088
1089 void RestartContainers(CLIExecutionContext& context)
1090 {
1091 WI_ASSERT(context.Data.Contains(Data::Session));
1092 auto& session = context.Data.Get<Data::Session>();
1093 StopContainerOptions options;
1094
1095 // WSLCSignalNone lets Docker use the container's configured STOPSIGNAL, or its default when none is configured.
1096 options.Signal = context.Args.GetValue<ArgType::Signal>(WSLCSignalNone);
1097
1098 if (context.Args.Contains(ArgType::Timeout))
1099 {
1100 options.Timeout = context.Args.GetValue<ArgType::Timeout>();
1101 }
1102
1103 ForEachContainer(context, [&](const std::string& id) { ContainerService::Restart(context.Terminal, session, id, options); });
1104 }
1105
1106 void ViewContainerLogs(CLIExecutionContext& context)
1107 {
1108 WI_ASSERT(context.Data.Contains(Data::Session));
1109 auto& session = context.Data.Get<Data::Session>();
1110 auto containerId = context.Args.GetValue<ArgType::ContainerId>();
1111 bool follow = context.Args.GetValue<ArgType::Follow>();
1112 bool timestamps = context.Args.GetValue<ArgType::Timestamps>();
1113
1114 ULONGLONG tail = 0;
1115 if (context.Args.Contains(ArgType::Tail))
1116 {
1117 tail = context.Args.GetValue<ArgType::Tail>();
1118 }
1119
1120 // N.B. since=0 and until=0 mean "unset" — the Docker API omits the parameter when the value is 0,
1121 // which is equivalent to "no lower/upper bound". This matches Docker CLI behavior where
1122 // `docker logs --since 0` returns all logs and `docker logs --until 0` applies no upper bound.
1123 LONGLONG since = 0;
1124 if (context.Args.Contains(ArgType::Since))
1125 {
1126 since = context.Args.GetValue<ArgType::Since>();
1127 }
1128
1129 LONGLONG until = 0;
1130 if (context.Args.Contains(ArgType::Until))
1131 {
1132 until = context.Args.GetValue<ArgType::Until>();
1133 }
1134
1135 ContainerService::Logs(session, WideToMultiByte(containerId), follow, timestamps, since, until, tail);
1136 }
1137
1138 void PruneContainers(CLIExecutionContext& context)
1139 {
1140 WI_ASSERT(context.Data.Contains(Data::Session));
1141 auto& session = context.Data.Get<Data::Session>();
1142
1143 auto result = ContainerService::Prune(session);
1144
1145 if (!result.PrunedContainers.empty())
1146 {
1147 context.Terminal.Output(L"{}\n", Localization::WSLCCLI_ContainerPruneDeletedHeader());
1148 for (const auto& containerId : result.PrunedContainers)
1149 {
1150 context.Terminal.Output(L"{}\n", MultiByteToWide(containerId));
1151 }
1152
1153 context.Terminal.Output(L"\n");
1154 }
1155
1156 context.Terminal.Output(
1157 L"{}\n", Localization::WSLCCLI_ContainerPruneSpaceReclaimedBytes(FormatHumanReadableSize(result.SpaceReclaimed, c_reclaimedSpacePrecision)));
1158 }
1159 } // namespace wsl::windows::wslc::task