From ffb4e186968486d297cd8792af3714fbb07f459e Mon Sep 17 00:00:00 2001 From: ggarzia-MSFT Date: Tue, 6 Oct 2026 16:16:20 -0700 Subject: [PATCH] Support reading the wslc image build context from stdin (#41765) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- localization/strings/en-US/Resources.resw | 11 +- src/windows/service/inc/wslc.idl | 6 +- src/windows/wslc/services/ImageService.cpp | 141 ++++++++++++++---- src/windows/wslcsession/WSLCSession.cpp | 39 ++++- test/windows/wslc/CommandLineTestCases.h | 2 + .../wslc/e2e/WSLCE2EImageBuildTests.cpp | 97 ++++++++++++ 6 files changed, 257 insertions(+), 39 deletions(-) diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index 67cf09d7..8b6126e4 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -2451,6 +2451,10 @@ For privacy information about this product please visit https://aka.ms/privacy.< Cannot write build output to the console for --output value '{}'. {FixedPlaceholder="{}"}{Locked="--output "}Command line arguments, file names and string inserts should not be translated + + Can't use stdin for both the build context and the Dockerfile. + {Locked="stdin"}{Locked="Dockerfile"}Command line arguments, file names and string inserts should not be translated + Missing required option: '{}' {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated @@ -3457,7 +3461,8 @@ On first run, creates the file with all settings commented out at their defaults Path for the saved image - Path to the build context directory + Path to the build context directory (use "-" to read a tar archive or Dockerfile from stdin) + {Locked="Dockerfile"}{Locked="stdin"}{Locked="-"}Command line arguments should not be translated Publish a port from a container to host @@ -3862,6 +3867,10 @@ On first run, creates the file with all settings commented out at their defaults Cannot write image to terminal. Use the -o option or redirect stdout. + + Cannot read build input from terminal. Pipe a Dockerfile or tar archive to stdin. + {Locked="Dockerfile"}{Locked="stdin"}Command line arguments, file names and string inserts should not be translated + Cannot export container to terminal. Use the -o option or redirect stdout. diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index c25bd2a0..bc5c3157 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -603,8 +603,8 @@ typedef struct _WSLCDeleteImageOptions typedef struct _WSLCBuildImageOptions { - LPCWSTR ContextPath; - WSLCHandle DockerfileHandle; + [unique, string] LPCWSTR ContextPath; // Windows build context directory. Must be null when ContextHandle is set. + WSLCHandle DockerfileHandle; // Dockerfile contents relayed to docker's stdin. Must be unset when ContextHandle is set. WSLCStringArray Tags; WSLCStringArray BuildArgs; // KEY=VALUE pairs passed as --build-arg to docker. LPCSTR Target; // Target build stage name passed as --target to docker. @@ -616,6 +616,8 @@ typedef struct _WSLCBuildImageOptions [unique, string] LPCWSTR OutputMountPath; // When set, the server mounts this Windows directory read-write into the VM and points the exporter's dest at it (plus OutputMountFile). Used for single-file exporters with a real destination; mutually exclusive with OutputHandle. [unique, string] LPCWSTR OutputMountFile; // Leaf filename within OutputMountPath that a single-file exporter writes to. [unique, string] LPCWSTR IidFilePath; // Absolute path of the client's --iidfile destination. The server mounts its parent directory read-write into the VM and passes --iidfile pointing at it, so buildx writes the image ID straight to the destination. + WSLCHandle ContextHandle; // When Type != WSLCHandleTypeUnknown, the server relays this stream to docker's stdin as the build context ("-"). The stream is either a tar archive (optionally compressed) or a Dockerfile built with an empty context. + [unique, string] LPCSTR DockerfilePath; // Dockerfile path inside the streamed context, passed as --file. Only valid with ContextHandle. } WSLCBuildImageOptions; typedef struct _WSLCTagImageOptions diff --git a/src/windows/wslc/services/ImageService.cpp b/src/windows/wslc/services/ImageService.cpp index f9fb062d..a0145e17 100644 --- a/src/windows/wslc/services/ImageService.cpp +++ b/src/windows/wslc/services/ImageService.cpp @@ -107,6 +107,112 @@ InputSource OpenImageInput(const std::wstring& input) return InputSource{std::move(handle), static_cast(fileSize.QuadPart)}; } +// Where the build context and Dockerfile come from. Either ContextPath names a Windows directory and +// DockerfileHandle supplies the Dockerfile, or ContextHandle streams the whole context over stdin and +// DockerfilePath optionally names the Dockerfile inside it. +struct BuildContextInput +{ + std::wstring ContextPath; + HANDLE ContextHandle = nullptr; + HANDLE DockerfileHandle = nullptr; + wil::unique_hfile Dockerfile; + std::optional SpooledStdin; + std::string DockerfilePath; +}; + +// Returns a relayable stdin handle. Character devices need a file because COM cannot relay their handles. +HANDLE OpenStdin(BuildContextInput& input) +{ + auto handle = GetStdHandle(STD_INPUT_HANDLE); + + // Intentional divergence from docker, which accepts a Dockerfile typed into an interactive terminal. + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_BuildStdinIsTerminalError(), IsConsoleHandle(handle)); + + if (GetFileType(handle) != FILE_TYPE_CHAR) + { + return handle; + } + + input.SpooledStdin.emplace( + GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, CREATE_ALWAYS, wsl::windows::common::filesystem::TempFileFlags::DeleteOnClose); + std::array buffer{}; + for (;;) + { + DWORD read{}; + if (!ReadFile(handle, buffer.data(), static_cast(buffer.size()), &read, nullptr)) + { + const auto error = GetLastError(); + // NUL reports ERROR_INVALID_FUNCTION instead of EOF when read as a character device. + if (error == ERROR_INVALID_FUNCTION) + { + break; + } + + THROW_WIN32(error); + } + + if (read == 0) + { + break; + } + + DWORD written{}; + THROW_IF_WIN32_BOOL_FALSE(WriteFile(input.SpooledStdin->Handle.get(), buffer.data(), read, &written, nullptr)); + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_WRITE_FAULT), written != read); + } + + THROW_LAST_ERROR_IF(SetFilePointer(input.SpooledStdin->Handle.get(), 0, nullptr, FILE_BEGIN) == INVALID_SET_FILE_POINTER); + return input.SpooledStdin->Handle.get(); +} + +BuildContextInput OpenBuildContext(const std::wstring& contextPath, const std::wstring& dockerfilePath) +{ + BuildContextInput input; + + if (contextPath == L"-") + { + THROW_HR_WITH_USER_ERROR_IF( + E_INVALIDARG, Localization::MessageWslcBuildStdinContextAndDockerfile(), dockerfilePath == L"-"); + + // The Dockerfile path is resolved inside the streamed context by the Linux builder. + auto pathInContext = dockerfilePath; + std::ranges::replace(pathInContext, L'\\', L'/'); + + input.ContextHandle = OpenStdin(input); + input.DockerfilePath = wsl::windows::common::string::WideToMultiByte(pathInContext); + return input; + } + + auto absolutePath = std::filesystem::absolute(contextPath); + THROW_HR_IF_MSG( + HRESULT_FROM_WIN32(ERROR_DIRECTORY), + !std::filesystem::is_directory(absolutePath), + "Path must be a directory: %ls", + absolutePath.c_str()); + + input.ContextPath = absolutePath.wstring(); + + if (dockerfilePath == L"-") + { + input.DockerfileHandle = OpenStdin(input); + return input; + } + + if (dockerfilePath.empty()) + { + input.Dockerfile = ResolveBuildFile(absolutePath); + } + else + { + input.Dockerfile.reset( + CreateFileW(dockerfilePath.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + THROW_LAST_ERROR_IF_MSG(!input.Dockerfile, "Failed to open Dockerfile: %ls", dockerfilePath.c_str()); + } + + input.DockerfileHandle = input.Dockerfile.get(); + return input; +} + } // namespace namespace wsl::windows::wslc::services { @@ -131,30 +237,7 @@ void ImageService::Build( IProgressCallback* callback, HANDLE cancelEvent) { - auto absolutePath = std::filesystem::absolute(contextPath); - THROW_HR_IF_MSG( - HRESULT_FROM_WIN32(ERROR_DIRECTORY), - !std::filesystem::is_directory(absolutePath), - "Path must be a directory: %ls", - absolutePath.c_str()); - - HANDLE dockerfileHandle = nullptr; - wil::unique_hfile dockerfile; - if (dockerfilePath == L"-") - { - dockerfileHandle = GetStdHandle(STD_INPUT_HANDLE); - } - else if (!dockerfilePath.empty()) - { - dockerfile.reset(CreateFileW(dockerfilePath.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); - THROW_LAST_ERROR_IF_MSG(!dockerfile, "Failed to open Dockerfile: %ls", dockerfilePath.c_str()); - dockerfileHandle = dockerfile.get(); - } - else - { - dockerfile = ResolveBuildFile(absolutePath); - dockerfileHandle = dockerfile.get(); - } + auto buildContext = OpenBuildContext(contextPath, dockerfilePath); auto toMultiByte = [](const std::vector& input, std::vector& strings, std::vector& pointers) { strings.reserve(input.size()); @@ -262,8 +345,6 @@ void ImageService::Build( } } - auto contextPathStr = absolutePath.wstring(); - // Resolve the --iidfile destination against the client's working directory; the server mounts its // parent directory read-write into the VM so buildx writes the image ID straight to it. std::wstring iidPathStr; @@ -273,8 +354,9 @@ void ImageService::Build( } WSLCBuildImageOptions options{ - .ContextPath = contextPathStr.c_str(), - .DockerfileHandle = ToCOMInputHandle(dockerfileHandle), + .ContextPath = buildContext.ContextPath.empty() ? nullptr : buildContext.ContextPath.c_str(), + .DockerfileHandle = buildContext.DockerfileHandle != nullptr ? ToCOMInputHandle(buildContext.DockerfileHandle) + : WSLCHandle{.Type = WSLCHandleTypeUnknown}, .Tags = {tagPointers.data(), static_cast(tagPointers.size())}, .BuildArgs = {buildArgPointers.data(), static_cast(buildArgPointers.size())}, .Target = targetStr.empty() ? nullptr : targetStr.c_str(), @@ -286,6 +368,9 @@ void ImageService::Build( .OutputMountPath = outputMountPath.empty() ? nullptr : outputMountPath.c_str(), .OutputMountFile = outputMountFile.empty() ? nullptr : outputMountFile.c_str(), .IidFilePath = iidPathStr.empty() ? nullptr : iidPathStr.c_str(), + .ContextHandle = buildContext.ContextHandle != nullptr ? ToCOMInputHandle(buildContext.ContextHandle) + : WSLCHandle{.Type = WSLCHandleTypeUnknown}, + .DockerfilePath = buildContext.DockerfilePath.empty() ? nullptr : buildContext.DockerfilePath.c_str(), }; THROW_IF_FAILED(session.Get()->BuildImage(&options, callback, cancelEvent)); diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index d3e5ffc5..537480f5 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -1017,8 +1017,15 @@ try WSLCExecutionContext context(this); RETURN_HR_IF_NULL(E_POINTER, Options); - RETURN_HR_IF_NULL(E_POINTER, Options->ContextPath); - RETURN_HR_IF(E_INVALIDARG, *Options->ContextPath == L'\0'); + + // The build context is either a Windows directory or a stream relayed to docker's stdin, never both. The stream is + // a tar archive, or a bare Dockerfile that is built with an empty context. A streamed context occupies stdin, so it + // cannot also carry a Dockerfile handle. + const bool streamContext = Options->ContextHandle.Type != WSLCHandleTypeUnknown; + const bool hasContextPath = Options->ContextPath != nullptr && *Options->ContextPath != L'\0'; + RETURN_HR_IF(E_INVALIDARG, streamContext == hasContextPath); + RETURN_HR_IF(E_INVALIDARG, streamContext && Options->DockerfileHandle.Type != WSLCHandleTypeUnknown); + RETURN_HR_IF(E_INVALIDARG, !streamContext && Options->DockerfilePath != nullptr); RETURN_HR_IF(E_INVALIDARG, Options->Tags.Count > 0 && Options->Tags.Values == nullptr); RETURN_HR_IF(E_INVALIDARG, Options->BuildArgs.Count > 0 && Options->BuildArgs.Values == nullptr); RETURN_HR_IF(E_INVALIDARG, Options->Labels.Count > 0 && Options->Labels.Values == nullptr); @@ -1029,7 +1036,7 @@ try "Invalid flags: 0x%x", Options->Flags); - auto buildFileHandle = OpenUserHandle(Options->DockerfileHandle); + auto stdinHandle = OpenUserHandle(streamContext ? Options->ContextHandle : Options->DockerfileHandle); std::optional comCall; if (ProgressCallback != nullptr) @@ -1078,7 +1085,11 @@ try buildEnv.emplace_back(std::string{"EXPERIMENTAL_BUILDKIT_SOURCE_POLICY="} + WSLCVirtualMachine::c_buildKitPolicyPath); } - auto mountPath = mountInVm(Options->ContextPath, TRUE); + std::string mountPath; + if (!streamContext) + { + mountPath = mountInVm(Options->ContextPath, TRUE); + } // Progress is requested as JSON so it can be parsed into the formatted progress messages sent to the // client. The raw JSON is a docker implementation detail and is never forwarded. @@ -1271,9 +1282,21 @@ try } } - buildArgs.push_back("-f"); - buildArgs.push_back("-"); - buildArgs.push_back(mountPath); + if (streamContext) + { + if (Options->DockerfilePath != nullptr) + { + buildArgs.push_back(std::format("--file={}", Options->DockerfilePath)); + } + + buildArgs.push_back("-"); + } + else + { + buildArgs.push_back("-f"); + buildArgs.push_back("-"); + buildArgs.push_back(mountPath); + } WSL_LOG("BuildImageStart", TraceLoggingValue(wsl::shared::string::Join(buildArgs, ' ').c_str(), "Command")); @@ -1290,7 +1313,7 @@ try auto io = CreateIOContext(); io.AddHandle( - std::make_unique>(buildFileHandle.Get(), common::io::HandleWrapper{buildProcess.GetStdHandle(WSLCFDStdin)}), + std::make_unique>(stdinHandle.Get(), common::io::HandleWrapper{buildProcess.GetStdHandle(WSLCFDStdin)}), MultiHandleWait::NeedNotComplete, [&buildProcess]() { // If we receive an error relaying stdin, it could be because the process exited. diff --git a/test/windows/wslc/CommandLineTestCases.h b/test/windows/wslc/CommandLineTestCases.h index afcf3d94..6e4f6cfd 100644 --- a/test/windows/wslc/CommandLineTestCases.h +++ b/test/windows/wslc/CommandLineTestCases.h @@ -396,6 +396,8 @@ COMMAND_LINE_TEST_CASE(L"image build C:\\context --tag test:latest", L"build", t COMMAND_LINE_TEST_CASE(L"image build C:\\context -t test", L"build", true) COMMAND_LINE_TEST_CASE(L"image build C:\\context --file Dockerfile.custom", L"build", true) COMMAND_LINE_TEST_CASE(L"image build C:\\context -f -", L"build", true) +COMMAND_LINE_TEST_CASE(L"image build - -t test", L"build", true) +COMMAND_LINE_TEST_CASE(L"image build - -f sub/Dockerfile", L"build", true) COMMAND_LINE_TEST_CASE(L"image build C:\\context -t test:latest -f Dockerfile.other", L"build", true) COMMAND_LINE_TEST_CASE(L"image build C:\\context -t tag1 -t tag2", L"build", true) COMMAND_LINE_TEST_CASE(L"image build C:\\context --tag tag1 --tag tag2 --tag tag3", L"build", true) diff --git a/test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp b/test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp index c9215f48..7fc2f8cd 100644 --- a/test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp @@ -1529,6 +1529,72 @@ class WSLCE2EImageBuildTests } } + WSLC_TEST_METHOD(WSLCE2E_Image_Build_StdinTarContext_Success) + { + BuildFromStdinArchive(BuiltImageStdinTar, L"-cf", L"Dockerfile", L""); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_StdinGzipTarContext_Success) + { + BuildFromStdinArchive(BuiltImageStdinGzip, L"-czf", L"Dockerfile", L""); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_StdinTarContext_FileInArchive_Success) + { + // The backslash is converted so the path resolves inside the Linux build context. + BuildFromStdinArchive(BuiltImageStdinTarFile, L"-cf", L"sub\\custom.Dockerfile", L"sub\\custom.Dockerfile"); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_StdinDockerfile_EmptyContext_Success) + { + auto imageCleanup = DeleteImageOnExit(BuiltImageStdinDockerfile); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-stdin-dockerfile"; + auto cleanup = SetupTestDirectory(testRoot); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"stdin-dockerfile-ok\"]\n"); + + auto buildResult = RunWslcWithStdinFile(std::format(L"build - -t {}", BuiltImageStdinDockerfile.NameAndTag()), dockerfilePath); + buildResult.Verify({.Stdout = L"", .ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageStdinDockerfile.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + VERIFY_ARE_EQUAL(BuiltImageStdinDockerfile.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData.RepoTags.value()[0])); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_StdinContext_Terminal_Fails) + { + // A pseudo console gives wslc a console stdin, which build must reject. + auto session = RunWslcInteractive(L"build - --progress=quiet", ElevationType::Elevated, PseudoConsole{200, 50}); + WaitForPseudoConsoleOutput(session, string::WideToMultiByte(Localization::WSLCCLI_BuildStdinIsTerminalError())); + VERIFY_ARE_EQUAL(1, session.Wait()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_StdinDockerfile_Terminal_Fails) + { + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-stdin-dockerfile-terminal"; + auto cleanup = SetupTestDirectory(testRoot); + + auto session = RunWslcInteractive( + std::format(L"build \"{}\" -f - --progress=quiet", testRoot.wstring()), ElevationType::Elevated, PseudoConsole{200, 50}); + WaitForPseudoConsoleOutput(session, string::WideToMultiByte(Localization::WSLCCLI_BuildStdinIsTerminalError())); + VERIFY_ARE_EQUAL(1, session.Wait()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_StdinNul_ReachesBuilder) + { + auto result = RunWslc(L"build - --progress=quiet"); + VERIFY_ARE_EQUAL(1u, result.ExitCode.value()); + VERIFY_IS_TRUE(result.StderrContainsSubstring(L"failed to read dockerfile"), result.Stderr.value().c_str()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_StdinContextAndStdinDockerfile_Fails) + { + auto buildResult = RunWslc(L"build - -f -"); + buildResult.Verify( + {.Stderr = FormatErrorMessage(L"Can't use stdin for both the build context and the Dockerfile.", L"E_INVALIDARG"), .ExitCode = 1}); + } + private: const TestImage BuiltImage{L"wslc-e2e-build-empty-context", L"latest", L""}; const TestImage BuiltImageTag1{L"wslc-e2e-build-args-tags", L"v1", L""}; @@ -1568,6 +1634,37 @@ private: const TestImage BuiltImageProgressPlain{L"wslc-e2e-build-progress-plain", L"latest", L""}; const TestImage BuiltImageProgressQuiet{L"wslc-e2e-build-progress-quiet", L"latest", L""}; const TestImage BuiltImageIidFileRelative{L"wslc-e2e-build-iidfile-relative", L"latest", L""}; + const TestImage BuiltImageStdinTar{L"wslc-e2e-build-stdin-tar", L"latest", L""}; + const TestImage BuiltImageStdinGzip{L"wslc-e2e-build-stdin-gzip", L"latest", L""}; + const TestImage BuiltImageStdinTarFile{L"wslc-e2e-build-stdin-tar-file", L"latest", L""}; + const TestImage BuiltImageStdinDockerfile{L"wslc-e2e-build-stdin-dockerfile", L"latest", L""}; + + // Archives a context holding a Dockerfile (at dockerfileInContext) and a marker file, streams it to + // `wslc build -` over stdin, and verifies the build could COPY the marker out of the streamed context. + void BuildFromStdinArchive(const TestImage& image, std::wstring_view tarFlags, std::wstring_view dockerfileInContext, std::wstring_view fileArg) + { + auto imageCleanup = DeleteImageOnExit(image); + auto testRoot = std::filesystem::current_path() / image.Name; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = testRoot / L"context"; + std::filesystem::create_directories((contextDir / dockerfileInContext).parent_path()); + WriteTestFileContent(contextDir / dockerfileInContext, "FROM debian:latest\nCOPY marker.txt /marker.txt\n"); + WriteTestFileContent(contextDir / L"marker.txt", "stdin-context-marker\n"); + + auto tarPath = testRoot / L"context.tar"; + auto tarCmd = std::format(L"tar.exe {} \"{}\" -C \"{}\" .", tarFlags, tarPath.wstring(), contextDir.wstring()); + wsl::windows::common::SubProcess tar(nullptr, tarCmd.c_str()); + VERIFY_ARE_EQUAL(0u, tar.RunAndCaptureOutput().ExitCode, L"tar.exe failed to create the build context archive"); + + auto fileOption = fileArg.empty() ? std::wstring{} : std::format(L" -f {}", fileArg); + auto buildResult = RunWslcWithStdinFile(std::format(L"build -{} -t {}", fileOption, image.NameAndTag()), tarPath); + buildResult.Verify({.Stdout = L"", .ExitCode = 0}); + + auto inspectData = InspectImage(image.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + VERIFY_ARE_EQUAL(image.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData.RepoTags.value()[0])); + } // Runs `tar.exe -tf ` and returns the member listing so tests can assert an exporter produced a // valid, non-empty archive that contains an expected entry.