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.