From da9c48cf224180e7b4b1ff9194eee66ec688ef26 Mon Sep 17 00:00:00 2001 From: Blue Date: Thu, 2 Jul 2026 14:09:29 -0700 Subject: [PATCH 1/7] Save state' --- localization/strings/en-US/Resources.resw | 22 +++ src/windows/common/WSLCContainerLauncher.cpp | 40 ++++ src/windows/common/WSLCContainerLauncher.h | 10 + src/windows/inc/docker_schema.h | 40 +++- src/windows/inc/wslc_schema.h | 36 +++- src/windows/service/inc/WSLCShared.idl | 3 +- src/windows/service/inc/wslc.idl | 6 + .../wslc/arguments/ArgumentDefinitions.h | 5 + .../wslc/arguments/ArgumentValidation.cpp | 160 ++++++++++++++++ .../wslc/arguments/ArgumentValidation.h | 3 + .../wslc/commands/ContainerCreateCommand.cpp | 5 + .../wslc/commands/ContainerRunCommand.cpp | 5 + src/windows/wslc/services/ContainerModel.h | 5 + .../wslc/services/ContainerService.cpp | 25 +++ src/windows/wslc/tasks/ContainerTasks.cpp | 25 +++ src/windows/wslcsession/WSLCContainer.cpp | 68 +++++++ test/windows/WSLCTests.cpp | 128 +++++++++++++ test/windows/wslc/CommandLineTestCases.h | 21 +++ .../WSLCCLIResourceLimitsParserUnitTests.cpp | 50 +++++ .../wslc/e2e/WSLCE2EContainerCreateTests.cpp | 148 +++++++++++---- .../wslc/e2e/WSLCE2EContainerRunTests.cpp | 171 ++++++++++++++---- test/windows/wslc/e2e/WSLCE2EHelpers.cpp | 28 +++ test/windows/wslc/e2e/WSLCE2EHelpers.h | 3 + 23 files changed, 936 insertions(+), 71 deletions(-) diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index fccec5f14d..5337da5639 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -2840,6 +2840,24 @@ On first run, creates the file with all settings commented out at their defaults Invalid {} value: '{}'. Only 'all' is supported. {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + Command to run to check container health + + + Time between running the health check (e.g. 30s, 1m30s) + {Locked="30s"}{Locked="1m30s"}Command line argument example values should not be translated + + + Consecutive failures needed to report the container as unhealthy + + + Start period for the container to initialize before health-check countdown (e.g. 30s, 1m30s) + {Locked="30s"}{Locked="1m30s"}Command line argument example values should not be translated + + + Maximum time to allow one health check to run (e.g. 30s, 1m30s) + {Locked="30s"}{Locked="1m30s"}Command line argument example values should not be translated + Delete images even if they are being used @@ -3077,6 +3095,10 @@ On first run, creates the file with all settings commented out at their defaults Invalid {} argument value: '{}'. Expected a memory size (e.g. 256M, 1G) {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated{Locked="256M"}{Locked="1G"} + + Invalid {} argument value: '{}'. Expected a duration (e.g. 30s, 1m30s) + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated{Locked="30s"}{Locked="1m30s"} + CID file '{}' already exists {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated diff --git a/src/windows/common/WSLCContainerLauncher.cpp b/src/windows/common/WSLCContainerLauncher.cpp index cdbb50a0c8..4f1379096d 100644 --- a/src/windows/common/WSLCContainerLauncher.cpp +++ b/src/windows/common/WSLCContainerLauncher.cpp @@ -139,6 +139,31 @@ void WSLCContainerLauncher::SetShmSize(int64_t ShmSize) m_shmSize = ShmSize; } +void WSLCContainerLauncher::SetHealthCmd(std::string&& HealthCmd) +{ + m_healthCmd = std::move(HealthCmd); +} + +void WSLCContainerLauncher::SetHealthInterval(int64_t Nanoseconds) +{ + m_healthInterval = Nanoseconds; +} + +void WSLCContainerLauncher::SetHealthTimeout(int64_t Nanoseconds) +{ + m_healthTimeout = Nanoseconds; +} + +void WSLCContainerLauncher::SetHealthStartPeriod(int64_t Nanoseconds) +{ + m_healthStartPeriod = Nanoseconds; +} + +void WSLCContainerLauncher::SetHealthRetries(LONG Retries) +{ + m_healthRetries = Retries; +} + void WSLCContainerLauncher::SetEntrypoint(std::vector&& entrypoint) { m_entrypoint = std::move(entrypoint); @@ -309,6 +334,21 @@ std::pair> WSLCContainerLauncher::C options.ShmSize = m_shmSize; + if (m_healthCmd.has_value() || m_healthInterval.has_value() || m_healthTimeout.has_value() || + m_healthStartPeriod.has_value() || m_healthRetries.has_value()) + { + if (m_healthCmd.has_value()) + { + options.HealthCmd = m_healthCmd->c_str(); + } + + options.HealthIntervalNs = m_healthInterval.value_or(0); + options.HealthTimeoutNs = m_healthTimeout.value_or(0); + options.HealthStartPeriodNs = m_healthStartPeriod.value_or(0); + options.HealthRetries = m_healthRetries.value_or(0); + WI_SetFlag(options.Flags, WSLCContainerFlagsHealthCheck); + } + if (!entrypointStorage.empty()) { options.Entrypoint = {entrypointStorage.data(), static_cast(entrypointStorage.size())}; diff --git a/src/windows/common/WSLCContainerLauncher.h b/src/windows/common/WSLCContainerLauncher.h index 0cde0d926f..e8acac7fc2 100644 --- a/src/windows/common/WSLCContainerLauncher.h +++ b/src/windows/common/WSLCContainerLauncher.h @@ -77,6 +77,11 @@ class WSLCContainerLauncher : private WSLCProcessLauncher void SetDefaultStopSignal(WSLCSignal Signal); void SetStopTimeout(LONG Timeout); void SetShmSize(int64_t ShmSize); + void SetHealthCmd(std::string&& HealthCmd); + void SetHealthInterval(int64_t Nanoseconds); + void SetHealthTimeout(int64_t Nanoseconds); + void SetHealthStartPeriod(int64_t Nanoseconds); + void SetHealthRetries(LONG Retries); void SetContainerFlags(WSLCContainerFlags Flags); void SetHostname(std::string&& Hostname); void SetDomainname(std::string&& Domainame); @@ -106,6 +111,11 @@ class WSLCContainerLauncher : private WSLCProcessLauncher WSLCSignal m_stopSignal = WSLCSignalNone; std::optional m_stopTimeout; int64_t m_shmSize = 0; + std::optional m_healthCmd; + std::optional m_healthInterval; + std::optional m_healthTimeout; + std::optional m_healthStartPeriod; + std::optional m_healthRetries; WSLCContainerFlags m_containerFlags = WSLCContainerFlagsNone; std::string m_hostname; std::string m_domainname; diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index 0f50a5e771..48d5071aba 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -282,6 +282,18 @@ struct NetworkSettings NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(NetworkSettings, Networks); }; +struct HealthConfig +{ + // ["NONE"] disables it, and ["CMD-SHELL", ] runs through the shell. + std::optional> Test; + std::optional Interval; + std::optional Timeout; + std::optional StartPeriod; + std::optional Retries; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(HealthConfig, Test, Interval, Timeout, StartPeriod, Retries); +}; + struct CreateContainer { using TResponse = CreatedContainer; @@ -304,11 +316,31 @@ struct CreateContainer std::vector Env; std::map ExposedPorts; std::map Labels; + std::optional Healthcheck; HostConfig HostConfig; NetworkingConfig NetworkingConfig; NLOHMANN_DEFINE_TYPE_INTRUSIVE_ONLY_SERIALIZE( - CreateContainer, Image, Cmd, Tty, OpenStdin, StdinOnce, Entrypoint, Env, ExposedPorts, HostConfig, StopSignal, StopTimeout, WorkingDir, User, Hostname, Domainname, Labels, NetworkingConfig); + CreateContainer, Image, Cmd, Tty, OpenStdin, StdinOnce, Entrypoint, Env, ExposedPorts, HostConfig, StopSignal, StopTimeout, WorkingDir, User, Hostname, Domainname, Labels, Healthcheck, NetworkingConfig); +}; + +struct HealthcheckResult +{ + std::string Start; + std::string End; + int ExitCode{}; + std::string Output; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(HealthcheckResult, Start, End, ExitCode, Output); +}; + +struct Health +{ + std::string Status; + int FailingStreak{}; + std::vector Log; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(Health, Status, FailingStreak, Log); }; struct ContainerInspectState @@ -318,8 +350,9 @@ struct ContainerInspectState int ExitCode{}; std::string StartedAt; std::string FinishedAt; + std::optional Health; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInspectState, Status, Running, ExitCode, StartedAt, FinishedAt); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInspectState, Status, Running, ExitCode, StartedAt, FinishedAt, Health); }; struct ContainerConfig @@ -332,8 +365,9 @@ struct ContainerConfig std::optional> Entrypoint; std::optional StopSignal; std::optional StopTimeout; + std::optional Healthcheck; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerConfig, Image, User, WorkingDir, Env, Cmd, Entrypoint, StopSignal, StopTimeout); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerConfig, Image, User, WorkingDir, Env, Cmd, Entrypoint, StopSignal, StopTimeout, Healthcheck); }; struct InspectMount diff --git a/src/windows/inc/wslc_schema.h b/src/windows/inc/wslc_schema.h index a936a33557..f5d7d76c7c 100644 --- a/src/windows/inc/wslc_schema.h +++ b/src/windows/inc/wslc_schema.h @@ -39,6 +39,25 @@ struct InspectMount NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(InspectMount, Type, Source, Destination, ReadWrite); }; +struct HealthcheckResult +{ + std::string Start; + std::string End; + int ExitCode{}; + std::string Output; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(HealthcheckResult, Start, End, ExitCode, Output); +}; + +struct Health +{ + std::string Status; + int FailingStreak{}; + std::vector Log; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(Health, Status, FailingStreak, Log); +}; + struct ContainerInspectState { std::string Status; @@ -46,8 +65,9 @@ struct ContainerInspectState int ExitCode{}; std::string StartedAt; std::string FinishedAt; + std::optional Health; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInspectState, Status, Running, ExitCode, StartedAt, FinishedAt); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInspectState, Status, Running, ExitCode, StartedAt, FinishedAt, Health); }; struct Ulimit @@ -69,6 +89,17 @@ struct InspectHostConfig NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(InspectHostConfig, NetworkMode, Memory, NanoCpus, Ulimits); }; +struct HealthConfig +{ + std::optional> Test; + std::optional Interval; + std::optional Timeout; + std::optional StartPeriod; + std::optional Retries; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(HealthConfig, Test, Interval, Timeout, StartPeriod, Retries); +}; + struct ContainerConfig { std::optional> Env; @@ -77,8 +108,9 @@ struct ContainerConfig std::string User; std::string WorkingDir; std::optional StopTimeout; + std::optional Healthcheck; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerConfig, Env, Cmd, Entrypoint, User, WorkingDir, StopTimeout); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerConfig, Env, Cmd, Entrypoint, User, WorkingDir, StopTimeout, Healthcheck); }; struct InspectEndpointSettings diff --git a/src/windows/service/inc/WSLCShared.idl b/src/windows/service/inc/WSLCShared.idl index 39e22eb76e..5ce6905f61 100644 --- a/src/windows/service/inc/WSLCShared.idl +++ b/src/windows/service/inc/WSLCShared.idl @@ -99,9 +99,10 @@ typedef enum _WSLCContainerFlags WSLCContainerFlagsInit = 4, // Run the container under an init process. WSLCContainerFlagsPublishAll = 8, // Publish all exposed ports. WSLCContainerFlagsStopTimeout = 16, // The StopTimeout field is set and should be honored (otherwise StopTimeout is ignored). + WSLCContainerFlagsHealthCheck = 32, // The Health* fields are set and should be honored (otherwise they are ignored). } WSLCContainerFlags; -cpp_quote("#define WSLCContainerFlagsValid (WSLCContainerFlagsRm | WSLCContainerFlagsGpu | WSLCContainerFlagsInit | WSLCContainerFlagsPublishAll | WSLCContainerFlagsStopTimeout)") +cpp_quote("#define WSLCContainerFlagsValid (WSLCContainerFlagsRm | WSLCContainerFlagsGpu | WSLCContainerFlagsInit | WSLCContainerFlagsPublishAll | WSLCContainerFlagsStopTimeout | WSLCContainerFlagsHealthCheck)") cpp_quote("DEFINE_ENUM_FLAG_OPERATORS(WSLCContainerFlags);") diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 9fc0fa4e53..491de533a4 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -268,6 +268,12 @@ typedef struct _WSLCContainerOptions // Ignored unless WSLCContainerFlagsStopTimeout is set in Flags. LONG StopTimeout; + + [unique] LPCSTR HealthCmd; + LONGLONG HealthIntervalNs; + LONGLONG HealthTimeoutNs; + LONGLONG HealthStartPeriodNs; + LONG HealthRetries; } WSLCContainerOptions; typedef char WSLCContainerId[WSLC_CONTAINER_ID_LENGTH + 1] ; diff --git a/src/windows/wslc/arguments/ArgumentDefinitions.h b/src/windows/wslc/arguments/ArgumentDefinitions.h index c43bb3c915..642433e3e3 100644 --- a/src/windows/wslc/arguments/ArgumentDefinitions.h +++ b/src/windows/wslc/arguments/ArgumentDefinitions.h @@ -63,6 +63,11 @@ _(Format, "format", NO_ALIAS, Kind::Value, L _(ForwardArgs, "arguments", NO_ALIAS, Kind::Forward, Localization::WSLCCLI_ForwardArgsDescription()) \ _(Gpus, "gpus", NO_ALIAS, Kind::Value, Localization::WSLCCLI_GpusArgDescription()) \ /*_(GroupId, "groupid", NO_ALIAS, Kind::Value, Localization::WSLCCLI_GroupIdArgDescription())*/ \ +_(HealthCmd, "health-cmd", NO_ALIAS, Kind::Value, Localization::WSLCCLI_HealthCmdArgDescription()) \ +_(HealthInterval, "health-interval", NO_ALIAS, Kind::Value, Localization::WSLCCLI_HealthIntervalArgDescription()) \ +_(HealthRetries, "health-retries", NO_ALIAS, Kind::Value, Localization::WSLCCLI_HealthRetriesArgDescription()) \ +_(HealthStartPeriod, "health-start-period", NO_ALIAS, Kind::Value, Localization::WSLCCLI_HealthStartPeriodArgDescription()) \ +_(HealthTimeout, "health-timeout", NO_ALIAS, Kind::Value, Localization::WSLCCLI_HealthTimeoutArgDescription()) \ _(Help, "help", WSLC_CLI_HELP_ARG, Kind::Flag, Localization::WSLCCLI_HelpArgDescription()) \ _(Hostname, "hostname", L"h", Kind::Value, Localization::WSLCCLI_HostnameArgDescription()) \ _(ImageForce, "force", L"f", Kind::Flag, Localization::WSLCCLI_ImageForceArgDescription()) \ diff --git a/src/windows/wslc/arguments/ArgumentValidation.cpp b/src/windows/wslc/arguments/ArgumentValidation.cpp index a2e949e741..3dacce8da2 100644 --- a/src/windows/wslc/arguments/ArgumentValidation.cpp +++ b/src/windows/wslc/arguments/ArgumentValidation.cpp @@ -19,8 +19,10 @@ Module Name: #include "ContainerModel.h" #include "Exceptions.h" #include "Localization.h" +#include #include #include +#include #include #include #include @@ -56,6 +58,23 @@ void Argument::Validate(const ArgMap& execArgs) const validation::ValidateMemorySize(execArgs.GetAll(), m_name); break; + case ArgType::HealthInterval: + validation::ValidateDuration(execArgs.GetAll(), m_name); + break; + + case ArgType::HealthTimeout: + validation::ValidateDuration(execArgs.GetAll(), m_name); + break; + + case ArgType::HealthStartPeriod: + validation::ValidateDuration(execArgs.GetAll(), m_name); + break; + + case ArgType::HealthRetries: + validation::ValidateIntegerFromString( + execArgs.GetAll(), m_name, [](int value) { return value >= 0; }); + break; + case ArgType::Memory: validation::ValidateMemorySize(execArgs.GetAll(), m_name); break; @@ -417,6 +436,147 @@ int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& a return static_cast(parsed.value()); } +// Parses a Go-style duration string (as used by Docker) into nanoseconds. The input is a possibly +// signed sequence of decimal numbers, each with an optional fraction and a required unit suffix. +// Valid units are "ns", "us"/"µs", "ms", "s", "m", "h". Returns std::nullopt on any parse error. +static std::optional TryParseGoDuration(const std::string& input) +{ + if (input.empty()) + { + return std::nullopt; + } + + size_t pos = 0; + bool negative = false; + if (input[pos] == '+' || input[pos] == '-') + { + negative = input[pos] == '-'; + pos++; + } + + // Special case: a bare "0" (with optional sign) is a valid zero duration. + if (input.substr(pos) == "0") + { + return 0; + } + + // Accumulate in a long double so fractional units (e.g. "1.5h") are handled, then round. + long double totalNanos = 0.0L; + bool sawValue = false; + + while (pos < input.size()) + { + // Parse the numeric part (integer and/or fraction). + const size_t numberStart = pos; + while (pos < input.size() && (std::isdigit(static_cast(input[pos])) || input[pos] == '.')) + { + pos++; + } + + const std::string numberStr = input.substr(numberStart, pos - numberStart); + if (numberStr.empty() || numberStr == "." || std::count(numberStr.begin(), numberStr.end(), '.') > 1) + { + return std::nullopt; + } + + // Parse the unit (everything up to the next digit or '.'). + const size_t unitStart = pos; + while (pos < input.size() && !std::isdigit(static_cast(input[pos])) && input[pos] != '.') + { + pos++; + } + + const std::string unit = input.substr(unitStart, pos - unitStart); + + long double multiplier{}; + if (unit == "ns") + { + multiplier = 1.0L; + } + else if (unit == "us" || unit == "\xC2\xB5s" /* µs (U+00B5) */ || unit == "\xCE\xBCs" /* μs (U+03BC) */) + { + multiplier = 1e3L; + } + else if (unit == "ms") + { + multiplier = 1e6L; + } + else if (unit == "s") + { + multiplier = 1e9L; + } + else if (unit == "m") + { + multiplier = 60e9L; + } + else if (unit == "h") + { + multiplier = 3600e9L; + } + else + { + return std::nullopt; + } + + long double value{}; + try + { + size_t consumed = 0; + value = std::stold(numberStr, &consumed); + if (consumed != numberStr.size()) + { + return std::nullopt; + } + } + catch (...) + { + return std::nullopt; + } + + totalNanos += value * multiplier; + sawValue = true; + } + + if (!sawValue) + { + return std::nullopt; + } + + if (negative) + { + totalNanos = -totalNanos; + } + + if (totalNanos > static_cast(std::numeric_limits::max()) || + totalNanos < static_cast(std::numeric_limits::min())) + { + return std::nullopt; + } + + return static_cast(std::llroundl(totalNanos)); +} + +void ValidateDuration(const std::vector& values, const std::wstring& argName) +{ + for (const auto& value : values) + { + std::ignore = GetDurationNanosFromString(value, argName); + } +} + +int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName) +{ + const std::string narrow = WideToMultiByte(input); + const auto parsed = TryParseGoDuration(narrow); + + if (!parsed.has_value() || parsed.value() < 0) + { + throw ArgumentException(Localization::WSLCCLI_InvalidDurationError(argName, input)); + } + + return parsed.value(); +} + void ValidateNanoCpus(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 83e0f4eb46..b25a807e03 100644 --- a/src/windows/wslc/arguments/ArgumentValidation.h +++ b/src/windows/wslc/arguments/ArgumentValidation.h @@ -66,6 +66,9 @@ WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring void ValidateMemorySize(const std::vector& values, const std::wstring& argName); int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName = {}); +void ValidateDuration(const std::vector& values, const std::wstring& argName); +int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName = {}); + void ValidateTimestamp(const std::vector& values, const std::wstring& argName); ULONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName = {}); void ValidateNanoCpus(const std::vector& values, const std::wstring& argName); diff --git a/src/windows/wslc/commands/ContainerCreateCommand.cpp b/src/windows/wslc/commands/ContainerCreateCommand.cpp index c21d0ecec7..44db123256 100644 --- a/src/windows/wslc/commands/ContainerCreateCommand.cpp +++ b/src/windows/wslc/commands/ContainerCreateCommand.cpp @@ -43,6 +43,11 @@ std::vector ContainerCreateCommand::GetArguments() const Argument::Create(ArgType::EnvFile, false, NO_LIMIT), // Argument::Create(ArgType::GroupId), Argument::Create(ArgType::Gpus), + Argument::Create(ArgType::HealthCmd), + Argument::Create(ArgType::HealthInterval), + Argument::Create(ArgType::HealthRetries), + Argument::Create(ArgType::HealthStartPeriod), + Argument::Create(ArgType::HealthTimeout), Argument::Create(ArgType::Hostname), Argument::Create(ArgType::Interactive), Argument::Create(ArgType::Label, false, NO_LIMIT), diff --git a/src/windows/wslc/commands/ContainerRunCommand.cpp b/src/windows/wslc/commands/ContainerRunCommand.cpp index 990b5dd8d4..46b398e870 100644 --- a/src/windows/wslc/commands/ContainerRunCommand.cpp +++ b/src/windows/wslc/commands/ContainerRunCommand.cpp @@ -43,6 +43,11 @@ std::vector ContainerRunCommand::GetArguments() const Argument::Create(ArgType::Env, false, NO_LIMIT), Argument::Create(ArgType::EnvFile, false, NO_LIMIT), Argument::Create(ArgType::Gpus), + Argument::Create(ArgType::HealthCmd), + Argument::Create(ArgType::HealthInterval), + Argument::Create(ArgType::HealthRetries), + Argument::Create(ArgType::HealthStartPeriod), + Argument::Create(ArgType::HealthTimeout), Argument::Create(ArgType::Hostname), Argument::Create(ArgType::Interactive), Argument::Create(ArgType::Label, false, NO_LIMIT), diff --git a/src/windows/wslc/services/ContainerModel.h b/src/windows/wslc/services/ContainerModel.h index eb7bd2f1a9..30bc5ed1fc 100644 --- a/src/windows/wslc/services/ContainerModel.h +++ b/src/windows/wslc/services/ContainerModel.h @@ -40,6 +40,11 @@ struct ContainerOptions WSLCSignal StopSignal = WSLCSignalNone; std::optional StopTimeout{}; std::optional ShmSize{}; + std::optional HealthCmd{}; + std::optional HealthInterval{}; // nanoseconds + std::optional HealthTimeout{}; // nanoseconds + std::optional HealthStartPeriod{}; // nanoseconds + std::optional HealthRetries{}; bool Gpu = false; std::vector Ports; std::vector Volumes; diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index 9a85ebf988..95be0877d5 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -146,6 +146,31 @@ static wsl::windows::common::RunningWSLCContainer CreateInternal( containerLauncher.SetShmSize(options.ShmSize.value()); } + if (options.HealthCmd.has_value()) + { + containerLauncher.SetHealthCmd(std::string(options.HealthCmd.value())); + } + + if (options.HealthInterval.has_value()) + { + containerLauncher.SetHealthInterval(options.HealthInterval.value()); + } + + if (options.HealthTimeout.has_value()) + { + containerLauncher.SetHealthTimeout(options.HealthTimeout.value()); + } + + if (options.HealthStartPeriod.has_value()) + { + containerLauncher.SetHealthStartPeriod(options.HealthStartPeriod.value()); + } + + if (options.HealthRetries.has_value()) + { + containerLauncher.SetHealthRetries(options.HealthRetries.value()); + } + if (options.MemoryBytes.has_value()) { containerLauncher.SetMemoryLimit(options.MemoryBytes.value()); diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index ad7d112d87..aa20982f50 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -453,6 +453,31 @@ void SetContainerOptionsFromArgs(CLIExecutionContext& context) options.ShmSize = validation::GetMemorySizeFromString(context.Args.Get()); } + if (context.Args.Contains(ArgType::HealthCmd)) + { + options.HealthCmd = WideToMultiByte(context.Args.Get()); + } + + if (context.Args.Contains(ArgType::HealthInterval)) + { + options.HealthInterval = validation::GetDurationNanosFromString(context.Args.Get()); + } + + if (context.Args.Contains(ArgType::HealthTimeout)) + { + options.HealthTimeout = validation::GetDurationNanosFromString(context.Args.Get()); + } + + if (context.Args.Contains(ArgType::HealthStartPeriod)) + { + options.HealthStartPeriod = validation::GetDurationNanosFromString(context.Args.Get()); + } + + if (context.Args.Contains(ArgType::HealthRetries)) + { + options.HealthRetries = validation::GetIntegerFromString(context.Args.Get()); + } + if (context.Args.Contains(ArgType::Memory)) { options.MemoryBytes = validation::GetMemorySizeFromString(context.Args.Get()); diff --git a/src/windows/wslcsession/WSLCContainer.cpp b/src/windows/wslcsession/WSLCContainer.cpp index 674b0b0cd9..006cb5199b 100644 --- a/src/windows/wslcsession/WSLCContainer.cpp +++ b/src/windows/wslcsession/WSLCContainer.cpp @@ -1297,6 +1297,24 @@ WslcInspectContainer WSLCContainerImpl::BuildInspectContainer(const DockerInspec wslcInspect.State.StartedAt = dockerInspect.State.StartedAt; wslcInspect.State.FinishedAt = dockerInspect.State.FinishedAt; + // Map the runtime health status, which Docker only reports when a health check is configured. + if (dockerInspect.State.Health.has_value()) + { + const auto& dockerHealth = dockerInspect.State.Health.value(); + + wslc_schema::Health health{}; + health.Status = dockerHealth.Status; + health.FailingStreak = dockerHealth.FailingStreak; + + health.Log.reserve(dockerHealth.Log.size()); + for (const auto& entry : dockerHealth.Log) + { + health.Log.push_back({entry.Start, entry.End, entry.ExitCode, entry.Output}); + } + + wslcInspect.State.Health = std::move(health); + } + wslcInspect.HostConfig.NetworkMode = dockerInspect.HostConfig.NetworkMode; wslcInspect.HostConfig.Memory = dockerInspect.HostConfig.Memory; wslcInspect.HostConfig.NanoCpus = dockerInspect.HostConfig.NanoCpus; @@ -1317,6 +1335,20 @@ WslcInspectContainer WSLCContainerImpl::BuildInspectContainer(const DockerInspec wslcInspect.Config.WorkingDir = dockerInspect.Config.WorkingDir; wslcInspect.Config.StopTimeout = dockerInspect.Config.StopTimeout; + if (dockerInspect.Config.Healthcheck.has_value()) + { + const auto& dockerHealth = dockerInspect.Config.Healthcheck.value(); + + wslc_schema::HealthConfig health{}; + health.Test = dockerHealth.Test; + health.Interval = dockerHealth.Interval; + health.Timeout = dockerHealth.Timeout; + health.StartPeriod = dockerHealth.StartPeriod; + health.Retries = dockerHealth.Retries; + + wslcInspect.Config.Healthcheck = std::move(health); + } + // Map WSLC port mappings (Windows host ports only). for (const auto& e : m_mappedPorts) { @@ -1515,6 +1547,42 @@ std::unique_ptr WSLCContainerImpl::Create( request.HostConfig.ShmSize = containerOptions.ShmSize; + if (WI_IsFlagSet(containerOptions.Flags, WSLCContainerFlagsHealthCheck)) + { + common::docker_schema::HealthConfig health{}; + + // A custom command maps to Docker's ["CMD-SHELL", ]. When no command is provided the + // Test field is left unset so the image's health check command (if any) is preserved. + if (containerOptions.HealthCmd != nullptr) + { + health.Test = std::vector{"CMD-SHELL", containerOptions.HealthCmd}; + } + + // Durations are already in nanoseconds; 0 means "use the daemon default", so only forward + // explicit non-zero values. + if (containerOptions.HealthIntervalNs != 0) + { + health.Interval = containerOptions.HealthIntervalNs; + } + + if (containerOptions.HealthTimeoutNs != 0) + { + health.Timeout = containerOptions.HealthTimeoutNs; + } + + if (containerOptions.HealthStartPeriodNs != 0) + { + health.StartPeriod = containerOptions.HealthStartPeriodNs; + } + + if (containerOptions.HealthRetries != 0) + { + health.Retries = containerOptions.HealthRetries; + } + + request.Healthcheck = std::move(health); + } + if (containerOptions.VolumesCount > 0) { THROW_HR_IF_NULL_MSG(E_INVALIDARG, containerOptions.Volumes, "Volumes is null with VolumesCount=%lu", containerOptions.VolumesCount); diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 04b07f9fa1..f99e97837e 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -1578,6 +1578,100 @@ class WSLCTests } } + WSLC_TEST_METHOD(BuildImageHealthCheck) + { + auto contextDir = std::filesystem::current_path() / "build-context-healthcheck"; + std::filesystem::create_directories(contextDir); + auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { + LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-healthcheck:latest", WSLCDeleteImageFlagsForce).first); + + std::filesystem::remove_all(contextDir); + }); + + // Create an image with a healthcheck that only passes once a specific file exists. + constexpr auto c_healthReadyFile = "/tmp/wslc-health-ready"; + + { + std::ofstream dockerfile(contextDir / "Dockerfile"); + dockerfile << "FROM debian:latest\n"; + dockerfile << "HEALTHCHECK --interval=1s --timeout=100ms --start-period=300s --retries=1000 CMD test -f " << c_healthReadyFile << "\n"; + dockerfile << "CMD [\"sleep\", \"99999\"]\n"; + } + + VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-healthcheck:latest")); + ExpectImagePresent(*m_defaultSession, "wslc-test-healthcheck:latest"); + + auto waitForHealthStatus = [](auto& container, const std::string& expectedStatus, std::chrono::seconds timeout) { + wsl::shared::retry::RetryWithTimeout( + [&]() { + const auto inspect = container.Inspect(); + THROW_HR_IF_MSG(E_FAIL, !inspect.State.Health.has_value(), "container does not report a health status yet"); + THROW_HR_IF_MSG( + E_FAIL, + inspect.State.Health->Status != expectedStatus, + "health status is '%hs', expected '%hs'", + inspect.State.Health->Status.c_str(), + expectedStatus.c_str()); + }, + std::chrono::milliseconds{100}, + timeout); + }; + + // Validate that the image's default health check is inherited by a started container, and that its runtime + // status stays "starting" until the health command passes, then deterministically becomes "healthy". + { + WSLCContainerLauncher launcher("wslc-test-healthcheck:latest", "wslc-healthcheck-test-default"); + auto container = launcher.Launch(*m_defaultSession); + + auto inspect = container.Inspect(); + VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value()); + + const auto& health = inspect.Config.Healthcheck.value(); + VERIFY_IS_TRUE(health.Test.has_value()); + const std::vector expectedTest{"CMD-SHELL", std::string("test -f ") + c_healthReadyFile}; + VERIFY_ARE_EQUAL(expectedTest, health.Test.value()); + VERIFY_ARE_EQUAL(1'000'000'000LL, health.Interval.value_or(0)); + VERIFY_ARE_EQUAL(100'000'000LL, health.Timeout.value_or(0)); + VERIFY_ARE_EQUAL(300'000'000'000LL, health.StartPeriod.value_or(0)); + + // The health command fails while the file is absent, so the container stays "starting". + waitForHealthStatus(container, "starting", 60s); + + auto touchProcess = WSLCProcessLauncher({}, {"/usr/bin/touch", c_healthReadyFile}).Launch(container.Get()); + ValidateProcessOutput(touchProcess, {}, 0); + + waitForHealthStatus(container, "healthy", 60s); + } + + // Validate that the image's default health check can be overridden, and that a failing (exit 1) check drives + // the runtime status to "unhealthy". + { + WSLCContainerLauncher launcher("wslc-test-healthcheck:latest", "wslc-healthcheck-test-override"); + launcher.SetHealthCmd("exit 1"); + launcher.SetHealthInterval(1'000'000'000LL); // 1s + launcher.SetHealthStartPeriod(1'000'000'000LL); // 1s + launcher.SetHealthRetries(1); + auto container = launcher.Launch(*m_defaultSession); + + auto inspect = container.Inspect(); + VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value()); + + const auto& health = inspect.Config.Healthcheck.value(); + VERIFY_IS_TRUE(health.Test.has_value()); + const std::vector expectedTest{"CMD-SHELL", "exit 1"}; + VERIFY_ARE_EQUAL(expectedTest, health.Test.value()); + VERIFY_ARE_EQUAL(1'000'000'000LL, health.Interval.value_or(0)); + // The override must set an explicit start period: otherwise the engine merges the image's healthcheck + // fields for any zero-valued field (see moby daemon merge()), inheriting the image's 300s start period, + // during which failing checks keep the container "starting" instead of transitioning to "unhealthy". + VERIFY_ARE_EQUAL(1'000'000'000LL, health.StartPeriod.value_or(0)); + VERIFY_ARE_EQUAL(1, health.Retries.value_or(0)); + + // Validate that the container transitions to "unhealthy" after the health command fails. + waitForHealthStatus(container, "unhealthy", 60s); + } + } + WSLC_TEST_METHOD(BuildImageWithContext) { auto contextDir = std::filesystem::current_path() / "build-context-file"; @@ -6294,6 +6388,40 @@ class WSLCTests } } + // Validate that health check options are forwarded to the container configuration. + { + WSLCContainerLauncher launcher("debian:latest", "test-container-health", {"sleep", "99999"}); + launcher.SetHealthCmd("exit 0"); + launcher.SetHealthInterval(5'000'000'000LL); // 5s + launcher.SetHealthTimeout(3'000'000'000LL); // 3s + launcher.SetHealthStartPeriod(1'000'000'000LL); // 1s + launcher.SetHealthRetries(2); + + auto container = launcher.Create(*m_defaultSession); + + auto inspect = container.Inspect(); + VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value()); + + const auto& health = inspect.Config.Healthcheck.value(); + VERIFY_IS_TRUE(health.Test.has_value()); + const std::vector expectedTest{"CMD-SHELL", "exit 0"}; + VERIFY_ARE_EQUAL(expectedTest, health.Test.value()); + VERIFY_ARE_EQUAL(5'000'000'000LL, health.Interval.value_or(0)); + VERIFY_ARE_EQUAL(3'000'000'000LL, health.Timeout.value_or(0)); + VERIFY_ARE_EQUAL(1'000'000'000LL, health.StartPeriod.value_or(0)); + VERIFY_ARE_EQUAL(2, health.Retries.value_or(0)); + } + + // Validate that a container without health options reports no health check. + { + WSLCContainerLauncher launcher("debian:latest", "test-container-no-health", {"sleep", "99999"}); + + auto container = launcher.Create(*m_defaultSession); + + auto inspect = container.Inspect(); + VERIFY_IS_FALSE(inspect.Config.Healthcheck.has_value()); + } + // Validate that Kill() works as expected { WSLCContainerLauncher launcher("debian:latest", "test-container-kill", {"sleep", "99999"}, {}); diff --git a/test/windows/wslc/CommandLineTestCases.h b/test/windows/wslc/CommandLineTestCases.h index 6ac8329e4e..7f8cbebeea 100644 --- a/test/windows/wslc/CommandLineTestCases.h +++ b/test/windows/wslc/CommandLineTestCases.h @@ -149,6 +149,27 @@ COMMAND_LINE_TEST_CASE(L"create --gpus all ubuntu", L"create", true) COMMAND_LINE_TEST_CASE(L"container create --gpus all ubuntu sh", L"create", true) COMMAND_LINE_TEST_CASE(L"create --gpus none ubuntu", L"create", false) // Only 'all' is supported COMMAND_LINE_TEST_CASE(L"create --gpus", L"create", false) // Missing value for --gpus +// Health check tests for container run +COMMAND_LINE_TEST_CASE(L"run --health-cmd \"exit 0\" ubuntu", L"run", true) +COMMAND_LINE_TEST_CASE( + L"run --health-interval 30s --health-timeout 5s --health-retries 3 --health-start-period 10s ubuntu", L"run", true) +COMMAND_LINE_TEST_CASE(L"run --health-interval 1m30s ubuntu", L"run", true) +COMMAND_LINE_TEST_CASE(L"run --health-interval notaduration ubuntu", L"run", false) // Invalid duration +COMMAND_LINE_TEST_CASE(L"run --health-timeout -5s ubuntu", L"run", false) // Negative duration +COMMAND_LINE_TEST_CASE(L"run --health-retries abc ubuntu", L"run", false) // Non-numeric retries +COMMAND_LINE_TEST_CASE(L"run --health-retries -1 ubuntu", L"run", false) // Negative retries +COMMAND_LINE_TEST_CASE(L"run --health-interval ubuntu", L"run", false) // Missing value for --health-interval +COMMAND_LINE_TEST_CASE(L"run --health-cmd", L"run", false) // Missing value for --health-cmd +// Health check tests for container create +COMMAND_LINE_TEST_CASE(L"create --health-cmd \"exit 0\" ubuntu", L"create", true) +COMMAND_LINE_TEST_CASE( + L"container create --health-cmd \"curl -f http://localhost/\" --health-interval 30s --health-timeout 5s --health-retries 3 " + L"--health-start-period 10s ubuntu", + L"create", + true) +COMMAND_LINE_TEST_CASE(L"create --health-start-period 500ms ubuntu", L"create", true) +COMMAND_LINE_TEST_CASE(L"create --health-timeout invalid ubuntu", L"create", false) // Invalid duration +COMMAND_LINE_TEST_CASE(L"create --health-retries 2.5 ubuntu", L"create", false) // Non-integer retries COMMAND_LINE_TEST_CASE(L"exec cont1 echo Hello", L"exec", true) COMMAND_LINE_TEST_CASE(L"exec cont1", L"exec", false) // Missing required command argument COMMAND_LINE_TEST_CASE(L"container exec -it cont1 sh -c \"echo a && echo b\"", L"exec", true) // docker exec example diff --git a/test/windows/wslc/WSLCCLIResourceLimitsParserUnitTests.cpp b/test/windows/wslc/WSLCCLIResourceLimitsParserUnitTests.cpp index 0943f81172..8d145ddfad 100644 --- a/test/windows/wslc/WSLCCLIResourceLimitsParserUnitTests.cpp +++ b/test/windows/wslc/WSLCCLIResourceLimitsParserUnitTests.cpp @@ -124,6 +124,56 @@ class WSLCCLIResourceLimitsParserUnitTests VERIFY_NO_THROW(validation::ValidateUlimit({L"nofile=1024", L"core=-1"}, L"ulimit")); VERIFY_THROWS(validation::ValidateUlimit({L"nofile=1024", L"bad"}, L"ulimit"), ArgumentException); } + + TEST_METHOD(Duration_Valid) + { + // (input, expected nanoseconds) + std::vector> valid = { + {L"0", 0LL}, + {L"1ns", 1LL}, + {L"500ms", 500'000'000LL}, + {L"30s", 30'000'000'000LL}, + {L"1m", 60'000'000'000LL}, + {L"1m30s", 90'000'000'000LL}, + {L"1h", 3'600'000'000'000LL}, + {L"1.5h", 5'400'000'000'000LL}, + {L"2h45m", 9'900'000'000'000LL}, + {L"100us", 100'000LL}, + }; + + for (const auto& [input, expected] : valid) + { + const auto actual = validation::GetDurationNanosFromString(input, L"health-interval"); + VERIFY_ARE_EQUAL(expected, actual); + } + } + + TEST_METHOD(Duration_Invalid) + { + // Each value should be rejected as an invalid duration. + const std::vector invalid = { + L"", + L"abc", + L"30", // missing unit + L"30sec", // unknown unit + L"30x", // unknown unit + L"-30s", // negative durations are rejected + L"30 s", // embedded whitespace + L"s", // missing number + L"1.2.3s" // multiple dots + }; + + for (const auto& input : invalid) + { + VERIFY_THROWS(validation::GetDurationNanosFromString(input, L"health-interval"), ArgumentException); + } + } + + TEST_METHOD(Duration_Validator) + { + VERIFY_NO_THROW(validation::ValidateDuration({L"30s", L"1m30s", L"0"}, L"health-interval")); + VERIFY_THROWS(validation::ValidateDuration({L"30s", L"bad"}, L"health-interval"), ArgumentException); + } }; } // namespace WSLCCLIResourceLimitsParserUnitTests diff --git a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp index 33e6c89abf..b138f60f07 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp @@ -821,6 +821,83 @@ class WSLCE2EContainerCreateTests } } + WSLC_TEST_METHOD(WSLCE2E_Container_Create_HealthCheck) + { + // All health-check options are forwarded to the container configuration. + { + auto result = RunWslc(std::format( + LR"(container create --health-cmd "exit 0" --health-interval 5s --health-timeout 3s --health-retries 2 --health-start-period 1s --name {} {})", + WslcContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value()); + + const auto& health = inspect.Config.Healthcheck.value(); + VERIFY_IS_TRUE(health.Test.has_value()); + const std::vector expectedTest{"CMD-SHELL", "exit 0"}; + VERIFY_ARE_EQUAL(expectedTest, health.Test.value()); + + // Durations are reported in nanoseconds. + VERIFY_IS_TRUE(health.Interval.has_value()); + VERIFY_ARE_EQUAL(5'000'000'000LL, health.Interval.value()); + VERIFY_IS_TRUE(health.Timeout.has_value()); + VERIFY_ARE_EQUAL(3'000'000'000LL, health.Timeout.value()); + VERIFY_IS_TRUE(health.StartPeriod.has_value()); + VERIFY_ARE_EQUAL(1'000'000'000LL, health.StartPeriod.value()); + VERIFY_IS_TRUE(health.Retries.has_value()); + VERIFY_ARE_EQUAL(2, health.Retries.value()); + + EnsureContainerDoesNotExist(WslcContainerName); + } + + // Only --health-cmd: the command is forwarded, other fields fall back to the daemon default. + { + auto result = RunWslc( + std::format(LR"(container create --health-cmd "exit 1" --name {} {})", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value()); + + const auto& health = inspect.Config.Healthcheck.value(); + VERIFY_IS_TRUE(health.Test.has_value()); + const std::vector expectedTest{"CMD-SHELL", "exit 1"}; + VERIFY_ARE_EQUAL(expectedTest, health.Test.value()); + + EnsureContainerDoesNotExist(WslcContainerName); + } + + // When no health option is specified, no health check is forwarded. + { + auto result = RunWslc(std::format(L"container create --name {} {}", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + VERIFY_IS_FALSE(inspect.Config.Healthcheck.has_value()); + EnsureContainerDoesNotExist(WslcContainerName); + } + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_HealthCheck_Invalid) + { + { + auto result = RunWslc(std::format( + L"container create --health-interval notaduration --name {} {}", WslcContainerName, DebianImage.NameAndTag())); + result.Verify( + {.Stderr = L"Invalid health-interval argument value: 'notaduration'. Expected a duration (e.g. 30s, 1m30s)\r\n", .ExitCode = 1}); + VerifyContainerIsNotListed(WslcContainerName); + } + + { + auto result = + RunWslc(std::format(L"container create --health-retries abc --name {} {}", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"Invalid health-retries argument value: abc\r\n", .ExitCode = 1}); + VerifyContainerIsNotListed(WslcContainerName); + } + } + WSLC_TEST_METHOD(WSLCE2E_Container_Create_StopSignal_Invalid) { { @@ -1269,38 +1346,45 @@ class WSLCE2EContainerCreateTests std::wstring GetAvailableOptions() const { std::wstringstream options; - options << L"The following options are available:\r\n" // - << L" --cidfile Write the container ID to the provided path\r\n" - << L" --cpus Number of CPUs (e.g. 0.5, 1, 2.5)\r\n" - << L" --dns IP address of the DNS nameserver in resolv.conf\r\n" - << L" --dns-option Set DNS options\r\n" - << L" --dns-search Set DNS search domains\r\n" - << L" --domainname Container domain name\r\n" - << L" --entrypoint Specifies the container init process executable\r\n" - << L" -e,--env Key=Value pairs for environment variables\r\n" - << L" --env-file File containing key=value pairs of env variables\r\n" - << L" --gpus Add GPU devices to the container ('all' to pass all GPUs)\r\n" - << L" -h,--hostname Container host name\r\n" - << L" -i,--interactive Attach to stdin and keep it open\r\n" - << L" -l,--label Set metadata on an object\r\n" - << L" -m,--memory Memory limit (e.g. 512M, 1G)\r\n" - << L" --name Name of the container\r\n" - << L" --network Connect a container to a network\r\n" - << L" --network-alias Add a network-scoped alias for the container\r\n" - << L" -p,--publish Publish a port from a container to host\r\n" - << L" -P,--publish-all Publish all exposed ports to random host ports\r\n" - << L" --rm Remove the container after it stops\r\n" - << L" --shm-size Size of /dev/shm (e.g. 64M, 1G)\r\n" - << L" --stop-signal Signal to stop the container\r\n" - << L" --stop-timeout Timeout (in seconds) to stop the container before killing it (-1 for no timeout)\r\n" - << L" --tmpfs Mount tmpfs to the container at the given path\r\n" - << L" -t,--tty Open a TTY with the container process.\r\n" - << L" --ulimit Ulimit options (format: =[:], use -1 for unlimited)\r\n" - << L" -u,--user User ID for the process (name|uid|uid:gid)\r\n" - << L" -v,--volume Bind mount a volume to the container\r\n" - << L" -w,--workdir Working directory inside the container\r\n" - << L" -?,--help Shows help about the selected command\r\n" - << L"\r\n"; + options + << L"The following options are available:\r\n" // + << L" --cidfile Write the container ID to the provided path\r\n" + << L" --cpus Number of CPUs (e.g. 0.5, 1, 2.5)\r\n" + << L" --dns IP address of the DNS nameserver in resolv.conf\r\n" + << L" --dns-option Set DNS options\r\n" + << L" --dns-search Set DNS search domains\r\n" + << L" --domainname Container domain name\r\n" + << L" --entrypoint Specifies the container init process executable\r\n" + << L" -e,--env Key=Value pairs for environment variables\r\n" + << L" --env-file File containing key=value pairs of env variables\r\n" + << L" --gpus Add GPU devices to the container ('all' to pass all GPUs)\r\n" + << L" --health-cmd Command to run to check container health\r\n" + << L" --health-interval Time between running the health check (e.g. 30s, 1m30s)\r\n" + << L" --health-retries Consecutive failures needed to report the container as unhealthy\r\n" + << L" --health-start-period Start period for the container to initialize before health-check countdown (e.g. 30s, " + L"1m30s)\r\n" + << L" --health-timeout Maximum time to allow one health check to run (e.g. 30s, 1m30s)\r\n" + << L" -h,--hostname Container host name\r\n" + << L" -i,--interactive Attach to stdin and keep it open\r\n" + << L" -l,--label Set metadata on an object\r\n" + << L" -m,--memory Memory limit (e.g. 512M, 1G)\r\n" + << L" --name Name of the container\r\n" + << L" --network Connect a container to a network\r\n" + << L" --network-alias Add a network-scoped alias for the container\r\n" + << L" -p,--publish Publish a port from a container to host\r\n" + << L" -P,--publish-all Publish all exposed ports to random host ports\r\n" + << L" --rm Remove the container after it stops\r\n" + << L" --shm-size Size of /dev/shm (e.g. 64M, 1G)\r\n" + << L" --stop-signal Signal to stop the container\r\n" + << L" --stop-timeout Timeout (in seconds) to stop the container before killing it (-1 for no timeout)\r\n" + << L" --tmpfs Mount tmpfs to the container at the given path\r\n" + << L" -t,--tty Open a TTY with the container process.\r\n" + << L" --ulimit Ulimit options (format: =[:], use -1 for unlimited)\r\n" + << L" -u,--user User ID for the process (name|uid|uid:gid)\r\n" + << L" -v,--volume Bind mount a volume to the container\r\n" + << L" -w,--workdir Working directory inside the container\r\n" + << L" -?,--help Shows help about the selected command\r\n" + << L"\r\n"; return options.str(); } }; diff --git a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp index 1f74a95e61..81c6a319e0 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp @@ -1116,6 +1116,104 @@ class WSLCE2EContainerRunTests } } + WSLC_TEST_METHOD(WSLCE2E_Container_Run_HealthCheck) + { + // All health-check options are forwarded to the container configuration. + { + auto result = RunWslc(std::format( + LR"(container run -d --health-cmd "exit 0" --health-interval 5s --health-timeout 3s --health-retries 2 --health-start-period 1s --name {} {} sleep infinity)", + WslcContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value()); + + const auto& health = inspect.Config.Healthcheck.value(); + VERIFY_IS_TRUE(health.Test.has_value()); + const std::vector expectedTest{"CMD-SHELL", "exit 0"}; + VERIFY_ARE_EQUAL(expectedTest, health.Test.value()); + + // Durations are reported in nanoseconds. + VERIFY_ARE_EQUAL(5'000'000'000LL, health.Interval.value_or(0)); + VERIFY_ARE_EQUAL(3'000'000'000LL, health.Timeout.value_or(0)); + VERIFY_ARE_EQUAL(1'000'000'000LL, health.StartPeriod.value_or(0)); + VERIFY_ARE_EQUAL(2, health.Retries.value_or(0)); + EnsureContainerDoesNotExist(WslcContainerName); + } + + // When no health option is specified, no health check is forwarded. + { + auto result = + RunWslc(std::format(L"container run -d --name {} {} sleep infinity", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + VERIFY_IS_FALSE(inspect.Config.Healthcheck.has_value()); + EnsureContainerDoesNotExist(WslcContainerName); + } + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Run_HealthCheck_Invalid) + { + auto result = RunWslc( + std::format(L"container run --rm --health-timeout invalid --name {} {}", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"Invalid health-timeout argument value: 'invalid'. Expected a duration (e.g. 30s, 1m30s)\r\n", .ExitCode = 1}); + EnsureContainerDoesNotExist(WslcContainerName); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Run_HealthStatus_Healthy) + { + // A health check that always succeeds should drive the container to the "healthy" state. + auto result = RunWslc(std::format( + LR"(container run -d --health-cmd "exit 0" --health-interval 1s --health-timeout 3s --health-retries 1 --name {} {} sleep infinity)", + WslcContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto health = WaitForContainerHealth(WslcContainerName, "healthy"); + VERIFY_ARE_EQUAL(0, health.FailingStreak); + VERIFY_IS_FALSE(health.Log.empty()); + VERIFY_ARE_EQUAL(0, health.Log.back().ExitCode); + + EnsureContainerDoesNotExist(WslcContainerName); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Run_HealthStatus_Unhealthy) + { + // A health check that always fails should drive the container to the "unhealthy" state. + auto result = RunWslc(std::format( + LR"(container run -d --health-cmd "exit 1" --health-interval 1s --health-timeout 3s --health-retries 1 --name {} {} sleep infinity)", + WslcContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto health = WaitForContainerHealth(WslcContainerName, "unhealthy"); + VERIFY_IS_TRUE(health.FailingStreak >= 1); + VERIFY_IS_FALSE(health.Log.empty()); + VERIFY_ARE_EQUAL(1, health.Log.back().ExitCode); + + EnsureContainerDoesNotExist(WslcContainerName); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Run_HealthStatus_Timeout) + { + // A health check that hangs past its timeout should be treated as a failure and drive the + // container to the "unhealthy" state. Docker reports a timed-out probe with exit code -1. + auto result = RunWslc(std::format( + LR"(container run -d --health-cmd "sleep 30" --health-interval 1s --health-timeout 1s --health-retries 1 --name {} {} sleep infinity)", + WslcContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto health = WaitForContainerHealth(WslcContainerName, "unhealthy"); + VERIFY_IS_TRUE(health.FailingStreak >= 1); + VERIFY_IS_FALSE(health.Log.empty()); + VERIFY_ARE_EQUAL(-1, health.Log.back().ExitCode); + + EnsureContainerDoesNotExist(WslcContainerName); + } + WSLC_TEST_METHOD(WSLCE2E_Container_Run_Cpus) { auto result = RunWslc(std::format(L"container run --name {} --cpus 1.5 {} true", WslcContainerName, DebianImage.NameAndTag())); @@ -1274,39 +1372,46 @@ class WSLCE2EContainerRunTests std::wstring GetAvailableOptions() const { std::wstringstream options; - options << L"The following options are available:\r\n" - << L" --cidfile Write the container ID to the provided path\r\n" - << L" --cpus Number of CPUs (e.g. 0.5, 1, 2.5)\r\n" - << L" -d,--detach Run container in detached mode\r\n" - << L" --dns IP address of the DNS nameserver in resolv.conf\r\n" - << L" --dns-option Set DNS options\r\n" - << L" --dns-search Set DNS search domains\r\n" - << L" --domainname Container domain name\r\n" - << L" --entrypoint Specifies the container init process executable\r\n" - << L" -e,--env Key=Value pairs for environment variables\r\n" - << L" --env-file File containing key=value pairs of env variables\r\n" - << L" --gpus Add GPU devices to the container ('all' to pass all GPUs)\r\n" - << L" -h,--hostname Container host name\r\n" - << L" -i,--interactive Attach to stdin and keep it open\r\n" - << L" -l,--label Set metadata on an object\r\n" - << L" -m,--memory Memory limit (e.g. 512M, 1G)\r\n" - << L" --name Name of the container\r\n" - << L" --network Connect a container to a network\r\n" - << L" --network-alias Add a network-scoped alias for the container\r\n" - << L" -p,--publish Publish a port from a container to host\r\n" - << L" -P,--publish-all Publish all exposed ports to random host ports\r\n" - << L" --rm Remove the container after it stops\r\n" - << L" --shm-size Size of /dev/shm (e.g. 64M, 1G)\r\n" - << L" --stop-signal Signal to stop the container\r\n" - << L" --stop-timeout Timeout (in seconds) to stop the container before killing it (-1 for no timeout)\r\n" - << L" --tmpfs Mount tmpfs to the container at the given path\r\n" - << L" -t,--tty Open a TTY with the container process.\r\n" - << L" --ulimit Ulimit options (format: =[:], use -1 for unlimited)\r\n" - << L" -u,--user User ID for the process (name|uid|uid:gid)\r\n" - << L" -v,--volume Bind mount a volume to the container\r\n" - << L" -w,--workdir Working directory inside the container\r\n" - << L" -?,--help Shows help about the selected command\r\n" - << L"\r\n"; + options + << L"The following options are available:\r\n" + << L" --cidfile Write the container ID to the provided path\r\n" + << L" --cpus Number of CPUs (e.g. 0.5, 1, 2.5)\r\n" + << L" -d,--detach Run container in detached mode\r\n" + << L" --dns IP address of the DNS nameserver in resolv.conf\r\n" + << L" --dns-option Set DNS options\r\n" + << L" --dns-search Set DNS search domains\r\n" + << L" --domainname Container domain name\r\n" + << L" --entrypoint Specifies the container init process executable\r\n" + << L" -e,--env Key=Value pairs for environment variables\r\n" + << L" --env-file File containing key=value pairs of env variables\r\n" + << L" --gpus Add GPU devices to the container ('all' to pass all GPUs)\r\n" + << L" --health-cmd Command to run to check container health\r\n" + << L" --health-interval Time between running the health check (e.g. 30s, 1m30s)\r\n" + << L" --health-retries Consecutive failures needed to report the container as unhealthy\r\n" + << L" --health-start-period Start period for the container to initialize before health-check countdown (e.g. 30s, " + L"1m30s)\r\n" + << L" --health-timeout Maximum time to allow one health check to run (e.g. 30s, 1m30s)\r\n" + << L" -h,--hostname Container host name\r\n" + << L" -i,--interactive Attach to stdin and keep it open\r\n" + << L" -l,--label Set metadata on an object\r\n" + << L" -m,--memory Memory limit (e.g. 512M, 1G)\r\n" + << L" --name Name of the container\r\n" + << L" --network Connect a container to a network\r\n" + << L" --network-alias Add a network-scoped alias for the container\r\n" + << L" -p,--publish Publish a port from a container to host\r\n" + << L" -P,--publish-all Publish all exposed ports to random host ports\r\n" + << L" --rm Remove the container after it stops\r\n" + << L" --shm-size Size of /dev/shm (e.g. 64M, 1G)\r\n" + << L" --stop-signal Signal to stop the container\r\n" + << L" --stop-timeout Timeout (in seconds) to stop the container before killing it (-1 for no timeout)\r\n" + << L" --tmpfs Mount tmpfs to the container at the given path\r\n" + << L" -t,--tty Open a TTY with the container process.\r\n" + << L" --ulimit Ulimit options (format: =[:], use -1 for unlimited)\r\n" + << L" -u,--user User ID for the process (name|uid|uid:gid)\r\n" + << L" -v,--volume Bind mount a volume to the container\r\n" + << L" -w,--workdir Working directory inside the container\r\n" + << L" -?,--help Shows help about the selected command\r\n" + << L"\r\n"; return options.str(); } }; diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp index 5d0b228d61..d460aaa6fc 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp @@ -288,6 +288,34 @@ wslc_schema::InspectContainer InspectContainer(const std::wstring& containerName return inspectData[0]; } +wslc_schema::Health WaitForContainerHealth(const std::wstring& containerName, const std::string_view& expectedStatus, std::chrono::milliseconds timeout) +{ + try + { + return wsl::shared::retry::RetryWithTimeout( + [&]() { + const auto inspect = InspectContainer(containerName); + THROW_HR_IF(E_FAIL, !inspect.State.Health.has_value()); + THROW_HR_IF(E_FAIL, inspect.State.Health->Status != expectedStatus); + return inspect.State.Health.value(); + }, + std::chrono::seconds(1), + timeout); + } + catch (...) + { + const auto inspect = InspectContainer(containerName); + const std::string actual = inspect.State.Health.has_value() ? inspect.State.Health->Status : ""; + VERIFY_FAIL(std::format( + L"Container '{}' did not reach health status '{}' (last status: '{}')", + containerName, + wsl::shared::string::MultiByteToWide(std::string(expectedStatus)), + wsl::shared::string::MultiByteToWide(actual)) + .c_str()); + throw; + } +} + wslc_schema::InspectImage InspectImage(const std::wstring& imageName) { auto result = RunWslc(std::format(L"image inspect {}", imageName)); diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.h b/test/windows/wslc/e2e/WSLCE2EHelpers.h index d81fa2b33f..ab38b26638 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.h +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.h @@ -155,6 +155,9 @@ std::string SendUdpAndReceive(uint16_t hostPort, const std::string& payload, con void WaitForContainerOutput(const std::wstring& containerName, std::string_view expected, std::chrono::milliseconds timeout = std::chrono::seconds(60)); +wsl::windows::common::wslc_schema::Health WaitForContainerHealth( + const std::wstring& containerName, const std::string_view& expectedStatus, std::chrono::milliseconds timeout = std::chrono::seconds(120)); + // Default timeout of 0 will execute once. template void VerifyContainerIsNotListed( From 140aec9b90281a83585078311b2d656ef3d7e594 Mon Sep 17 00:00:00 2001 From: Blue Date: Thu, 2 Jul 2026 15:01:01 -0700 Subject: [PATCH 2/7] Update flags --- test/windows/WSLCTests.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 2bc07da8f4..69f82fbce1 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -5729,7 +5729,7 @@ class WSLCTests // Invalid container flags are rejected with E_INVALIDARG. options.Image = "debian:latest"; - options.Flags = static_cast(0x20); + options.Flags = static_cast(0x40); VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->CreateContainer(&options, nullptr, &container)); // Invalid init process flags are rejected with E_INVALIDARG. From 42f0cd28401cf4b4514d11a396090284893c3455 Mon Sep 17 00:00:00 2001 From: Blue Date: Mon, 6 Jul 2026 15:25:42 -0700 Subject: [PATCH 3/7] Fix tests --- test/windows/WSLCTests.cpp | 3 ++- test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp | 6 +++--- test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 69f82fbce1..2ac85aaad1 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -1594,7 +1594,8 @@ class WSLCTests { std::ofstream dockerfile(contextDir / "Dockerfile"); dockerfile << "FROM debian:latest\n"; - dockerfile << "HEALTHCHECK --interval=1s --timeout=100ms --start-period=300s --retries=1000 CMD test -f " << c_healthReadyFile << "\n"; + dockerfile << "HEALTHCHECK --interval=1s --timeout=100ms --start-period=300s --retries=1000 CMD test -f " + << c_healthReadyFile << "\n"; dockerfile << "CMD [\"sleep\", \"99999\"]\n"; } diff --git a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp index b138f60f07..1338ccf628 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp @@ -1337,9 +1337,9 @@ class WSLCE2EContainerCreateTests { std::wstringstream commands; commands << L"The following arguments are available:\r\n" - << L" image Image name\r\n" - << L" command The command to run\r\n" - << L" arguments Arguments to pass to container's init process\r\n\r\n"; + << L" image Image name\r\n" + << L" command The command to run\r\n" + << L" arguments Arguments to pass to container's init process\r\n\r\n"; return commands.str(); } diff --git a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp index 7d75c9aa32..e84923bd14 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp @@ -1362,9 +1362,9 @@ class WSLCE2EContainerRunTests { std::wstringstream commands; commands << L"The following arguments are available:\r\n" - << L" image Image name\r\n" - << L" command The command to run\r\n" - << L" arguments Arguments to pass to container's init process\r\n" + << L" image Image name\r\n" + << L" command The command to run\r\n" + << L" arguments Arguments to pass to container's init process\r\n" << L"\r\n"; return commands.str(); } From 6457a8561558a54d94f7c2e4f2071449a0408caf Mon Sep 17 00:00:00 2001 From: Blue Date: Mon, 6 Jul 2026 17:06:48 -0700 Subject: [PATCH 4/7] Implement --no-healthcheck --- localization/strings/en-US/Resources.resw | 3 ++ src/windows/common/WSLCContainerLauncher.cpp | 5 ++++ src/windows/common/WSLCContainerLauncher.h | 1 + src/windows/service/inc/WSLCShared.idl | 3 +- .../wslc/arguments/ArgumentDefinitions.h | 1 + .../wslc/commands/ContainerCreateCommand.cpp | 1 + .../wslc/commands/ContainerRunCommand.cpp | 1 + src/windows/wslc/services/ContainerModel.h | 1 + .../wslc/services/ContainerService.cpp | 5 ++++ src/windows/wslc/tasks/ContainerTasks.cpp | 5 ++++ src/windows/wslcsession/WSLCContainer.cpp | 16 ++++++---- test/windows/WSLCTests.cpp | 29 +++++++++++++++++++ .../wslc/e2e/WSLCE2EContainerCreateTests.cpp | 1 + .../wslc/e2e/WSLCE2EContainerRunTests.cpp | 1 + 14 files changed, 67 insertions(+), 6 deletions(-) diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index 451c0cda15..ccbe818308 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -2915,6 +2915,9 @@ On first run, creates the file with all settings commented out at their defaults Do not use cache when building the image + + Disable any container-specified health check + Do not delete untagged parents diff --git a/src/windows/common/WSLCContainerLauncher.cpp b/src/windows/common/WSLCContainerLauncher.cpp index 4f1379096d..77b98cdd46 100644 --- a/src/windows/common/WSLCContainerLauncher.cpp +++ b/src/windows/common/WSLCContainerLauncher.cpp @@ -164,6 +164,11 @@ void WSLCContainerLauncher::SetHealthRetries(LONG Retries) m_healthRetries = Retries; } +void WSLCContainerLauncher::SetNoHealthcheck() +{ + WI_SetFlag(m_containerFlags, WSLCContainerFlagsNoHealthCheck); +} + void WSLCContainerLauncher::SetEntrypoint(std::vector&& entrypoint) { m_entrypoint = std::move(entrypoint); diff --git a/src/windows/common/WSLCContainerLauncher.h b/src/windows/common/WSLCContainerLauncher.h index e8acac7fc2..0e9a63f42b 100644 --- a/src/windows/common/WSLCContainerLauncher.h +++ b/src/windows/common/WSLCContainerLauncher.h @@ -82,6 +82,7 @@ class WSLCContainerLauncher : private WSLCProcessLauncher void SetHealthTimeout(int64_t Nanoseconds); void SetHealthStartPeriod(int64_t Nanoseconds); void SetHealthRetries(LONG Retries); + void SetNoHealthcheck(); void SetContainerFlags(WSLCContainerFlags Flags); void SetHostname(std::string&& Hostname); void SetDomainname(std::string&& Domainame); diff --git a/src/windows/service/inc/WSLCShared.idl b/src/windows/service/inc/WSLCShared.idl index 5ce6905f61..6d9a476211 100644 --- a/src/windows/service/inc/WSLCShared.idl +++ b/src/windows/service/inc/WSLCShared.idl @@ -100,9 +100,10 @@ typedef enum _WSLCContainerFlags WSLCContainerFlagsPublishAll = 8, // Publish all exposed ports. WSLCContainerFlagsStopTimeout = 16, // The StopTimeout field is set and should be honored (otherwise StopTimeout is ignored). WSLCContainerFlagsHealthCheck = 32, // The Health* fields are set and should be honored (otherwise they are ignored). + WSLCContainerFlagsNoHealthCheck = 64, // Disable any container/image-specified health check. } WSLCContainerFlags; -cpp_quote("#define WSLCContainerFlagsValid (WSLCContainerFlagsRm | WSLCContainerFlagsGpu | WSLCContainerFlagsInit | WSLCContainerFlagsPublishAll | WSLCContainerFlagsStopTimeout | WSLCContainerFlagsHealthCheck)") +cpp_quote("#define WSLCContainerFlagsValid (WSLCContainerFlagsRm | WSLCContainerFlagsGpu | WSLCContainerFlagsInit | WSLCContainerFlagsPublishAll | WSLCContainerFlagsStopTimeout | WSLCContainerFlagsHealthCheck | WSLCContainerFlagsNoHealthCheck)") cpp_quote("DEFINE_ENUM_FLAG_OPERATORS(WSLCContainerFlags);") diff --git a/src/windows/wslc/arguments/ArgumentDefinitions.h b/src/windows/wslc/arguments/ArgumentDefinitions.h index 6aed845bf8..d281c6a5d8 100644 --- a/src/windows/wslc/arguments/ArgumentDefinitions.h +++ b/src/windows/wslc/arguments/ArgumentDefinitions.h @@ -88,6 +88,7 @@ _(NetworkName, "network-name", NO_ALIAS, Kind::Positional, L /*_(NoDNS, "no-dns", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_NoDNSArgDescription())*/ \ _(NoCache, "no-cache", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_NoCacheArgDescription()) \ _(NoColor, "no-color", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_NoColorArgDescription()) \ +_(NoHealthcheck, "no-healthcheck", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_NoHealthcheckArgDescription()) \ _(NoPrune, "no-prune", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_NoPruneArgDescription()) \ _(NoTrunc, "no-trunc", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_NoTruncArgDescription()) \ _(ObjectId, "object-id", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_ObjectIdArgDescription()) \ diff --git a/src/windows/wslc/commands/ContainerCreateCommand.cpp b/src/windows/wslc/commands/ContainerCreateCommand.cpp index 44db123256..114d54dfc1 100644 --- a/src/windows/wslc/commands/ContainerCreateCommand.cpp +++ b/src/windows/wslc/commands/ContainerCreateCommand.cpp @@ -57,6 +57,7 @@ std::vector ContainerCreateCommand::GetArguments() const Argument::Create(ArgType::NetworkAlias, false, NO_LIMIT), // Argument::Create(ArgType::NoDNS), // Argument::Create(ArgType::Progress), + Argument::Create(ArgType::NoHealthcheck), Argument::Create(ArgType::Publish, false, NO_LIMIT), Argument::Create(ArgType::PublishAll), Argument::Create(ArgType::Remove), diff --git a/src/windows/wslc/commands/ContainerRunCommand.cpp b/src/windows/wslc/commands/ContainerRunCommand.cpp index 46b398e870..1dba19881c 100644 --- a/src/windows/wslc/commands/ContainerRunCommand.cpp +++ b/src/windows/wslc/commands/ContainerRunCommand.cpp @@ -57,6 +57,7 @@ std::vector ContainerRunCommand::GetArguments() const Argument::Create(ArgType::NetworkAlias, false, NO_LIMIT), // Argument::Create(ArgType::NoDNS), // Argument::Create(ArgType::Progress), + Argument::Create(ArgType::NoHealthcheck), Argument::Create(ArgType::Publish, false, NO_LIMIT), Argument::Create(ArgType::PublishAll), // Argument::Create(ArgType::Pull), diff --git a/src/windows/wslc/services/ContainerModel.h b/src/windows/wslc/services/ContainerModel.h index 30bc5ed1fc..4d8ff2cc59 100644 --- a/src/windows/wslc/services/ContainerModel.h +++ b/src/windows/wslc/services/ContainerModel.h @@ -45,6 +45,7 @@ struct ContainerOptions std::optional HealthTimeout{}; // nanoseconds std::optional HealthStartPeriod{}; // nanoseconds std::optional HealthRetries{}; + bool NoHealthcheck = false; bool Gpu = false; std::vector Ports; std::vector Volumes; diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index 95be0877d5..b0e66fd479 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -171,6 +171,11 @@ static wsl::windows::common::RunningWSLCContainer CreateInternal( containerLauncher.SetHealthRetries(options.HealthRetries.value()); } + if (options.NoHealthcheck) + { + containerLauncher.SetNoHealthcheck(); + } + if (options.MemoryBytes.has_value()) { containerLauncher.SetMemoryLimit(options.MemoryBytes.value()); diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index aa20982f50..9e51460056 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -478,6 +478,11 @@ void SetContainerOptionsFromArgs(CLIExecutionContext& context) options.HealthRetries = validation::GetIntegerFromString(context.Args.Get()); } + if (context.Args.Contains(ArgType::NoHealthcheck)) + { + options.NoHealthcheck = true; + } + if (context.Args.Contains(ArgType::Memory)) { options.MemoryBytes = validation::GetMemorySizeFromString(context.Args.Get()); diff --git a/src/windows/wslcsession/WSLCContainer.cpp b/src/windows/wslcsession/WSLCContainer.cpp index 006cb5199b..f3ecddcbfc 100644 --- a/src/windows/wslcsession/WSLCContainer.cpp +++ b/src/windows/wslcsession/WSLCContainer.cpp @@ -1547,19 +1547,25 @@ std::unique_ptr WSLCContainerImpl::Create( request.HostConfig.ShmSize = containerOptions.ShmSize; - if (WI_IsFlagSet(containerOptions.Flags, WSLCContainerFlagsHealthCheck)) + if (WI_IsFlagSet(containerOptions.Flags, WSLCContainerFlagsNoHealthCheck)) + { + THROW_HR_IF_MSG( + E_INVALIDARG, + WI_IsFlagSet(containerOptions.Flags, WSLCContainerFlagsHealthCheck), + "WSLCContainerFlagsHealthCheck and WSLCContainerFlagsNoHealthCheck cannot be combined"); + + request.Healthcheck.emplace().Test = std::vector{"NONE"}; + } + else if (WI_IsFlagSet(containerOptions.Flags, WSLCContainerFlagsHealthCheck)) { common::docker_schema::HealthConfig health{}; - // A custom command maps to Docker's ["CMD-SHELL", ]. When no command is provided the - // Test field is left unset so the image's health check command (if any) is preserved. if (containerOptions.HealthCmd != nullptr) { health.Test = std::vector{"CMD-SHELL", containerOptions.HealthCmd}; } - // Durations are already in nanoseconds; 0 means "use the daemon default", so only forward - // explicit non-zero values. + // N.B. '0' will use the default value from the image. if (containerOptions.HealthIntervalNs != 0) { health.Interval = containerOptions.HealthIntervalNs; diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 2ac85aaad1..95924f6aa4 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -1671,6 +1671,35 @@ class WSLCTests // Validate that the container transitions to "unhealthy" after the health command fails. waitForHealthStatus(container, "unhealthy", 60s); } + + // Validate that WSLCContainerFlagsNoHealthCheck disables the image's default health check. + { + WSLCContainerLauncher launcher("wslc-test-healthcheck:latest", "wslc-healthcheck-test-disabled"); + launcher.SetNoHealthcheck(); + auto container = launcher.Launch(*m_defaultSession); + + auto inspect = container.Inspect(); + VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value()); + + const auto& health = inspect.Config.Healthcheck.value(); + VERIFY_IS_TRUE(health.Test.has_value()); + const std::vector expectedTest{"NONE"}; + VERIFY_ARE_EQUAL(expectedTest, health.Test.value()); + + // A disabled health check is not monitored, so the container never reports a runtime health status. + VERIFY_IS_FALSE(inspect.State.Health.has_value()); + } + + // Validate that combining WSLCContainerFlagsNoHealthCheck with an explicit health check command is rejected. + { + WSLCContainerLauncher launcher("wslc-test-healthcheck:latest", "wslc-healthcheck-test-conflict"); + launcher.SetNoHealthcheck(); + launcher.SetHealthCmd("exit 0"); + + auto [result, container] = launcher.CreateNoThrow(*m_defaultSession); + VERIFY_ARE_EQUAL(result, E_INVALIDARG); + VERIFY_IS_FALSE(container.has_value()); + } } WSLC_TEST_METHOD(BuildImageWithContext) diff --git a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp index 1338ccf628..514a1ce9ff 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp @@ -1371,6 +1371,7 @@ class WSLCE2EContainerCreateTests << L" --name Name of the container\r\n" << L" --network Connect a container to a network\r\n" << L" --network-alias Add a network-scoped alias for the container\r\n" + << L" --no-healthcheck Disable any container-specified health check\r\n" << L" -p,--publish Publish a port from a container to host\r\n" << L" -P,--publish-all Publish all exposed ports to random host ports\r\n" << L" --rm Remove the container after it stops\r\n" diff --git a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp index e84923bd14..0661f79f9f 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp @@ -1398,6 +1398,7 @@ class WSLCE2EContainerRunTests << L" --name Name of the container\r\n" << L" --network Connect a container to a network\r\n" << L" --network-alias Add a network-scoped alias for the container\r\n" + << L" --no-healthcheck Disable any container-specified health check\r\n" << L" -p,--publish Publish a port from a container to host\r\n" << L" -P,--publish-all Publish all exposed ports to random host ports\r\n" << L" --rm Remove the container after it stops\r\n" From 6c61a945ea26e135eaeeabd54219802270374796 Mon Sep 17 00:00:00 2001 From: Blue Date: Mon, 6 Jul 2026 17:30:52 -0700 Subject: [PATCH 5/7] Save state --- localization/strings/en-US/Resources.resw | 4 +++ src/windows/inc/docker_schema.h | 1 - .../wslc/arguments/ArgumentValidation.cpp | 26 ++++++++++++------- src/windows/wslcsession/WSLCContainer.cpp | 1 - .../WSLCCLIResourceLimitsParserUnitTests.cpp | 14 ++-------- .../wslc/e2e/WSLCE2EContainerCreateTests.cpp | 16 +++++++++++- 6 files changed, 37 insertions(+), 25 deletions(-) diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index 1bb18a0871..b00bb31a6f 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -3102,6 +3102,10 @@ On first run, creates the file with all settings commented out at their defaults Invalid {} argument value: '{}'. Expected a duration (e.g. 30s, 1m30s) {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated{Locked="30s"}{Locked="1m30s"} + + The '--no-healthcheck' option cannot be combined with other health check options. + {Locked="--no-healthcheck'"}Command line arguments, file names and string inserts should not be translated + CID file '{}' already exists {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index f2975ba9a2..87b383a829 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -286,7 +286,6 @@ struct NetworkSettings struct HealthConfig { - // ["NONE"] disables it, and ["CMD-SHELL", ] runs through the shell. std::optional> Test; std::optional Interval; std::optional Timeout; diff --git a/src/windows/wslc/arguments/ArgumentValidation.cpp b/src/windows/wslc/arguments/ArgumentValidation.cpp index 3dacce8da2..7975146a7c 100644 --- a/src/windows/wslc/arguments/ArgumentValidation.cpp +++ b/src/windows/wslc/arguments/ArgumentValidation.cpp @@ -75,6 +75,14 @@ void Argument::Validate(const ArgMap& execArgs) const execArgs.GetAll(), m_name, [](int value) { return value >= 0; }); break; + case ArgType::NoHealthcheck: + if (execArgs.Contains(ArgType::HealthCmd) || execArgs.Contains(ArgType::HealthInterval) || execArgs.Contains(ArgType::HealthTimeout) || + execArgs.Contains(ArgType::HealthStartPeriod) || execArgs.Contains(ArgType::HealthRetries)) + { + throw ArgumentException(Localization::WSLCCLI_NoHealthcheckConflictError()); + } + break; + case ArgType::Memory: validation::ValidateMemorySize(execArgs.GetAll(), m_name); break; @@ -436,10 +444,8 @@ int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& a return static_cast(parsed.value()); } -// Parses a Go-style duration string (as used by Docker) into nanoseconds. The input is a possibly -// signed sequence of decimal numbers, each with an optional fraction and a required unit suffix. -// Valid units are "ns", "us"/"µs", "ms", "s", "m", "h". Returns std::nullopt on any parse error. -static std::optional TryParseGoDuration(const std::string& input) +// Parses duration string into nanoseconds. +static std::optional TryParseDuration(const std::string& input) { if (input.empty()) { @@ -495,23 +501,23 @@ static std::optional TryParseGoDuration(const std::string& input) } else if (unit == "us" || unit == "\xC2\xB5s" /* µs (U+00B5) */ || unit == "\xCE\xBCs" /* μs (U+03BC) */) { - multiplier = 1e3L; + multiplier = 1000L; } else if (unit == "ms") { - multiplier = 1e6L; + multiplier = 1000000L; } else if (unit == "s") { - multiplier = 1e9L; + multiplier = 1000000000L; } else if (unit == "m") { - multiplier = 60e9L; + multiplier = 60000000000L; } else if (unit == "h") { - multiplier = 3600e9L; + multiplier = 3600000000000L; } else { @@ -567,7 +573,7 @@ void ValidateDuration(const std::vector& values, const std::wstrin int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName) { const std::string narrow = WideToMultiByte(input); - const auto parsed = TryParseGoDuration(narrow); + const auto parsed = TryParseDuration(narrow); if (!parsed.has_value() || parsed.value() < 0) { diff --git a/src/windows/wslcsession/WSLCContainer.cpp b/src/windows/wslcsession/WSLCContainer.cpp index f3ecddcbfc..ff1e79f5e1 100644 --- a/src/windows/wslcsession/WSLCContainer.cpp +++ b/src/windows/wslcsession/WSLCContainer.cpp @@ -1297,7 +1297,6 @@ WslcInspectContainer WSLCContainerImpl::BuildInspectContainer(const DockerInspec wslcInspect.State.StartedAt = dockerInspect.State.StartedAt; wslcInspect.State.FinishedAt = dockerInspect.State.FinishedAt; - // Map the runtime health status, which Docker only reports when a health check is configured. if (dockerInspect.State.Health.has_value()) { const auto& dockerHealth = dockerInspect.State.Health.value(); diff --git a/test/windows/wslc/WSLCCLIResourceLimitsParserUnitTests.cpp b/test/windows/wslc/WSLCCLIResourceLimitsParserUnitTests.cpp index 8d145ddfad..a8a5308759 100644 --- a/test/windows/wslc/WSLCCLIResourceLimitsParserUnitTests.cpp +++ b/test/windows/wslc/WSLCCLIResourceLimitsParserUnitTests.cpp @@ -127,9 +127,9 @@ class WSLCCLIResourceLimitsParserUnitTests TEST_METHOD(Duration_Valid) { - // (input, expected nanoseconds) std::vector> valid = { {L"0", 0LL}, + {L"0s", 0LL}, {L"1ns", 1LL}, {L"500ms", 500'000'000LL}, {L"30s", 30'000'000'000LL}, @@ -150,18 +150,8 @@ class WSLCCLIResourceLimitsParserUnitTests TEST_METHOD(Duration_Invalid) { - // Each value should be rejected as an invalid duration. const std::vector invalid = { - L"", - L"abc", - L"30", // missing unit - L"30sec", // unknown unit - L"30x", // unknown unit - L"-30s", // negative durations are rejected - L"30 s", // embedded whitespace - L"s", // missing number - L"1.2.3s" // multiple dots - }; + L"", L"-1", L"s", L"abc", L"30", L"30sec", L"30x", L"-30s", L"30 s", L"s", L"1.2.3s", L"9223372036854775808s"}; for (const auto& input : invalid) { diff --git a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp index 514a1ce9ff..c3145d6883 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp @@ -852,7 +852,7 @@ class WSLCE2EContainerCreateTests EnsureContainerDoesNotExist(WslcContainerName); } - // Only --health-cmd: the command is forwarded, other fields fall back to the daemon default. + // Only --health-cmd: the command is forwarded, other fields fall back to the default. { auto result = RunWslc( std::format(LR"(container create --health-cmd "exit 1" --name {} {})", WslcContainerName, DebianImage.NameAndTag())); @@ -896,6 +896,20 @@ class WSLCE2EContainerCreateTests result.Verify({.Stderr = L"Invalid health-retries argument value: abc\r\n", .ExitCode = 1}); VerifyContainerIsNotListed(WslcContainerName); } + + { + auto result = RunWslc(std::format( + LR"(container create --no-healthcheck --health-cmd "exit 0" --name {} {})", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"The '--no-healthcheck' option cannot be combined with other health check options.\r\n", .ExitCode = 1}); + VerifyContainerIsNotListed(WslcContainerName); + } + + { + auto result = RunWslc(std::format( + L"container create --no-healthcheck --health-interval 5s --name {} {}", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"The '--no-healthcheck' option cannot be combined with other health check options.\r\n", .ExitCode = 1}); + VerifyContainerIsNotListed(WslcContainerName); + } } WSLC_TEST_METHOD(WSLCE2E_Container_Create_StopSignal_Invalid) From ffc80ed4317eeb458f9e9ffc9240a5e56cb3abaf Mon Sep 17 00:00:00 2001 From: Blue Date: Mon, 6 Jul 2026 17:46:18 -0700 Subject: [PATCH 6/7] Cleanup diff --- test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp index 0661f79f9f..4ad50632fb 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp @@ -1198,8 +1198,6 @@ class WSLCE2EContainerRunTests WSLC_TEST_METHOD(WSLCE2E_Container_Run_HealthStatus_Timeout) { - // A health check that hangs past its timeout should be treated as a failure and drive the - // container to the "unhealthy" state. Docker reports a timed-out probe with exit code -1. auto result = RunWslc(std::format( LR"(container run -d --health-cmd "sleep 30" --health-interval 1s --health-timeout 1s --health-retries 1 --name {} {} sleep infinity)", WslcContainerName, From dcc73a66d657ff8a69439186dd374fe90f068ada Mon Sep 17 00:00:00 2001 From: Blue Date: Tue, 7 Jul 2026 12:29:45 -0700 Subject: [PATCH 7/7] Fix tests --- test/windows/WSLCTests.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 5e08594099..207a734141 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -5876,7 +5876,7 @@ class WSLCTests // Invalid container flags are rejected with E_INVALIDARG. options.Image = "debian:latest"; - options.Flags = static_cast(0x40); + options.Flags = static_cast(0x80); VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->CreateContainer(&options, nullptr, &container)); // Invalid init process flags are rejected with E_INVALIDARG.