Fix wslc image inspect missing RootFS, ExposedPorts, Volumes, and StopSignal (#40811)

beena352 committed Jun 17, 2026 at 13:56 UTC 26d4698fb19578aa92fa9b827a5e6738eb50abf6
8 files changed +153 -54
src/shared/inc/JsonUtils.h
+13
@@ -34,6 +34,19 @@ namespace wsl::shared {
34
35 constexpr int c_jsonPrettyPrintIndent = 2;
36
37 +struct EmptyObject
38 +{
39 +};
40 +
41 +inline void to_json(nlohmann::json& j, const EmptyObject&)
42 +{
43 + j = nlohmann::json::object();
44 +}
45 +
46 +inline void from_json(const nlohmann::json&, EmptyObject&)
47 +{
48 +}
49 +
50 template <typename T>
51 std::string ToJson(const T& Value, int indent = -1)
52 {
src/windows/inc/docker_schema.h
+2 -17
@@ -20,6 +20,8 @@ Abstract:
20
21 namespace wsl::windows::common::docker_schema {
22
23 +using wsl::shared::EmptyObject;
24 +
25 struct CreatedContainer
26 {
27 std::string Id;
@@ -170,23 +172,6 @@ struct ContainerNetworkRequest
172 NLOHMANN_DEFINE_TYPE_INTRUSIVE_ONLY_SERIALIZE(ContainerNetworkRequest, Container);
173 };
174
173 -struct EmptyObject
174 -{
175 -};
176 -
177 -inline void to_json(nlohmann::json& j, const EmptyObject& memory)
178 -{
179 - UNREFERENCED_PARAMETER(memory);
180 - j = nlohmann::json::object();
181 -}
182 -
183 -inline void from_json(const nlohmann::json& j, EmptyObject& obj)
184 -{
185 - // EmptyObject has no fields, so nothing to deserialize
186 - UNREFERENCED_PARAMETER(j);
187 - UNREFERENCED_PARAMETER(obj);
188 -}
189 -
175 struct Mount
176 {
177 std::string Name;
src/windows/inc/wslc_schema.h
+16 -2
@@ -18,6 +18,8 @@ Abstract:
18
19 namespace wsl::windows::common::wslc_schema {
20
21 +using wsl::shared::EmptyObject;
22 +
23 struct InspectPortBinding
24 {
25 // WSLC always binds to localhost. Included for Docker API compatibility.
@@ -119,11 +121,22 @@ struct ImageConfig
121 std::optional<std::vector<std::string>> Cmd;
122 std::optional<std::vector<std::string>> Entrypoint;
123 std::optional<std::vector<std::string>> Env;
124 + std::optional<std::map<std::string, EmptyObject>> ExposedPorts;
125 std::optional<std::map<std::string, std::string>> Labels;
126 + std::string StopSignal;
127 std::string User;
128 + std::optional<std::map<std::string, EmptyObject>> Volumes;
129 std::string WorkingDir;
130
126 - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ImageConfig, Cmd, Entrypoint, Env, Labels, User, WorkingDir);
131 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ImageConfig, Cmd, Entrypoint, Env, ExposedPorts, Labels, StopSignal, User, Volumes, WorkingDir);
132 +};
133 +
134 +struct ImageRootFS
135 +{
136 + std::string Type;
137 + std::optional<std::vector<std::string>> Layers;
138 +
139 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ImageRootFS, Type, Layers);
140 };
141
142 struct InspectImage
@@ -140,9 +153,10 @@ struct InspectImage
153 int64_t Size{};
154 std::optional<std::map<std::string, std::string>> Metadata;
155 std::optional<ImageConfig> Config;
156 + std::optional<ImageRootFS> RootFS;
157
158 NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(
145 - InspectImage, Id, RepoTags, RepoDigests, Parent, Comment, Created, Author, Architecture, Os, Size, Metadata, Config);
159 + InspectImage, Id, RepoTags, RepoDigests, Parent, Comment, Created, Author, Architecture, Os, Size, Metadata, Config, RootFS);
160 };
161
162 struct InspectVolume
src/windows/wslcsession/WSLCSession.cpp
+30
@@ -127,12 +127,42 @@ wslc_schema::InspectImage ConvertInspectImage(const docker_schema::InspectImage&
127 wslcConfig.Entrypoint = dockerConfig.Entrypoint;
128 wslcConfig.Env = dockerConfig.Env;
129 wslcConfig.Labels = dockerConfig.Labels;
130 + wslcConfig.StopSignal = dockerConfig.StopSignal;
131 wslcConfig.User = dockerConfig.User;
132 wslcConfig.WorkingDir = dockerConfig.WorkingDir;
133
134 + if (dockerConfig.ExposedPorts.has_value())
135 + {
136 + std::map<std::string, wslc_schema::EmptyObject> ports;
137 + for (const auto& [port, _] : dockerConfig.ExposedPorts.value())
138 + {
139 + ports.emplace(port, wslc_schema::EmptyObject{});
140 + }
141 + wslcConfig.ExposedPorts = std::move(ports);
142 + }
143 +
144 + if (dockerConfig.Volumes.has_value())
145 + {
146 + std::map<std::string, wslc_schema::EmptyObject> volumes;
147 + for (const auto& [path, _] : dockerConfig.Volumes.value())
148 + {
149 + volumes.emplace(path, wslc_schema::EmptyObject{});
150 + }
151 + wslcConfig.Volumes = std::move(volumes);
152 + }
153 +
154 wslcInspect.Config = wslcConfig;
155 }
156
157 + if (dockerInspect.RootFS.has_value())
158 + {
159 + const auto& dockerRootFS = dockerInspect.RootFS.value();
160 + wslc_schema::ImageRootFS wslcRootFS{};
161 + wslcRootFS.Type = dockerRootFS.Type;
162 + wslcRootFS.Layers = dockerRootFS.Layers;
163 + wslcInspect.RootFS = std::move(wslcRootFS);
164 + }
165 +
166 return wslcInspect;
167 }
168
test/windows/wslc/e2e/WSLCE2EHelpers.cpp
+8
@@ -583,6 +583,14 @@ void WriteTestFile(const std::filesystem::path& filePath, const std::vector<std:
583 VERIFY_IS_TRUE(file.good());
584 }
585
586 +void WriteTestFileContent(const std::filesystem::path& filePath, const std::string& content)
587 +{
588 + std::ofstream file(filePath, std::ios::out | std::ios::trunc | std::ios::binary);
589 + THROW_HR_IF_MSG(E_FAIL, !file.is_open(), "Failed to open %ls for writing", filePath.c_str());
590 + file << content;
591 + THROW_HR_IF_MSG(E_FAIL, !file.good(), "Failed to write to %ls", filePath.c_str());
592 +}
593 +
594 std::wstring GetPythonHttpServerScript(uint16_t port)
595 {
596 return std::format(L"python3 -m http.server {}", port);
test/windows/wslc/e2e/WSLCE2EHelpers.h
+18
@@ -133,6 +133,24 @@ void EnsureVolumeDoesNotExist(const std::wstring& volumeName);
133 void EnsureNetworkDoesNotExist(const std::wstring& networkName);
134
135 void WriteTestFile(const std::filesystem::path& filePath, const std::vector<std::string>& envVariableLines);
136 +void WriteTestFileContent(const std::filesystem::path& filePath, const std::string& content);
137 +
138 +// Sets up a clean test directory and returns a scope_exit to remove it.
139 +inline auto SetupTestDirectory(const std::filesystem::path& directory)
140 +{
141 + std::error_code ec;
142 + std::filesystem::remove_all(directory, ec);
143 + THROW_HR_IF_MSG(E_FAIL, ec.value() != 0 && std::filesystem::exists(directory), "%hs", ec.message().c_str());
144 +
145 + std::filesystem::create_directories(directory, ec);
146 + THROW_HR_IF_MSG(E_FAIL, ec.value() != 0 || !std::filesystem::exists(directory), "%hs", ec.message().c_str());
147 +
148 + return wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [directory]() {
149 + std::error_code removeError;
150 + std::filesystem::remove_all(directory, removeError);
151 + });
152 +}
153 +
154 std::wstring GetPythonHttpServerScript(uint16_t port);
155
156 // Default timeout of 0 will execute once.
test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp
+10 -35
@@ -15,7 +15,6 @@ Abstract:
15 #include "windows/Common.h"
16 #include "WSLCExecutor.h"
17 #include "WSLCE2EHelpers.h"
18 -#include <fstream>
18
19 namespace WSLCE2ETests {
20
@@ -60,7 +59,7 @@ class WSLCE2EImageBuildTests
59 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
60
61 auto dockerfilePath = testRoot / L"Dockerfile";
63 - WriteTestFile(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"wslc-e2e-build-ok\"]\n");
62 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"wslc-e2e-build-ok\"]\n");
63
64 auto buildResult = RunWslc(
65 std::format(L"build \"{}\" -f \"{}\" -t {}", contextDir.wstring(), dockerfilePath.wstring(), BuiltImage.NameAndTag()));
@@ -84,10 +83,10 @@ class WSLCE2EImageBuildTests
83
84 // Create a simple file in the context directory
85 auto filePath = contextDir / L"hello.txt";
87 - WriteTestFile(filePath, "hello from wslc build\n");
86 + WriteTestFileContent(filePath, "hello from wslc build\n");
87
88 auto dockerfilePath = testRoot / L"Dockerfile";
90 - WriteTestFile(
89 + WriteTestFileContent(
90 dockerfilePath,
91 "FROM debian:latest\n"
92 "ARG TEST_LABEL=default_value\n"
@@ -134,7 +133,7 @@ class WSLCE2EImageBuildTests
133 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
134
135 auto dockerfilePath = testRoot / L"Dockerfile";
137 - WriteTestFile(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"pull-ok\"]\n");
136 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"pull-ok\"]\n");
137
138 // Build with --pull --verbose. When --pull causes docker to resolve the base image
139 // from the registry, the FROM step includes a @sha256: digest (e.g.
@@ -158,7 +157,7 @@ class WSLCE2EImageBuildTests
157 THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
158
159 auto dockerfilePath = testRoot / L"Dockerfile";
161 - WriteTestFile(
160 + WriteTestFileContent(
161 dockerfilePath,
162 "FROM debian:latest AS build-stage\n"
163 "RUN echo build > /stage.txt\n"
@@ -198,8 +197,8 @@ class WSLCE2EImageBuildTests
197 auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-both-files";
198 auto cleanup = SetupTestDirectory(testRoot);
199
201 - WriteTestFile(testRoot / L"Dockerfile", "FROM debian:latest\n");
202 - WriteTestFile(testRoot / L"Containerfile", "FROM debian:latest\n");
200 + WriteTestFileContent(testRoot / L"Dockerfile", "FROM debian:latest\n");
201 + WriteTestFileContent(testRoot / L"Containerfile", "FROM debian:latest\n");
202
203 auto buildResult = RunWslc(std::format(L"build \"{}\"", testRoot.wstring()));
204 buildResult.Verify(
@@ -226,7 +225,7 @@ class WSLCE2EImageBuildTests
225 auto cleanup = SetupTestDirectory(testRoot);
226
227 auto containerfilePath = testRoot / L"Containerfile";
229 - WriteTestFile(containerfilePath, "FROM debian:latest\n");
228 + WriteTestFileContent(containerfilePath, "FROM debian:latest\n");
229
230 // Deny read access so wslc cannot open the file.
231 SetPathAccess(containerfilePath, GENERIC_READ, DENY_ACCESS);
@@ -254,7 +253,7 @@ class WSLCE2EImageBuildTests
253 // `RUN date +%N` produces a different output each invocation, so without caching the
254 // resulting layer (and therefore the image id) changes every build.
255 auto dockerfilePath = testRoot / L"Dockerfile";
257 - WriteTestFile(
256 + WriteTestFileContent(
257 dockerfilePath,
258 "FROM debian:latest\n"
259 "RUN date +%N > /timestamp.txt\n");
@@ -296,7 +295,7 @@ private:
295 auto testRoot = std::filesystem::current_path() / image.Name;
296 auto cleanup = SetupTestDirectory(testRoot);
297
299 - WriteTestFile(testRoot / fileName, "FROM debian:latest\nCMD [\"echo\", \"build-ok\"]\n");
298 + WriteTestFileContent(testRoot / fileName, "FROM debian:latest\nCMD [\"echo\", \"build-ok\"]\n");
299
300 auto buildResult = RunWslc(std::format(L"build \"{}\" -t {}", testRoot.wstring(), image.NameAndTag()));
301 buildResult.Verify({.Stderr = L"", .ExitCode = 0});
@@ -318,29 +317,5 @@ private:
317 EnsureImageIsDeleted(BuiltImageContainerfile);
318 EnsureImageIsDeleted(BuiltImageNoCache);
319 }
321 -
322 - static auto SetupTestDirectory(const std::filesystem::path& testRoot)
323 - {
324 - std::error_code ec;
325 - std::filesystem::remove_all(testRoot, ec);
326 - THROW_HR_IF_MSG(E_FAIL, ec.value() != 0 && std::filesystem::exists(testRoot), "%hs", ec.message().c_str());
327 -
328 - std::filesystem::create_directories(testRoot, ec);
329 - THROW_HR_IF_MSG(E_FAIL, ec.value() != 0 || !std::filesystem::exists(testRoot), "%hs", ec.message().c_str());
330 -
331 - return wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [testRoot]() {
332 - std::error_code removeError;
333 - std::filesystem::remove_all(testRoot, removeError);
334 - });
335 - }
336 -
337 - static void WriteTestFile(const std::filesystem::path& path, const std::string& content)
338 - {
339 - std::ofstream file(path);
340 - THROW_HR_IF(E_FAIL, !file.is_open());
341 - file << content;
342 - THROW_HR_IF(E_FAIL, !file.good());
343 - file.close();
344 - }
320 };
321 } // namespace WSLCE2ETests
test/windows/wslc/e2e/WSLCE2EImageInspectTests.cpp
+56
@@ -32,6 +32,7 @@ class WSLCE2EImageInspectTests
32
33 TEST_CLASS_CLEANUP(ClassCleanup)
34 {
35 + EnsureImageIsDeleted(BuiltExposeImage);
36 EnsureImageIsDeleted(DebianImage);
37 return true;
38 }
@@ -64,12 +65,67 @@ class WSLCE2EImageInspectTests
65 VERIFY_IS_TRUE(inspectData[0].RepoTags.has_value());
66 VERIFY_ARE_EQUAL(1u, inspectData[0].RepoTags.value().size());
67 VERIFY_ARE_EQUAL(DebianImage.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData[0].RepoTags.value()[0]));
68 +
69 + // Verify RootFS is populated.
70 + VERIFY_IS_TRUE(inspectData[0].RootFS.has_value());
71 + VERIFY_ARE_EQUAL(std::string{"layers"}, inspectData[0].RootFS.value().Type);
72 + VERIFY_IS_TRUE(inspectData[0].RootFS.value().Layers.has_value());
73 + VERIFY_IS_GREATER_THAN(inspectData[0].RootFS.value().Layers.value().size(), 0u);
74 +
75 + // Debian has no ExposedPorts or Volumes.
76 + VERIFY_IS_TRUE(inspectData[0].Config.has_value());
77 + VERIFY_IS_FALSE(inspectData[0].Config.value().ExposedPorts.has_value());
78 + VERIFY_IS_FALSE(inspectData[0].Config.value().Volumes.has_value());
79 + }
80 +
81 + WSLC_TEST_METHOD(WSLCE2E_Image_Inspect_ConfigExtras_Success)
82 + {
83 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-inspect-config-extras";
84 + auto cleanupDir = SetupTestDirectory(testRoot);
85 +
86 + auto contextDir = testRoot / L"context";
87 + std::error_code ec;
88 + std::filesystem::create_directories(contextDir, ec);
89 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
90 +
91 + auto dockerfilePath = testRoot / L"Dockerfile";
92 + WriteTestFileContent(
93 + dockerfilePath,
94 + std::format(
95 + "FROM {}\n"
96 + "EXPOSE 8080/tcp\n"
97 + "EXPOSE 9090/udp\n"
98 + "VOLUME /data\n"
99 + "VOLUME /var/log/app\n"
100 + "STOPSIGNAL SIGTERM\n",
101 + wsl::shared::string::WideToMultiByte(DebianImage.NameAndTag())));
102 +
103 + auto buildResult = RunWslc(std::format(
104 + L"build \"{}\" -f \"{}\" -t {}", contextDir.wstring(), dockerfilePath.wstring(), BuiltExposeImage.NameAndTag()));
105 + buildResult.Verify({.Stderr = L"", .ExitCode = 0});
106 +
107 + auto inspectData = InspectImage(BuiltExposeImage.NameAndTag());
108 + VERIFY_IS_TRUE(inspectData.Config.has_value());
109 + const auto& config = inspectData.Config.value();
110 +
111 + VERIFY_IS_TRUE(config.ExposedPorts.has_value());
112 + const auto& ports = config.ExposedPorts.value();
113 + VERIFY_IS_TRUE(ports.contains("8080/tcp"));
114 + VERIFY_IS_TRUE(ports.contains("9090/udp"));
115 +
116 + VERIFY_IS_TRUE(config.Volumes.has_value());
117 + const auto& volumes = config.Volumes.value();
118 + VERIFY_IS_TRUE(volumes.contains("/data"));
119 + VERIFY_IS_TRUE(volumes.contains("/var/log/app"));
120 +
121 + VERIFY_ARE_EQUAL(std::string{"SIGTERM"}, config.StopSignal);
122 }
123
124 private:
125 const std::wstring WslcContainerName = L"wslc-test-container";
126 const TestImage& DebianImage = DebianTestImage();
127 const TestImage& InvalidImage = InvalidTestImage();
128 + const TestImage BuiltExposeImage{L"wslc-e2e-inspect-config-extras", L"latest", L""};
129
130 std::wstring GetHelpMessage() const
131 {