Support reading the wslc image build context from stdin (#41765)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
ggarzia-MSFTandCopilot authored and GitHub committed 2026-10-06 16:16:20 -07:00
1 parent 4c49847670
commit ffb4e18696
6 files changed
+257 -39

No files matched your search

+10 -1
View File
@@ -2451,6 +2451,10 @@ For privacy information about this product please visit https://aka.ms/privacy.<
<value>Cannot write build output to the console for --output value '{}'.</value>
<comment>{FixedPlaceholder="{}"}{Locked="--output "}Command line arguments, file names and string inserts should not be translated</comment>
</data>
<data name="MessageWslcBuildStdinContextAndDockerfile" xml:space="preserve">
<value>Can't use stdin for both the build context and the Dockerfile.</value>
<comment>{Locked="stdin"}{Locked="Dockerfile"}Command line arguments, file names and string inserts should not be translated</comment>
</data>
<data name = "MessageWslcMissingVolumeOption" xml:space = "preserve" >
<value>Missing required option: '{}'</value>
<comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
@@ -3457,7 +3461,8 @@ On first run, creates the file with all settings commented out at their defaults
<value>Path for the saved image</value>
</data>
<data name="WSLCCLI_PathArgDescription" xml:space="preserve">
<value>Path to the build context directory</value>
<value>Path to the build context directory (use "-" to read a tar archive or Dockerfile from stdin)</value>
<comment>{Locked="Dockerfile"}{Locked="stdin"}{Locked="-"}Command line arguments should not be translated</comment>
</data>
<data name="WSLCCLI_PublishArgDescription" xml:space="preserve">
<value>Publish a port from a container to host</value>
@@ -3862,6 +3867,10 @@ On first run, creates the file with all settings commented out at their defaults
<data name="WSLCCLI_ImageSaveStdoutIsTerminalError" xml:space="preserve">
<value>Cannot write image to terminal. Use the -o option or redirect stdout.</value>
</data>
<data name="WSLCCLI_BuildStdinIsTerminalError" xml:space="preserve">
<value>Cannot read build input from terminal. Pipe a Dockerfile or tar archive to stdin.</value>
<comment>{Locked="Dockerfile"}{Locked="stdin"}Command line arguments, file names and string inserts should not be translated</comment>
</data>
<data name="WSLCCLI_ContainerExportStdoutIsTerminalError" xml:space="preserve">
<value>Cannot export container to terminal. Use the -o option or redirect stdout.</value>
</data>
+4 -2
View File
@@ -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
+113 -28
View File
@@ -107,6 +107,112 @@ InputSource OpenImageInput(const std::wstring& input)
return InputSource{std::move(handle), static_cast<ULONGLONG>(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<wsl::windows::common::filesystem::TempFile> 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<char, 4096> buffer{};
for (;;)
{
DWORD read{};
if (!ReadFile(handle, buffer.data(), static_cast<DWORD>(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<std::wstring>& input, std::vector<std::string>& strings, std::vector<LPCSTR>& 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<ULONG>(tagPointers.size())},
.BuildArgs = {buildArgPointers.data(), static_cast<ULONG>(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));
+31 -8
View File
@@ -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<UserCOMCallback> 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<io::RelayHandle<io::ReadHandle>>(buildFileHandle.Get(), common::io::HandleWrapper{buildProcess.GetStdHandle(WSLCFDStdin)}),
std::make_unique<io::RelayHandle<io::ReadHandle>>(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.
+2
View File
@@ -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)
@@ -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 <path>` and returns the member listing so tests can assert an exporter produced a
// valid, non-empty archive that contains an expected entry.