diff --git a/CMakeLists.txt b/CMakeLists.txt index 7119eafcc..1400001af 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -285,6 +285,7 @@ set(COMMON_LINK_LIBRARIES Shlwapi.lib synchronization.lib Bcrypt.lib + Crypt32.lib icu.lib) set(MSI_LINK_LIBRARIES diff --git a/packages.config b/packages.config index 3063f85a1..98e332420 100644 --- a/packages.config +++ b/packages.config @@ -21,7 +21,7 @@ - + diff --git a/src/windows/WslcSDK/wslcsdk.cpp b/src/windows/WslcSDK/wslcsdk.cpp index e78f511ea..702d78251 100644 --- a/src/windows/WslcSDK/wslcsdk.cpp +++ b/src/windows/WslcSDK/wslcsdk.cpp @@ -685,7 +685,6 @@ try containerOptions.ContainerNetwork.ContainerNetworkType = internalContainerSettings->networking; // TODO: No user access - // containerOptions.Entrypoint; // containerOptions.Labels; // containerOptions.LabelsCount; // containerOptions.StopSignal; @@ -1186,8 +1185,7 @@ try auto progressCallback = ProgressCallback::CreateIf(options); - // TODO: Auth - return errorInfoWrapper.CaptureResult(internalType->session->PullImage(options->uri, nullptr, progressCallback.get())); + return errorInfoWrapper.CaptureResult(internalType->session->PullImage(options->uri, options->registryAuth, progressCallback.get())); } CATCH_RETURN(); @@ -1282,6 +1280,72 @@ try } CATCH_RETURN(); +STDAPI WslcTagSessionImage(_In_ WslcSession session, _In_ const WslcTagImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage) +try +{ + ErrorInfoWrapper errorInfoWrapper{errorMessage}; + auto internalType = CheckAndGetInternalType(session); + RETURN_HR_IF_NULL(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), internalType->session); + RETURN_HR_IF_NULL(E_POINTER, options); + RETURN_HR_IF_NULL(E_INVALIDARG, options->image); + RETURN_HR_IF_NULL(E_INVALIDARG, options->repo); + RETURN_HR_IF_NULL(E_INVALIDARG, options->tag); + + WSLCTagImageOptions runtimeOptions{}; + runtimeOptions.Image = options->image; + runtimeOptions.Repo = options->repo; + runtimeOptions.Tag = options->tag; + + return errorInfoWrapper.CaptureResult(internalType->session->TagImage(&runtimeOptions)); +} +CATCH_RETURN(); + +STDAPI WslcPushSessionImage(_In_ WslcSession session, _In_ const WslcPushImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage) +try +{ + ErrorInfoWrapper errorInfoWrapper{errorMessage}; + auto internalType = CheckAndGetInternalType(session); + RETURN_HR_IF_NULL(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), internalType->session); + RETURN_HR_IF_NULL(E_POINTER, options); + RETURN_HR_IF_NULL(E_INVALIDARG, options->image); + RETURN_HR_IF_NULL(E_INVALIDARG, options->registryAuth); + + auto progressCallback = ProgressCallback::CreateIf(options); + + return errorInfoWrapper.CaptureResult(internalType->session->PushImage(options->image, options->registryAuth, progressCallback.get())); +} +CATCH_RETURN(); + +STDAPI WslcSessionAuthenticate( + _In_ WslcSession session, + _In_z_ PCSTR serverAddress, + _In_z_ PCSTR username, + _In_z_ PCSTR password, + _Outptr_result_z_ PSTR* identityToken, + _Outptr_opt_result_z_ PWSTR* errorMessage) +try +{ + ErrorInfoWrapper errorInfoWrapper{errorMessage}; + auto internalType = CheckAndGetInternalType(session); + RETURN_HR_IF_NULL(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), internalType->session); + RETURN_HR_IF_NULL(E_POINTER, serverAddress); + RETURN_HR_IF_NULL(E_POINTER, username); + RETURN_HR_IF_NULL(E_POINTER, password); + RETURN_HR_IF_NULL(E_POINTER, identityToken); + + *identityToken = nullptr; + + wil::unique_cotaskmem_ansistring token; + auto hr = errorInfoWrapper.CaptureResult(internalType->session->Authenticate(serverAddress, username, password, &token)); + if (SUCCEEDED(hr)) + { + *identityToken = token.release(); + } + + return errorInfoWrapper; +} +CATCH_RETURN(); + STDAPI WslcListSessionImages(_In_ WslcSession session, _Outptr_result_buffer_(*count) WslcImageInfo** images, _Out_ uint32_t* count) try { diff --git a/src/windows/WslcSDK/wslcsdk.def b/src/windows/WslcSDK/wslcsdk.def index 63e60a436..800c57d5c 100644 --- a/src/windows/WslcSDK/wslcsdk.def +++ b/src/windows/WslcSDK/wslcsdk.def @@ -23,6 +23,7 @@ WslcSetSessionSettingsTimeout WslcSetSessionSettingsVhd WslcTerminateSession +WslcSessionAuthenticate WslcPullSessionImage WslcImportSessionImage WslcImportSessionImageFromFile @@ -32,6 +33,8 @@ WslcDeleteSessionImage WslcListSessionImages WslcCreateSessionVhdVolume WslcDeleteSessionVhdVolume +WslcTagSessionImage +WslcPushSessionImage WslcSetContainerSettingsDomainName WslcSetContainerSettingsName diff --git a/src/windows/WslcSDK/wslcsdk.h b/src/windows/WslcSDK/wslcsdk.h index 33d45e11e..5aa51dc84 100644 --- a/src/windows/WslcSDK/wslcsdk.h +++ b/src/windows/WslcSDK/wslcsdk.h @@ -392,11 +392,6 @@ typedef struct WslcImageProgressMessage _Out_ WslcImageProgressDetail detail; } WslcImageProgressMessage; -typedef struct WslcRegistryAuthenticationInformation -{ - // TBD -} WslcRegistryAuthenticationInformation; - // pointer-to-function typedef (unambiguous) typedef HRESULT(CALLBACK* WslcContainerImageProgressCallback)(const WslcImageProgressMessage* progress, PVOID context); @@ -406,7 +401,7 @@ typedef struct WslcPullImageOptions _In_z_ PCSTR uri; WslcContainerImageProgressCallback progressCallback; PVOID progressCallbackContext; - _In_opt_ const WslcRegistryAuthenticationInformation* authInfo; + _In_opt_z_ PCSTR registryAuth; } WslcPullImageOptions; STDAPI WslcPullSessionImage(_In_ WslcSession session, _In_ const WslcPullImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage); @@ -457,6 +452,58 @@ typedef struct WslcImageInfo STDAPI WslcDeleteSessionImage(_In_ WslcSession session, _In_z_ PCSTR nameOrId, _Outptr_opt_result_z_ PWSTR* errorMessage); +typedef struct WslcTagImageOptions +{ + _In_z_ PCSTR image; // Source image name or ID. + _In_z_ PCSTR repo; // Target repository name. + _In_z_ PCSTR tag; // Target tag name. +} WslcTagImageOptions; + +STDAPI WslcTagSessionImage(_In_ WslcSession session, _In_ const WslcTagImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage); + +typedef struct WslcPushImageOptions +{ + _In_z_ PCSTR image; + _In_z_ PCSTR registryAuth; // Base64-encoded X-Registry-Auth header value. + _In_opt_ WslcContainerImageProgressCallback progressCallback; + _In_opt_ PVOID progressCallbackContext; +} WslcPushImageOptions; + +STDAPI WslcPushSessionImage(_In_ WslcSession session, _In_ const WslcPushImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage); + +// Authenticates with a container registry and returns an identity token. +// +// Parameters: +// session +// A valid WslcSession handle. +// +// serverAddress +// The registry server address (e.g. "127.0.0.1:5000"). +// +// username +// The username for authentication. +// +// password +// The password for authentication. +// +// identityToken +// On success, receives a pointer to a null-terminated ANSI string +// containing the identity token. +// +// The string is allocated using CoTaskMemAlloc. The caller takes +// ownership of the returned memory and must free it by calling +// CoTaskMemFree when it is no longer needed. +// +// Return Value: +// S_OK on success. Otherwise, an HRESULT error code indicating the failure. +STDAPI WslcSessionAuthenticate( + _In_ WslcSession session, + _In_z_ PCSTR serverAddress, + _In_z_ PCSTR username, + _In_z_ PCSTR password, + _Outptr_result_z_ PSTR* identityToken, + _Outptr_opt_result_z_ PWSTR* errorMessage); + // Retrieves the list of container images // Parameters: // session diff --git a/src/windows/common/WSLCContainerLauncher.cpp b/src/windows/common/WSLCContainerLauncher.cpp index 4d0744b9c..78eb56403 100644 --- a/src/windows/common/WSLCContainerLauncher.cpp +++ b/src/windows/common/WSLCContainerLauncher.cpp @@ -11,6 +11,8 @@ Module Name: This file contains the implementation for WSLCContainerLauncher. --*/ + +#include "precomp.h" #include "WSLCContainerLauncher.h" using wsl::windows::common::ClientRunningWSLCProcess; diff --git a/src/windows/common/WSLCContainerLauncher.h b/src/windows/common/WSLCContainerLauncher.h index 900a24c06..4643bc875 100644 --- a/src/windows/common/WSLCContainerLauncher.h +++ b/src/windows/common/WSLCContainerLauncher.h @@ -79,6 +79,7 @@ class WSLCContainerLauncher : private WSLCProcessLauncher void SetDnsSearchDomains(std::vector&& DnsSearchDomains); void SetDnsOptions(std::vector&& DnsOptions); + using WSLCProcessLauncher::FormatResult; using WSLCProcessLauncher::SetUser; using WSLCProcessLauncher::SetWorkingDirectory; diff --git a/src/windows/common/wslutil.cpp b/src/windows/common/wslutil.cpp index d9a71e77f..3b3783e0e 100644 --- a/src/windows/common/wslutil.cpp +++ b/src/windows/common/wslutil.cpp @@ -15,6 +15,7 @@ Module Name: #include "precomp.h" #include "wslutil.h" #include "WslPluginApi.h" +#include #include "wslinstallerservice.h" #include "wslc.h" @@ -1422,4 +1423,49 @@ catch (...) { LOG_CAUGHT_EXCEPTION(); return nullptr; +} + +std::string wsl::windows::common::wslutil::Base64Encode(const std::string& input) +{ + DWORD base64Size = 0; + THROW_IF_WIN32_BOOL_FALSE(CryptBinaryToStringA( + reinterpret_cast(input.c_str()), static_cast(input.size()), CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, nullptr, &base64Size)); + + auto buffer = std::make_unique(base64Size); + THROW_IF_WIN32_BOOL_FALSE(CryptBinaryToStringA( + reinterpret_cast(input.c_str()), + static_cast(input.size()), + CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, + buffer.get(), + &base64Size)); + + return std::string(buffer.get()); +} + +std::string wsl::windows::common::wslutil::Base64Decode(const std::string& encoded) +{ + DWORD size = 0; + THROW_IF_WIN32_BOOL_FALSE(CryptStringToBinaryA( + encoded.c_str(), static_cast(encoded.size()), CRYPT_STRING_BASE64, nullptr, &size, nullptr, nullptr)); + + std::string result(size, '\0'); + THROW_IF_WIN32_BOOL_FALSE(CryptStringToBinaryA( + encoded.c_str(), static_cast(encoded.size()), CRYPT_STRING_BASE64, reinterpret_cast(result.data()), &size, nullptr, nullptr)); + + result.resize(size); + return result; +} + +std::string wsl::windows::common::wslutil::BuildRegistryAuthHeader(const std::string& username, const std::string& password, const std::string& serverAddress) +{ + nlohmann::json authJson = {{"username", username}, {"password", password}, {"serveraddress", serverAddress}}; + + return Base64Encode(authJson.dump()); +} + +std::string wsl::windows::common::wslutil::BuildRegistryAuthHeader(const std::string& identityToken, const std::string& serverAddress) +{ + nlohmann::json authJson = {{"identitytoken", identityToken}, {"serveraddress", serverAddress}}; + + return Base64Encode(authJson.dump()); } \ No newline at end of file diff --git a/src/windows/common/wslutil.h b/src/windows/common/wslutil.h index defb0d309..5ae95f0c9 100644 --- a/src/windows/common/wslutil.h +++ b/src/windows/common/wslutil.h @@ -330,4 +330,15 @@ WSLCHandle ToCOMInputHandle(HANDLE Handle); winrt::Windows::Management::Deployment::PackageVolume GetSystemVolume(); +std::string Base64Encode(const std::string& input); +std::string Base64Decode(const std::string& encoded); + +// Builds the base64-encoded X-Registry-Auth header value used by Docker APIs +// (PullImage, PushImage, etc.) from the given credentials. +std::string BuildRegistryAuthHeader(const std::string& username, const std::string& password, const std::string& serverAddress); + +// Builds the base64-encoded X-Registry-Auth header value from an identity token +// returned by Authenticate(). +std::string BuildRegistryAuthHeader(const std::string& identityToken, const std::string& serverAddress); + } // namespace wsl::windows::common::wslutil diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index e56e7ec4a..accb4cfbc 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -48,6 +48,25 @@ struct EmptyRequest using TResponse = void; }; +struct AuthRequest +{ + using TResponse = struct AuthResponse; + + std::string username; + std::string password; + std::string serveraddress; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(AuthRequest, username, password, serveraddress); +}; + +struct AuthResponse +{ + std::string Status; + std::optional IdentityToken; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(AuthResponse, Status, IdentityToken); +}; + struct CreateVolume { using TResponse = void; diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 94ee186c1..f6517fd70 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -677,6 +677,9 @@ interface IWSLCSession : IUnknown HRESULT DeleteVolume([in] LPCSTR Name); HRESULT ListVolumes([out, size_is(, *Count)] WSLCVolumeInformation** Volumes, [out] ULONG* Count); HRESULT InspectVolume([in] LPCSTR Name, [out] LPSTR* Output); + + HRESULT Authenticate([in] LPCSTR ServerAddress, [in] LPCSTR Username, [in] LPCSTR Password, [out] LPSTR* IdentityToken); + HRESULT PushImage([in] LPCSTR Image, [in] LPCSTR RegistryAuthenticationInformation, [in, unique] IProgressCallback* ProgressCallback); } // diff --git a/src/windows/wslcsession/DockerHTTPClient.cpp b/src/windows/wslcsession/DockerHTTPClient.cpp index 38870c82b..793997a44 100644 --- a/src/windows/wslcsession/DockerHTTPClient.cpp +++ b/src/windows/wslcsession/DockerHTTPClient.cpp @@ -149,7 +149,8 @@ DockerHTTPClient::DockerHTTPClient(wsl::shared::SocketChannel&& Channel, HANDLE { } -std::unique_ptr DockerHTTPClient::PullImage(const std::string& Repo, const std::optional& tagOrDigest) +std::unique_ptr DockerHTTPClient::PullImage( + const std::string& Repo, const std::optional& tagOrDigest, const std::optional& registryAuth) { auto url = URL::Create("/images/create"); @@ -162,16 +163,20 @@ std::unique_ptr DockerHTTPClient::PullImag url.SetParameter("tag", tagOrDigest.value()); } - return SendRequestImpl(verb::post, url, {}, {}); + std::map customHeaders; + + if (registryAuth.has_value()) + { + customHeaders["X-Registry-Auth"] = registryAuth.value(); + } + + return SendRequestImpl(verb::post, url, {}, customHeaders); } std::unique_ptr DockerHTTPClient::LoadImage(uint64_t ContentLength) { return SendRequestImpl( - verb::post, - URL::Create("/images/load"), - {}, - {{http::field::content_type, "application/x-tar"}, {http::field::content_length, std::to_string(ContentLength)}}); + verb::post, URL::Create("/images/load"), {}, {{"Content-Type", "application/x-tar"}, {"Content-Length", std::to_string(ContentLength)}}); } std::unique_ptr DockerHTTPClient::ImportImage(const std::string& Repo, const std::string& Tag, uint64_t ContentLength) @@ -181,8 +186,7 @@ std::unique_ptr DockerHTTPClient::ImportIm url.SetParameter("repo", Repo); url.SetParameter("fromSrc", "-"); - return SendRequestImpl( - verb::post, url, {}, {{http::field::content_type, "application/x-tar"}, {http::field::content_length, std::to_string(ContentLength)}}); + return SendRequestImpl(verb::post, url, {}, {{"Content-Type", "application/x-tar"}, {"Content-Length", std::to_string(ContentLength)}}); } void DockerHTTPClient::TagImage(const std::string& Id, const std::string& Repo, const std::string& Tag) @@ -194,6 +198,28 @@ void DockerHTTPClient::TagImage(const std::string& Id, const std::string& Repo, Transaction(verb::post, url); } +std::unique_ptr DockerHTTPClient::PushImage( + const std::string& ImageName, const std::optional& tag, const std::string& registryAuth) +{ + auto url = URL::Create("/images/{}/push", ImageName); + + if (tag.has_value()) + { + url.SetParameter("tag", tag.value()); + } + + std::map customHeaders = {{"X-Registry-Auth", registryAuth}}; + return SendRequestImpl(verb::post, url, {}, customHeaders); +} + +std::string DockerHTTPClient::Authenticate(const std::string& serverAddress, const std::string& username, const std::string& password) +{ + auto response = Transaction( + verb::post, URL::Create("/auth"), {.username = username, .password = password, .serveraddress = serverAddress}); + + return response.IdentityToken.value_or(""); +} + std::vector DockerHTTPClient::ListImages(bool all, bool digests, const ListImagesFilters& filters) { auto url = URL::Create("/images/json"); @@ -361,8 +387,7 @@ docker_schema::InspectExec DockerHTTPClient::InspectExec(const std::string& Id) wil::unique_socket DockerHTTPClient::AttachContainer(const std::string& Id, const std::optional& DetachKeys) { - std::map headers{ - {boost::beast::http::field::upgrade, "tcp"}, {boost::beast::http::field::connection, "upgrade"}}; + std::map headers{{"Upgrade", "tcp"}, {"Connection", "upgrade"}}; auto url = URL::Create("/containers/{}/attach", Id); url.SetParameter("stream", true); @@ -460,8 +485,7 @@ docker_schema::CreateExecResponse DockerHTTPClient::CreateExec(const std::string wil::unique_socket DockerHTTPClient::StartExec(const std::string& Id, const common::docker_schema::StartExec& Request) { - std::map headers{ - {boost::beast::http::field::upgrade, "tcp"}, {boost::beast::http::field::connection, "upgrade"}}; + std::map headers{{"Upgrade", "tcp"}, {"Connection", "upgrade"}}; auto url = URL::Create("/exec/{}/start", Id); @@ -678,7 +702,7 @@ void DockerHTTPClient::DockerHttpResponseHandle::OnResponseBytes(const gsl::span } std::unique_ptr DockerHTTPClient::SendRequestImpl( - verb Method, const URL& Url, const std::string& Body, const std::map& Headers) + verb Method, const URL& Url, const std::string& Body, const std::map& Headers) { auto context = std::make_unique(ConnectSocket()); @@ -696,9 +720,9 @@ std::unique_ptr DockerHTTPClient::SendRequ req.set(http::field::connection, "close"); req.set(http::field::accept, "application/json"); - for (const auto [field, value] : Headers) + for (const auto& [name, value] : Headers) { - req.set(field, value); + req.set(name, value); } http::write(context->stream, req); @@ -718,7 +742,7 @@ std::unique_ptr DockerHTTPClient::SendRequ } std::pair DockerHTTPClient::SendRequest( - verb Method, const URL& Url, const std::string& Body, const std::map& Headers) + verb Method, const URL& Url, const std::string& Body, const std::map& Headers) { // Write the request auto context = SendRequestImpl(Method, Url, Body, Headers); diff --git a/src/windows/wslcsession/DockerHTTPClient.h b/src/windows/wslcsession/DockerHTTPClient.h index fe9d23f66..34628098f 100644 --- a/src/windows/wslcsession/DockerHTTPClient.h +++ b/src/windows/wslcsession/DockerHTTPClient.h @@ -166,10 +166,13 @@ class DockerHTTPClient std::vector absentLabels; }; - std::unique_ptr PullImage(const std::string& Repo, const std::optional& tagOrDigest); + std::unique_ptr PullImage( + const std::string& Repo, const std::optional& tagOrDigest, const std::optional& registryAuth = std::nullopt); std::unique_ptr ImportImage(const std::string& Repo, const std::string& Tag, uint64_t ContentLength); std::unique_ptr LoadImage(uint64_t ContentLength); void TagImage(const std::string& Id, const std::string& Repo, const std::string& Tag); + std::unique_ptr PushImage(const std::string& ImageName, const std::optional& tag, const std::string& registryAuth); + std::string Authenticate(const std::string& serverAddress, const std::string& username, const std::string& password); std::vector ListImages(bool all = false, bool digests = false, const ListImagesFilters& filters = {}); common::docker_schema::InspectImage InspectImage(const std::string& NameOrId); std::vector DeleteImage(const char* Image, bool Force, bool NoPrune); // Image can be ID or Repo:Tag. @@ -240,13 +243,13 @@ class DockerHTTPClient wil::unique_socket ConnectSocket(); std::unique_ptr SendRequestImpl( - boost::beast::http::verb Method, const URL& Url, const std::string& Body, const std::map& Headers); + boost::beast::http::verb Method, const URL& Url, const std::string& Body, const std::map& Headers = {}); std::pair SendRequestAndReadResponse( boost::beast::http::verb Method, const URL& Url, const std::string& Body = ""); std::pair SendRequest( - boost::beast::http::verb Method, const URL& Url, const std::string& Body, const std::map& Headers = {}); + boost::beast::http::verb Method, const URL& Url, const std::string& Body, const std::map& Headers = {}); template auto Transaction(boost::beast::http::verb Method, const URL& Url, const TRequest& RequestObject = {}) diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index 820ffb4df..f82c4af73 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -15,7 +15,6 @@ Module Name: #include "precomp.h" #include "WSLCSession.h" #include "WSLCContainer.h" -#include #include "ServiceProcessLauncher.h" #include "WslCoreFilesystem.h" @@ -30,20 +29,6 @@ constexpr auto c_containerdStorage = "/var/lib/docker"; namespace { -std::string Base64Decode(const std::string& encoded) -{ - DWORD size = 0; - THROW_IF_WIN32_BOOL_FALSE(CryptStringToBinaryA( - encoded.c_str(), static_cast(encoded.size()), CRYPT_STRING_BASE64, nullptr, &size, nullptr, nullptr)); - - std::string result(size, '\0'); - THROW_IF_WIN32_BOOL_FALSE(CryptStringToBinaryA( - encoded.c_str(), static_cast(encoded.size()), CRYPT_STRING_BASE64, reinterpret_cast(result.data()), &size, nullptr, nullptr)); - - result.resize(size); - return result; -} - // Resolve \r overwrites: for each \n-delimited line, keep only the content after the last \r. // This collapses terminal progress updates (e.g. "50%\r75%\r100%") to their final state. std::string ResolveCarriageReturns(const std::string& input) @@ -134,19 +119,6 @@ std::string IndentLines(const std::string& input, const std::string& prefix) return result; } -std::pair> ParseImage(const std::string& Input) -{ - size_t separator = Input.find_last_of(':'); - if (separator == std::string::npos) - { - return {Input, {}}; - } - - THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessageWslcInvalidImage(Input), separator >= Input.size() - 1 || separator == 0); - - return {Input.substr(0, separator), Input.substr(separator + 1)}; -} - void ValidateName(LPCSTR Name) { const auto& locale = std::locale::classic(); @@ -461,26 +433,8 @@ void WSLCSession::StartDockerd() m_dockerdProcess->GetExitEvent(), std::bind(&WSLCSession::OnDockerdExited, this))); } -HRESULT WSLCSession::PullImage(LPCSTR Image, LPCSTR RegistryAuthenticationInformation, IProgressCallback* ProgressCallback) -try +void WSLCSession::StreamImageOperation(DockerHTTPClient::HTTPRequestContext& requestContext, LPCSTR Image, LPCSTR OperationName, IProgressCallback* ProgressCallback) { - COMServiceExecutionContext context; - - RETURN_HR_IF_NULL(E_POINTER, Image); - RETURN_HR_IF(E_NOTIMPL, RegistryAuthenticationInformation != nullptr && *RegistryAuthenticationInformation != '\0'); - - auto lock = m_lock.lock_shared(); - THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value()); - - auto [repo, tagOrDigest] = wslutil::ParseImage(Image); - - if (!tagOrDigest.has_value()) - { - tagOrDigest = "latest"; - } - - auto requestContext = m_dockerClient->PullImage(repo, tagOrDigest); - auto io = CreateIOContext(); struct Response @@ -489,19 +443,22 @@ try bool isJson = false; }; - std::optional pullResponse; + std::optional httpResponse; auto onHttpResponse = [&](const boost::beast::http::message& response) { - WSL_LOG("PullHttpResponse", TraceLoggingValue(static_cast(response.result()), "StatusCode")); + WSL_LOG( + "ImageOperationHttpResponse", + TraceLoggingValue(OperationName, "Operation"), + TraceLoggingValue(static_cast(response.result()), "StatusCode")); auto it = response.find(boost::beast::http::field::content_type); - pullResponse.emplace(response.result(), it != response.end() && it->value().starts_with("application/json")); + httpResponse.emplace(response.result(), it != response.end() && it->value().starts_with("application/json")); }; std::string errorJson; std::optional reportedError; auto onChunk = [&](const gsl::span& Content) { - if (pullResponse.has_value() && pullResponse->result != boost::beast::http::status::ok) + if (httpResponse.has_value() && httpResponse->result != boost::beast::http::status::ok) { // If the status code is an error, then this is an error message, not a progress update. errorJson.append(Content.data(), Content.size()); @@ -509,7 +466,11 @@ try } std::string contentString{Content.begin(), Content.end()}; - WSL_LOG("ImagePullProgress", TraceLoggingValue(Image, "Image"), TraceLoggingValue(contentString.c_str(), "Content")); + WSL_LOG( + "ImageOperationProgress", + TraceLoggingValue(OperationName, "Operation"), + TraceLoggingValue(Image, "Image"), + TraceLoggingValue(contentString.c_str(), "Content")); auto parsed = wsl::shared::FromJson(contentString.c_str()); @@ -519,7 +480,8 @@ try { LOG_HR_MSG( E_UNEXPECTED, - "Received multiple error messages during image pull. Previous: %hs, New: %hs", + "Received multiple error messages during image %hs. Previous: %hs, New: %hs", + OperationName, reportedError->c_str(), parsed.errorDetail->message.c_str()); } @@ -538,18 +500,18 @@ try auto onCompleted = [&]() { io.Cancel(); }; io.AddHandle(std::make_unique( - *requestContext, std::move(onHttpResponse), std::move(onChunk), std::move(onCompleted))); + requestContext, std::move(onHttpResponse), std::move(onChunk), std::move(onCompleted))); io.Run({}); - THROW_HR_IF(E_UNEXPECTED, !pullResponse.has_value()); + THROW_HR_IF(E_UNEXPECTED, !httpResponse.has_value()); - if (pullResponse->result != boost::beast::http::status::ok) + if (httpResponse->result != boost::beast::http::status::ok) { std::string errorMessage; - if (pullResponse->isJson) + if (httpResponse->isJson) { - // pull failed, parse the error message. + // operation failed, parse the error message. errorMessage = wsl::shared::FromJson(errorJson.c_str()).message; } else @@ -558,11 +520,11 @@ try errorMessage = errorJson; } - if (pullResponse->result == boost::beast::http::status::not_found) + if (httpResponse->result == boost::beast::http::status::not_found) { THROW_HR_WITH_USER_ERROR(WSLC_E_IMAGE_NOT_FOUND, errorMessage); } - else if (pullResponse->result == boost::beast::http::status::bad_request) + else if (httpResponse->result == boost::beast::http::status::bad_request) { THROW_HR_WITH_USER_ERROR(E_INVALIDARG, errorMessage); } @@ -576,6 +538,34 @@ try // Can happen if an error is returned during progress after receiving an OK status. THROW_HR_WITH_USER_ERROR(E_FAIL, reportedError.value().c_str()); } +} + +HRESULT WSLCSession::PullImage(LPCSTR Image, LPCSTR RegistryAuthenticationInformation, IProgressCallback* ProgressCallback) +try +{ + COMServiceExecutionContext context; + + RETURN_HR_IF_NULL(E_POINTER, Image); + + auto lock = m_lock.lock_shared(); + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value()); + + auto [repo, tagOrDigest] = wslutil::ParseImage(Image); + + if (!tagOrDigest.has_value()) + { + tagOrDigest = "latest"; + } + + std::optional registryAuth; + + if (RegistryAuthenticationInformation != nullptr && *RegistryAuthenticationInformation != '\0') + { + registryAuth = std::string(RegistryAuthenticationInformation); + } + + auto requestContext = m_dockerClient->PullImage(repo, tagOrDigest, registryAuth); + StreamImageOperation(*requestContext, Image, "Pull", ProgressCallback); return S_OK; } @@ -682,7 +672,7 @@ try continue; } - std::string decoded = Base64Decode(log.data); + std::string decoded = wslutil::Base64Decode(log.data); if (!decoded.empty()) { auto& logBuffer = vertexLogs[log.vertex]; @@ -1274,6 +1264,25 @@ try } CATCH_RETURN(); +HRESULT WSLCSession::PushImage(LPCSTR Image, LPCSTR RegistryAuthenticationInformation, IProgressCallback* ProgressCallback) +try +{ + COMServiceExecutionContext context; + + RETURN_HR_IF_NULL(E_POINTER, Image); + RETURN_HR_IF_NULL(E_POINTER, RegistryAuthenticationInformation); + + auto lock = m_lock.lock_shared(); + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value()); + + auto [repo, tagOrDigest] = wslutil::ParseImage(Image); + auto requestContext = m_dockerClient->PushImage(repo, tagOrDigest, RegistryAuthenticationInformation); + StreamImageOperation(*requestContext, Image, "Push", ProgressCallback); + + return S_OK; +} +CATCH_RETURN(); + HRESULT WSLCSession::InspectImage(_In_ LPCSTR ImageNameOrId, _Out_ LPSTR* Output) try { @@ -1317,6 +1326,35 @@ try } CATCH_RETURN(); +HRESULT WSLCSession::Authenticate(_In_ LPCSTR ServerAddress, _In_ LPCSTR Username, _In_ LPCSTR Password, _Out_ LPSTR* IdentityToken) +try +{ + COMServiceExecutionContext context; + + RETURN_HR_IF_NULL(E_POINTER, ServerAddress); + RETURN_HR_IF_NULL(E_POINTER, Username); + RETURN_HR_IF_NULL(E_POINTER, Password); + RETURN_HR_IF_NULL(E_POINTER, IdentityToken); + + *IdentityToken = nullptr; + + auto lock = m_lock.lock_shared(); + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value()); + + wil::unique_cotaskmem_ansistring token; + + try + { + auto response = m_dockerClient->Authenticate(ServerAddress, Username, Password); + token = wil::make_unique_ansistring(response.c_str()); + } + CATCH_AND_THROW_DOCKER_USER_ERROR("Failed to authenticate with registry: %hs", ServerAddress); + + *IdentityToken = token.release(); + return S_OK; +} +CATCH_RETURN(); + HRESULT WSLCSession::PruneImages(const WSLCPruneImagesOptions* Options, WSLCDeletedImageInformation** DeletedImages, ULONG* DeletedImagesCount, ULONGLONG* SpaceReclaimed) try { diff --git a/src/windows/wslcsession/WSLCSession.h b/src/windows/wslcsession/WSLCSession.h index 43dfb2a00..84f3792f1 100644 --- a/src/windows/wslcsession/WSLCSession.h +++ b/src/windows/wslcsession/WSLCSession.h @@ -81,7 +81,9 @@ class DECLSPEC_UUID("4877FEFC-4977-4929-A958-9F36AA1892A4") WSLCSession IFACEMETHOD(ListImages)(_In_opt_ const WSLCListImageOptions* Options, _Out_ WSLCImageInformation** Images, _Out_ ULONG* Count) override; IFACEMETHOD(DeleteImage)(_In_ const WSLCDeleteImageOptions* Options, _Out_ WSLCDeletedImageInformation** DeletedImages, _Out_ ULONG* Count) override; IFACEMETHOD(TagImage)(_In_ const WSLCTagImageOptions* Options) override; + IFACEMETHOD(PushImage)(_In_ LPCSTR Image, _In_ LPCSTR RegistryAuthenticationInformation, _In_opt_ IProgressCallback* ProgressCallback) override; IFACEMETHOD(InspectImage)(_In_ LPCSTR ImageNameOrId, _Out_ LPSTR* Output) override; + IFACEMETHOD(Authenticate)(_In_ LPCSTR ServerAddress, _In_ LPCSTR Username, _In_ LPCSTR Password, _Out_ LPSTR* IdentityToken) override; IFACEMETHOD(PruneImages)( _In_opt_ const WSLCPruneImagesOptions* Options, _Out_ WSLCDeletedImageInformation** DeletedImages, @@ -138,6 +140,7 @@ class DECLSPEC_UUID("4877FEFC-4977-4929-A958-9F36AA1892A4") WSLCSession void RecoverExistingVolumes(); void SaveImageImpl(std::pair& RequestCodePair, WSLCHandle OutputHandle, HANDLE CancelEvent); + void StreamImageOperation(DockerHTTPClient::HTTPRequestContext& requestContext, LPCSTR Image, LPCSTR OperationName, IProgressCallback* ProgressCallback); std::optional m_dockerClient; std::optional m_virtualMachine; diff --git a/test/windows/CMakeLists.txt b/test/windows/CMakeLists.txt index e2f8b2caf..7af8e465f 100644 --- a/test/windows/CMakeLists.txt +++ b/test/windows/CMakeLists.txt @@ -36,7 +36,8 @@ target_link_libraries(wsltests VirtDisk.lib Wer.lib Dbghelp.lib - sfc.lib) + sfc.lib + Crypt32.lib) add_dependencies(wsltests wslserviceidl wslclib wslc wslcsdk) add_subdirectory(testplugin) diff --git a/test/windows/Common.cpp b/test/windows/Common.cpp index f775c2ecf..948a5f826 100644 --- a/test/windows/Common.cpp +++ b/test/windows/Common.cpp @@ -2843,6 +2843,10 @@ std::filesystem::path GetTestImagePath(std::string_view imageName) { result /= L"HelloWorldSaved.tar"; } + else if (imageName == "wslc-registry:latest") + { + result /= L"wslc-registry.tar"; + } else { THROW_HR_MSG(E_INVALIDARG, "Unknown test image: %hs", imageName.data()); diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 5f5df349b..826962237 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -85,6 +85,21 @@ class WSLCTests LoadTestImage("python:3.12-alpine"); } + if (!hasImage("hello-world:latest")) + { + LoadTestImage("hello-world:latest"); + } + + if (!hasImage("alpine:latest")) + { + LoadTestImage("alpine:latest"); + } + + if (!hasImage("wslc-registry:latest")) + { + LoadTestImage("wslc-registry:latest"); + } + PruneResult result; VERIFY_SUCCEEDED(m_defaultSession->PruneContainers(nullptr, 0, 0, &result.result)); if (result.result.ContainersCount > 0) @@ -167,6 +182,52 @@ class WSLCTests return RunningWSLCContainer(std::move(rawContainer), {}); } + std::pair StartLocalRegistry(const std::string& username = {}, const std::string& password = {}, USHORT port = 5000) + { + std::vector env = {std::format("REGISTRY_HTTP_ADDR=0.0.0.0:{}", port)}; + if (!username.empty()) + { + env.push_back(std::format("USERNAME={}", username)); + env.push_back(std::format("PASSWORD={}", password)); + } + + WSLCContainerLauncher launcher("wslc-registry:latest", {}, {}, env); + launcher.SetEntrypoint({"/entrypoint.sh"}); + launcher.AddPort(port, port, AF_INET); + + auto container = launcher.Launch(*m_defaultSession, WSLCContainerStartFlagsNone); + + auto registryAddress = std::format("127.0.0.1:{}", port); + auto registryUrl = std::format(L"http://{}", registryAddress); + ExpectHttpResponse(registryUrl.c_str(), 200, true); + + return {std::move(container), std::move(registryAddress)}; + } + + std::string PushImageToRegistry(const std::string& imageName, const std::string& registryAddress, const std::string& registryAuth) + { + auto [repo, tag] = ParseImage(imageName); + auto registryImage = std::format("{}/{}:{}", registryAddress, repo, tag.value_or("latest")); + auto registryRepo = std::format("{}/{}", registryAddress, repo); + auto registryTag = tag.value_or("latest"); + + WSLCTagImageOptions tagOptions{}; + tagOptions.Image = imageName.c_str(); + tagOptions.Repo = registryRepo.c_str(); + tagOptions.Tag = registryTag.c_str(); + + // Tag the image with the registry address so it can be pushed. + VERIFY_SUCCEEDED(m_defaultSession->TagImage(&tagOptions)); + + // Ensures the tag is removed to allow tests to try to push or pull the same image again. + auto cleanup = wil::scope_exit_log( + WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_FAILED(DeleteImageNoThrow(registryImage, WSLCDeleteImageFlagsNone).first); }); + + VERIFY_SUCCEEDED(m_defaultSession->PushImage(registryImage.c_str(), registryAuth.c_str(), nullptr)); + + return registryImage; + } + WSLC_TEST_METHOD(GetVersion) { wil::com_ptr sessionManager; @@ -418,27 +479,18 @@ class WSLCTests WSLC_TEST_METHOD(PullImage) { { - HRESULT pullResult = m_defaultSession->PullImage("hello-world:linux", nullptr, nullptr); + // Start a local registry without auth and push hello-world:latest to it. + auto [registryContainer, registryAddress] = StartLocalRegistry(); - // Skip test if error is due to rate limit. - if (pullResult == E_FAIL) - { - auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo(); - if (comError.has_value()) - { - if (wcsstr(comError->Message.get(), L"toomanyrequests") != nullptr) - { - LogWarning("Skipping PullImage test due to rate limiting."); - return; - } - } - } + auto image = PushImageToRegistry("hello-world:latest", registryAddress, BuildRegistryAuthHeader("", "", registryAddress)); + ExpectImagePresent(*m_defaultSession, image.c_str(), false); - VERIFY_SUCCEEDED(pullResult); + VERIFY_SUCCEEDED(m_defaultSession->PullImage(image.c_str(), nullptr, nullptr)); + auto cleanup = wil::scope_exit([&]() { LOG_IF_FAILED(DeleteImageNoThrow(image, WSLCDeleteImageFlagsForce).first); }); // Verify that the image is in the list of images. - ExpectImagePresent(*m_defaultSession, "hello-world:linux"); - WSLCContainerLauncher launcher("hello-world:linux", "wslc-pull-image-container"); + ExpectImagePresent(*m_defaultSession, image.c_str()); + WSLCContainerLauncher launcher(image, "wslc-pull-image-container"); auto container = launcher.Launch(*m_defaultSession); auto result = container.GetInitProcess().WaitAndCaptureOutput(); @@ -453,10 +505,7 @@ class WSLCTests L"access to the resource is denied"; VERIFY_ARE_EQUAL(m_defaultSession->PullImage("does-not:exist", nullptr, nullptr), WSLC_E_IMAGE_NOT_FOUND); - auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo(); - VERIFY_IS_TRUE(comError.has_value()); - - VERIFY_ARE_EQUAL(expectedError, comError->Message.get()); + ValidateCOMErrorMessage(expectedError.c_str()); } // Validate that PullImage() returns the appropriate error if the session is terminated. @@ -473,7 +522,30 @@ class WSLCTests WSLC_TEST_METHOD(PullImageAdvanced) { - // TODO: Enable once custom registries are supported, to avoid hitting public registry rate limits. + // Start a local registry without auth to avoid Docker Hub rate limits. + auto [registryContainer, registryAddress] = StartLocalRegistry(); + auto auth = BuildRegistryAuthHeader("", "", registryAddress); + + auto validatePull = [&](const std::string& sourceImage) { + // Push the source image to the local registry. + auto registryImage = PushImageToRegistry(sourceImage, registryAddress, auth); + ExpectImagePresent(*m_defaultSession, registryImage.c_str(), false); + + VERIFY_SUCCEEDED(m_defaultSession->PullImage(registryImage.c_str(), nullptr, nullptr)); + + auto cleanup = + wil::scope_exit([&]() { LOG_IF_FAILED(DeleteImageNoThrow(registryImage, WSLCDeleteImageFlagsForce).first); }); + + ExpectImagePresent(*m_defaultSession, registryImage.c_str()); + }; + + validatePull("debian:latest"); + validatePull("alpine:latest"); + validatePull("hello-world:latest"); + } + + WSLC_TEST_METHOD(PullImageFromDockerHub) + { SKIP_TEST_UNSTABLE(); auto validatePull = [&](const std::string& Image, const std::optional& ExpectedTag = {}) { @@ -528,17 +600,58 @@ class WSLCTests VERIFY_ARE_EQUAL(session->PullImage("pytorch/pytorch", nullptr, nullptr), E_FAIL); - auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo(); - VERIFY_IS_TRUE(comError.has_value()); + ValidateCOMErrorMessageContains(L"no space left on device"); + } + } - // The error message can't be compared directly because it contains an unpredicable path: - // "write /var/lib/docker/tmp/GetImageBlob1760660623: no space left on device" - if (StrStrW(comError->Message.get(), L"no space left on device") == nullptr) - { - LogError("Unexpected error message: %ls", comError->Message.get()); - VERIFY_FAIL(); - } + WSLC_TEST_METHOD(PushImage) + { + auto emptyAuth = BuildRegistryAuthHeader("", "", ""); + + // Validate that pushing a non-existent image fails. + { + VERIFY_ARE_EQUAL(m_defaultSession->PushImage("does-not-exist:latest", emptyAuth.c_str(), nullptr), E_FAIL); + ValidateCOMErrorMessage(L"An image does not exist locally with the tag: does-not-exist"); + } + + // Validate passing empty auth string returns an appropriate error. + { + VERIFY_ARE_EQUAL(m_defaultSession->PushImage("does-not-exist:latest", "", nullptr), E_INVALIDARG); } + + // Validate that PushImage() returns the appropriate error if the session is terminated. + { + VERIFY_SUCCEEDED(m_defaultSession->Terminate()); + auto cleanup = wil::scope_exit([&]() { ResetTestSession(); }); + + VERIFY_ARE_EQUAL(m_defaultSession->PushImage("hello-world:latest", emptyAuth.c_str(), nullptr), HRESULT_FROM_WIN32(ERROR_INVALID_STATE)); + } + } + + WSLC_TEST_METHOD(Authenticate) + { + constexpr auto c_username = "wslctest"; + constexpr auto c_password = "password"; + + auto [registryContainer, registryAddress] = StartLocalRegistry(c_username, c_password); + + wil::unique_cotaskmem_ansistring token; + VERIFY_ARE_EQUAL(m_defaultSession->Authenticate(registryAddress.c_str(), c_username, "wrong-password", &token), E_FAIL); + ValidateCOMErrorMessageContains(L"failed with status: 401 Unauthorized"); + + VERIFY_SUCCEEDED(m_defaultSession->Authenticate(registryAddress.c_str(), c_username, c_password, &token)); + VERIFY_IS_NOT_NULL(token.get()); + + auto xRegistryAuth = BuildRegistryAuthHeader(c_username, c_password, registryAddress); + auto image = PushImageToRegistry("hello-world:latest", registryAddress, xRegistryAuth); + + // Pulling without credentials should fail. + VERIFY_ARE_EQUAL(m_defaultSession->PullImage(image.c_str(), nullptr, nullptr), E_FAIL); + ValidateCOMErrorMessageContains(L"no basic auth credentials"); + + // Pulling with credentials should succeed. + VERIFY_SUCCEEDED(m_defaultSession->PullImage(image.c_str(), xRegistryAuth.c_str(), nullptr)); + ExpectImagePresent(*m_defaultSession, image.c_str()); } WSLC_TEST_METHOD(ListImages) @@ -1002,6 +1115,31 @@ class WSLCTests } } + void ValidateCOMErrorMessageContains(const std::wstring& ExpectedSubstring) + { + auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo(); + + if (comError.has_value()) + { + if (!comError->Message) + { + LogError("Expected COM error containing: '%ls', but COM error message was null", ExpectedSubstring.c_str()); + VERIFY_FAIL(); + } + + if (wcsstr(comError->Message.get(), ExpectedSubstring.c_str()) == nullptr) + { + LogError("Expected COM error containing: '%ls', but got: '%ls'", ExpectedSubstring.c_str(), comError->Message.get()); + VERIFY_FAIL(); + } + } + else + { + LogError("Expected COM error containing: '%ls' but none was set", ExpectedSubstring.c_str()); + VERIFY_FAIL(); + } + } + HRESULT BuildImageFromContext(const std::filesystem::path& contextDir, const WSLCBuildImageOptions* options) { auto dockerfileHandle = wil::open_file((contextDir / "Dockerfile").c_str()); @@ -1578,16 +1716,8 @@ class WSLCTests VERIFY_ARE_EQUAL(E_ABORT, result.get_future().get()); } - TEST_METHOD(AnonymousVolumes) + WSLC_TEST_METHOD(AnonymousVolumes) { - // TODO: Add more test coverage once anonymous volumes are fully supported and switch to using -v instead of building an image. - - if (!LxsstuVmMode()) - { - LogSkipped("This test is only applicable to WSL2"); - return; - } - auto contextDir = std::filesystem::current_path() / "build-context"; std::filesystem::create_directories(contextDir); auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { diff --git a/test/windows/WslcSdkTests.cpp b/test/windows/WslcSdkTests.cpp index f67e22e7a..55c29ebcc 100644 --- a/test/windows/WslcSdkTests.cpp +++ b/test/windows/WslcSdkTests.cpp @@ -15,6 +15,8 @@ Module Name: #include "precomp.h" #include "Common.h" #include "wslcsdk.h" +#include "WslcsdkPrivate.h" +#include "WSLCContainerLauncher.h" #include "wslc_schema.h" #include @@ -201,7 +203,7 @@ class WslcSdkTests VERIFY_SUCCEEDED(WslcCreateSession(&sessionSettings, &m_defaultSession, nullptr)); // Pull images required by the tests (no-op if already present). - for (const char* image : {"debian:latest", "python:3.12-alpine"}) + for (const char* image : {"debian:latest", "python:3.12-alpine", "hello-world:latest", "wslc-registry:latest"}) { LoadTestImage(image); } @@ -323,60 +325,6 @@ class WslcSdkTests // Image tests // ----------------------------------------------------------------------- - WSLC_TEST_METHOD(PullImage) - { - // Positive: pull a well-known image. - { - WslcPullImageOptions opts{}; - opts.uri = "hello-world:linux"; - wil::unique_cotaskmem_string errorMsg; - HRESULT pullResult = WslcPullSessionImage(m_defaultSession, &opts, &errorMsg); - - // Skip test if error is due to rate limit. - if (pullResult == E_FAIL) - { - if (errorMsg) - { - if (wcsstr(errorMsg.get(), L"toomanyrequests") != nullptr) - { - LogWarning("Skipping PullImage test due to rate limiting."); - return; - } - } - } - - VERIFY_SUCCEEDED(pullResult); - - // Verify the image is usable by running a container from it. - auto output = RunContainerAndCapture(m_defaultSession, "hello-world:linux", {}); - VERIFY_IS_TRUE(output.stdoutOutput.find("Hello from Docker!") != std::string::npos); - } - - // Negative: pull an image that does not exist. - { - WslcPullImageOptions opts{}; - opts.uri = "does-not:exist"; - wil::unique_cotaskmem_string errorMsg; - VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), WSLC_E_IMAGE_NOT_FOUND); - - // An error message should be present. - VERIFY_IS_NOT_NULL(errorMsg.get()); - } - - // Negative: null options pointer must fail. - { - wil::unique_cotaskmem_string errorMsg; - VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, nullptr, &errorMsg), E_POINTER); - } - - // Negative: null URI inside options must fail. - { - WslcPullImageOptions opts{}; - opts.uri = nullptr; - VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG); - } - } - WSLC_TEST_METHOD(ImageList) { // Positive: session has images pre-loaded — list must return at least one entry. @@ -565,34 +513,17 @@ class WslcSdkTests WSLC_TEST_METHOD(ImageDelete) { - auto checkForImage = [this](std::string_view image) -> bool { - WslcImageInfo* images = nullptr; - uint32_t count = 0; - VERIFY_SUCCEEDED(WslcListSessionImages(m_defaultSession, &images, &count)); - auto cleanupImages = wil::scope_exit([images]() { CoTaskMemFree(images); }); - bool found = false; - for (uint32_t i = 0; i < count; ++i) - { - if (images[i].name == image) - { - found = true; - break; - } - } - return found; - }; - - // Setup: load hello-world:latest so we have something to delete. - LoadTestImage("hello-world:latest"); - - VERIFY_IS_TRUE(checkForImage("hello-world:latest")); + VERIFY_IS_TRUE(HasImage("hello-world:latest")); // Positive: delete an existing image. wil::unique_cotaskmem_string errorMsg; VERIFY_SUCCEEDED(WslcDeleteSessionImage(m_defaultSession, "hello-world:latest", &errorMsg)); // Verify the image is no longer present in the list. - VERIFY_IS_FALSE(checkForImage("hello-world:latest")); + VERIFY_IS_FALSE(HasImage("hello-world:latest")); + + // Reload the image for subsequent tests. + LoadTestImage("hello-world:latest"); // Negative: null name must fail. VERIFY_ARE_EQUAL(WslcDeleteSessionImage(m_defaultSession, nullptr, nullptr), E_POINTER); @@ -1976,6 +1907,284 @@ class WslcSdkTests } } + // ----------------------------------------------------------------------- + // Authentication helpers + // ----------------------------------------------------------------------- + + // Starts a local registry container with host-mode networking and returns [container, registryAddress]. + // Uses the COM API (via GetInternalType) with WSLCContainerLauncher to get host-mode networking, + // which the SDK doesn't expose. Host networking shares the VM's network namespace, so the registry + // is reachable at 127.0.0.1: from both dockerd (inside the VM) and the host. + std::pair StartLocalRegistry( + const std::string& username = {}, const std::string& password = {}, uint16_t port = 5000) + { + VERIFY_IS_TRUE(HasImage("wslc-registry:latest")); + + std::vector env = {std::format("REGISTRY_HTTP_ADDR=0.0.0.0:{}", port)}; + if (!username.empty()) + { + env.push_back(std::format("USERNAME={}", username)); + env.push_back(std::format("PASSWORD={}", password)); + } + + wsl::windows::common::WSLCContainerLauncher launcher("wslc-registry:latest", {}, {}, env); + launcher.SetEntrypoint({"/entrypoint.sh"}); + launcher.AddPort(port, port, AF_INET); + + // Get the IWSLCSession COM object from the SDK session handle. + auto& session = *reinterpret_cast(m_defaultSession)->session; + auto container = launcher.Launch(session, WSLCContainerStartFlagsNone); + + auto registryAddress = std::format("127.0.0.1:{}", port); + + // Wait for the registry to be ready by probing from the host. + auto hostUrl = std::format(L"http://{}", registryAddress); + ExpectHttpResponse(hostUrl.c_str(), 200, true); + + return {std::move(container), registryAddress}; + } + + // Tags and pushes an image to a local registry via the SDK APIs. + void PushImageToRegistry(const std::string& repo, const std::string& tag, const std::string& registryAddress, const std::string& registryAuth) + { + auto imageName = std::format("{}:{}", repo, tag); + auto registryImage = std::format("{}/{}:{}", registryAddress, repo, tag); + auto registryRepo = std::format("{}/{}", registryAddress, repo); + + VERIFY_IS_TRUE(HasImage(imageName)); + + // Tag the image with the registry address so it can be pushed. + WslcTagImageOptions tagOptions{}; + tagOptions.image = imageName.c_str(); + tagOptions.repo = registryRepo.c_str(); + tagOptions.tag = tag.c_str(); + VERIFY_SUCCEEDED(WslcTagSessionImage(m_defaultSession, &tagOptions, nullptr)); + + // Ensures the registry-prefixed tag is removed after the push. + auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { + LOG_IF_FAILED(WslcDeleteSessionImage(m_defaultSession, registryImage.c_str(), nullptr)); + }); + + WslcPushImageOptions pushOptions{}; + pushOptions.image = registryImage.c_str(); + pushOptions.registryAuth = registryAuth.c_str(); + VERIFY_SUCCEEDED(WslcPushSessionImage(m_defaultSession, &pushOptions, nullptr)); + } + + bool HasImage(const std::string& imageName) + { + wil::unique_cotaskmem_array_ptr images; + VERIFY_SUCCEEDED(WslcListSessionImages(m_defaultSession, images.addressof(), images.size_address())); + + for (const auto& image : images) + { + if (image.name == imageName) + { + return true; + } + } + return false; + } + + // ----------------------------------------------------------------------- + // Authentication tests + // ----------------------------------------------------------------------- + + WSLC_TEST_METHOD(AuthenticateTests) + { + constexpr auto c_username = "wslctest"; + constexpr auto c_password = "password"; + + auto [registryContainer, registryAddress] = StartLocalRegistry(c_username, c_password); + + // Negative: wrong password must fail. + { + wil::unique_cotaskmem_ansistring token; + wil::unique_cotaskmem_string errorMsg; + VERIFY_ARE_EQUAL( + WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), c_username, "wrong-password", &token, &errorMsg), E_FAIL); + VERIFY_IS_NOT_NULL(errorMsg.get()); + } + + // Positive: correct credentials must succeed and return a non-null token. + { + wil::unique_cotaskmem_ansistring token; + wil::unique_cotaskmem_string errorMsg; + VERIFY_SUCCEEDED(WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), c_username, c_password, &token, &errorMsg)); + VERIFY_IS_NOT_NULL(token.get()); + } + + auto xRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader(c_username, c_password, registryAddress); + PushImageToRegistry("hello-world", "latest", registryAddress, xRegistryAuth); + + auto image = std::format("{}/hello-world:latest", registryAddress); + + // Pulling with credentials should succeed. + { + WslcPullImageOptions opts{}; + opts.uri = image.c_str(); + opts.registryAuth = xRegistryAuth.c_str(); + VERIFY_SUCCEEDED(WslcPullSessionImage(m_defaultSession, &opts, nullptr)); + VERIFY_IS_TRUE(HasImage(image)); + } + + // Negative: Pulling without credentials should fail. + { + WslcPullImageOptions opts{}; + opts.uri = image.c_str(); + + wil::unique_cotaskmem_string errorMsg; + VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), E_FAIL); + VERIFY_IS_NOT_NULL(errorMsg.get()); + } + + // Negative: Pulling with bad credentials should fail. + { + auto badAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader(c_username, "wrong", registryAddress); + + WslcPullImageOptions opts{}; + opts.uri = image.c_str(); + opts.registryAuth = badAuth.c_str(); + + wil::unique_cotaskmem_string errorMsg; + VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), E_FAIL); + VERIFY_IS_NOT_NULL(errorMsg.get()); + } + + // Negative: null parameters must fail. + { + wil::unique_cotaskmem_ansistring token; + VERIFY_ARE_EQUAL(WslcSessionAuthenticate(m_defaultSession, nullptr, c_username, c_password, &token, nullptr), E_POINTER); + VERIFY_ARE_EQUAL(WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), nullptr, c_password, &token, nullptr), E_POINTER); + VERIFY_ARE_EQUAL(WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), c_username, nullptr, &token, nullptr), E_POINTER); + VERIFY_ARE_EQUAL(WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), c_username, c_password, nullptr, nullptr), E_POINTER); + } + } + + WSLC_TEST_METHOD(PullImage) + { + // Start a local registry without auth to avoid Docker Hub rate limits. + auto [registryContainer, registryAddress] = StartLocalRegistry(); + auto xRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader("", "", registryAddress); + + { + // Push hello-world:latest to the local registry. + PushImageToRegistry("hello-world", "latest", registryAddress, xRegistryAuth); + + auto image = std::format("{}/hello-world:latest", registryAddress); + + // Delete the image locally so the pull is a real pull. + WslcDeleteSessionImage(m_defaultSession, image.c_str(), nullptr); + + // Pull from the local registry. + { + WslcPullImageOptions opts{}; + opts.uri = image.c_str(); + VERIFY_SUCCEEDED(WslcPullSessionImage(m_defaultSession, &opts, nullptr)); + } + + // Verify the pulled image is in the image list. + VERIFY_IS_TRUE(HasImage(image)); + + // Verify the image is usable by running a container from it. + auto output = RunContainerAndCapture(m_defaultSession, image.c_str(), {}); + VERIFY_IS_TRUE(output.stdoutOutput.find("Hello from Docker!") != std::string::npos); + } + + // Negative: pull an image that does not exist. + { + auto image = std::format("{}/does-not-exist", registryAddress); + + WslcPullImageOptions opts{}; + opts.uri = image.c_str(); + opts.registryAuth = xRegistryAuth.c_str(); + + wil::unique_cotaskmem_string errorMsg; + VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), WSLC_E_IMAGE_NOT_FOUND); + } + + // Negative: null URI inside options must fail. + { + WslcPullImageOptions opts{}; + opts.uri = nullptr; + + wil::unique_cotaskmem_string errorMsg; + VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), E_INVALIDARG); + } + } + + WSLC_TEST_METHOD(PushImage) + { + auto emptyRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader("", "", ""); + + // Negative: pushing a non-existent image must fail. + { + WslcPushImageOptions opts{}; + opts.image = "does-not-exist"; + opts.registryAuth = emptyRegistryAuth.c_str(); + + wil::unique_cotaskmem_string errorMsg; + VERIFY_ARE_EQUAL(WslcPushSessionImage(m_defaultSession, &opts, &errorMsg), E_FAIL); + VERIFY_IS_NOT_NULL(errorMsg.get()); + } + + // Negative: null options must fail. + VERIFY_ARE_EQUAL(WslcPushSessionImage(m_defaultSession, nullptr, nullptr), E_POINTER); + + // Negative: null image inside options must fail. + { + WslcPushImageOptions opts{}; + opts.image = nullptr; + opts.registryAuth = emptyRegistryAuth.c_str(); + + VERIFY_ARE_EQUAL(WslcPushSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG); + } + } + + WSLC_TEST_METHOD(TagImage) + { + // Positive: tag an existing image. + { + WslcTagImageOptions opts{}; + opts.image = "debian:latest"; + opts.repo = "debian"; + opts.tag = "sdk-test-tag"; + VERIFY_SUCCEEDED(WslcTagSessionImage(m_defaultSession, &opts, nullptr)); + + // Verify the tag is present. + VERIFY_IS_TRUE(HasImage("debian:sdk-test-tag")); + + // Cleanup: delete the tag. + WslcDeleteSessionImage(m_defaultSession, "debian:sdk-test-tag", nullptr); + } + + // Negative: null options must fail. + VERIFY_ARE_EQUAL(WslcTagSessionImage(m_defaultSession, nullptr, nullptr), E_POINTER); + + // Negative: null fields must fail. + { + WslcTagImageOptions opts{}; + opts.image = nullptr; + opts.repo = "debian"; + opts.tag = "test"; + VERIFY_ARE_EQUAL(WslcTagSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG); + } + { + WslcTagImageOptions opts{}; + opts.image = "debian:latest"; + opts.repo = nullptr; + opts.tag = "test"; + VERIFY_ARE_EQUAL(WslcTagSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG); + } + { + WslcTagImageOptions opts{}; + opts.image = "debian:latest"; + opts.repo = "debian"; + opts.tag = nullptr; + VERIFY_ARE_EQUAL(WslcTagSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG); + } + } + // ----------------------------------------------------------------------- // Stub tests for unimplemented (E_NOTIMPL) functions. // Each of these confirms the current state of the SDK; once the underlying diff --git a/tools/test/images/build-image.ps1 b/tools/test/images/build-image.ps1 new file mode 100644 index 000000000..b9b11c7e4 --- /dev/null +++ b/tools/test/images/build-image.ps1 @@ -0,0 +1,48 @@ +<# +.SYNOPSIS + Builds a custom test registry image using wslc and saves it as a .tar file. +.DESCRIPTION + This script builds a custom image using wslc from a specified Dockerfile and saves the resulting image as a .tar file. + This is useful for preparing test images for WSL container tests. +.PARAMETER DockerfileDir + Path to the directory containing the Dockerfile to build. +.PARAMETER ImageTag + Tag for the built image. +.PARAMETER OutputFile + Path to save the exported .tar file. Defaults to .tar in the current directory. +#> + +[CmdletBinding(SupportsShouldProcess)] +param ( + [string]$DockerfileDir, + [string]$ImageTag, + [string]$OutputFile = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ($OutputFile -eq "") { + $OutputFile = Join-Path $PWD "$(Split-Path -Leaf $DockerfileDir).tar" +} + +# Verify $OutputFile is a valid path, we can write to it, and that it has a .tar extension +if ([System.IO.Path]::GetExtension($OutputFile) -ne ".tar") { + if (-not $PSCmdlet.ShouldContinue("Are you sure you want to continue?", "Output file '$OutputFile' is not a .tar file.")) { + throw "Aborting due to invalid output file extension." + } +} + + +if ($PSCmdlet.ShouldProcess($ImageTag, "Build image from '$DockerfileDir'")) { + & wslc build -t $ImageTag $DockerfileDir + if ($LASTEXITCODE -ne 0) { throw "wslc build failed with exit code $LASTEXITCODE" } +} + +if ($PSCmdlet.ShouldProcess($OutputFile, "Save image '$ImageTag'")) { + & wslc save --output $OutputFile $ImageTag + if ($LASTEXITCODE -ne 0) { throw "wslc save failed with exit code $LASTEXITCODE" } + + Write-Host "Image built and saved to $OutputFile successfully." +} + diff --git a/tools/test/images/wslc-registry/Dockerfile b/tools/test/images/wslc-registry/Dockerfile new file mode 100644 index 000000000..6d5084810 --- /dev/null +++ b/tools/test/images/wslc-registry/Dockerfile @@ -0,0 +1,8 @@ +FROM registry:3 + +RUN apk add --no-cache apache2-utils + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tools/test/images/wslc-registry/entrypoint.sh b/tools/test/images/wslc-registry/entrypoint.sh new file mode 100644 index 000000000..08333fab4 --- /dev/null +++ b/tools/test/images/wslc-registry/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +if [ -n "$USERNAME" ]; then + mkdir -p /auth + htpasswd -Bbn "$USERNAME" "$PASSWORD" > /auth/htpasswd + + export REGISTRY_AUTH=htpasswd + export REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd + export REGISTRY_AUTH_HTPASSWD_REALM="WSLC Registry" +fi + +exec registry serve /etc/distribution/config.yml