diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index aa0f5bf72c..17e9126cbe 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -2134,10 +2134,6 @@ Usage: Container '{}' is running. {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated - - ContainerIpAddress is not yet supported. - {Locked="ContainerIpAddress"}Command line arguments, file names and string inserts should not be translated - Port mappings are not supported with container network mode; ports are owned by the target container. @@ -2383,18 +2379,33 @@ For privacy information about this product please visit https://aka.ms/privacy.< Invalid stop timeout value: {} {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated - - Additional networks are not allowed when the primary network mode is 'host' or 'none'. - {Locked="host"}{Locked="none"}Command line arguments, file names and string inserts should not be translated + + The primary network mode '{}' does not support connecting or disconnecting additional networks. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated Duplicate network: '{}' {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated - - Endpoint settings are not yet supported (network '{}'). + + Duplicate driver option '{}'. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Invalid driver option '{}'; expected 'key=value'. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Unknown endpoint setting '{}' for network '{}'. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Only one IP address may be specified for network '{}'. {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + Network link cannot be empty. + Network alias cannot be empty. @@ -2402,6 +2413,10 @@ For privacy information about this product please visit https://aka.ms/privacy.< Network aliases require a user-defined network. Use --network to specify one. {Locked="--network "}Command line arguments, file names and string inserts should not be translated + + Endpoint settings are not supported for network mode '{}'. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + Network aliases cannot be specified when multiple networks are requested. Use a single --network argument. {Locked="--network "}Command line arguments, file names and string inserts should not be translated @@ -2992,6 +3007,22 @@ On first run, creates the file with all settings commented out at their defaults Add a network-scoped alias for the container + + Set endpoint driver options (--driver-opt key=value) + {Locked="--driver-opt "}Command line arguments, file names and string inserts should not be translated + + + Assign the container an IPv4 address on the network + {Locked="IPv4"}Command line arguments, file names and string inserts should not be translated + + + Add link to another container (--link name:alias) + {Locked="--link "}Command line arguments, file names and string inserts should not be translated + + + Add a link-local IPv4 address for the container + {Locked="IPv4"}Command line arguments, file names and string inserts should not be translated + Invalid {} value: network alias cannot be empty or whitespace {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 87b383a829..549fb023ea 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -166,14 +166,78 @@ struct Network NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(Network, Id, Name, Driver, Scope, Internal, IPAM, Options, Labels); }; +struct EndpointIPAMConfig +{ + std::string IPv4Address; + std::optional> LinkLocalIPs; +}; + +inline void to_json(nlohmann::json& j, const EndpointIPAMConfig& v) +{ + j = nlohmann::json::object(); + if (!v.IPv4Address.empty()) + { + j["IPv4Address"] = v.IPv4Address; + } + if (v.LinkLocalIPs.has_value() && !v.LinkLocalIPs->empty()) + { + j["LinkLocalIPs"] = *v.LinkLocalIPs; + } +} + +struct EndpointConfig +{ + std::optional> Aliases; + std::optional IPAMConfig; + std::optional> Links; + std::optional> DriverOpts; +}; + +inline void to_json(nlohmann::json& j, const EndpointConfig& v) +{ + j = nlohmann::json::object(); + if (v.Aliases.has_value() && !v.Aliases->empty()) + { + j["Aliases"] = *v.Aliases; + } + if (v.IPAMConfig.has_value()) + { + auto ipam = nlohmann::json(*v.IPAMConfig); + if (!ipam.empty()) + { + j["IPAMConfig"] = std::move(ipam); + } + } + if (v.Links.has_value() && !v.Links->empty()) + { + j["Links"] = *v.Links; + } + if (v.DriverOpts.has_value() && !v.DriverOpts->empty()) + { + j["DriverOpts"] = *v.DriverOpts; + } +} + struct ContainerNetworkRequest { using TResponse = void; std::string Container; - - NLOHMANN_DEFINE_TYPE_INTRUSIVE_ONLY_SERIALIZE(ContainerNetworkRequest, Container); + std::optional EndpointConfig; }; +inline void to_json(nlohmann::json& j, const ContainerNetworkRequest& v) +{ + j = nlohmann::json{{"Container", v.Container}}; + if (v.EndpointConfig.has_value()) + { + auto endpoint = nlohmann::json(*v.EndpointConfig); + if (!endpoint.empty()) + { + j["EndpointConfig"] = std::move(endpoint); + } + } +} + struct Mount { std::string Name; @@ -245,6 +309,14 @@ struct HostConfig HostConfig, Mounts, PortBindings, NetworkMode, Init, Dns, DnsSearch, DnsOptions, Binds, Tmpfs, Devices, DeviceRequests, ShmSize, Memory, NanoCpus, Ulimits); }; +struct InspectEndpointIPAMConfig +{ + std::string IPv4Address; + std::optional> LinkLocalIPs; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(InspectEndpointIPAMConfig, IPv4Address, LinkLocalIPs); +}; + struct EndpointSettings { std::string IPAddress; @@ -252,24 +324,13 @@ struct EndpointSettings std::string MacAddress; int IPPrefixLen{}; std::optional> Aliases; + std::optional> Links; + std::optional> DriverOpts; + std::optional IPAMConfig; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(EndpointSettings, IPAddress, Gateway, MacAddress, IPPrefixLen, Aliases); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(EndpointSettings, IPAddress, Gateway, MacAddress, IPPrefixLen, Aliases, Links, DriverOpts, IPAMConfig); }; -struct EndpointConfig -{ - std::optional> Aliases; -}; - -inline void to_json(nlohmann::json& j, const EndpointConfig& v) -{ - j = nlohmann::json::object(); - if (v.Aliases.has_value() && !v.Aliases->empty()) - { - j["Aliases"] = *v.Aliases; - } -} - struct NetworkingConfig { std::map EndpointsConfig; diff --git a/src/windows/inc/wslc_schema.h b/src/windows/inc/wslc_schema.h index ccbd20a1fd..92c9b9eb22 100644 --- a/src/windows/inc/wslc_schema.h +++ b/src/windows/inc/wslc_schema.h @@ -113,6 +113,14 @@ struct ContainerConfig NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerConfig, Env, Cmd, Entrypoint, User, WorkingDir, StopTimeout, Healthcheck); }; +struct InspectEndpointIPAMConfig +{ + std::string IPv4Address; + std::vector LinkLocalIPs; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(InspectEndpointIPAMConfig, IPv4Address, LinkLocalIPs); +}; + struct InspectEndpointSettings { std::string IPAddress; @@ -120,8 +128,11 @@ struct InspectEndpointSettings std::string MacAddress; int IPPrefixLen{}; std::vector Aliases; + std::vector Links; + std::map DriverOpts; + std::optional IPAMConfig; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(InspectEndpointSettings, IPAddress, Gateway, MacAddress, IPPrefixLen, Aliases); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(InspectEndpointSettings, IPAddress, Gateway, MacAddress, IPPrefixLen, Aliases, Links, DriverOpts, IPAMConfig); }; struct InspectNetworkSettings diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 35e272a71f..e0f67727a9 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -232,7 +232,10 @@ typedef struct _WSLCNetworkConnection typedef struct _WSLCNetworkConnectionOptions { [unique] LPCSTR NetworkName; - [unique] LPCSTR ContainerIpAddress; // Reserved for future --ip support; must be NULL today. + // Endpoint settings for the network connection (Aliases, IPAddress, Links, LinkLocalIPs, DriverOpts). + // KVP-encoded; duplicate keys are allowed (e.g., multiple "Aliases" entries). + [unique, size_is(SettingsCount)] const KeyValuePair* Settings; + ULONG SettingsCount; } WSLCNetworkConnectionOptions; typedef struct _WSLCContainerNetwork diff --git a/src/windows/wslc/arguments/ArgumentDefinitions.h b/src/windows/wslc/arguments/ArgumentDefinitions.h index 268ab69ff8..db463e711b 100644 --- a/src/windows/wslc/arguments/ArgumentDefinitions.h +++ b/src/windows/wslc/arguments/ArgumentDefinitions.h @@ -51,6 +51,7 @@ _(DNSOption, "dns-option", NO_ALIAS, Kind::Value, L _(DNSSearch, "dns-search", NO_ALIAS, Kind::Value, Localization::WSLCCLI_DNSSearchArgDescription()) \ _(Domainname, "domainname", NO_ALIAS, Kind::Value, Localization::WSLCCLI_DomainnameArgDescription()) \ _(Driver, "driver", L"d", Kind::Value, Localization::WSLCCLI_DriverOptionDescription()) \ +_(DriverOpt, "driver-opt", NO_ALIAS, Kind::Value, Localization::WSLCCLI_DriverOptArgDescription()) \ _(Entrypoint, "entrypoint", NO_ALIAS, Kind::Value, Localization::WSLCCLI_EntrypointArgDescription()) \ _(Env, "env", L"e", Kind::Value, Localization::WSLCCLI_EnvArgDescription()) \ _(EnvFile, "env-file", NO_ALIAS, Kind::Value, Localization::WSLCCLI_EnvFileArgDescription()) \ @@ -78,9 +79,12 @@ _(ImportFile, "file", NO_ALIAS, Kind::Positional, L _(Input, "input", L"i", Kind::Value, Localization::WSLCCLI_InputArgDescription()) \ _(Interactive, "interactive", L"i", Kind::Flag, Localization::WSLCCLI_InteractiveArgDescription()) \ _(Internal, "internal", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_NetworkInternalArgDescription()) \ +_(IpAddress, "ip", NO_ALIAS, Kind::Value, Localization::WSLCCLI_IpAddressArgDescription()) \ _(Label, "label", L"l", Kind::Value, Localization::WSLCCLI_LabelArgDescription()) \ _(Last, "last", L"n", Kind::Value, Localization::WSLCCLI_LastArgDescription()) \ _(Latest, "latest", L"l", Kind::Flag, Localization::WSLCCLI_LatestArgDescription()) \ +_(Link, "link", NO_ALIAS, Kind::Value, Localization::WSLCCLI_LinkArgDescription()) \ +_(LinkLocalIp, "link-local-ip", NO_ALIAS, Kind::Value, Localization::WSLCCLI_LinkLocalIpArgDescription()) \ _(Memory, "memory", L"m", Kind::Value, Localization::WSLCCLI_MemoryArgDescription()) \ _(Name, "name", NO_ALIAS, Kind::Value, Localization::WSLCCLI_NameArgDescription()) \ _(Network, "network", NO_ALIAS, Kind::Value, Localization::WSLCCLI_NetworkArgDescription()) \ diff --git a/src/windows/wslc/commands/NetworkConnectCommand.cpp b/src/windows/wslc/commands/NetworkConnectCommand.cpp index 462af190fb..daee586b7d 100644 --- a/src/windows/wslc/commands/NetworkConnectCommand.cpp +++ b/src/windows/wslc/commands/NetworkConnectCommand.cpp @@ -29,6 +29,11 @@ std::vector NetworkConnectCommand::GetArguments() const return { Argument::Create(ArgType::NetworkName, true), Argument::Create(ArgType::ContainerId, true), + Argument::Create(ArgType::DriverOpt, false, NO_LIMIT), + Argument::Create(ArgType::IpAddress, false), + Argument::Create(ArgType::Link, false, NO_LIMIT), + Argument::Create(ArgType::LinkLocalIp, false, NO_LIMIT), + Argument::Create(ArgType::NetworkAlias, false, NO_LIMIT), }; } @@ -44,7 +49,8 @@ std::wstring NetworkConnectCommand::LongDescription() const void NetworkConnectCommand::ExecuteInternal(CLIExecutionContext& context) const { - context << ResolveSession // + context << ResolveSession // + << SetNetworkEndpointOptionsFromArgs // << ConnectNetwork; } } // namespace wsl::windows::wslc diff --git a/src/windows/wslc/core/ExecutionContextData.h b/src/windows/wslc/core/ExecutionContextData.h index b43018e2c9..93c185884f 100644 --- a/src/windows/wslc/core/ExecutionContextData.h +++ b/src/windows/wslc/core/ExecutionContextData.h @@ -15,6 +15,7 @@ Module Name: #include "EnumVariantMap.h" #include "ContainerModel.h" #include "ImageModel.h" +#include "NetworkModel.h" #include "SessionModel.h" #include "wslc.h" @@ -39,6 +40,7 @@ enum class Data : size_t Images, Volumes, Networks, + NetworkEndpointOptions, Max }; @@ -55,6 +57,7 @@ namespace details { DEFINE_DATA_MAPPING(Images, std::vector); DEFINE_DATA_MAPPING(Volumes, std::vector); DEFINE_DATA_MAPPING(Networks, std::vector); + DEFINE_DATA_MAPPING(NetworkEndpointOptions, wsl::windows::wslc::models::NetworkEndpointOptions); } // namespace details struct DataMap : wsl::windows::wslc::EnumBasedVariantMap diff --git a/src/windows/wslc/services/NetworkModel.h b/src/windows/wslc/services/NetworkModel.h index c696f6eb1f..62eb04daef 100644 --- a/src/windows/wslc/services/NetworkModel.h +++ b/src/windows/wslc/services/NetworkModel.h @@ -30,6 +30,26 @@ struct CreateNetworkOptions std::optional Gateway; }; +struct NetworkEndpointOptions +{ + std::vector Aliases; + std::optional IpAddress; + std::vector Links; + std::vector LinkLocalIps; + std::vector DriverOpts; +}; + +struct ConnectNetworkOptions +{ + std::string NetworkName; + std::string ContainerId; + std::vector Aliases; + std::optional IpAddress; + std::vector Links; + std::vector LinkLocalIps; + std::vector DriverOpts; +}; + struct PruneNetworksResult { std::vector PrunedNetworks; diff --git a/src/windows/wslc/services/NetworkService.cpp b/src/windows/wslc/services/NetworkService.cpp index 5e98756d51..052f645c4f 100644 --- a/src/windows/wslc/services/NetworkService.cpp +++ b/src/windows/wslc/services/NetworkService.cpp @@ -116,13 +116,46 @@ models::PruneNetworksResult NetworkService::Prune(models::Session& session, cons return result; } -void NetworkService::Connect(models::Session& session, const std::string& networkName, const std::string& containerId) +void NetworkService::Connect(models::Session& session, const models::ConnectNetworkOptions& connectOptions) { wil::com_ptr container; - THROW_IF_FAILED(session.Get()->OpenContainer(containerId.c_str(), &container)); + THROW_IF_FAILED(session.Get()->OpenContainer(connectOptions.ContainerId.c_str(), &container)); + + // Build KVPs so the pointers remain valid for the duration of the ConnectToNetwork call. + std::vector settings; + settings.reserve( + connectOptions.Aliases.size() + connectOptions.Links.size() + connectOptions.LinkLocalIps.size() + + connectOptions.DriverOpts.size() + (connectOptions.IpAddress.has_value() ? 1 : 0)); + + for (const auto& alias : connectOptions.Aliases) + { + settings.push_back({.Key = "Aliases", .Value = alias.c_str()}); + } + + if (connectOptions.IpAddress.has_value()) + { + settings.push_back({.Key = "IPAddress", .Value = connectOptions.IpAddress->c_str()}); + } + + for (const auto& link : connectOptions.Links) + { + settings.push_back({.Key = "Links", .Value = link.c_str()}); + } + + for (const auto& linkLocalIp : connectOptions.LinkLocalIps) + { + settings.push_back({.Key = "LinkLocalIPs", .Value = linkLocalIp.c_str()}); + } + + for (const auto& entry : connectOptions.DriverOpts) + { + settings.push_back({.Key = "DriverOpts", .Value = entry.c_str()}); + } WSLCNetworkConnectionOptions options{}; - options.NetworkName = networkName.c_str(); + options.NetworkName = connectOptions.NetworkName.c_str(); + options.Settings = settings.empty() ? nullptr : settings.data(); + options.SettingsCount = static_cast(settings.size()); THROW_IF_FAILED(container->ConnectToNetwork(&options)); } diff --git a/src/windows/wslc/services/NetworkService.h b/src/windows/wslc/services/NetworkService.h index e0e30db79b..ab66a30b7d 100644 --- a/src/windows/wslc/services/NetworkService.h +++ b/src/windows/wslc/services/NetworkService.h @@ -25,7 +25,7 @@ struct NetworkService static std::vector List(models::Session& session); static wsl::windows::common::wslc_schema::Network Inspect(models::Session& session, const std::string& name); static models::PruneNetworksResult Prune(models::Session& session, const std::vector>& filters = {}); - static void Connect(models::Session& session, const std::string& networkName, const std::string& containerId); + static void Connect(models::Session& session, const models::ConnectNetworkOptions& connectOptions); static void Disconnect(models::Session& session, const std::string& networkName, const std::string& containerId); }; } // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/tasks/NetworkTasks.cpp b/src/windows/wslc/tasks/NetworkTasks.cpp index 423cd74ada..dc33b3f39e 100644 --- a/src/windows/wslc/tasks/NetworkTasks.cpp +++ b/src/windows/wslc/tasks/NetworkTasks.cpp @@ -231,12 +231,21 @@ void PruneNetworks(CLIExecutionContext& context) void ConnectNetwork(CLIExecutionContext& context) { WI_ASSERT(context.Data.Contains(Data::Session)); + WI_ASSERT(context.Data.Contains(Data::NetworkEndpointOptions)); WI_ASSERT(context.Args.Contains(ArgType::NetworkName)); WI_ASSERT(context.Args.Contains(ArgType::ContainerId)); - const auto networkName = WideToMultiByte(context.Args.Get()); - const auto containerId = WideToMultiByte(context.Args.Get()); - NetworkService::Connect(context.Data.Get(), networkName, containerId); + const auto& endpoint = context.Data.Get(); + models::ConnectNetworkOptions options{}; + options.NetworkName = WideToMultiByte(context.Args.Get()); + options.ContainerId = WideToMultiByte(context.Args.Get()); + options.Aliases = endpoint.Aliases; + options.IpAddress = endpoint.IpAddress; + options.Links = endpoint.Links; + options.LinkLocalIps = endpoint.LinkLocalIps; + options.DriverOpts = endpoint.DriverOpts; + + NetworkService::Connect(context.Data.Get(), options); } void DisconnectNetwork(CLIExecutionContext& context) @@ -249,4 +258,36 @@ void DisconnectNetwork(CLIExecutionContext& context) const auto containerId = WideToMultiByte(context.Args.Get()); NetworkService::Disconnect(context.Data.Get(), networkName, containerId); } + +void SetNetworkEndpointOptionsFromArgs(CLIExecutionContext& context) +{ + models::NetworkEndpointOptions options{}; + + for (const auto& alias : context.Args.GetAll()) + { + options.Aliases.emplace_back(WideToMultiByte(alias)); + } + + if (context.Args.Contains(ArgType::IpAddress)) + { + options.IpAddress = WideToMultiByte(context.Args.Get()); + } + + for (const auto& link : context.Args.GetAll()) + { + options.Links.emplace_back(WideToMultiByte(link)); + } + + for (const auto& linkLocalIp : context.Args.GetAll()) + { + options.LinkLocalIps.emplace_back(WideToMultiByte(linkLocalIp)); + } + + for (const auto& driverOpt : context.Args.GetAll()) + { + options.DriverOpts.emplace_back(WideToMultiByte(driverOpt)); + } + + context.Data.Add(std::move(options)); +} } // namespace wsl::windows::wslc::task diff --git a/src/windows/wslc/tasks/NetworkTasks.h b/src/windows/wslc/tasks/NetworkTasks.h index ad590ae1a1..5ff66f7743 100644 --- a/src/windows/wslc/tasks/NetworkTasks.h +++ b/src/windows/wslc/tasks/NetworkTasks.h @@ -24,4 +24,5 @@ void ListNetworks(wsl::windows::wslc::execution::CLIExecutionContext& context); void PruneNetworks(wsl::windows::wslc::execution::CLIExecutionContext& context); void ConnectNetwork(wsl::windows::wslc::execution::CLIExecutionContext& context); void DisconnectNetwork(wsl::windows::wslc::execution::CLIExecutionContext& context); +void SetNetworkEndpointOptionsFromArgs(wsl::windows::wslc::execution::CLIExecutionContext& context); } // namespace wsl::windows::wslc::task diff --git a/src/windows/wslcsession/WSLCContainer.cpp b/src/windows/wslcsession/WSLCContainer.cpp index 24c7399932..318df6797f 100644 --- a/src/windows/wslcsession/WSLCContainer.cpp +++ b/src/windows/wslcsession/WSLCContainer.cpp @@ -231,9 +231,8 @@ std::string ResolveNetworkMode(LPCSTR networkMode, bool hasRequestedPorts, const return std::string{mode}; } -// Parses the primary endpoint's Settings KVP. Today only "Aliases" is recognised; unknown -// keys are rejected to avoid silently dropping caller data (mirrors the per-connection guard). -EndpointConfig ResolvePrimaryEndpointConfig(const KeyValuePair* settings, ULONG count, std::string_view networkMode) +// Unknown Settings keys are rejected rather than silently dropped, so callers get a clear error. +EndpointConfig ResolveEndpointConfig(const KeyValuePair* settings, ULONG count, std::string_view networkName) { EndpointConfig config{}; if (count == 0) @@ -245,24 +244,83 @@ EndpointConfig ResolvePrimaryEndpointConfig(const KeyValuePair* settings, ULONG auto parsed = ParseKeyMultiValuePairs(settings, count); + static constexpr std::array knownKeys{"Aliases", "IPAddress", "Links", "LinkLocalIPs", "DriverOpts"}; for (const auto& [key, _] : parsed) { THROW_HR_WITH_USER_ERROR_IF( - E_NOTIMPL, Localization::MessageWslcEndpointSettingsNotSupported(std::string{networkMode}), key != "Aliases"); + E_INVALIDARG, + Localization::MessageWslcEndpointSettingUnknown(key, std::string{networkName}), + std::find(knownKeys.begin(), knownKeys.end(), key) == knownKeys.end()); } + auto isBlank = [](const std::string& value) { + return value.empty() || std::all_of(value.begin(), value.end(), [](unsigned char ch) { return std::isspace(ch); }); + }; + if (auto it = parsed.find("Aliases"); it != parsed.end()) { for (const auto& alias : it->second) { - THROW_HR_WITH_USER_ERROR_IF( - E_INVALIDARG, - Localization::MessageWslcAliasEmpty(), - alias.empty() || std::all_of(alias.begin(), alias.end(), [](unsigned char ch) { return std::isspace(ch); })); + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessageWslcAliasEmpty(), isBlank(alias)); } config.Aliases = std::move(it->second); } + if (auto it = parsed.find("IPAddress"); it != parsed.end()) + { + THROW_HR_WITH_USER_ERROR_IF( + E_INVALIDARG, Localization::MessageWslcIpAddressSingleValue(std::string{networkName}), it->second.size() != 1); + + const auto& address = it->second.front(); + in_addr parsedAddress{}; + ParseIpv4Address(address.c_str(), parsedAddress); + + EndpointIPAMConfig ipam{}; + ipam.IPv4Address = address; + config.IPAMConfig = std::move(ipam); + } + + if (auto it = parsed.find("Links"); it != parsed.end()) + { + for (const auto& link : it->second) + { + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessageWslcLinkEmpty(), isBlank(link)); + } + config.Links = std::move(it->second); + } + + if (auto it = parsed.find("LinkLocalIPs"); it != parsed.end()) + { + for (const auto& address : it->second) + { + in_addr parsedAddress{}; + ParseIpv4Address(address.c_str(), parsedAddress); + } + if (!config.IPAMConfig.has_value()) + { + config.IPAMConfig = EndpointIPAMConfig{}; + } + config.IPAMConfig->LinkLocalIPs = std::move(it->second); + } + + if (auto it = parsed.find("DriverOpts"); it != parsed.end()) + { + std::map driverOpts; + for (const auto& entry : it->second) + { + const auto separator = entry.find('='); + THROW_HR_WITH_USER_ERROR_IF( + E_INVALIDARG, Localization::MessageWslcDriverOptInvalid(entry), separator == std::string::npos || separator == 0); + + auto key = entry.substr(0, separator); + auto value = entry.substr(separator + 1); + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessageWslcDriverOptInvalid(entry), isBlank(key)); + THROW_HR_WITH_USER_ERROR_IF( + E_INVALIDARG, Localization::MessageWslcDriverOptDuplicate(key), !driverOpts.try_emplace(key, std::move(value)).second); + } + config.DriverOpts = std::move(driverOpts); + } + return config; } @@ -294,9 +352,7 @@ std::map ResolveEndpoints( WSLC_E_NETWORK_NOT_FOUND, Localization::MessageWslcNetworkNotFound(name), !sessionNetworks.contains(name)); } - // Per-endpoint Settings are reserved for future use (IPAddress, Aliases, ...). No keys - // are interpreted today, so reject any non-empty payload rather than silently dropping it. - THROW_HR_WITH_USER_ERROR_IF(E_NOTIMPL, Localization::MessageWslcEndpointSettingsNotSupported(name), connections[i].SettingsCount > 0); + it->second = ResolveEndpointConfig(connections[i].Settings, connections[i].SettingsCount, name); } return resolved; } @@ -1523,6 +1579,15 @@ WslcInspectContainer WSLCContainerImpl::BuildInspectContainer(const DockerInspec wslcEndpoint.MacAddress = endpoint.MacAddress; wslcEndpoint.IPPrefixLen = endpoint.IPPrefixLen; wslcEndpoint.Aliases = endpoint.Aliases.value_or(std::vector{}); + wslcEndpoint.Links = endpoint.Links.value_or(std::vector{}); + wslcEndpoint.DriverOpts = endpoint.DriverOpts.value_or(std::map{}); + if (endpoint.IPAMConfig.has_value()) + { + wslc_schema::InspectEndpointIPAMConfig ipam{}; + ipam.IPv4Address = endpoint.IPAMConfig->IPv4Address; + ipam.LinkLocalIPs = endpoint.IPAMConfig->LinkLocalIPs.value_or(std::vector{}); + wslcEndpoint.IPAMConfig = std::move(ipam); + } wslcInspect.NetworkSettings.Networks[name] = std::move(wslcEndpoint); } @@ -1846,29 +1911,32 @@ std::shared_ptr WSLCContainerImpl::Create( containerOptions.ContainerNetwork.Networks, containerOptions.ContainerNetwork.NetworksCount, networkMode, sessionNetworks); auto primaryConfig = - ResolvePrimaryEndpointConfig(containerOptions.ContainerNetwork.Settings, containerOptions.ContainerNetwork.SettingsCount, networkMode); + ResolveEndpointConfig(containerOptions.ContainerNetwork.Settings, containerOptions.ContainerNetwork.SettingsCount, networkMode); - // Aliases require a user-defined endpoint. bridge/host/none/container: modes don't support them. THROW_HR_WITH_USER_ERROR_IF( E_INVALIDARG, Localization::MessageWslcAliasRequiresUserDefinedNetwork(), primaryConfig.Aliases.has_value() && (networkMode == "bridge" || !NetworkModeAllocatesVmPorts(networkMode))); + const bool hasNonAliasEndpointSettings = + primaryConfig.IPAMConfig.has_value() || primaryConfig.Links.has_value() || primaryConfig.DriverOpts.has_value(); + // N.B. NetworkModeAllocatesVmPorts is reused here as the "supports endpoint settings" predicate: modes + // that lack a dedicated netns (host/none/container:*) also can't accept per-endpoint settings. + THROW_HR_WITH_USER_ERROR_IF( + E_INVALIDARG, + Localization::MessageWslcEndpointSettingsRequireNetwork(networkMode), + hasNonAliasEndpointSettings && !NetworkModeAllocatesVmPorts(networkMode)); + auto mappedPorts = BuildPortMappings(ports, networkMode, virtualMachine); request.HostConfig.NetworkMode = networkMode; request.NetworkingConfig.EndpointsConfig = std::move(endpoints); - // Docker API v1.44 (Docker 25.x) requires the primary network to be present in EndpointsConfig - // when EndpointsConfig is non-empty. Insert it so Docker attaches all networks at create time. - // Also insert when the caller supplied aliases for the primary endpoint. - if (NetworkModeAllocatesVmPorts(networkMode) && (!request.NetworkingConfig.EndpointsConfig.empty() || primaryConfig.Aliases.has_value())) + const bool hasPrimaryEndpointSettings = primaryConfig.Aliases.has_value() || primaryConfig.IPAMConfig.has_value() || + primaryConfig.Links.has_value() || primaryConfig.DriverOpts.has_value(); + if (NetworkModeAllocatesVmPorts(networkMode) && (!request.NetworkingConfig.EndpointsConfig.empty() || hasPrimaryEndpointSettings)) { - auto [it, _] = request.NetworkingConfig.EndpointsConfig.try_emplace(networkMode); - if (primaryConfig.Aliases.has_value()) - { - it->second.Aliases = std::move(primaryConfig.Aliases); - } + request.NetworkingConfig.EndpointsConfig[networkMode] = std::move(primaryConfig); } for (const auto& e : mappedPorts) @@ -2670,20 +2738,20 @@ void WSLCContainerImpl::GetLabels(WSLCLabelInformation** Labels, ULONG* Count) c void WSLCContainerImpl::ConnectToNetwork(const WSLCNetworkConnectionOptions* Options) { THROW_HR_IF(E_POINTER, Options == nullptr); - THROW_HR_WITH_USER_ERROR_IF(E_NOTIMPL, Localization::MessageWslcContainerIpAddressNotSupported(), Options->ContainerIpAddress != nullptr); THROW_HR_WITH_USER_ERROR_IF( E_INVALIDARG, Localization::MessageWslcNetworkNameRequired(), !Options->NetworkName || strlen(Options->NetworkName) == 0); + auto endpointConfig = ResolveEndpointConfig(Options->Settings, Options->SettingsCount, Options->NetworkName); + auto lock = m_lock.lock_shared(); THROW_HR_WITH_USER_ERROR_IF( - E_INVALIDARG, - Localization::MessageWslcAdditionalNetworksRequirePrimary(), - m_networkMode == "host" || m_networkMode == "none"); + E_INVALIDARG, Localization::MessageWslcNetworkModeNoAdditionalNetworks(m_networkMode), !NetworkModeAllocatesVmPorts(m_networkMode)); common::docker_schema::ContainerNetworkRequest request{}; request.Container = m_id; + request.EndpointConfig = std::move(endpointConfig); try { @@ -2710,9 +2778,7 @@ void WSLCContainerImpl::DisconnectFromNetwork(LPCSTR NetworkName) auto lock = m_lock.lock_shared(); THROW_HR_WITH_USER_ERROR_IF( - E_INVALIDARG, - Localization::MessageWslcAdditionalNetworksRequirePrimary(), - m_networkMode == "host" || m_networkMode == "none"); + E_INVALIDARG, Localization::MessageWslcNetworkModeNoAdditionalNetworks(m_networkMode), !NetworkModeAllocatesVmPorts(m_networkMode)); common::docker_schema::ContainerNetworkRequest request{}; request.Container = m_id; diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index edc13644b6..ff939ed9d1 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -7159,12 +7159,10 @@ class WSLCTests ValidateCOMErrorMessageContains(L"Network name"); } - WSLC_TEST_METHOD(ContainerNetworkEndpointSettingsNotImplementedTest) + WSLC_TEST_METHOD(ContainerNetworkEndpointSettingsUnknownKeyTest) { - // Per-endpoint Settings (Aliases, IPAMConfig, etc.) are reserved for a future PR. - // Until that lands, any non-empty Settings payload must be rejected with E_NOTIMPL - // so callers don't silently lose data. - const std::string networkName = "custom-net-settings"; + // Unknown endpoint setting keys must be rejected at container creation time. + const std::string networkName = "custom-net-settings-unknown"; LOG_IF_FAILED(m_defaultSession->DeleteNetwork(networkName.c_str())); WSLCNetworkOptions networkOptions{}; @@ -7174,7 +7172,7 @@ class WSLCTests auto networkCleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteNetwork(networkName.c_str())); }); LPCSTR args[] = {"sleep", "99999"}; - KeyValuePair settings[] = {{"Aliases", "my-alias"}}; + KeyValuePair settings[] = {{"BogusKey", "value"}}; WSLCNetworkConnection connection{}; connection.NetworkName = networkName.c_str(); connection.Settings = settings; @@ -7182,7 +7180,7 @@ class WSLCTests WSLCContainerOptions options{}; options.Image = "debian:latest"; - options.Name = "test-endpoint-settings"; + options.Name = "test-endpoint-settings-unknown"; options.InitProcessOptions.CommandLine = {.Values = args, .Count = ARRAYSIZE(args)}; options.ContainerNetwork.NetworkMode = "bridge"; options.ContainerNetwork.Networks = &connection; @@ -7190,8 +7188,10 @@ class WSLCTests wil::com_ptr container; auto hr = m_defaultSession->CreateContainer(&options, nullptr, &container); - VERIFY_ARE_EQUAL(E_NOTIMPL, hr); - ValidateCOMErrorMessage(L"Endpoint settings are not yet supported (network 'custom-net-settings')."); + VERIFY_ARE_EQUAL(E_INVALIDARG, hr); + const auto expectedError = + std::format(L"Unknown endpoint setting 'BogusKey' for network '{}'.", std::wstring(networkName.begin(), networkName.end())); + ValidateCOMErrorMessage(expectedError); } WSLC_TEST_METHOD(ContainerUnsupportedColonNetworkModeRejectedTest) @@ -7544,34 +7544,36 @@ class WSLCTests expectBothReject(container, nonExistentNetwork.c_str(), WSLC_E_NETWORK_NOT_FOUND, expectedError.c_str()); } - // Host and none mode rejection. + // Host, none, and container:* mode rejection. { const std::string networkName = "test-connect-mode-net"; createNetwork(networkName, "172.52.0.0/16"); auto netCleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteNetwork(networkName.c_str())); }); - auto expectModeRejection = [&](const std::string& name, std::string mode) { - auto container = launchContainer(name, std::move(mode)); - expectBothReject( - container, - networkName.c_str(), - E_INVALIDARG, - L"Additional networks are not allowed when the primary network mode is 'host' or 'none'."); + auto expectModeRejection = [&](const std::string& name, const std::string& mode) { + auto container = launchContainer(name, mode); + const auto expected = std::format( + L"The primary network mode '{}' does not support connecting or disconnecting additional networks.", + std::wstring(mode.begin(), mode.end())); + expectBothReject(container, networkName.c_str(), E_INVALIDARG, expected.c_str()); }; expectModeRejection("test-connect-host-ctr", "host"); expectModeRejection("test-connect-none-ctr", "none"); - } - // ContainerIpAddress not supported. - { - auto container = launchContainer("test-connect-ip-ctr"); + // container: is resolved to container: internally, so the emitted mode string + // contains the target's container ID rather than its name. Verify HRESULT + a stable substring. + const std::string ctrModeTarget = "test-connect-ctrmode-target"; + auto target = launchContainer(ctrModeTarget); + auto ctrModeContainer = launchContainer("test-connect-ctrmode-ctr", "container:" + ctrModeTarget); + const std::wstring expectedSubstring = L"does not support connecting or disconnecting additional networks"; WSLCNetworkConnectionOptions options{}; - options.NetworkName = "bridge"; - options.ContainerIpAddress = "10.0.0.5"; - VERIFY_ARE_EQUAL(E_NOTIMPL, container.Get().ConnectToNetwork(&options)); - ValidateCOMErrorMessage(L"ContainerIpAddress is not yet supported."); + options.NetworkName = networkName.c_str(); + VERIFY_ARE_EQUAL(E_INVALIDARG, ctrModeContainer.Get().ConnectToNetwork(&options)); + ValidateCOMErrorMessageContains(expectedSubstring); + VERIFY_ARE_EQUAL(E_INVALIDARG, ctrModeContainer.Get().DisconnectFromNetwork(networkName.c_str())); + ValidateCOMErrorMessageContains(expectedSubstring); } // Connect and disconnect from the container's primary network. @@ -7617,6 +7619,221 @@ class WSLCTests } } + WSLC_TEST_METHOD(NetworkConnectEndpointSettingsTest) + { + const std::string networkName = "connect-endpoint-net"; + const std::string subnet = "172.70.0.0/16"; + + LOG_IF_FAILED(m_defaultSession->DeleteNetwork(networkName.c_str())); + WSLCNetworkOptions netOpts{}; + netOpts.Name = networkName.c_str(); + netOpts.Driver = "bridge"; + netOpts.Subnet = subnet.c_str(); + VERIFY_SUCCEEDED(m_defaultSession->CreateNetwork(&netOpts, nullptr)); + auto netCleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteNetwork(networkName.c_str())); }); + + auto launchContainer = [&](const std::string& name) { + WSLCContainerLauncher launcher("debian:latest", name, {"sleep", "99999"}, {}, "bridge"); + return launcher.Launch(*m_defaultSession); + }; + + auto expectConnectReject = + [&](auto& container, const std::vector& settings, HRESULT expectedResult, const std::wstring& expectedMessage) { + WSLCNetworkConnectionOptions options{}; + options.NetworkName = networkName.c_str(); + options.Settings = settings.data(); + options.SettingsCount = static_cast(settings.size()); + VERIFY_ARE_EQUAL(expectedResult, container.Get().ConnectToNetwork(&options)); + ValidateCOMErrorMessage(expectedMessage); + }; + + // Unknown endpoint setting key rejected. + { + auto container = launchContainer("connect-endpoint-unknown"); + const std::string unknownKey = "BogusKey"; + const std::string unknownValue = "value"; + const auto expected = std::format( + L"Unknown endpoint setting '{}' for network '{}'.", + std::wstring(unknownKey.begin(), unknownKey.end()), + std::wstring(networkName.begin(), networkName.end())); + expectConnectReject(container, {{unknownKey.c_str(), unknownValue.c_str()}}, E_INVALIDARG, expected); + } + + // Malformed IPv4 address rejected. + { + auto container = launchContainer("connect-endpoint-badip"); + const std::string badIp = "not-an-ip"; + const auto expected = std::format(L"Invalid IP address '{}'", std::wstring(badIp.begin(), badIp.end())); + expectConnectReject(container, {{"IPAddress", badIp.c_str()}}, E_INVALIDARG, expected); + } + + // Multiple IPAddress values rejected. + { + auto container = launchContainer("connect-endpoint-dupip"); + const std::string firstIp = "172.70.0.5"; + const std::string secondIp = "172.70.0.6"; + const auto expected = std::format( + L"Only one IP address may be specified for network '{}'.", std::wstring(networkName.begin(), networkName.end())); + expectConnectReject(container, {{"IPAddress", firstIp.c_str()}, {"IPAddress", secondIp.c_str()}}, E_INVALIDARG, expected); + } + + // Empty link rejected. + { + auto container = launchContainer("connect-endpoint-emptylink"); + expectConnectReject(container, {{"Links", ""}}, E_INVALIDARG, L"Network link cannot be empty."); + } + + // Malformed driver option (missing '=') rejected. + { + auto container = launchContainer("connect-endpoint-badopt"); + const std::string badEntry = "no-equals"; + const auto expected = + std::format(L"Invalid driver option '{}'; expected 'key=value'.", std::wstring(badEntry.begin(), badEntry.end())); + expectConnectReject(container, {{"DriverOpts", badEntry.c_str()}}, E_INVALIDARG, expected); + } + + // Duplicate driver option key rejected. + { + auto container = launchContainer("connect-endpoint-dupopt"); + const std::string dupKey = "mtu"; + const std::string firstEntry = "mtu=1500"; + const std::string secondEntry = "mtu=1400"; + const auto expected = std::format(L"Duplicate driver option '{}'.", std::wstring(dupKey.begin(), dupKey.end())); + expectConnectReject(container, {{"DriverOpts", firstEntry.c_str()}, {"DriverOpts", secondEntry.c_str()}}, E_INVALIDARG, expected); + } + + // Malformed link-local IPv4 address rejected. + { + auto container = launchContainer("connect-endpoint-badlli"); + const std::string badIp = "not-a-link-local"; + const auto expected = std::format(L"Invalid IP address '{}'", std::wstring(badIp.begin(), badIp.end())); + expectConnectReject(container, {{"LinkLocalIPs", badIp.c_str()}}, E_INVALIDARG, expected); + } + + // Empty/whitespace-only alias rejected. + { + auto container = launchContainer("connect-endpoint-emptyalias"); + expectConnectReject(container, {{"Aliases", " "}}, E_INVALIDARG, L"Network alias cannot be empty."); + } + + // Driver option with empty key ('=value') rejected. + { + auto container = launchContainer("connect-endpoint-emptykey"); + const std::string badEntry = "=value"; + const auto expected = + std::format(L"Invalid driver option '{}'; expected 'key=value'.", std::wstring(badEntry.begin(), badEntry.end())); + expectConnectReject(container, {{"DriverOpts", badEntry.c_str()}}, E_INVALIDARG, expected); + } + + // Driver option value containing '=' — everything after the first '=' is preserved verbatim. + { + auto container = launchContainer("connect-endpoint-eqvalue"); + const std::string driverOptKey = "com.docker.network.endpoint.custom"; + const std::string driverOptValue = "a=b=c"; + const std::string driverOptEntry = driverOptKey + "=" + driverOptValue; + + const std::vector settings{{"DriverOpts", driverOptEntry.c_str()}}; + WSLCNetworkConnectionOptions options{}; + options.NetworkName = networkName.c_str(); + options.Settings = settings.data(); + options.SettingsCount = static_cast(settings.size()); + VERIFY_SUCCEEDED(container.Get().ConnectToNetwork(&options)); + + auto inspect = container.Inspect(); + VERIFY_IS_TRUE(inspect.NetworkSettings.Networks.contains(networkName)); + const auto& endpoint = inspect.NetworkSettings.Networks.at(networkName); + const auto opt = endpoint.DriverOpts.find(driverOptKey); + VERIFY_IS_TRUE(opt != endpoint.DriverOpts.end()); + VERIFY_ARE_EQUAL(driverOptValue, opt->second); + } + + // Aliases + IPAddress + LinkLocalIPs + DriverOpts together — success, inspect verifies every field. + { + auto container = launchContainer("connect-endpoint-combo"); + const std::string alias1 = "primary-alias"; + const std::string alias2 = "secondary-alias"; + const std::string ipAddress = "172.70.0.42"; + const std::string linkLocal = "169.254.10.5"; + const std::string driverOptEntry = "com.docker.network.endpoint.custom=verify"; + const std::string driverOptKey = "com.docker.network.endpoint.custom"; + const std::string driverOptValue = "verify"; + + const std::vector settings{ + {"Aliases", alias1.c_str()}, + {"Aliases", alias2.c_str()}, + {"IPAddress", ipAddress.c_str()}, + {"LinkLocalIPs", linkLocal.c_str()}, + {"DriverOpts", driverOptEntry.c_str()}, + }; + + WSLCNetworkConnectionOptions options{}; + options.NetworkName = networkName.c_str(); + options.Settings = settings.data(); + options.SettingsCount = static_cast(settings.size()); + VERIFY_SUCCEEDED(container.Get().ConnectToNetwork(&options)); + + auto inspect = container.Inspect(); + VERIFY_IS_TRUE(inspect.NetworkSettings.Networks.contains(networkName)); + const auto& endpoint = inspect.NetworkSettings.Networks.at(networkName); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Aliases, alias1) != endpoint.Aliases.end()); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Aliases, alias2) != endpoint.Aliases.end()); + VERIFY_ARE_EQUAL(ipAddress, endpoint.IPAddress); + VERIFY_IS_TRUE(endpoint.IPAMConfig.has_value()); + VERIFY_ARE_EQUAL(ipAddress, endpoint.IPAMConfig->IPv4Address); + VERIFY_IS_TRUE(std::ranges::find(endpoint.IPAMConfig->LinkLocalIPs, linkLocal) != endpoint.IPAMConfig->LinkLocalIPs.end()); + const auto opt = endpoint.DriverOpts.find(driverOptKey); + VERIFY_IS_TRUE(opt != endpoint.DriverOpts.end()); + VERIFY_ARE_EQUAL(driverOptValue, opt->second); + } + + // Links: connect two containers to the same user-defined bridge, verify Links echoes back in inspect. + { + const std::string targetName = "connect-endpoint-link-target"; + const std::string targetAlias = "db"; + auto target = launchContainer(targetName); + WSLCNetworkConnectionOptions targetOptions{}; + targetOptions.NetworkName = networkName.c_str(); + const std::vector targetSettings{{"Aliases", targetAlias.c_str()}}; + targetOptions.Settings = targetSettings.data(); + targetOptions.SettingsCount = static_cast(targetSettings.size()); + VERIFY_SUCCEEDED(target.Get().ConnectToNetwork(&targetOptions)); + + auto source = launchContainer("connect-endpoint-link-source"); + const std::string linkEntry = targetName + ":" + targetAlias; + const std::vector sourceSettings{{"Links", linkEntry.c_str()}}; + WSLCNetworkConnectionOptions sourceOptions{}; + sourceOptions.NetworkName = networkName.c_str(); + sourceOptions.Settings = sourceSettings.data(); + sourceOptions.SettingsCount = static_cast(sourceSettings.size()); + VERIFY_SUCCEEDED(source.Get().ConnectToNetwork(&sourceOptions)); + + auto inspect = source.Inspect(); + VERIFY_IS_TRUE(inspect.NetworkSettings.Networks.contains(networkName)); + const auto& endpoint = inspect.NetworkSettings.Networks.at(networkName); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Links, linkEntry) != endpoint.Links.end()); + } + + // Connecting a stopped container succeeds — Docker allows attach in created/exited state. + { + auto container = launchContainer("connect-endpoint-stopped"); + VERIFY_SUCCEEDED(container.Get().Stop(WSLCSignalSIGKILL, 0)); + + const std::string alias = "stopped-alias"; + const std::vector settings{{"Aliases", alias.c_str()}}; + + WSLCNetworkConnectionOptions options{}; + options.NetworkName = networkName.c_str(); + options.Settings = settings.data(); + options.SettingsCount = static_cast(settings.size()); + VERIFY_SUCCEEDED(container.Get().ConnectToNetwork(&options)); + + auto inspect = container.Inspect(); + VERIFY_IS_TRUE(inspect.NetworkSettings.Networks.contains(networkName)); + const auto& endpoint = inspect.NetworkSettings.Networks.at(networkName); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Aliases, alias) != endpoint.Aliases.end()); + } + } + WSLC_TEST_METHOD(NetworkAliasCreateTest) { auto createNetwork = [&](const std::string& name, const char* subnet) { @@ -7729,14 +7946,16 @@ class WSLCTests expectError("alias-ctr-empty", networkName, {""}, E_INVALIDARG, L"Network alias cannot be empty."); } - // Unknown KVP key on primary settings — rejected with E_NOTIMPL. + // Unknown KVP key on primary settings — rejected with the unified endpoint-settings error. { const std::string networkName = "alias-net-unknown"; + const std::string unknownKey = "BogusKey"; + const std::string unknownValue = "value"; createNetwork(networkName, "172.63.0.0/16"); auto netCleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteNetwork(networkName.c_str())); }); LPCSTR args[] = {"sleep", "99999"}; - const KeyValuePair settings[] = {{"IPAddress", "10.0.0.5"}}; + const KeyValuePair settings[] = {{unknownKey.c_str(), unknownValue.c_str()}}; WSLCContainerOptions options{}; options.Image = "debian:latest"; options.Name = "alias-ctr-unknown"; @@ -7746,11 +7965,92 @@ class WSLCTests options.ContainerNetwork.SettingsCount = ARRAYSIZE(settings); wil::com_ptr container; - VERIFY_ARE_EQUAL(E_NOTIMPL, m_defaultSession->CreateContainer(&options, nullptr, &container)); + VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->CreateContainer(&options, nullptr, &container)); ValidateCOMErrorMessage(std::format( - L"Endpoint settings are not yet supported (network '{}').", - std::wstring(networkName.begin(), networkName.end())) - .c_str()); + L"Unknown endpoint setting '{}' for network '{}'.", + std::wstring(unknownKey.begin(), unknownKey.end()), + std::wstring(networkName.begin(), networkName.end()))); + } + + // Primary endpoint settings (IPAddress + Aliases + LinkLocalIPs + DriverOpts) round-trip via inspect at create time. + { + const std::string networkName = "alias-net-combo"; + createNetwork(networkName, "172.64.0.0/16"); + auto netCleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteNetwork(networkName.c_str())); }); + + const std::string alias1 = "primary-alias"; + const std::string alias2 = "secondary-alias"; + const std::string ipAddress = "172.64.0.42"; + const std::string linkLocal = "169.254.11.5"; + const std::string driverOptEntry = "com.docker.network.endpoint.custom=verify"; + const std::string driverOptKey = "com.docker.network.endpoint.custom"; + const std::string driverOptValue = "verify"; + + LPCSTR args[] = {"sleep", "99999"}; + const KeyValuePair settings[] = { + {"Aliases", alias1.c_str()}, + {"Aliases", alias2.c_str()}, + {"IPAddress", ipAddress.c_str()}, + {"LinkLocalIPs", linkLocal.c_str()}, + {"DriverOpts", driverOptEntry.c_str()}, + }; + WSLCContainerOptions options{}; + options.Image = "debian:latest"; + options.Name = "alias-ctr-combo"; + options.InitProcessOptions.CommandLine = {.Values = args, .Count = ARRAYSIZE(args)}; + options.ContainerNetwork.NetworkMode = networkName.c_str(); + options.ContainerNetwork.Settings = settings; + options.ContainerNetwork.SettingsCount = ARRAYSIZE(settings); + + wil::com_ptr containerCom; + VERIFY_SUCCEEDED(m_defaultSession->CreateContainer(&options, nullptr, &containerCom)); + RunningWSLCContainer container(std::move(containerCom), WSLCProcessFlagsNone); + VERIFY_SUCCEEDED(container.Get().Start(WSLCContainerStartFlagsAttach, nullptr, nullptr)); + + auto inspect = container.Inspect(); + VERIFY_IS_TRUE(inspect.NetworkSettings.Networks.contains(networkName)); + const auto& endpoint = inspect.NetworkSettings.Networks.at(networkName); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Aliases, alias1) != endpoint.Aliases.end()); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Aliases, alias2) != endpoint.Aliases.end()); + VERIFY_ARE_EQUAL(ipAddress, endpoint.IPAddress); + VERIFY_IS_TRUE(endpoint.IPAMConfig.has_value()); + VERIFY_ARE_EQUAL(ipAddress, endpoint.IPAMConfig->IPv4Address); + VERIFY_IS_TRUE(std::ranges::find(endpoint.IPAMConfig->LinkLocalIPs, linkLocal) != endpoint.IPAMConfig->LinkLocalIPs.end()); + const auto opt = endpoint.DriverOpts.find(driverOptKey); + VERIFY_IS_TRUE(opt != endpoint.DriverOpts.end()); + VERIFY_ARE_EQUAL(driverOptValue, opt->second); + } + + // Primary endpoint Links: launch a target container with an alias, then a source with --link at create time. + { + const std::string networkName = "alias-net-link"; + const std::string targetName = "alias-ctr-link-target"; + const std::string targetAlias = "db"; + createNetwork(networkName, "172.65.0.0/16"); + auto netCleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteNetwork(networkName.c_str())); }); + + auto target = launchWithAliases(targetName, networkName, {targetAlias}); + + const std::string linkEntry = targetName + ":" + targetAlias; + LPCSTR args[] = {"sleep", "99999"}; + const KeyValuePair settings[] = {{"Links", linkEntry.c_str()}}; + WSLCContainerOptions options{}; + options.Image = "debian:latest"; + options.Name = "alias-ctr-link-source"; + options.InitProcessOptions.CommandLine = {.Values = args, .Count = ARRAYSIZE(args)}; + options.ContainerNetwork.NetworkMode = networkName.c_str(); + options.ContainerNetwork.Settings = settings; + options.ContainerNetwork.SettingsCount = ARRAYSIZE(settings); + + wil::com_ptr sourceCom; + VERIFY_SUCCEEDED(m_defaultSession->CreateContainer(&options, nullptr, &sourceCom)); + RunningWSLCContainer source(std::move(sourceCom), WSLCProcessFlagsNone); + VERIFY_SUCCEEDED(source.Get().Start(WSLCContainerStartFlagsAttach, nullptr, nullptr)); + + auto inspect = source.Inspect(); + VERIFY_IS_TRUE(inspect.NetworkSettings.Networks.contains(networkName)); + const auto& endpoint = inspect.NetworkSettings.Networks.at(networkName); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Links, linkEntry) != endpoint.Links.end()); } } diff --git a/test/windows/wslc/WSLCCLIExecutionUnitTests.cpp b/test/windows/wslc/WSLCCLIExecutionUnitTests.cpp index 9a5ca6218c..2cc25cb0ef 100644 --- a/test/windows/wslc/WSLCCLIExecutionUnitTests.cpp +++ b/test/windows/wslc/WSLCCLIExecutionUnitTests.cpp @@ -110,6 +110,12 @@ class WSLCCLIExecutionUnitTests dataMap.Add(std::move(networks)); handled = true; } + else if (dataType == Data::NetworkEndpointOptions) + { + wsl::windows::wslc::models::NetworkEndpointOptions endpointOptions; + dataMap.Add(std::move(endpointOptions)); + handled = true; + } if (!handled) { diff --git a/test/windows/wslc/e2e/WSLCE2ENetworkTests.cpp b/test/windows/wslc/e2e/WSLCE2ENetworkTests.cpp index e31a7258e7..7a9964e0ca 100644 --- a/test/windows/wslc/e2e/WSLCE2ENetworkTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2ENetworkTests.cpp @@ -33,6 +33,7 @@ class WSLCE2ENetworkTests TEST_CLASS_CLEANUP(ClassCleanup) { EnsureContainerDoesNotExist(WslcContainerName); + EnsureContainerDoesNotExist(WslcTargetContainerName); EnsureNetworkDoesNotExist(TestNetworkName); EnsureImageIsDeleted(DebianImage); return true; @@ -41,6 +42,7 @@ class WSLCE2ENetworkTests TEST_METHOD_SETUP(TestMethodSetup) { EnsureContainerDoesNotExist(WslcContainerName); + EnsureContainerDoesNotExist(WslcTargetContainerName); EnsureNetworkDoesNotExist(TestNetworkName); return true; } @@ -156,7 +158,9 @@ class WSLCE2ENetworkTests result = RunWslc(std::format(L"network connect {} {}", TestNetworkName, WslcContainerName)); VERIFY_ARE_EQUAL(1u, result.ExitCode.value()); VERIFY_IS_TRUE(result.Stderr.has_value()); - VerifyPatternMatch(string::WideToMultiByte(result.Stderr.value()), "*'host' or 'none'*Error code: *\r\n"); + VerifyPatternMatch( + string::WideToMultiByte(result.Stderr.value()), + "*does not support connecting or disconnecting additional networks*Error code: *\r\n"); } WSLC_TEST_METHOD(WSLCE2E_Network_Connect_AlreadyConnected_DockerErrorPropagated) @@ -174,6 +178,110 @@ class WSLCE2ENetworkTests VerifyPatternMatch(string::WideToMultiByte(result.Stderr.value()), "*already exists*Error code: *\r\n"); } + WSLC_TEST_METHOD(WSLCE2E_Network_Connect_WithEndpointFlags_RoundTrips) + { + const std::wstring subnet = L"172.72.0.0/16"; + const std::wstring ipAddress = L"172.72.0.42"; + const std::wstring alias1 = L"primary-alias"; + const std::wstring alias2 = L"secondary-alias"; + const std::wstring linkLocal = L"169.254.11.5"; + + auto result = RunWslc(std::format(L"network create --driver bridge --subnet {} {}", subnet, TestNetworkName)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"container run -d --name {} {} sleep infinity", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format( + L"network connect --network-alias {} --network-alias {} --ip {} --link-local-ip {} {} {}", alias1, alias2, ipAddress, linkLocal, TestNetworkName, WslcContainerName)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + const auto networkKey = string::WideToMultiByte(TestNetworkName); + VERIFY_IS_TRUE(inspect.NetworkSettings.Networks.contains(networkKey)); + const auto& endpoint = inspect.NetworkSettings.Networks.at(networkKey); + VERIFY_ARE_EQUAL(string::WideToMultiByte(ipAddress), endpoint.IPAddress); + VERIFY_IS_TRUE(endpoint.IPAMConfig.has_value()); + VERIFY_ARE_EQUAL(string::WideToMultiByte(ipAddress), endpoint.IPAMConfig->IPv4Address); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Aliases, string::WideToMultiByte(alias1)) != endpoint.Aliases.end()); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Aliases, string::WideToMultiByte(alias2)) != endpoint.Aliases.end()); + VERIFY_IS_TRUE( + std::ranges::find(endpoint.IPAMConfig->LinkLocalIPs, string::WideToMultiByte(linkLocal)) != + endpoint.IPAMConfig->LinkLocalIPs.end()); + } + + WSLC_TEST_METHOD(WSLCE2E_Network_Connect_InvalidIp_Rejected) + { + const std::wstring badIp = L"not-an-ip"; + + auto result = RunWslc(std::format(L"network create --driver bridge {}", TestNetworkName)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"container run -d --name {} {} sleep infinity", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"network connect --ip {} {} {}", badIp, TestNetworkName, WslcContainerName)); + VERIFY_ARE_EQUAL(1u, result.ExitCode.value()); + VERIFY_IS_TRUE(result.Stderr.has_value()); + VerifyPatternMatch( + string::WideToMultiByte(result.Stderr.value()), std::format("*Invalid IP address '{}'*", string::WideToMultiByte(badIp))); + } + + WSLC_TEST_METHOD(WSLCE2E_Network_Connect_DriverOpt_RoundTrips) + { + const std::wstring driverOptKey = L"com.docker.network.endpoint.custom"; + const std::wstring driverOptValue = L"verify"; + + auto result = RunWslc(std::format(L"network create --driver bridge {}", TestNetworkName)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"container run -d --name {} {} sleep infinity", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"network connect --driver-opt {}={} {} {}", driverOptKey, driverOptValue, TestNetworkName, WslcContainerName)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + const auto networkKey = string::WideToMultiByte(TestNetworkName); + VERIFY_IS_TRUE(inspect.NetworkSettings.Networks.contains(networkKey)); + const auto& endpoint = inspect.NetworkSettings.Networks.at(networkKey); + const auto keyUtf8 = string::WideToMultiByte(driverOptKey); + const auto valueUtf8 = string::WideToMultiByte(driverOptValue); + const auto driverOptIt = endpoint.DriverOpts.find(keyUtf8); + VERIFY_IS_TRUE(driverOptIt != endpoint.DriverOpts.end()); + VERIFY_ARE_EQUAL(valueUtf8, driverOptIt->second); + } + + WSLC_TEST_METHOD(WSLCE2E_Network_Connect_Link_RoundTrips) + { + const std::wstring targetAlias = L"db"; + + auto result = RunWslc(std::format(L"network create --driver bridge {}", TestNetworkName)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format( + L"container run -d --network {} --network-alias {} --name {} {} sleep infinity", + TestNetworkName, + targetAlias, + WslcTargetContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"container run -d --name {} {} sleep infinity", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const std::wstring linkEntry = std::format(L"{}:{}", WslcTargetContainerName, targetAlias); + result = RunWslc(std::format(L"network connect --link {} {} {}", linkEntry, TestNetworkName, WslcContainerName)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + const auto networkKey = string::WideToMultiByte(TestNetworkName); + VERIFY_IS_TRUE(inspect.NetworkSettings.Networks.contains(networkKey)); + const auto& endpoint = inspect.NetworkSettings.Networks.at(networkKey); + const auto linkEntryUtf8 = string::WideToMultiByte(linkEntry); + VERIFY_IS_TRUE(std::ranges::find(endpoint.Links, linkEntryUtf8) != endpoint.Links.end()); + } + WSLC_TEST_METHOD(WSLCE2E_Network_Disconnect_Valid) { auto result = RunWslc(std::format(L"network create --driver bridge {}", TestNetworkName)); @@ -218,6 +326,7 @@ class WSLCE2ENetworkTests private: const std::wstring WslcContainerName = L"wslc-e2e-network-connect-container"; + const std::wstring WslcTargetContainerName = L"wslc-e2e-network-connect-target"; const std::wstring TestNetworkName = L"wslc-e2e-network-connect"; const TestImage& DebianImage = DebianTestImage(); };