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.