master
cpp 1,598 lines 77.3 KB
Raw
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 WSLCE2EImageBuildTests.cpp
8
9 Abstract:
10
11 This file contains end-to-end tests for WSLC image build.
12 --*/
13
14 #include "precomp.h"
15 #include "windows/Common.h"
16 #include "WSLCExecutor.h"
17 #include "WSLCE2EHelpers.h"
18 #include "TestImageRegistry.h"
19
20 namespace WSLCE2ETests {
21 using namespace wsl::shared;
22
23 class WSLCE2EImageBuildTests
24 {
25 WSLC_TEST_CLASS(WSLCE2EImageBuildTests)
26
27 TEST_CLASS_SETUP(ClassSetup)
28 {
29 DeleteImagesWithRepositoryPrefix(c_builtImagePrefix);
30 TestImageRegistry::Instance().EnsureLoaded(DebianTestImage());
31 return true;
32 }
33
34 TEST_CLASS_CLEANUP(ClassCleanup)
35 {
36 DeleteImagesWithRepositoryPrefix(c_builtImagePrefix);
37 return true;
38 }
39
40 // Each test owns and cleans up exactly the image(s) it builds via DeleteImageOnExit, so there is
41 // no per-method sweep. DeleteImagesWithRepositoryPrefix in the class setup/cleanup above is only a
42 // safety net for images left behind by a crashed run.
43 static constexpr auto c_builtImagePrefix = L"wslc-e2e-build-";
44
45 // Port for the local registry backing the --pull test; distinct from the other test classes.
46 static constexpr USHORT c_registryPort = 15005;
47
48 // Returns an RAII guard that best-effort deletes the given image when it goes out of scope. It is
49 // deliberately non-throwing (no VERIFY) because it may run while the stack unwinds after a test
50 // failure; the class-level prune is the authoritative cleanup.
51 static auto DeleteImageOnExit(std::wstring imageNameAndTag)
52 {
53 return wil::scope_exit([imageNameAndTag = std::move(imageNameAndTag)]() {
54 try
55 {
56 RunWslc(std::format(L"image delete --force {}", imageNameAndTag));
57 }
58 CATCH_LOG()
59 });
60 }
61
62 static auto DeleteImageOnExit(const TestImage& image)
63 {
64 return DeleteImageOnExit(image.NameAndTag());
65 }
66
67 // All secret tests build from this single shared (empty) context directory. Each distinct mounted
68 // directory consumes a virtiofs share slot while it is mounted, so reusing a single context path
69 // helps keep secret tests from exhausting the per-session share budget.
70 //
71 // The per-test Dockerfile is streamed via -f (never mounted); each file secret causes the server to
72 // mount that secret file's parent directory read-only for the duration of that build.
73 static std::filesystem::path SharedSecretBuildContext()
74 {
75 auto dir = std::filesystem::current_path() / L"wslc-e2e-build-secret-context";
76 std::error_code ec;
77 std::filesystem::create_directories(dir, ec);
78 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::is_directory(dir));
79 return dir;
80 }
81
82 // All --output tests build from this single shared (empty) context directory, for the same reason
83 // as SharedSecretBuildContext above: the session never releases virtiofs shares (see
84 // WSLCVirtualMachine::UnmountWindowsFolder), so giving each --output test its own context directory
85 // would permanently consume one share slot per test and eventually exhaust the session's budget.
86 // Reusing one path keeps all --output builds to a single shared slot. Each test's Dockerfile is
87 // streamed via -f and its output artifacts (tarballs, extracted trees) live under its own testRoot,
88 // so none of that is mounted.
89 static std::filesystem::path SharedOutputBuildContext()
90 {
91 auto dir = std::filesystem::current_path() / L"wslc-e2e-build-output-context";
92 std::error_code ec;
93 std::filesystem::create_directories(dir, ec);
94 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::is_directory(dir));
95 return dir;
96 }
97
98 WSLC_TEST_METHOD(WSLCE2E_Image_Build_EmptyContextDirectory_Success)
99 {
100 auto imageCleanup = DeleteImageOnExit(BuiltImage);
101 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-empty-context";
102 auto cleanup = SetupTestDirectory(testRoot);
103
104 auto contextDir = testRoot / L"context";
105 std::error_code ec;
106 std::filesystem::create_directories(contextDir, ec);
107 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
108
109 auto dockerfilePath = testRoot / L"Dockerfile";
110 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"wslc-e2e-build-ok\"]\n");
111
112 auto buildResult = RunWslc(
113 std::format(L"build \"{}\" -f \"{}\" -t {}", contextDir.wstring(), dockerfilePath.wstring(), BuiltImage.NameAndTag()));
114 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
115
116 auto inspectData = InspectImage(BuiltImage.NameAndTag());
117 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
118 VERIFY_ARE_EQUAL(1u, inspectData.RepoTags.value().size());
119 VERIFY_ARE_EQUAL(BuiltImage.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData.RepoTags.value()[0]));
120 }
121
122 WSLC_TEST_METHOD(WSLCE2E_Image_Build_UnicodeOutput_Success)
123 {
124 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-unicode-output";
125 auto cleanup = SetupTestDirectory(testRoot);
126
127 auto dockerfilePath = testRoot / L"Dockerfile";
128 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo 安装依赖\n");
129
130 auto buildResult = RunWslc(std::format(
131 L"build \"{}\" -f \"{}\" --output type=cacheonly", SharedOutputBuildContext().wstring(), dockerfilePath.wstring()));
132 buildResult.Verify({.ExitCode = 0});
133 VERIFY_IS_TRUE(buildResult.StderrContainsSubstring(wsl::shared::string::MultiByteToWide("安装依赖")));
134 }
135
136 WSLC_TEST_METHOD(WSLCE2E_Image_Build_BuildArgsFileAndMultipleTags_Success)
137 {
138 auto imageCleanup1 = DeleteImageOnExit(BuiltImageTag1);
139 auto imageCleanup2 = DeleteImageOnExit(BuiltImageTag2);
140 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-args-tags";
141 auto cleanup = SetupTestDirectory(testRoot);
142
143 auto contextDir = testRoot / L"context";
144 std::error_code ec;
145 std::filesystem::create_directories(contextDir, ec);
146 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
147
148 // Create a simple file in the context directory
149 auto filePath = contextDir / L"hello.txt";
150 WriteTestFileContent(filePath, "hello from wslc build\n");
151
152 auto dockerfilePath = testRoot / L"Dockerfile";
153 WriteTestFileContent(
154 dockerfilePath,
155 "FROM debian:latest\n"
156 "ARG TEST_LABEL=default_value\n"
157 "LABEL test_label=$TEST_LABEL\n"
158 "COPY hello.txt /hello.txt\n"
159 "CMD [\"cat\", \"/hello.txt\"]\n");
160
161 auto buildResult = RunWslc(std::format(
162 L"build \"{}\" -f \"{}\" -t {} -t {} --build-arg TEST_LABEL=wslc_e2e_test",
163 contextDir.wstring(),
164 dockerfilePath.wstring(),
165 BuiltImageTag1.NameAndTag(),
166 BuiltImageTag2.NameAndTag()));
167 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
168
169 // Verify both tags are present by inspecting each one
170 auto inspectData1 = InspectImage(BuiltImageTag1.NameAndTag());
171 VERIFY_IS_TRUE(inspectData1.RepoTags.has_value());
172
173 auto inspectData2 = InspectImage(BuiltImageTag2.NameAndTag());
174
175 // Both tags refer to the same image
176 VERIFY_ARE_EQUAL(inspectData1.Id, inspectData2.Id);
177
178 // Verify the build arg was applied as a label
179 VERIFY_IS_TRUE(inspectData1.Config.has_value());
180 VERIFY_IS_TRUE(inspectData1.Config.value().Labels.has_value());
181 const auto& labels = inspectData1.Config.value().Labels.value();
182 auto it = labels.find("test_label");
183 VERIFY_IS_TRUE(it != labels.end());
184 VERIFY_ARE_EQUAL(std::string("wslc_e2e_test"), it->second);
185 }
186
187 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Pull_Success)
188 {
189 // A local registry acts as the private image source that --pull re-resolves the base image from.
190 TestImageRegistry::Instance().EnsureLoaded(AlpineTestImage());
191
192 auto session = OpenDefaultElevatedSession();
193 auto [registryContainer, registryAddress] = StartLocalRegistry(*session, "", "", c_registryPort);
194
195 auto registryImage = TagImageForRegistry(AlpineTestImage().NameAndTag(), string::MultiByteToWide(registryAddress));
196 auto registryImageCleanup = DeleteImageOnExit(registryImage);
197
198 RunWslcAndVerify(std::format(L"push {}", registryImage), {.Stderr = L"", .ExitCode = 0});
199
200 auto imageCleanup = DeleteImageOnExit(BuiltImagePull);
201 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-pull";
202 auto cleanup = SetupTestDirectory(testRoot);
203
204 auto contextDir = testRoot / L"context";
205 std::error_code ec;
206 std::filesystem::create_directories(contextDir, ec);
207 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
208
209 auto dockerfilePath = testRoot / L"Dockerfile";
210 auto dockerfile = std::format("FROM {}\nCMD [\"echo\", \"pull-ok\"]\n", string::WideToMultiByte(registryImage));
211 WriteTestFileContent(dockerfilePath, dockerfile);
212
213 // The base image is already local, so only --pull can make the FROM step resolve a registry digest.
214 auto buildResult = RunWslc(std::format(
215 L"build \"{}\" -f \"{}\" -t {} --pull --verbose", contextDir.wstring(), dockerfilePath.wstring(), BuiltImagePull.NameAndTag()));
216 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
217
218 VERIFY_IS_TRUE(buildResult.StderrContainsSubstring(std::format(L"{}@sha256:", registryImage)));
219 }
220
221 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Target_Success)
222 {
223 auto imageCleanup = DeleteImageOnExit(BuiltImageTarget);
224 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-target";
225 auto cleanup = SetupTestDirectory(testRoot);
226
227 auto contextDir = testRoot / L"context";
228 std::error_code ec;
229 std::filesystem::create_directories(contextDir, ec);
230 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
231
232 auto dockerfilePath = testRoot / L"Dockerfile";
233 WriteTestFileContent(
234 dockerfilePath,
235 "FROM debian:latest AS build-stage\n"
236 "RUN echo build > /stage.txt\n"
237 "\n"
238 "FROM debian:latest AS final-stage\n"
239 "COPY --from=build-stage /stage.txt /stage.txt\n"
240 "CMD [\"cat\", \"/stage.txt\"]\n");
241
242 auto buildResult = RunWslc(std::format(
243 L"build \"{}\" -f \"{}\" -t {} --target build-stage", contextDir.wstring(), dockerfilePath.wstring(), BuiltImageTarget.NameAndTag()));
244 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
245
246 auto inspectData = InspectImage(BuiltImageTarget.NameAndTag());
247 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
248 VERIFY_ARE_EQUAL(1u, inspectData.RepoTags.value().size());
249 VERIFY_ARE_EQUAL(BuiltImageTarget.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData.RepoTags.value()[0]));
250
251 // Verify that --target stopped at build-stage: the image should NOT have the CMD
252 // from final-stage. If --target were ignored, the CMD would be ["cat", "/stage.txt"].
253 VERIFY_IS_TRUE(inspectData.Config.has_value());
254 const std::vector<std::string> finalStageCmd{"cat", "/stage.txt"};
255 VERIFY_IS_TRUE(!inspectData.Config.value().Cmd.has_value() || inspectData.Config.value().Cmd.value() != finalStageCmd);
256 }
257
258 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Label_Success)
259 {
260 auto imageCleanup = DeleteImageOnExit(BuiltImageLabel);
261 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-label";
262 auto cleanup = SetupTestDirectory(testRoot);
263
264 auto contextDir = testRoot / L"context";
265 std::error_code ec;
266 std::filesystem::create_directories(contextDir, ec);
267 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
268
269 auto dockerfilePath = testRoot / L"Dockerfile";
270 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"label-ok\"]\n");
271
272 // Use both the short alias (-l) and long form (--label) to confirm both parse paths.
273 auto buildResult = RunWslc(std::format(
274 L"build \"{}\" -f \"{}\" -t {} -l first=one --label second=two",
275 contextDir.wstring(),
276 dockerfilePath.wstring(),
277 BuiltImageLabel.NameAndTag()));
278 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
279
280 auto inspectData = InspectImage(BuiltImageLabel.NameAndTag());
281 VERIFY_IS_TRUE(inspectData.Config.has_value());
282 VERIFY_IS_TRUE(inspectData.Config.value().Labels.has_value());
283 const auto& labels = inspectData.Config.value().Labels.value();
284
285 auto firstIt = labels.find("first");
286 VERIFY_IS_TRUE(firstIt != labels.end());
287 VERIFY_ARE_EQUAL(std::string("one"), firstIt->second);
288
289 auto secondIt = labels.find("second");
290 VERIFY_IS_TRUE(secondIt != labels.end());
291 VERIFY_ARE_EQUAL(std::string("two"), secondIt->second);
292 }
293
294 WSLC_TEST_METHOD(WSLCE2E_Image_Build_LabelOverridesDockerfile_Success)
295 {
296 auto imageCleanup = DeleteImageOnExit(BuiltImageLabelOverride);
297 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-label-override";
298 auto cleanup = SetupTestDirectory(testRoot);
299
300 auto contextDir = testRoot / L"context";
301 std::error_code ec;
302 std::filesystem::create_directories(contextDir, ec);
303 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
304
305 auto dockerfilePath = testRoot / L"Dockerfile";
306 WriteTestFileContent(
307 dockerfilePath, "FROM debian:latest\nLABEL conflict=from-dockerfile\nCMD [\"echo\", \"label-override-ok\"]\n");
308
309 auto buildResult = RunWslc(std::format(
310 L"build \"{}\" -f \"{}\" -t {} --label conflict=from-cli",
311 contextDir.wstring(),
312 dockerfilePath.wstring(),
313 BuiltImageLabelOverride.NameAndTag()));
314 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
315
316 auto inspectData = InspectImage(BuiltImageLabelOverride.NameAndTag());
317 VERIFY_IS_TRUE(inspectData.Config.has_value());
318 VERIFY_IS_TRUE(inspectData.Config.value().Labels.has_value());
319 const auto& labels = inspectData.Config.value().Labels.value();
320 auto it = labels.find("conflict");
321 VERIFY_IS_TRUE(it != labels.end());
322 VERIFY_ARE_EQUAL(std::string("from-cli"), it->second);
323 }
324
325 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_Env_Success)
326 {
327 // Set the env var the --secret will reference; ensure cleanup so we don't leak into other tests.
328 constexpr auto envName = L"WSLC_E2E_SECRET_VALUE";
329 constexpr auto envValue = L"expected-secret-content-12345";
330 ScopedEnvVariable envVar(envName, envValue);
331
332 auto imageCleanup = DeleteImageOnExit(BuiltImageSecret);
333 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-env";
334 auto cleanup = SetupTestDirectory(testRoot);
335
336 auto contextDir = SharedSecretBuildContext();
337
338 // RUN with type=secret asserts the secret value matches; if mismatched, RUN exits non-zero and the build fails.
339 auto dockerfilePath = testRoot / L"Dockerfile";
340 WriteTestFileContent(
341 dockerfilePath,
342 "# syntax=docker/dockerfile:1\n"
343 "FROM debian:latest\n"
344 "RUN --mount=type=secret,id=mysecret "
345 "[ \"$(cat /run/secrets/mysecret)\" = \"expected-secret-content-12345\" ]\n"
346 "CMD [\"echo\", \"secret-ok\"]\n");
347
348 auto buildResult = RunWslc(std::format(
349 L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,env=WSLC_E2E_SECRET_VALUE",
350 contextDir.wstring(),
351 dockerfilePath.wstring(),
352 BuiltImageSecret.NameAndTag()));
353 buildResult.Verify({.ExitCode = 0});
354
355 auto inspectData = InspectImage(BuiltImageSecret.NameAndTag());
356 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
357 }
358
359 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_BareId_UsesEnvNamedById_Success)
360 {
361 // Docker parity: '--secret id=NAME' with no env=/src= reads the host env var named NAME.
362 constexpr auto envName = L"WSLC_E2E_BARE_SECRET";
363 constexpr auto envValue = L"bare-id-secret-content-67890";
364 ScopedEnvVariable envVar(envName, envValue);
365
366 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretBareId);
367 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-bare-id";
368 auto cleanup = SetupTestDirectory(testRoot);
369
370 auto contextDir = SharedSecretBuildContext();
371
372 // The docker secret id equals the env var name, so the mount reads /run/secrets/<envName>.
373 auto dockerfilePath = testRoot / L"Dockerfile";
374 WriteTestFileContent(
375 dockerfilePath,
376 "# syntax=docker/dockerfile:1\n"
377 "FROM debian:latest\n"
378 "RUN --mount=type=secret,id=WSLC_E2E_BARE_SECRET "
379 "[ \"$(cat /run/secrets/WSLC_E2E_BARE_SECRET)\" = \"bare-id-secret-content-67890\" ]\n"
380 "CMD [\"echo\", \"secret-ok\"]\n");
381
382 auto buildResult = RunWslc(std::format(
383 L"build \"{}\" -f \"{}\" -t {} --secret id=WSLC_E2E_BARE_SECRET",
384 contextDir.wstring(),
385 dockerfilePath.wstring(),
386 BuiltImageSecretBareId.NameAndTag()));
387 buildResult.Verify({.ExitCode = 0});
388
389 auto inspectData = InspectImage(BuiltImageSecretBareId.NameAndTag());
390 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
391 }
392
393 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_BareIdUnsetVar_Fails)
394 {
395 // Docker parity: '--secret id=NAME' with no env=/src= reads the host env var named NAME, and
396 // errors when that variable is unset (unlike an explicit 'env=', which yields an empty value).
397 constexpr auto envName = L"WSLC_E2E_SECRET_BARE_ID_UNSET";
398 ScopedEnvVariable envVar(envName); // Clears it (restoring any prior value on exit) so a leaked value can't taint the test.
399
400 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-bare-id-unset";
401 auto cleanup = SetupTestDirectory(testRoot);
402
403 auto contextDir = testRoot / L"context";
404 std::error_code ec;
405 std::filesystem::create_directories(contextDir, ec);
406 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
407
408 auto dockerfilePath = testRoot / L"Dockerfile";
409 WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
410
411 auto buildResult = RunWslc(std::format(
412 L"build \"{}\" -f \"{}\" --secret id=WSLC_E2E_SECRET_BARE_ID_UNSET", contextDir.wstring(), dockerfilePath.wstring()));
413 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
414 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
415 VERIFY_IS_FALSE(buildResult.Stderr->empty());
416 }
417
418 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_MissingEnvVar_EmptyValue_Success)
419 {
420 // Docker parity: an unset environment variable yields an empty secret value, not an error.
421 constexpr auto envName = L"WSLC_E2E_SECRET_UNSET_VAR";
422 ScopedEnvVariable envVar(envName); // Clears it (restoring any prior value on exit) so a leaked value can't taint the test.
423
424 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretMissingEnv);
425 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-missing";
426 auto cleanup = SetupTestDirectory(testRoot);
427
428 auto contextDir = SharedSecretBuildContext();
429
430 auto dockerfilePath = testRoot / L"Dockerfile";
431 WriteTestFileContent(
432 dockerfilePath,
433 "# syntax=docker/dockerfile:1\n"
434 "FROM debian:latest\n"
435 "RUN --mount=type=secret,id=mysecret [ -z \"$(cat /run/secrets/mysecret)\" ]\n"
436 "CMD [\"echo\", \"secret-empty-ok\"]\n");
437
438 auto buildResult = RunWslc(std::format(
439 L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,env=WSLC_E2E_SECRET_UNSET_VAR",
440 contextDir.wstring(),
441 dockerfilePath.wstring(),
442 BuiltImageSecretMissingEnv.NameAndTag()));
443 buildResult.Verify({.ExitCode = 0});
444
445 auto inspectData = InspectImage(BuiltImageSecretMissingEnv.NameAndTag());
446 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
447 }
448
449 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_Src_Success)
450 {
451 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretSrc);
452 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-src";
453 auto cleanup = SetupTestDirectory(testRoot);
454
455 auto contextDir = SharedSecretBuildContext();
456 std::error_code ec;
457
458 // Place the secret OUTSIDE the build context; the server mounts the secret file's parent
459 // directory read-only and references the file in place, so its bytes are never copied.
460 auto secretDir = testRoot / L"secrets";
461 std::filesystem::create_directories(secretDir, ec);
462 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(secretDir));
463 auto secretFile = secretDir / L"token.txt";
464 WriteTestFileContent(secretFile, "file-secret-content-67890");
465
466 auto dockerfilePath = testRoot / L"Dockerfile";
467 WriteTestFileContent(
468 dockerfilePath,
469 "# syntax=docker/dockerfile:1\n"
470 "FROM debian:latest\n"
471 "RUN --mount=type=secret,id=mysecret "
472 "[ \"$(cat /run/secrets/mysecret)\" = \"file-secret-content-67890\" ]\n"
473 "CMD [\"echo\", \"secret-src-ok\"]\n");
474
475 auto buildResult = RunWslc(std::format(
476 L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"",
477 contextDir.wstring(),
478 dockerfilePath.wstring(),
479 BuiltImageSecretSrc.NameAndTag(),
480 secretFile.wstring()));
481 buildResult.Verify({.ExitCode = 0});
482
483 auto inspectData = InspectImage(BuiltImageSecretSrc.NameAndTag());
484 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
485 }
486
487 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_SrcSymlink_Success)
488 {
489 // A symlink whose target lives in a separate directory must resolve to the target's content.
490 // The client canonicalizes the link to its target; the server mounts the *target's* parent
491 // directory read-only and references the resolved file in place, so its bytes are never copied.
492 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretSrcSymlink);
493 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-src-symlink";
494 auto cleanup = SetupTestDirectory(testRoot);
495
496 auto contextDir = SharedSecretBuildContext();
497 std::error_code ec;
498
499 auto targetDir = testRoot / L"target";
500 std::filesystem::create_directories(targetDir, ec);
501 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(targetDir));
502 auto targetFile = targetDir / L"real-secret.txt";
503 WriteTestFileContent(targetFile, "symlinked-secret-content-44444");
504
505 auto linkDir = testRoot / L"links";
506 std::filesystem::create_directories(linkDir, ec);
507 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(linkDir));
508 auto linkFile = linkDir / L"token.txt";
509 std::filesystem::create_symlink(targetFile, linkFile);
510
511 auto dockerfilePath = testRoot / L"Dockerfile";
512 WriteTestFileContent(
513 dockerfilePath,
514 "# syntax=docker/dockerfile:1\n"
515 "FROM debian:latest\n"
516 "RUN --mount=type=secret,id=mysecret "
517 "[ \"$(cat /run/secrets/mysecret)\" = \"symlinked-secret-content-44444\" ]\n"
518 "CMD [\"echo\", \"secret-symlink-ok\"]\n");
519
520 auto buildResult = RunWslc(std::format(
521 L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"",
522 contextDir.wstring(),
523 dockerfilePath.wstring(),
524 BuiltImageSecretSrcSymlink.NameAndTag(),
525 linkFile.wstring()));
526 buildResult.Verify({.ExitCode = 0});
527
528 auto inspectData = InspectImage(BuiltImageSecretSrcSymlink.NameAndTag());
529 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
530 }
531
532 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_SrcFileMissing_Fails)
533 {
534 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-src-missing";
535 auto cleanup = SetupTestDirectory(testRoot);
536
537 auto contextDir = testRoot / L"context";
538 std::error_code ec;
539 std::filesystem::create_directories(contextDir, ec);
540 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
541
542 auto dockerfilePath = testRoot / L"Dockerfile";
543 WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
544
545 // Build should fail if the src file does not exist
546 auto missingFile = testRoot / L"does-not-exist.txt";
547 auto buildResult = RunWslc(std::format(
548 L"build \"{}\" -f \"{}\" --secret id=x,src=\"{}\"", contextDir.wstring(), dockerfilePath.wstring(), missingFile.wstring()));
549 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
550 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
551 VERIFY_IS_FALSE(buildResult.Stderr->empty());
552 }
553
554 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_EnvAndSrc_EnvWins_Success)
555 {
556 // Docker parity: when both 'env=' and 'src=' are given, the environment variable wins and
557 // the file path is ignored (no error).
558 constexpr auto envName = L"WSLC_E2E_ENV_WINS_VALUE";
559 constexpr auto envValue = L"env-wins-content-55555";
560 ScopedEnvVariable envVar(envName, envValue);
561
562 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretEnvWins);
563 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-both";
564 auto cleanup = SetupTestDirectory(testRoot);
565
566 auto contextDir = SharedSecretBuildContext();
567
568 // The src file holds different content; it must be ignored in favor of the env value.
569 auto secretFile = testRoot / L"ignored.txt";
570 WriteTestFileContent(secretFile, "this-file-should-be-ignored");
571
572 auto dockerfilePath = testRoot / L"Dockerfile";
573 WriteTestFileContent(
574 dockerfilePath,
575 "# syntax=docker/dockerfile:1\n"
576 "FROM debian:latest\n"
577 "RUN --mount=type=secret,id=mysecret "
578 "[ \"$(cat /run/secrets/mysecret)\" = \"env-wins-content-55555\" ]\n"
579 "CMD [\"echo\", \"secret-env-wins-ok\"]\n");
580
581 auto buildResult = RunWslc(std::format(
582 L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,env=WSLC_E2E_ENV_WINS_VALUE,src=\"{}\"",
583 contextDir.wstring(),
584 dockerfilePath.wstring(),
585 BuiltImageSecretEnvWins.NameAndTag(),
586 secretFile.wstring()));
587 buildResult.Verify({.ExitCode = 0});
588
589 auto inspectData = InspectImage(BuiltImageSecretEnvWins.NameAndTag());
590 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
591 }
592
593 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_TypeEnv_Success)
594 {
595 constexpr auto envName = L"WSLC_E2E_TYPE_ENV_VALUE";
596 constexpr auto envValue = L"type-env-content-11111";
597 ScopedEnvVariable envVar(envName, envValue);
598
599 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretTypeEnv);
600 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-env";
601 auto cleanup = SetupTestDirectory(testRoot);
602
603 auto contextDir = SharedSecretBuildContext();
604
605 auto dockerfilePath = testRoot / L"Dockerfile";
606 WriteTestFileContent(
607 dockerfilePath,
608 "# syntax=docker/dockerfile:1\n"
609 "FROM debian:latest\n"
610 "RUN --mount=type=secret,id=mysecret "
611 "[ \"$(cat /run/secrets/mysecret)\" = \"type-env-content-11111\" ]\n"
612 "CMD [\"echo\", \"secret-ok\"]\n");
613
614 auto buildResult = RunWslc(std::format(
615 L"build \"{}\" -f \"{}\" -t {} --secret type=env,id=mysecret,env=WSLC_E2E_TYPE_ENV_VALUE",
616 contextDir.wstring(),
617 dockerfilePath.wstring(),
618 BuiltImageSecretTypeEnv.NameAndTag()));
619 buildResult.Verify({.ExitCode = 0});
620
621 auto inspectData = InspectImage(BuiltImageSecretTypeEnv.NameAndTag());
622 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
623 }
624
625 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_TypeEnvSrcIsEnvName_Success)
626 {
627 // Docker parity: with type=env, a bare src= names the env var to read (not a file path).
628 constexpr auto envName = L"WSLC_E2E_TYPE_ENV_SRC_VALUE";
629 constexpr auto envValue = L"type-env-src-content-22222";
630 ScopedEnvVariable envVar(envName, envValue);
631
632 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretTypeEnvSrc);
633 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-env-src";
634 auto cleanup = SetupTestDirectory(testRoot);
635
636 auto contextDir = SharedSecretBuildContext();
637
638 auto dockerfilePath = testRoot / L"Dockerfile";
639 WriteTestFileContent(
640 dockerfilePath,
641 "# syntax=docker/dockerfile:1\n"
642 "FROM debian:latest\n"
643 "RUN --mount=type=secret,id=mysecret "
644 "[ \"$(cat /run/secrets/mysecret)\" = \"type-env-src-content-22222\" ]\n"
645 "CMD [\"echo\", \"secret-ok\"]\n");
646
647 auto buildResult = RunWslc(std::format(
648 L"build \"{}\" -f \"{}\" -t {} --secret type=env,id=mysecret,src=WSLC_E2E_TYPE_ENV_SRC_VALUE",
649 contextDir.wstring(),
650 dockerfilePath.wstring(),
651 BuiltImageSecretTypeEnvSrc.NameAndTag()));
652 buildResult.Verify({.ExitCode = 0});
653
654 auto inspectData = InspectImage(BuiltImageSecretTypeEnvSrc.NameAndTag());
655 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
656 }
657
658 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_TypeFile_Success)
659 {
660 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretTypeFile);
661 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-file";
662 auto cleanup = SetupTestDirectory(testRoot);
663
664 auto contextDir = SharedSecretBuildContext();
665
666 auto secretFile = testRoot / L"token.txt";
667 WriteTestFileContent(secretFile, "type-file-content-33333");
668
669 auto dockerfilePath = testRoot / L"Dockerfile";
670 WriteTestFileContent(
671 dockerfilePath,
672 "# syntax=docker/dockerfile:1\n"
673 "FROM debian:latest\n"
674 "RUN --mount=type=secret,id=mysecret "
675 "[ \"$(cat /run/secrets/mysecret)\" = \"type-file-content-33333\" ]\n"
676 "CMD [\"echo\", \"secret-ok\"]\n");
677
678 auto buildResult = RunWslc(std::format(
679 L"build \"{}\" -f \"{}\" -t {} --secret type=file,id=mysecret,src=\"{}\"",
680 contextDir.wstring(),
681 dockerfilePath.wstring(),
682 BuiltImageSecretTypeFile.NameAndTag(),
683 secretFile.wstring()));
684 buildResult.Verify({.ExitCode = 0});
685
686 auto inspectData = InspectImage(BuiltImageSecretTypeFile.NameAndTag());
687 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
688 }
689
690 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_BinaryFile_Success)
691 {
692 // A file secret must be delivered byte-for-byte, including an embedded NUL and high bytes that
693 // an environment-variable (NUL-terminated, text-only) transport could never carry. The content
694 // below is 13 bytes with a NUL at offset 6; the in-container checks assert both the exact byte
695 // count (proving no NUL truncation) and that the bytes on either side of the NUL survived.
696 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretBinary);
697 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-binary";
698 auto cleanup = SetupTestDirectory(testRoot);
699
700 auto contextDir = SharedSecretBuildContext();
701
702 auto secretFile = testRoot / L"blob.bin";
703 WriteTestFileContent(secretFile, std::string("before\0after\xff", 13));
704
705 auto dockerfilePath = testRoot / L"Dockerfile";
706 WriteTestFileContent(
707 dockerfilePath,
708 "# syntax=docker/dockerfile:1\n"
709 "FROM debian:latest\n"
710 "RUN --mount=type=secret,id=mysecret "
711 "[ \"$(wc -c < /run/secrets/mysecret)\" = \"13\" ] && "
712 "[ \"$(tr -d '\\000' < /run/secrets/mysecret | tr -d '\\377')\" = \"beforeafter\" ]\n"
713 "CMD [\"echo\", \"secret-binary-ok\"]\n");
714
715 auto buildResult = RunWslc(std::format(
716 L"build \"{}\" -f \"{}\" -t {} --secret type=file,id=mysecret,src=\"{}\"",
717 contextDir.wstring(),
718 dockerfilePath.wstring(),
719 BuiltImageSecretBinary.NameAndTag(),
720 secretFile.wstring()));
721 buildResult.Verify({.ExitCode = 0});
722
723 auto inspectData = InspectImage(BuiltImageSecretBinary.NameAndTag());
724 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
725 }
726
727 // Builds a file secret of the given size (filled with a single repeated byte) and asserts, inside the
728 // container, both the exact byte count and that every byte survived intact. Verifies the client->service
729 // transport carries the secret byte-for-byte regardless of size.
730 void RunSizedFileSecretSuccess(const TestImage& image, const std::wstring& subdir, size_t size)
731 {
732 auto imageCleanup = DeleteImageOnExit(image);
733 auto testRoot = std::filesystem::current_path() / subdir;
734 auto cleanup = SetupTestDirectory(testRoot);
735
736 auto contextDir = SharedSecretBuildContext();
737
738 auto secretFile = testRoot / L"secret.bin";
739 WriteTestFileContent(secretFile, std::string(size, 'A'));
740
741 auto dockerfilePath = testRoot / L"Dockerfile";
742 WriteTestFileContent(
743 dockerfilePath,
744 std::format(
745 "# syntax=docker/dockerfile:1\n"
746 "FROM debian:latest\n"
747 "RUN --mount=type=secret,id=mysecret "
748 "[ \"$(wc -c < /run/secrets/mysecret)\" = \"{}\" ] && "
749 "[ -z \"$(tr -d 'A' < /run/secrets/mysecret)\" ]\n"
750 "CMD [\"echo\", \"secret-size-ok\"]\n",
751 size));
752
753 auto buildResult = RunWslc(std::format(
754 L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"",
755 contextDir.wstring(),
756 dockerfilePath.wstring(),
757 image.NameAndTag(),
758 secretFile.wstring()));
759 buildResult.Verify({.ExitCode = 0});
760
761 auto inspectData = InspectImage(image.NameAndTag());
762 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
763 }
764
765 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_EmptyFile_Success)
766 {
767 // A zero-byte file secret must mount as an empty (but present) file.
768 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretEmptyFile);
769 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-empty-file";
770 auto cleanup = SetupTestDirectory(testRoot);
771
772 auto contextDir = SharedSecretBuildContext();
773
774 auto secretFile = testRoot / L"empty.bin";
775 WriteTestFileContent(secretFile, "");
776
777 auto dockerfilePath = testRoot / L"Dockerfile";
778 WriteTestFileContent(
779 dockerfilePath,
780 "# syntax=docker/dockerfile:1\n"
781 "FROM debian:latest\n"
782 "RUN --mount=type=secret,id=mysecret "
783 "[ -f /run/secrets/mysecret ] && [ \"$(wc -c < /run/secrets/mysecret)\" = \"0\" ]\n"
784 "CMD [\"echo\", \"secret-empty-ok\"]\n");
785
786 auto buildResult = RunWslc(std::format(
787 L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"",
788 contextDir.wstring(),
789 dockerfilePath.wstring(),
790 BuiltImageSecretEmptyFile.NameAndTag(),
791 secretFile.wstring()));
792 buildResult.Verify({.ExitCode = 0});
793
794 auto inspectData = InspectImage(BuiltImageSecretEmptyFile.NameAndTag());
795 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
796 }
797
798 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_LargeFile_Success)
799 {
800 // A mid-size (256 KiB) secret is well within BuildKit's cap and exercises a multi-page transport.
801 RunSizedFileSecretSuccess(BuiltImageSecretLarge, L"wslc-e2e-build-secret-large", 256 * 1024);
802 }
803
804 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_MaxSizeFile_Success)
805 {
806 // Exactly BuildKit's per-secret cap (500 KiB == 512000 bytes) must still succeed.
807 RunSizedFileSecretSuccess(BuiltImageSecretMaxSize, L"wslc-e2e-build-secret-max-size", c_maxSecretSize);
808 }
809
810 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_OversizeFile_Fails)
811 {
812 // One byte over BuildKit's per-secret cap (500 KiB + 1). The file is forwarded and mounted, and
813 // BuildKit enforces its MaxSecretSize limit when the secret is consumed, so the build fails.
814 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-oversize";
815 auto cleanup = SetupTestDirectory(testRoot);
816
817 auto contextDir = SharedSecretBuildContext();
818
819 auto secretFile = testRoot / L"secret.bin";
820 WriteTestFileContent(secretFile, std::string(c_maxSecretSize + 1, 'A'));
821
822 auto dockerfilePath = testRoot / L"Dockerfile";
823 WriteTestFileContent(
824 dockerfilePath,
825 "# syntax=docker/dockerfile:1\n"
826 "FROM debian:latest\n"
827 "RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret > /dev/null\n"
828 "CMD [\"echo\", \"secret-oversize\"]\n");
829
830 auto buildResult = RunWslc(std::format(
831 L"build \"{}\" -f \"{}\" --secret id=mysecret,src=\"{}\"", contextDir.wstring(), dockerfilePath.wstring(), secretFile.wstring()));
832 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
833 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
834 VERIFY_IS_FALSE(buildResult.Stderr->empty());
835 }
836
837 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_MultipleFiles_Success)
838 {
839 // Several file secrets in one build: two share a directory (the server mounts it once, deduped)
840 // and a third lives elsewhere (a second mount). All three must be delivered with their own
841 // content, exercising the multi-mount/dedup path for in-place file secrets.
842 auto imageCleanup = DeleteImageOnExit(BuiltImageSecretMultiple);
843 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-multi";
844 auto cleanup = SetupTestDirectory(testRoot);
845
846 auto contextDir = SharedSecretBuildContext();
847 std::error_code ec;
848
849 auto dirA = testRoot / L"a";
850 auto dirB = testRoot / L"b";
851 std::filesystem::create_directories(dirA, ec);
852 std::filesystem::create_directories(dirB, ec);
853 THROW_HR_IF(E_FAIL, !std::filesystem::exists(dirA) || !std::filesystem::exists(dirB));
854
855 auto secret1 = dirA / L"s1.txt";
856 auto secret2 = dirA / L"s2.txt";
857 auto secret3 = dirB / L"s3.txt";
858 WriteTestFileContent(secret1, "multi-secret-one-11111");
859 WriteTestFileContent(secret2, "multi-secret-two-22222");
860 WriteTestFileContent(secret3, "multi-secret-three-33333");
861
862 auto dockerfilePath = testRoot / L"Dockerfile";
863 WriteTestFileContent(
864 dockerfilePath,
865 "# syntax=docker/dockerfile:1\n"
866 "FROM debian:latest\n"
867 "RUN --mount=type=secret,id=s1 --mount=type=secret,id=s2 --mount=type=secret,id=s3 "
868 "[ \"$(cat /run/secrets/s1)\" = \"multi-secret-one-11111\" ] && "
869 "[ \"$(cat /run/secrets/s2)\" = \"multi-secret-two-22222\" ] && "
870 "[ \"$(cat /run/secrets/s3)\" = \"multi-secret-three-33333\" ]\n"
871 "CMD [\"echo\", \"secret-multi-ok\"]\n");
872
873 auto buildResult = RunWslc(std::format(
874 L"build \"{}\" -f \"{}\" -t {} --secret id=s1,src=\"{}\" --secret id=s2,src=\"{}\" --secret id=s3,src=\"{}\"",
875 contextDir.wstring(),
876 dockerfilePath.wstring(),
877 BuiltImageSecretMultiple.NameAndTag(),
878 secret1.wstring(),
879 secret2.wstring(),
880 secret3.wstring()));
881 buildResult.Verify({.ExitCode = 0});
882
883 auto inspectData = InspectImage(BuiltImageSecretMultiple.NameAndTag());
884 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
885 }
886
887 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_UnknownType_Fails)
888 {
889 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-bad";
890 auto cleanup = SetupTestDirectory(testRoot);
891
892 auto contextDir = testRoot / L"context";
893 std::error_code ec;
894 std::filesystem::create_directories(contextDir, ec);
895 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
896
897 auto dockerfilePath = testRoot / L"Dockerfile";
898 WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
899
900 auto buildResult =
901 RunWslc(std::format(L"build \"{}\" -f \"{}\" --secret id=x,type=bogus", contextDir.wstring(), dockerfilePath.wstring()));
902 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
903 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
904 VERIFY_IS_TRUE(buildResult.Stderr->find(L"Invalid --secret value 'id=x,type=bogus': unsupported secret type 'bogus'") != std::wstring::npos);
905 }
906
907 // An invalid --output spec is rejected client-side before any build runs. This exercises the
908 // full parser through the real binary and asserts the localized "Invalid --output value" wrapper.
909 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_UnsupportedType_Fails)
910 {
911 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-type-bad";
912 auto cleanup = SetupTestDirectory(testRoot);
913
914 auto contextDir = SharedOutputBuildContext();
915
916 auto dockerfilePath = testRoot / L"Dockerfile";
917 WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
918
919 auto buildResult =
920 RunWslc(std::format(L"build \"{}\" -f \"{}\" --output type=bogus", contextDir.wstring(), dockerfilePath.wstring()));
921 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
922 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
923 VERIFY_IS_TRUE(buildResult.Stderr->find(L"Invalid --output value 'type=bogus': unsupported output type 'bogus'") != std::wstring::npos);
924 }
925
926 // The docker exporter loads the built image into the engine's image store, so the result is
927 // host-observable via inspect. The -t flag supplies the tag; the default docker builder does not
928 // honor the exporter 'name=' attribute for tagging (that requires the docker-container driver), so
929 // these tests deliberately tag with -t rather than name=.
930 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeDockerWithTagFlag_LoadsIntoStore_Success)
931 {
932 auto imageCleanup = DeleteImageOnExit(BuiltImageOutputDockerTag);
933 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-docker-tag";
934 auto cleanup = SetupTestDirectory(testRoot);
935
936 auto contextDir = SharedOutputBuildContext();
937
938 auto dockerfilePath = testRoot / L"Dockerfile";
939 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"output-docker-tag-ok\"]\n");
940
941 auto buildResult = RunWslc(std::format(
942 L"build \"{}\" -f \"{}\" -t {} --output type=docker",
943 contextDir.wstring(),
944 dockerfilePath.wstring(),
945 BuiltImageOutputDockerTag.NameAndTag()));
946 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
947
948 auto inspectData = InspectImage(BuiltImageOutputDockerTag.NameAndTag());
949 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
950 VERIFY_ARE_EQUAL(1u, inspectData.RepoTags.value().size());
951 VERIFY_ARE_EQUAL(BuiltImageOutputDockerTag.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData.RepoTags.value()[0]));
952 }
953
954 // The docker exporter produces a complete, correct image (not just a tag). Build with a
955 // distinctive CMD and verify it round-trips through inspect, proving --output built a real image.
956 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeDocker_ProducesImageWithConfig_Success)
957 {
958 auto imageCleanup = DeleteImageOnExit(BuiltImageOutputDockerConfig);
959 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-docker-config";
960 auto cleanup = SetupTestDirectory(testRoot);
961
962 auto contextDir = SharedOutputBuildContext();
963
964 auto dockerfilePath = testRoot / L"Dockerfile";
965 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"output-docker-config-ok\"]\n");
966
967 auto buildResult = RunWslc(std::format(
968 L"build \"{}\" -f \"{}\" -t {} --output type=docker",
969 contextDir.wstring(),
970 dockerfilePath.wstring(),
971 BuiltImageOutputDockerConfig.NameAndTag()));
972 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
973
974 auto inspectData = InspectImage(BuiltImageOutputDockerConfig.NameAndTag());
975 VERIFY_IS_TRUE(inspectData.Config.has_value());
976 VERIFY_IS_TRUE(inspectData.Config.value().Cmd.has_value());
977 const std::vector<std::string> expectedCmd{"echo", "output-docker-config-ok"};
978 VERIFY_ARE_EQUAL(expectedCmd, inspectData.Config.value().Cmd.value());
979 }
980
981 // The tar exporter streams a filesystem tarball out of the VM to a client-side file. Verify
982 // the file is a valid, non-empty tar that contains the marker written by the build. Asserting the
983 // file is non-empty guards the regression where the streamed tarball once came back with 0 bytes.
984 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeTarToFile_ProducesValidTarball_Success)
985 {
986 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-tar-file";
987 auto cleanup = SetupTestDirectory(testRoot);
988
989 auto contextDir = SharedOutputBuildContext();
990
991 auto dockerfilePath = testRoot / L"Dockerfile";
992 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-tar-marker > /wslc-build-marker.txt\n");
993
994 auto tarPath = testRoot / L"out.tar";
995 auto buildResult = RunWslc(std::format(
996 L"build \"{}\" -f \"{}\" --output type=tar,dest=\"{}\"", contextDir.wstring(), dockerfilePath.wstring(), tarPath.wstring()));
997 buildResult.Verify({.ExitCode = 0});
998
999 VERIFY_IS_TRUE(std::filesystem::exists(tarPath));
1000 VERIFY_IS_TRUE(std::filesystem::file_size(tarPath) > 0, L"the streamed tarball must not be empty");
1001 VERIFY_IS_TRUE(ListTarEntries(tarPath).find(L"wslc-build-marker.txt") != std::wstring::npos);
1002 }
1003
1004 // dest=- streams the tarball to the client's stdout (matching docker). This is the exact path
1005 // that once regressed to an empty tarball, so it redirects stdout to a file and asserts the result is
1006 // a non-empty tar containing the build marker.
1007 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeTarToStdout_ProducesValidTarball_Success)
1008 {
1009 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-tar-stdout";
1010 auto cleanup = SetupTestDirectory(testRoot);
1011
1012 auto contextDir = SharedOutputBuildContext();
1013
1014 auto dockerfilePath = testRoot / L"Dockerfile";
1015 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-tar-marker > /wslc-build-marker.txt\n");
1016
1017 auto tarPath = testRoot / L"stdout.tar";
1018 auto buildResult = RunWslcAndRedirectToFile(
1019 std::format(L"build \"{}\" -f \"{}\" --output type=tar,dest=-", contextDir.wstring(), dockerfilePath.wstring()), tarPath);
1020 buildResult.Verify({.ExitCode = 0});
1021
1022 VERIFY_IS_TRUE(std::filesystem::exists(tarPath));
1023 VERIFY_IS_TRUE(std::filesystem::file_size(tarPath) > 0, L"the streamed tarball must not be empty");
1024 VERIFY_IS_TRUE(ListTarEntries(tarPath).find(L"wslc-build-marker.txt") != std::wstring::npos);
1025 }
1026
1027 // The local exporter writes a Linux directory tree, which cannot be materialized faithfully on a
1028 // Windows destination, so it is rejected client-side before any build runs.
1029 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeLocalToDirectory_Rejected_Fails)
1030 {
1031 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-local-dir";
1032 auto cleanup = SetupTestDirectory(testRoot);
1033
1034 auto contextDir = SharedOutputBuildContext();
1035
1036 auto dockerfilePath = testRoot / L"Dockerfile";
1037 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-local-marker > /wslc-build-marker.txt\n");
1038
1039 auto destDir = testRoot / L"export";
1040 auto buildResult = RunWslc(std::format(
1041 L"build \"{}\" -f \"{}\" --output type=local,dest=\"{}\"", contextDir.wstring(), dockerfilePath.wstring(), destDir.wstring()));
1042 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1043 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1044 VERIFY_IS_TRUE(buildResult.Stderr->find(L"directory exporters are not supported") != std::wstring::npos);
1045 }
1046
1047 // The local exporter is a directory exporter, which is not supported, so it is rejected client-side
1048 // before any build runs (dest=- included).
1049 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeLocalToStdout_Rejected_Fails)
1050 {
1051 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-local-stdout";
1052 auto cleanup = SetupTestDirectory(testRoot);
1053
1054 auto contextDir = SharedOutputBuildContext();
1055
1056 auto dockerfilePath = testRoot / L"Dockerfile";
1057 WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
1058
1059 auto buildResult =
1060 RunWslc(std::format(L"build \"{}\" -f \"{}\" --output type=local,dest=-", contextDir.wstring(), dockerfilePath.wstring()));
1061 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1062 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1063 VERIFY_IS_TRUE(
1064 buildResult.Stderr->find(L"Invalid --output value 'type=local,dest=-': directory exporters are not supported") != std::wstring::npos);
1065 }
1066
1067 // The image exporter loads the built image into the engine's image store (like the docker
1068 // exporter with no dest), so the result is host-observable via inspect. Tag with -t.
1069 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeImage_LoadsIntoStore_Success)
1070 {
1071 auto imageCleanup = DeleteImageOnExit(BuiltImageOutputImage);
1072 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-image";
1073 auto cleanup = SetupTestDirectory(testRoot);
1074
1075 auto contextDir = SharedOutputBuildContext();
1076
1077 auto dockerfilePath = testRoot / L"Dockerfile";
1078 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"output-image-ok\"]\n");
1079
1080 auto buildResult = RunWslc(std::format(
1081 L"build \"{}\" -f \"{}\" -t {} --output type=image",
1082 contextDir.wstring(),
1083 dockerfilePath.wstring(),
1084 BuiltImageOutputImage.NameAndTag()));
1085 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
1086
1087 auto inspectData = InspectImage(BuiltImageOutputImage.NameAndTag());
1088 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
1089 VERIFY_ARE_EQUAL(1u, inspectData.RepoTags.value().size());
1090 VERIFY_ARE_EQUAL(BuiltImageOutputImage.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData.RepoTags.value()[0]));
1091 }
1092
1093 // The cacheonly exporter runs the build only to populate the build cache, producing no image
1094 // artifact. Verify the build succeeds and, because nothing is exported, the tag is not in the store.
1095 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeCacheOnly_ProducesNoImage_Success)
1096 {
1097 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-cacheonly";
1098 auto cleanup = SetupTestDirectory(testRoot);
1099
1100 auto contextDir = SharedOutputBuildContext();
1101
1102 auto dockerfilePath = testRoot / L"Dockerfile";
1103 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"cacheonly-ok\"]\n");
1104
1105 // Guard against a leaked image if cacheonly ever regresses to loading into the store.
1106 auto imageCleanup = DeleteImageOnExit(BuiltImageOutputCacheOnly);
1107
1108 auto buildResult = RunWslc(std::format(
1109 L"build \"{}\" -f \"{}\" -t {} --output type=cacheonly",
1110 contextDir.wstring(),
1111 dockerfilePath.wstring(),
1112 BuiltImageOutputCacheOnly.NameAndTag()));
1113 buildResult.Verify({.ExitCode = 0});
1114
1115 // cacheonly exports nothing, so the tag must not resolve in the image store.
1116 auto inspectResult = RunWslc(std::format(L"image inspect {}", BuiltImageOutputCacheOnly.NameAndTag()));
1117 VERIFY_ARE_NOT_EQUAL(0u, inspectResult.ExitCode.value_or(0u), L"cacheonly must not load an image into the store");
1118 }
1119
1120 // tar with no 'dest=' defaults to streaming a tarball to stdout ('dest=-'), matching buildx.
1121 // Redirect stdout to a file and assert the result is a non-empty tar containing the build marker.
1122 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeTarNoDest_StreamsToStdout_Success)
1123 {
1124 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-tar-nodest";
1125 auto cleanup = SetupTestDirectory(testRoot);
1126
1127 auto contextDir = SharedOutputBuildContext();
1128
1129 auto dockerfilePath = testRoot / L"Dockerfile";
1130 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-tar-nodest-marker > /wslc-build-marker.txt\n");
1131
1132 auto tarPath = testRoot / L"stdout.tar";
1133 auto buildResult = RunWslcAndRedirectToFile(
1134 std::format(L"build \"{}\" -f \"{}\" --output type=tar", contextDir.wstring(), dockerfilePath.wstring()), tarPath);
1135 buildResult.Verify({.ExitCode = 0});
1136
1137 VERIFY_IS_TRUE(std::filesystem::exists(tarPath));
1138 VERIFY_IS_TRUE(std::filesystem::file_size(tarPath) > 0, L"the streamed tarball must not be empty");
1139 VERIFY_IS_TRUE(ListTarEntries(tarPath).find(L"wslc-build-marker.txt") != std::wstring::npos);
1140 }
1141
1142 // A failing build step must surface as a non-zero exit with the image exporter, and the tag
1143 // must not be left in the store. This is the failing counterpart to TypeImage_LoadsIntoStore.
1144 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeImage_BuildFailure_Fails)
1145 {
1146 auto imageCleanup = DeleteImageOnExit(BuiltImageOutputImageFail);
1147 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-image-fail";
1148 auto cleanup = SetupTestDirectory(testRoot);
1149
1150 auto contextDir = SharedOutputBuildContext();
1151
1152 auto dockerfilePath = testRoot / L"Dockerfile";
1153 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN exit 7\n");
1154
1155 auto buildResult = RunWslc(std::format(
1156 L"build \"{}\" -f \"{}\" -t {} --output type=image",
1157 contextDir.wstring(),
1158 dockerfilePath.wstring(),
1159 BuiltImageOutputImageFail.NameAndTag()));
1160 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1161 VERIFY_IS_TRUE(buildResult.StderrContainsSubstring(L"failed to solve"));
1162
1163 auto inspectResult = RunWslc(std::format(L"image inspect {}", BuiltImageOutputImageFail.NameAndTag()));
1164 VERIFY_ARE_NOT_EQUAL(0u, inspectResult.ExitCode.value_or(0u), L"a failed build must not leave an image in the store");
1165 }
1166
1167 // A failing build step must surface as a non-zero exit with the cacheonly exporter. This is
1168 // the failing counterpart to TypeCacheOnly_ProducesNoImage.
1169 WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeCacheOnly_BuildFailure_Fails)
1170 {
1171 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-cacheonly-fail";
1172 auto cleanup = SetupTestDirectory(testRoot);
1173
1174 auto contextDir = SharedOutputBuildContext();
1175
1176 auto dockerfilePath = testRoot / L"Dockerfile";
1177 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN exit 7\n");
1178
1179 auto buildResult =
1180 RunWslc(std::format(L"build \"{}\" -f \"{}\" --output type=cacheonly", contextDir.wstring(), dockerfilePath.wstring()));
1181 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1182 VERIFY_IS_TRUE(buildResult.StderrContainsSubstring(L"failed to solve"));
1183 }
1184
1185 WSLC_TEST_METHOD(WSLCE2E_Image_Build_DockerfileInContextDir_Success)
1186 {
1187 auto imageCleanup = DeleteImageOnExit(BuiltImageDockerfile);
1188 BuildFromContextFile(L"Dockerfile", BuiltImageDockerfile);
1189 }
1190
1191 WSLC_TEST_METHOD(WSLCE2E_Image_Build_ContainerfileInContextDir_Success)
1192 {
1193 auto imageCleanup = DeleteImageOnExit(BuiltImageContainerfile);
1194 BuildFromContextFile(L"Containerfile", BuiltImageContainerfile);
1195 }
1196
1197 WSLC_TEST_METHOD(WSLCE2E_Image_Build_BothDockerfileAndContainerfile_Fails)
1198 {
1199 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-both-files";
1200 auto cleanup = SetupTestDirectory(testRoot);
1201
1202 WriteTestFileContent(testRoot / L"Dockerfile", "FROM debian:latest\n");
1203 WriteTestFileContent(testRoot / L"Containerfile", "FROM debian:latest\n");
1204
1205 auto buildResult = RunWslc(std::format(L"build \"{}\"", testRoot.wstring()));
1206 buildResult.Verify(
1207 {.Stderr =
1208 FormatErrorMessage(L"Both Dockerfile and Containerfile found. Use -f to select the file to use", L"E_INVALIDARG"),
1209 .ExitCode = 1});
1210 }
1211
1212 WSLC_TEST_METHOD(WSLCE2E_Image_Build_NeitherDockerfileNorContainerfile_Fails)
1213 {
1214 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-no-files";
1215 auto cleanup = SetupTestDirectory(testRoot);
1216
1217 auto absolutePath = std::filesystem::absolute(testRoot);
1218 auto buildResult = RunWslc(std::format(L"build \"{}\"", testRoot.wstring()));
1219 buildResult.Verify(
1220 {.Stderr = FormatErrorMessage(
1221 std::format(L"No Containerfile or Dockerfile found in '{}'", absolutePath.wstring()), L"E_INVALIDARG"),
1222 .ExitCode = 1});
1223 }
1224
1225 WSLC_TEST_METHOD(WSLCE2E_Image_Build_ContainerfileAccessDenied_Fails)
1226 {
1227 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-access-denied";
1228 auto cleanup = SetupTestDirectory(testRoot);
1229
1230 auto containerfilePath = testRoot / L"Containerfile";
1231 WriteTestFileContent(containerfilePath, "FROM debian:latest\n");
1232
1233 // Deny read access so wslc cannot open the file.
1234 SetPathAccess(containerfilePath, GENERIC_READ, DENY_ACCESS);
1235
1236 auto restore = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [containerfilePath]() { DeleteFileW(containerfilePath.c_str()); });
1237
1238 auto absoluteContainerfilePath = std::filesystem::absolute(containerfilePath);
1239 auto buildResult = RunWslc(std::format(L"build \"{}\"", testRoot.wstring()));
1240 buildResult.Verify(
1241 {.Stderr = FormatErrorMessage(
1242 std::format(L"Failed to open '{}': Access is denied. ", absoluteContainerfilePath.wstring()), L"E_ACCESSDENIED"),
1243 .ExitCode = 1});
1244 }
1245
1246 WSLC_TEST_METHOD(WSLCE2E_Image_Build_NoCache_Success)
1247 {
1248 auto imageCleanup = DeleteImageOnExit(BuiltImageNoCache);
1249 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-no-cache";
1250 auto cleanup = SetupTestDirectory(testRoot);
1251
1252 auto contextDir = testRoot / L"context";
1253 std::error_code ec;
1254 std::filesystem::create_directories(contextDir, ec);
1255 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
1256
1257 // `RUN date +%N` produces a different output each invocation, so without caching the
1258 // resulting layer (and therefore the image id) changes every build.
1259 auto dockerfilePath = testRoot / L"Dockerfile";
1260 WriteTestFileContent(
1261 dockerfilePath,
1262 "FROM debian:latest\n"
1263 "RUN date +%N > /timestamp.txt\n");
1264
1265 const auto buildCmd =
1266 std::format(L"build \"{}\" -f \"{}\" -t {}", contextDir.wstring(), dockerfilePath.wstring(), BuiltImageNoCache.NameAndTag());
1267
1268 // Seed the cache.
1269 auto firstBuild = RunWslc(buildCmd);
1270 firstBuild.Verify({.Stdout = L"", .ExitCode = 0});
1271 const auto firstId = InspectImage(BuiltImageNoCache.NameAndTag()).Id;
1272 VERIFY_ARE_NOT_EQUAL(std::string{}, firstId);
1273
1274 // A repeated build without --no-cache should hit the cache and produce the same id.
1275 auto cachedBuild = RunWslc(buildCmd);
1276 cachedBuild.Verify({.Stdout = L"", .ExitCode = 0});
1277 const auto cachedId = InspectImage(BuiltImageNoCache.NameAndTag()).Id;
1278 VERIFY_ARE_EQUAL(firstId, cachedId, L"Repeated build without --no-cache should reuse the cached layer");
1279 VERIFY_IS_TRUE(
1280 cachedBuild.StderrContainsSubstring(L"[2/2] CACHED"),
1281 L"A reused layer must be reported as cached in the build output");
1282
1283 // --no-cache must re-run the non-deterministic step, producing a new id.
1284 auto noCacheBuild = RunWslc(buildCmd + L" --no-cache");
1285 noCacheBuild.Verify({.Stdout = L"", .ExitCode = 0});
1286 const auto noCacheId = InspectImage(BuiltImageNoCache.NameAndTag()).Id;
1287 VERIFY_ARE_NOT_EQUAL(firstId, noCacheId, L"--no-cache must rebuild the non-deterministic RUN step");
1288 VERIFY_IS_FALSE(
1289 noCacheBuild.StderrContainsSubstring(L"[2/2] CACHED"),
1290 L"A step re-run under --no-cache must not be reported as cached");
1291 }
1292
1293 // --iidfile writes the built image's ID to the given host path on success, matching docker build
1294 // --iidfile. The file must contain the same sha256 digest the image is stored under.
1295 WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_Success)
1296 {
1297 auto imageCleanup = DeleteImageOnExit(BuiltImageIidFile);
1298 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile";
1299 auto cleanup = SetupTestDirectory(testRoot);
1300
1301 auto contextDir = SharedOutputBuildContext();
1302
1303 auto dockerfilePath = testRoot / L"Dockerfile";
1304 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-iidfile-marker > /marker.txt\n");
1305
1306 // Point --iidfile at a path that does not yet exist.
1307 const auto iidFilePath = testRoot / L"image.id";
1308
1309 auto buildResult = RunWslc(std::format(
1310 L"build \"{}\" -f \"{}\" -t {} --iidfile \"{}\"",
1311 contextDir.wstring(),
1312 dockerfilePath.wstring(),
1313 BuiltImageIidFile.NameAndTag(),
1314 iidFilePath.wstring()));
1315 buildResult.Verify({.ExitCode = 0});
1316
1317 VERIFY_IS_TRUE(std::filesystem::exists(iidFilePath));
1318 const auto iid = ReadFileContent(iidFilePath.wstring());
1319 VERIFY_IS_TRUE(iid.starts_with(L"sha256:"), L"iidfile must contain a sha256 digest");
1320 VERIFY_ARE_EQUAL(static_cast<size_t>(71), iid.size(), L"iidfile must contain sha256: plus a 64-char hex digest");
1321
1322 // The digest written to the iidfile must match the ID the image is stored under.
1323 const auto inspectedId = InspectImage(BuiltImageIidFile.NameAndTag()).Id;
1324 VERIFY_ARE_EQUAL(inspectedId, wsl::windows::common::string::WideToMultiByte(iid));
1325 }
1326
1327 // --iidfile must accept a path relative to the caller's current directory. The client is responsible
1328 // for making the path absolute before it reaches the service, which rejects non-absolute paths.
1329 // std::filesystem::weakly_canonical alone is not sufficient here: --iidfile names a file that does
1330 // not exist yet, so there is no leading element to canonicalize and the path is returned unchanged.
1331 WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_RelativePath)
1332 {
1333 auto imageCleanup = DeleteImageOnExit(BuiltImageIidFileRelative);
1334 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile-relative";
1335 auto cleanup = SetupTestDirectory(testRoot);
1336
1337 auto contextDir = SharedOutputBuildContext();
1338
1339 auto dockerfilePath = testRoot / L"Dockerfile";
1340 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-iidfile-relative-marker > /marker.txt\n");
1341
1342 // Run wslc from testRoot so the --iidfile argument below resolves against it. Declared after
1343 // the directory cleanup so the working directory is restored before the directory is removed.
1344 auto originalDirectory = std::filesystem::current_path();
1345 auto restoreDirectory = wil::scope_exit([&]() {
1346 std::error_code ec;
1347 std::filesystem::current_path(originalDirectory, ec);
1348 });
1349 std::filesystem::current_path(testRoot);
1350
1351 const std::wstring relativeIidFile = L"image.id";
1352 VERIFY_IS_FALSE(std::filesystem::path(relativeIidFile).is_absolute());
1353 VERIFY_IS_FALSE(std::filesystem::exists(testRoot / relativeIidFile));
1354
1355 auto buildResult = RunWslc(std::format(
1356 L"build \"{}\" -f \"{}\" -t {} --iidfile \"{}\"",
1357 contextDir.wstring(),
1358 dockerfilePath.wstring(),
1359 BuiltImageIidFileRelative.NameAndTag(),
1360 relativeIidFile));
1361 buildResult.Verify({.ExitCode = 0});
1362
1363 VERIFY_IS_TRUE(std::filesystem::exists(testRoot / relativeIidFile), L"--iidfile must accept a relative path");
1364 const auto iid = ReadFileContent((testRoot / relativeIidFile).wstring());
1365 VERIFY_IS_TRUE(iid.starts_with(L"sha256:"), L"iidfile must contain a sha256 digest");
1366
1367 // The digest written to the iidfile must match the ID the image is stored under.
1368 const auto inspectedId = InspectImage(BuiltImageIidFileRelative.NameAndTag()).Id;
1369 VERIFY_ARE_EQUAL(inspectedId, wsl::windows::common::string::WideToMultiByte(iid));
1370 }
1371
1372 // A failing build must not write the iidfile (matching docker: the file only appears on success).
1373 WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_BuildFailure_NoFileWritten)
1374 {
1375 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile-fail";
1376 auto cleanup = SetupTestDirectory(testRoot);
1377
1378 auto contextDir = SharedOutputBuildContext();
1379
1380 auto dockerfilePath = testRoot / L"Dockerfile";
1381 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN exit 7\n");
1382
1383 const auto iidFilePath = testRoot / L"image.id";
1384
1385 auto buildResult = RunWslc(std::format(
1386 L"build \"{}\" -f \"{}\" --iidfile \"{}\"", contextDir.wstring(), dockerfilePath.wstring(), iidFilePath.wstring()));
1387 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1388 VERIFY_IS_FALSE(std::filesystem::exists(iidFilePath), L"a failed build must not leave an iidfile behind");
1389 }
1390
1391 // Unlike --output, --iidfile does not create a missing parent directory (matching docker). The server
1392 // mounts the parent into the VM, so a missing directory must surface as a clean error, not a crash.
1393 WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_ParentDirectoryMissing_Fails)
1394 {
1395 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile-noparent";
1396 auto cleanup = SetupTestDirectory(testRoot);
1397
1398 auto contextDir = SharedOutputBuildContext();
1399
1400 auto dockerfilePath = testRoot / L"Dockerfile";
1401 WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
1402
1403 const auto iidFilePath = testRoot / L"does-not-exist" / L"image.id";
1404
1405 auto buildResult = RunWslc(std::format(
1406 L"build \"{}\" -f \"{}\" --iidfile \"{}\"", contextDir.wstring(), dockerfilePath.wstring(), iidFilePath.wstring()));
1407 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1408 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1409 VERIFY_IS_FALSE(buildResult.Stderr->empty());
1410 VERIFY_IS_FALSE(std::filesystem::exists(iidFilePath));
1411 VERIFY_IS_FALSE(std::filesystem::exists(iidFilePath.parent_path()), L"--iidfile must not create its parent directory");
1412 }
1413
1414 // The iidfile's parent is mounted read-write, but the destination file itself may still be
1415 // unwritable. buildx must fail rather than silently reporting success.
1416 WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_NotWritable_Fails)
1417 {
1418 auto imageCleanup = DeleteImageOnExit(BuiltImageIidFileNotWritable);
1419 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile-readonly";
1420 auto cleanup = SetupTestDirectory(testRoot);
1421
1422 auto contextDir = SharedOutputBuildContext();
1423
1424 auto dockerfilePath = testRoot / L"Dockerfile";
1425 WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
1426
1427 // Pre-create the destination and deny write access so buildx cannot write the image ID to it.
1428 const auto iidFilePath = testRoot / L"image.id";
1429 WriteTestFileContent(iidFilePath, "original-content");
1430 SetPathAccess(iidFilePath, GENERIC_WRITE, DENY_ACCESS);
1431
1432 // The deny ACE also blocks this test from reading the file back, so it must be revoked before
1433 // any assertion on the contents, and before cleanup can delete the file.
1434 auto restore = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [iidFilePath]() {
1435 SetPathAccess(iidFilePath, 0, REVOKE_ACCESS);
1436 DeleteFileW(iidFilePath.c_str());
1437 });
1438
1439 auto buildResult = RunWslc(std::format(
1440 L"build \"{}\" -f \"{}\" -t {} --iidfile \"{}\"",
1441 contextDir.wstring(),
1442 dockerfilePath.wstring(),
1443 BuiltImageIidFileNotWritable.NameAndTag(),
1444 iidFilePath.wstring()));
1445 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1446 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1447 VERIFY_IS_FALSE(buildResult.Stderr->empty());
1448
1449 // buildx truncates the destination when it opens it, so the previous contents are not preserved.
1450 // What matters is that a failed write never leaves an image ID behind.
1451 SetPathAccess(iidFilePath, 0, REVOKE_ACCESS);
1452 const auto contents = ReadFileContent(iidFilePath.wstring());
1453 VERIFY_IS_FALSE(contents.starts_with(L"sha256:"), L"a failed iidfile write must not leave an image ID behind");
1454 }
1455
1456 WSLC_TEST_METHOD(WSLCE2E_Image_Build_ProgressInvalid_Fails)
1457 {
1458 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-progress-bad";
1459 auto cleanup = SetupTestDirectory(testRoot);
1460
1461 auto contextDir = testRoot / L"context";
1462 std::error_code ec;
1463 std::filesystem::create_directories(contextDir, ec);
1464 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
1465
1466 auto dockerfilePath = testRoot / L"Dockerfile";
1467 WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
1468
1469 // Invalid --progress values are rejected client-side before any build runs.
1470 auto buildResult =
1471 RunWslc(std::format(L"build \"{}\" -f \"{}\" --progress=bogus", contextDir.wstring(), dockerfilePath.wstring()));
1472 VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1473 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1474 VERIFY_IS_TRUE(buildResult.Stderr->find(L"is not a recognized progress type") != std::wstring::npos);
1475 }
1476
1477 WSLC_TEST_METHOD(WSLCE2E_Image_Build_ProgressPlain_NoEscapeSequences_Success)
1478 {
1479 auto imageCleanup = DeleteImageOnExit(BuiltImageProgressPlain);
1480 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-progress-plain";
1481 auto cleanup = SetupTestDirectory(testRoot);
1482
1483 auto contextDir = testRoot / L"context";
1484 std::error_code ec;
1485 std::filesystem::create_directories(contextDir, ec);
1486 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
1487
1488 auto dockerfilePath = testRoot / L"Dockerfile";
1489 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"plain-ok\"]\n");
1490
1491 // plain emits progress text but never color/cursor VT escape sequences (ESC, 0x1b).
1492 auto buildResult = RunWslc(std::format(
1493 L"build \"{}\" -f \"{}\" -t {} --progress=plain",
1494 contextDir.wstring(),
1495 dockerfilePath.wstring(),
1496 BuiltImageProgressPlain.NameAndTag()));
1497 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
1498
1499 VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1500 VERIFY_IS_TRUE(buildResult.Stderr->find(L'\x1b') == std::wstring::npos, L"plain mode must not emit VT escape sequences");
1501 }
1502
1503 WSLC_TEST_METHOD(WSLCE2E_Image_Build_ProgressQuiet_NoProgressOutput_Success)
1504 {
1505 auto imageCleanup = DeleteImageOnExit(BuiltImageProgressQuiet);
1506 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-progress-quiet";
1507 auto cleanup = SetupTestDirectory(testRoot);
1508
1509 auto contextDir = testRoot / L"context";
1510 std::error_code ec;
1511 std::filesystem::create_directories(contextDir, ec);
1512 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
1513
1514 auto dockerfilePath = testRoot / L"Dockerfile";
1515 WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"quiet-ok\"]\n");
1516
1517 // quiet suppresses all progress output on a successful build.
1518 auto buildResult = RunWslc(std::format(
1519 L"build \"{}\" -f \"{}\" -t {} --progress=quiet",
1520 contextDir.wstring(),
1521 dockerfilePath.wstring(),
1522 BuiltImageProgressQuiet.NameAndTag()));
1523 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
1524
1525 if (buildResult.Stderr.has_value())
1526 {
1527 VERIFY_IS_TRUE(buildResult.Stderr->empty(), L"quiet mode must not emit build progress on success");
1528 }
1529 }
1530
1531 private:
1532 const TestImage BuiltImage{L"wslc-e2e-build-empty-context", L"latest", L""};
1533 const TestImage BuiltImageTag1{L"wslc-e2e-build-args-tags", L"v1", L""};
1534 const TestImage BuiltImageTag2{L"wslc-e2e-build-args-tags", L"v2", L""};
1535 const TestImage BuiltImagePull{L"wslc-e2e-build-pull", L"latest", L""};
1536 const TestImage BuiltImageTarget{L"wslc-e2e-build-target", L"latest", L""};
1537 const TestImage BuiltImageDockerfile{L"wslc-e2e-build-dockerfile-ctx", L"latest", L""};
1538 const TestImage BuiltImageContainerfile{L"wslc-e2e-build-containerfile-ctx", L"latest", L""};
1539 const TestImage BuiltImageNoCache{L"wslc-e2e-build-no-cache", L"latest", L""};
1540 const TestImage BuiltImageLabel{L"wslc-e2e-build-label", L"latest", L""};
1541 const TestImage BuiltImageLabelOverride{L"wslc-e2e-build-label-override", L"latest", L""};
1542 const TestImage BuiltImageSecret{L"wslc-e2e-build-secret-env", L"latest", L""};
1543 const TestImage BuiltImageSecretBareId{L"wslc-e2e-build-secret-bare-id", L"latest", L""};
1544 const TestImage BuiltImageSecretMissingEnv{L"wslc-e2e-build-secret-missing-env", L"latest", L""};
1545 const TestImage BuiltImageSecretEnvWins{L"wslc-e2e-build-secret-env-wins", L"latest", L""};
1546 const TestImage BuiltImageSecretTypeEnv{L"wslc-e2e-build-secret-type-env", L"latest", L""};
1547 const TestImage BuiltImageSecretTypeEnvSrc{L"wslc-e2e-build-secret-type-env-src", L"latest", L""};
1548 const TestImage BuiltImageSecretTypeFile{L"wslc-e2e-build-secret-type-file", L"latest", L""};
1549 const TestImage BuiltImageSecretSrc{L"wslc-e2e-build-secret-src", L"latest", L""};
1550 const TestImage BuiltImageSecretSrcSymlink{L"wslc-e2e-build-secret-src-symlink", L"latest", L""};
1551 const TestImage BuiltImageSecretBinary{L"wslc-e2e-build-secret-binary", L"latest", L""};
1552 const TestImage BuiltImageSecretEmptyFile{L"wslc-e2e-build-secret-empty-file", L"latest", L""};
1553 const TestImage BuiltImageSecretLarge{L"wslc-e2e-build-secret-large", L"latest", L""};
1554 const TestImage BuiltImageSecretMaxSize{L"wslc-e2e-build-secret-max-size", L"latest", L""};
1555 const TestImage BuiltImageSecretMultiple{L"wslc-e2e-build-secret-multi", L"latest", L""};
1556
1557 // Maximum secret size allowed by BuildKit (500kb)
1558 static constexpr size_t c_maxSecretSize = 500 * 1024;
1559
1560 const TestImage BuiltImageOutputDockerTag{L"wslc-e2e-build-output-docker-tag", L"latest", L""};
1561 const TestImage BuiltImageOutputDockerConfig{L"wslc-e2e-build-output-docker-config", L"latest", L""};
1562 const TestImage BuiltImageOutputImage{L"wslc-e2e-build-output-image", L"latest", L""};
1563 const TestImage BuiltImageOutputImageFail{L"wslc-e2e-build-output-image-fail", L"latest", L""};
1564 const TestImage BuiltImageOutputCacheOnly{L"wslc-e2e-build-output-cacheonly", L"latest", L""};
1565 const TestImage BuiltImageIidFile{L"wslc-e2e-build-iidfile", L"latest", L""};
1566 const TestImage BuiltImageIidFileNotWritable{L"wslc-e2e-build-iidfile-readonly", L"latest", L""};
1567 const TestImage BuiltImageProgressPlain{L"wslc-e2e-build-progress-plain", L"latest", L""};
1568 const TestImage BuiltImageProgressQuiet{L"wslc-e2e-build-progress-quiet", L"latest", L""};
1569 const TestImage BuiltImageIidFileRelative{L"wslc-e2e-build-iidfile-relative", L"latest", L""};
1570
1571 // Runs `tar.exe -tf <path>` and returns the member listing so tests can assert an exporter produced a
1572 // valid, non-empty archive that contains an expected entry.
1573 static std::wstring ListTarEntries(const std::filesystem::path& tarPath)
1574 {
1575 auto cmd = std::format(L"tar.exe -tf \"{}\"", tarPath.wstring());
1576 wsl::windows::common::SubProcess process(nullptr, cmd.c_str());
1577 auto output = process.RunAndCaptureOutput();
1578 VERIFY_ARE_EQUAL(0u, output.ExitCode, L"tar.exe failed to list the produced archive");
1579 return output.Stdout;
1580 }
1581
1582 void BuildFromContextFile(const std::wstring& fileName, const TestImage& image)
1583 {
1584 auto testRoot = std::filesystem::current_path() / image.Name;
1585 auto cleanup = SetupTestDirectory(testRoot);
1586
1587 WriteTestFileContent(testRoot / fileName, "FROM debian:latest\nCMD [\"echo\", \"build-ok\"]\n");
1588
1589 auto buildResult = RunWslc(std::format(L"build \"{}\" -t {}", testRoot.wstring(), image.NameAndTag()));
1590 buildResult.Verify({.Stdout = L"", .ExitCode = 0});
1591
1592 auto inspectData = InspectImage(image.NameAndTag());
1593 VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
1594 VERIFY_ARE_EQUAL(1u, inspectData.RepoTags.value().size());
1595 VERIFY_ARE_EQUAL(image.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData.RepoTags.value()[0]));
1596 }
1597 };
1598 } // namespace WSLCE2ETests