@samitouri / QOSAMI-WSL / commits / aab83ef0

Add wslc build --iidfile option (#41212)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

ggarzia-MSFT committed Aug 7, 2026 at 09:44 UTC aab83ef0872ad079caa50a449905f06257cafdd8
9 files changed +165 -4
localization/strings/en-US/Resources.resw
+3
@@ -3191,6 +3191,9 @@ On first run, creates the file with all settings commented out at their defaults
3191 <data name="WSLCCLI_CIDFileArgDescription" xml:space="preserve">
3192 <value>Write the container ID to the provided path</value>
3193 </data>
3194 + <data name="WSLCCLI_IidFileArgDescription" xml:space="preserve">
3195 + <value>Write the image ID to the provided path</value>
3196 + </data>
3197 <data name="WSLCCLI_DNSArgDescription" xml:space="preserve">
3198 <value>IP address of the DNS nameserver in resolv.conf</value>
3199 <comment>{Locked="resolv.conf"}Command line arguments should not be translated</comment>
src/windows/service/inc/wslc.idl
+1
@@ -563,6 +563,7 @@ typedef struct _WSLCBuildImageOptions
563 WSLCHandle OutputHandle; // When Type != WSLCHandleTypeUnknown, the server streams the single-file exporter output (a tar/oci/docker tarball, or dest=- stdout) to this handle after a successful build.
564 [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.
565 [unique, string] LPCWSTR OutputMountFile; // Leaf filename within OutputMountPath that a single-file exporter writes to.
566 + [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.
567 } WSLCBuildImageOptions;
568
569 typedef struct _WSLCTagImageOptions
src/windows/wslc/arguments/ArgumentDefinitions.h
+1
@@ -76,6 +76,7 @@ _(Hostname, "hostname", L"h", Kind::Value, L
76 _(ImageForce, "force", L"f", Kind::Flag, Localization::WSLCCLI_ImageForceArgDescription()) \
77 _(ImageId, "image", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_ImageIdArgDescription()) \
78 _(ImportFile, "file", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_ImportFileArgDescription()) \
79 +_(IidFile, "iidfile", NO_ALIAS, Kind::Value, Localization::WSLCCLI_IidFileArgDescription()) \
80 _(Input, "input", L"i", Kind::Value, Localization::WSLCCLI_InputArgDescription()) \
81 _(InspectFormat, "format", NO_ALIAS, Kind::Value, Localization::WSLCCLI_InspectFormatArgDescription()) \
82 _(Interactive, "interactive", L"i", Kind::Flag, Localization::WSLCCLI_InteractiveArgDescription()) \
src/windows/wslc/commands/ImageBuildCommand.cpp
+1
@@ -32,6 +32,7 @@ std::vector<Argument> ImageBuildCommand::GetArguments() const
32 Argument::Create(ArgType::BuildPull),
33 Argument::Create(ArgType::BuildTarget),
34 Argument::Create(ArgType::File),
35 + Argument::Create(ArgType::IidFile),
36 Argument::Create(ArgType::Label, false, Limit::Unlimited),
37 Argument::Create(ArgType::NoCache),
38 Argument::Create(ArgType::Output, false, std::nullopt, Localization::WSLCCLI_BuildOutputArgDescription()),
src/windows/wslc/services/ImageService.cpp
+11
@@ -123,6 +123,7 @@ void ImageService::Build(
123 const std::wstring& dockerfilePath,
124 const std::wstring& target,
125 const std::optional<BuildOutput>& output,
126 + const std::optional<std::wstring>& iidFilePath,
127 WSLCBuildImageFlags flags,
128 IProgressCallback* callback,
129 HANDLE cancelEvent)
@@ -259,6 +260,15 @@ void ImageService::Build(
260 }
261
262 auto contextPathStr = absolutePath.wstring();
263 +
264 + // Resolve the --iidfile destination against the client's working directory; the server mounts its
265 + // parent directory read-write into the VM so buildx writes the image ID straight to it.
266 + std::wstring iidPathStr;
267 + if (iidFilePath.has_value())
268 + {
269 + iidPathStr = std::filesystem::weakly_canonical(std::filesystem::absolute(*iidFilePath)).wstring();
270 + }
271 +
272 WSLCBuildImageOptions options{
273 .ContextPath = contextPathStr.c_str(),
274 .DockerfileHandle = ToCOMInputHandle(dockerfileHandle),
@@ -272,6 +282,7 @@ void ImageService::Build(
282 .OutputHandle = outputHandle != nullptr ? ToCOMInputHandle(outputHandle) : WSLCHandle{.Type = WSLCHandleTypeUnknown},
283 .OutputMountPath = outputMountPath.empty() ? nullptr : outputMountPath.c_str(),
284 .OutputMountFile = outputMountFile.empty() ? nullptr : outputMountFile.c_str(),
285 + .IidFilePath = iidPathStr.empty() ? nullptr : iidPathStr.c_str(),
286 };
287
288 THROW_IF_FAILED(session.Get()->BuildImage(&options, callback, cancelEvent));
src/windows/wslc/services/ImageService.h
+1
@@ -57,6 +57,7 @@ public:
57 const std::wstring& dockerfilePath,
58 const std::wstring& target,
59 const std::optional<BuildOutput>& output,
60 + const std::optional<std::wstring>& iidFilePath,
61 WSLCBuildImageFlags flags,
62 IProgressCallback* callback,
63 HANDLE cancelEvent = nullptr);
src/windows/wslc/tasks/ImageTasks.cpp
+7 -1
@@ -134,6 +134,12 @@ void BuildImage(CLIExecutionContext& context)
134 output = validation::ParseOutputSpec(context.Args.Get<ArgType::Output>());
135 }
136
137 + std::optional<std::wstring> iidFilePath;
138 + if (context.Args.Contains(ArgType::IidFile))
139 + {
140 + iidFilePath = context.Args.Get<ArgType::IidFile>();
141 + }
142 +
143 WSLCBuildImageFlags flags = WSLCBuildImageFlagsNone;
144 WI_SetFlagIf(flags, WSLCBuildImageFlagsVerbose, context.Args.GetFlag<ArgType::Verbose>());
145 WI_SetFlagIf(flags, WSLCBuildImageFlagsNoCache, context.Args.GetFlag<ArgType::NoCache>());
@@ -142,7 +148,7 @@ void BuildImage(CLIExecutionContext& context)
148 auto cancelEvent = context.CreateCancelEvent();
149 BuildImageCallback callback(context.Reporter, cancelEvent, context.Args.GetFlag<ArgType::Verbose>());
150 services::ImageService::Build(
145 - session, contextPath, tags, buildArgs, labels, secrets, dockerfilePath, target, output, flags, &callback, cancelEvent);
151 + session, contextPath, tags, buildArgs, labels, secrets, dockerfilePath, target, output, iidFilePath, flags, &callback, cancelEvent);
152 }
153
154 void GetImages(CLIExecutionContext& context)
src/windows/wslcsession/WSLCSession.cpp
+20 -3
@@ -1014,9 +1014,9 @@ try
1014
1015 // Reserve up front so mountInVm's push_back can never reallocate-and-throw after a successful
1016 // MountWindowsFolder, which would leak a mount the scope_exit hasn't recorded yet. At most the build
1017 - // context (1), the single-file exporter output destination (1), and one parent directory per file
1018 - // secret are mounted.
1019 - mountedPaths.reserve(static_cast<size_t>(2) + Options->Secrets.Count);
1017 + // context (1), the single-file exporter output destination (1), the --iidfile destination (1), and
1018 + // one parent directory per file secret are mounted.
1019 + mountedPaths.reserve(static_cast<size_t>(3) + Options->Secrets.Count);
1020
1021 auto mountPath = mountInVm(Options->ContextPath, TRUE);
1022
@@ -1081,6 +1081,23 @@ try
1081 buildArgs.push_back("--output");
1082 buildArgs.push_back(outputSpec);
1083 }
1084 +
1085 + // Docker-style --iidfile. The destination's parent directory is mounted read-write into the VM so
1086 + // buildx writes the image ID straight to the client's --iidfile path.
1087 + if (Options->IidFilePath != nullptr && Options->IidFilePath[0] != L'\0')
1088 + {
1089 + std::filesystem::path iidPath(Options->IidFilePath);
1090 + // The client and server have different current directories, so a relative path is ambiguous.
1091 + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessagePathNotAbsolute(Options->IidFilePath), !iidPath.is_absolute());
1092 +
1093 + auto iidParent = iidPath.parent_path();
1094 + auto iidFileNameUtf8 = wsl::shared::string::WideToMultiByte(iidPath.filename().wstring());
1095 + RETURN_HR_IF(E_INVALIDARG, iidParent.empty() || iidFileNameUtf8.empty());
1096 +
1097 + auto iidMountPath = mountInVm(iidParent.c_str(), FALSE);
1098 + buildArgs.push_back("--iidfile");
1099 + buildArgs.push_back(std::format("{}/{}", iidMountPath, iidFileNameUtf8));
1100 + }
1101 for (ULONG i = 0; i < Options->Tags.Count; i++)
1102 {
1103 RETURN_HR_IF_NULL(E_INVALIDARG, Options->Tags.Values[i]);
test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp
+120
@@ -1267,6 +1267,124 @@ class WSLCE2EImageBuildTests
1267 VERIFY_ARE_NOT_EQUAL(firstId, noCacheId, L"--no-cache must rebuild the non-deterministic RUN step");
1268 }
1269
1270 + // --iidfile writes the built image's ID to the given host path on success, matching docker build
1271 + // --iidfile. The file must contain the same sha256 digest the image is stored under.
1272 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_Success)
1273 + {
1274 + auto imageCleanup = DeleteImageOnExit(BuiltImageIidFile);
1275 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile";
1276 + auto cleanup = SetupTestDirectory(testRoot);
1277 +
1278 + auto contextDir = SharedOutputBuildContext();
1279 +
1280 + auto dockerfilePath = testRoot / L"Dockerfile";
1281 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-iidfile-marker > /marker.txt\n");
1282 +
1283 + // Point --iidfile at a path that does not yet exist.
1284 + const auto iidFilePath = testRoot / L"image.id";
1285 +
1286 + auto buildResult = RunWslc(std::format(
1287 + L"build \"{}\" -f \"{}\" -t {} --iidfile \"{}\"",
1288 + contextDir.wstring(),
1289 + dockerfilePath.wstring(),
1290 + BuiltImageIidFile.NameAndTag(),
1291 + iidFilePath.wstring()));
1292 + buildResult.Verify({.ExitCode = 0});
1293 +
1294 + VERIFY_IS_TRUE(std::filesystem::exists(iidFilePath));
1295 + const auto iid = ReadFileContent(iidFilePath.wstring());
1296 + VERIFY_IS_TRUE(iid.starts_with(L"sha256:"), L"iidfile must contain a sha256 digest");
1297 + VERIFY_ARE_EQUAL(static_cast<size_t>(71), iid.size(), L"iidfile must contain sha256: plus a 64-char hex digest");
1298 +
1299 + // The digest written to the iidfile must match the ID the image is stored under.
1300 + const auto inspectedId = InspectImage(BuiltImageIidFile.NameAndTag()).Id;
1301 + VERIFY_ARE_EQUAL(inspectedId, wsl::windows::common::string::WideToMultiByte(iid));
1302 + }
1303 +
1304 + // A failing build must not write the iidfile (matching docker: the file only appears on success).
1305 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_BuildFailure_NoFileWritten)
1306 + {
1307 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile-fail";
1308 + auto cleanup = SetupTestDirectory(testRoot);
1309 +
1310 + auto contextDir = SharedOutputBuildContext();
1311 +
1312 + auto dockerfilePath = testRoot / L"Dockerfile";
1313 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN exit 7\n");
1314 +
1315 + const auto iidFilePath = testRoot / L"image.id";
1316 +
1317 + auto buildResult = RunWslc(std::format(
1318 + L"build \"{}\" -f \"{}\" --iidfile \"{}\"", contextDir.wstring(), dockerfilePath.wstring(), iidFilePath.wstring()));
1319 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1320 + VERIFY_IS_FALSE(std::filesystem::exists(iidFilePath), L"a failed build must not leave an iidfile behind");
1321 + }
1322 +
1323 + // Unlike --output, --iidfile does not create a missing parent directory (matching docker). The server
1324 + // mounts the parent into the VM, so a missing directory must surface as a clean error, not a crash.
1325 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_ParentDirectoryMissing_Fails)
1326 + {
1327 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile-noparent";
1328 + auto cleanup = SetupTestDirectory(testRoot);
1329 +
1330 + auto contextDir = SharedOutputBuildContext();
1331 +
1332 + auto dockerfilePath = testRoot / L"Dockerfile";
1333 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
1334 +
1335 + const auto iidFilePath = testRoot / L"does-not-exist" / L"image.id";
1336 +
1337 + auto buildResult = RunWslc(std::format(
1338 + L"build \"{}\" -f \"{}\" --iidfile \"{}\"", contextDir.wstring(), dockerfilePath.wstring(), iidFilePath.wstring()));
1339 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1340 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1341 + VERIFY_IS_FALSE(buildResult.Stderr->empty());
1342 + VERIFY_IS_FALSE(std::filesystem::exists(iidFilePath));
1343 + VERIFY_IS_FALSE(std::filesystem::exists(iidFilePath.parent_path()), L"--iidfile must not create its parent directory");
1344 + }
1345 +
1346 + // The iidfile's parent is mounted read-write, but the destination file itself may still be
1347 + // unwritable. buildx must fail rather than silently reporting success.
1348 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_NotWritable_Fails)
1349 + {
1350 + auto imageCleanup = DeleteImageOnExit(BuiltImageIidFileNotWritable);
1351 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile-readonly";
1352 + auto cleanup = SetupTestDirectory(testRoot);
1353 +
1354 + auto contextDir = SharedOutputBuildContext();
1355 +
1356 + auto dockerfilePath = testRoot / L"Dockerfile";
1357 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
1358 +
1359 + // Pre-create the destination and deny write access so buildx cannot write the image ID to it.
1360 + const auto iidFilePath = testRoot / L"image.id";
1361 + WriteTestFileContent(iidFilePath, "original-content");
1362 + SetPathAccess(iidFilePath, GENERIC_WRITE, DENY_ACCESS);
1363 +
1364 + // The deny ACE also blocks this test from reading the file back, so it must be revoked before
1365 + // any assertion on the contents, and before cleanup can delete the file.
1366 + auto restore = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [iidFilePath]() {
1367 + SetPathAccess(iidFilePath, 0, REVOKE_ACCESS);
1368 + DeleteFileW(iidFilePath.c_str());
1369 + });
1370 +
1371 + auto buildResult = RunWslc(std::format(
1372 + L"build \"{}\" -f \"{}\" -t {} --iidfile \"{}\"",
1373 + contextDir.wstring(),
1374 + dockerfilePath.wstring(),
1375 + BuiltImageIidFileNotWritable.NameAndTag(),
1376 + iidFilePath.wstring()));
1377 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1378 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1379 + VERIFY_IS_FALSE(buildResult.Stderr->empty());
1380 +
1381 + // buildx truncates the destination when it opens it, so the previous contents are not preserved.
1382 + // What matters is that a failed write never leaves an image ID behind.
1383 + SetPathAccess(iidFilePath, 0, REVOKE_ACCESS);
1384 + const auto contents = ReadFileContent(iidFilePath.wstring());
1385 + VERIFY_IS_FALSE(contents.starts_with(L"sha256:"), L"a failed iidfile write must not leave an image ID behind");
1386 + }
1387 +
1388 private:
1389 const TestImage BuiltImage{L"wslc-e2e-build-empty-context", L"latest", L""};
1390 const TestImage BuiltImageTag1{L"wslc-e2e-build-args-tags", L"v1", L""};
@@ -1301,6 +1419,8 @@ private:
1419 const TestImage BuiltImageOutputImage{L"wslc-e2e-build-output-image", L"latest", L""};
1420 const TestImage BuiltImageOutputImageFail{L"wslc-e2e-build-output-image-fail", L"latest", L""};
1421 const TestImage BuiltImageOutputCacheOnly{L"wslc-e2e-build-output-cacheonly", L"latest", L""};
1422 + const TestImage BuiltImageIidFile{L"wslc-e2e-build-iidfile", L"latest", L""};
1423 + const TestImage BuiltImageIidFileNotWritable{L"wslc-e2e-build-iidfile-readonly", L"latest", L""};
1424
1425 // Runs `tar.exe -tf <path>` and returns the member listing so tests can assert an exporter produced a
1426 // valid, non-empty archive that contains an expected entry.