Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,116 @@
<ItemGroup>
<ReferenceCopyLocalPaths Include="$(MSBuildThisFileDirectory)..\runtimes\win-$(WSLCSDK_Platform)\wslcsdk.dll" />
</ItemGroup>

<!-- ================================================================== -->
<!-- Container Image Build Targets -->
<!-- ================================================================== -->

<!-- Default properties -->
<PropertyGroup>
<WslcImageOutputDir Condition="'$(WslcImageOutputDir)' == ''">$(OutDir)</WslcImageOutputDir>
<WslcCliPath Condition="'$(WslcCliPath)' == '' AND Exists('$(ProgramW6432)\WSL\wslc.exe')">$(ProgramW6432)\WSL\wslc.exe</WslcCliPath>
<WslcCliPath Condition="'$(WslcCliPath)' == ''">wslc</WslcCliPath>
</PropertyGroup>

<!-- Default metadata for WslcImage items -->
<ItemDefinitionGroup>
<WslcImage>
<Tag>latest</Tag>
<OutputDir>$(WslcImageOutputDir)</OutputDir>
</WslcImage>
</ItemDefinitionGroup>

<!-- Auto-detection fallback: if no WslcImage declared, detect container/Dockerfile -->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should do that. The developer is not required to build their container images here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, this is just fallback, will remove it later.

<ItemGroup Condition="'@(WslcImage)' == '' AND Exists('$(ProjectDir)container\Dockerfile')">
<WslcImage Include="$(MSBuildProjectName)"
Dockerfile="$(ProjectDir)container\Dockerfile"
Context="$(ProjectDir)container"
Sources="$(ProjectDir)container" />
</ItemGroup>

<ItemGroup Condition="'@(WslcImage)' == '' AND Exists('$(ProjectDir)docker\Dockerfile')">
<WslcImage Include="$(MSBuildProjectName)"
Dockerfile="$(ProjectDir)docker\Dockerfile"
Context="$(ProjectDir)docker"
Sources="$(ProjectDir)docker" />
</ItemGroup>

<!-- Check that wslc CLI is installed -->
<Target Name="WslcCheckDependencies"
BeforeTargets="WslcBuildAllImages"
Condition="'@(WslcImage)' != ''">

<Exec Command="&quot;$(WslcCliPath)&quot; --version"
IgnoreExitCode="true"
ConsoleToMSBuild="true"
StandardOutputImportance="low"
StandardErrorImportance="low">
<Output TaskParameter="ExitCode" PropertyName="_WslcExitCode" />
</Exec>

<Error Condition="'$(_WslcExitCode)' != '0'"
Code="WSLC0001"
Text="The WSLC runtime was not found. Install WSL by running: wsl --install --no-distribution" />
</Target>

<!-- Outer target: dispatch each WslcImage to inner target via MSBuild task -->
<Target Name="WslcBuildAllImages"
AfterTargets="Build"
DependsOnTargets="WslcCheckDependencies"
Condition="'@(WslcImage)' != ''">
Comment on lines +64 to +67
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WslcBuildAllImages runs AfterTargets="Build" whenever @(WslcImage) is non-empty. In Visual Studio this will also execute during design-time builds (when $(DesignTimeBuild) is true), which can trigger expensive wslc image build calls and slow the IDE. Add a guard to skip both WslcBuildAllImages (and ideally WslcCheckDependencies) during design-time builds (e.g., require $(DesignTimeBuild) != 'true').

Copilot uses AI. Check for mistakes.

<MSBuild Projects="$(MSBuildProjectFullPath)"
Targets="_WslcBuildSingleImage"
Properties="Configuration=$(Configuration);Platform=$(Platform);_WslcName=%(WslcImage.Identity);_WslcTag=%(WslcImage.Tag);_WslcDockerfile=%(WslcImage.Dockerfile);_WslcContext=%(WslcImage.Context);_WslcSourceDir=%(WslcImage.Sources);_WslcOutput=%(WslcImage.OutputDir)" />
</Target>
Comment on lines +69 to +72
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

%(WslcImage.Sources) can be semicolon-delimited (multi-directory Sources), but here it’s passed through the MSBuild task Properties attribute, which itself is semicolon-delimited. Without escaping, values after the first ; will be treated as separate properties and the extra source dirs will be dropped/break parsing. Escape this value (e.g., via MSBuild::Escape) or pass the source directories via an item list instead of a property string.

Copilot uses AI. Check for mistakes.

<!--
Collect source files for incremental check.
Wildcards are expanded here in ItemGroup Include (not in Inputs attribute or MSBuild task Properties,
where they get escaped). Sources can be semicolon separated directory paths;
each is expanded with **\* to collect all files recursively.
-->
<Target Name="_WslcCollectSources" BeforeTargets="_WslcBuildSingleImage">
<ItemGroup>
<_WslcSourceDirs Include="$(_WslcSourceDir.Split(';'))" />
<_WslcSourceFiles Include="$(_WslcDockerfile)" />
<_WslcSourceFiles Include="%(_WslcSourceDirs.Identity)\**\*" Condition="'%(_WslcSourceDirs.Identity)' != ''" />
</ItemGroup>
</Target>

<!-- Inner target: build a single image with incremental check -->
<Target Name="_WslcBuildSingleImage"
DependsOnTargets="_WslcCollectSources"
Inputs="@(_WslcSourceFiles)"
Outputs="$(IntDir)wslc_$(_WslcName).marker">

Comment on lines +91 to +93
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The marker file path uses $(_WslcName) (from %(WslcImage.Identity)) directly: $(IntDir)wslc_$(_WslcName).marker. If an image name contains / (common for registry/repo names like ghcr.io/org/image), this becomes a nested path and MakeDir only creates $(IntDir), so the target can fail to create/touch the marker. Consider deriving a file-system-safe marker name (e.g., replace invalid path chars or use a hash) and use that for marker/tlog filenames.

Copilot uses AI. Check for mistakes.
<MakeDir Directories="$(IntDir)" />

<Message Importance="high"
Text="WSLC: Building image '$(_WslcName):$(_WslcTag)'..." />

<Exec Command="&quot;$(WslcCliPath)&quot; image build -t &quot;$(_WslcName):$(_WslcTag)&quot; -f &quot;$(_WslcDockerfile)&quot; &quot;$(_WslcContext)&quot;"
ConsoleToMSBuild="true" />

<MakeDir Directories="$(_WslcOutput)" />

<Exec Command="&quot;$(WslcCliPath)&quot; image save -o &quot;$(_WslcOutput)\$(_WslcName).tar&quot; &quot;$(_WslcName):$(_WslcTag)&quot;"
ConsoleToMSBuild="true" />

<Touch Files="$(IntDir)wslc_$(_WslcName).marker" AlwaysCreate="true" />

<Message Importance="high"
Text="WSLC: [$(_WslcName)] Image '$(_WslcName):$(_WslcTag)' saved to '$(_WslcOutput)\$(_WslcName).tar'." />
</Target>

<!-- Clean container artifacts -->
<Target Name="WslcClean"
AfterTargets="Clean"
Condition="'@(WslcImage)' != ''">

<Delete Files="$(IntDir)wslc_%(WslcImage.Identity).marker" />
<Delete Files="%(WslcImage.OutputDir)\%(WslcImage.Identity).tar" />
</Target>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,52 @@
</ItemDefinitionGroup>

<Import Project="$(MSBuildThisFileDirectory)..\Microsoft.WSL.Containers.common.targets" />

<!-- ================================================================== -->
<!-- C++ specific: Write .tlog files for VS Fast Up-to-Date Check -->
<!-- ================================================================== -->

<Target Name="_WslcWriteAllTlogs"
AfterTargets="WslcBuildAllImages"
Condition="'@(WslcImage)' != '' AND '$(TLogLocation)' != ''">

<MSBuild Projects="$(MSBuildProjectFullPath)"
Targets="_WslcWriteSingleTlog"
Properties="Configuration=$(Configuration);Platform=$(Platform);_WslcName=%(WslcImage.Identity);_WslcDockerfile=%(WslcImage.Dockerfile);_WslcSourceDir=%(WslcImage.Sources);_WslcTlogDir=$([MSBuild]::EnsureTrailingSlash('$(TLogLocation)'))" />
</Target>

<Target Name="_WslcWriteSingleTlog">
<ItemGroup>
<_WslcTlogSourceDirs Include="$(_WslcSourceDir.Split(';'))" />
<_WslcTlogInputs Include="$(_WslcDockerfile)" />
<_WslcTlogInputs Condition="'%(_WslcTlogSourceDirs.Identity)' != ''" Include="%(_WslcTlogSourceDirs.Identity)\**\*" />
</ItemGroup>

<PropertyGroup>
<_WslcDockerfileFullPath>$([System.IO.Path]::GetFullPath('$(_WslcDockerfile)'))</_WslcDockerfileFullPath>
<_WslcMarkerFullPath>$([System.IO.Path]::GetFullPath('$(IntDir)wslc_$(_WslcName).marker'))</_WslcMarkerFullPath>
<_WslcTarFullPath>$([System.IO.Path]::GetFullPath('$(WslcImageOutputDir)$(_WslcName).tar'))</_WslcTarFullPath>
</PropertyGroup>

<WriteLinesToFile
File="$(_WslcTlogDir)wslc.$(_WslcName).read.1.tlog"
Lines="^$(_WslcDockerfileFullPath);@(_WslcTlogInputs->'%(FullPath)')"
Overwrite="true"
Encoding="Unicode" />

<WriteLinesToFile
File="$(_WslcTlogDir)wslc.$(_WslcName).write.1.tlog"
Lines="^$(_WslcDockerfileFullPath);$(_WslcMarkerFullPath);$(_WslcTarFullPath)"
Overwrite="true"
Encoding="Unicode" />
</Target>

<!-- Clean tlog files -->
<Target Name="_WslcCleanTlogs"
AfterTargets="WslcClean"
Condition="'@(WslcImage)' != '' AND '$(TLogLocation)' != ''">
<Delete Files="$(TLogLocation)wslc.%(WslcImage.Identity).read.1.tlog" />
<Delete Files="$(TLogLocation)wslc.%(WslcImage.Identity).write.1.tlog" />
</Target>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,23 @@

<Import Project="$(MSBuildThisFileDirectory)..\Microsoft.WSL.Containers.common.targets" />

<!-- ================================================================== -->
<!-- C# specific: CPS Up-to-Date Check for container source files -->
<!-- ================================================================== -->

<!-- Register marker files as build outputs for CPS up-to-date check -->
<ItemGroup Condition="'$(UsingMicrosoftNETSdk)' == 'true'">
<UpToDateCheckBuilt Include="$(IntDir)wslc_%(WslcImage.Identity).marker" />
</ItemGroup>

<!-- Expand multi-directory Sources into UpToDateCheckInput at target time -->
<Target Name="_WslcRegisterUpToDateCheckInputs"
BeforeTargets="CollectUpToDateCheckBuiltDesignTime"
Condition="'$(UsingMicrosoftNETSdk)' == 'true' AND '@(WslcImage)' != ''">
<ItemGroup>
<_WslcUpToDateDirs Include="%(WslcImage.Sources)" Separator=";" />
<UpToDateCheckInput Include="%(_WslcUpToDateDirs.Identity)\**\*" Condition="'%(_WslcUpToDateDirs.Identity)' != ''" />
</ItemGroup>
</Target>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildThisFileDirectory)..\build\Microsoft.WSL.Containers.common.targets" />
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildThisFileDirectory)..\..\build\native\Microsoft.WSL.Containers.targets" />
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,92 @@ unset(_wslcsdk_arch)
unset(_wslcsdk_root)
unset(_wslcsdk_include_dir)
unset(_wslcsdk_lib_dir)

# ============================================================================
# Container Image Build Targets
# ============================================================================
#
# Provides the wslc_add_image() function for declaring container image
# build targets with incremental rebuild support.
#
# Usage:
# find_package(Microsoft.WSL.Containers REQUIRED)
#
# wslc_add_image(
# NAME my-server
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have separate target name and image registry/name:tag here?
Maybe we should just merge the image registry/name and image tag into one image reference parameter.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now NAME is specifically used for CMake target names, and the newly added optional parameter IMAGE is used for the image registry/name. If IMAGE is omitted, it will automatically fall back to NAME.

Does this make sense?

# DOCKERFILE container/Dockerfile
# CONTEXT container/
# SOURCES container/src/*.cpp container/src/*.h
# TAG latest
# OUTPUT ${CMAKE_BINARY_DIR}/images
# )
#
# # With explicit image registry/name (IMAGE defaults to NAME if omitted):
# wslc_add_image(
# NAME my-server
# IMAGE ghcr.io/myorg/my-server
# TAG v1.2.3
# DOCKERFILE container/Dockerfile
# CONTEXT container/
# )

function(wslc_add_image)
cmake_parse_arguments(
PARSE_ARGV 0 ARG
"" # options (none)
"NAME;IMAGE;TAG;DOCKERFILE;CONTEXT;OUTPUT" # one-value keywords
"SOURCES" # multi-value keywords
)

# Validate required arguments
if(NOT ARG_NAME)
message(FATAL_ERROR "wslc_add_image: NAME is required")
endif()
if(NOT ARG_DOCKERFILE)
message(FATAL_ERROR "wslc_add_image: DOCKERFILE is required")
endif()
if(NOT ARG_CONTEXT)
message(FATAL_ERROR "wslc_add_image: CONTEXT is required")
endif()

# Defaults
if(NOT ARG_IMAGE)
set(ARG_IMAGE "${ARG_NAME}")
endif()
if(NOT ARG_TAG)
set(ARG_TAG "latest")
endif()
if(NOT ARG_OUTPUT)
set(ARG_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}")
endif()

# Find wslc CLI
if(NOT WSLC_CLI_PATH)
find_program(WSLC_CLI_PATH wslc PATHS "$ENV{ProgramW6432}/WSL" "$ENV{ProgramFiles}/WSL")
if(NOT WSLC_CLI_PATH)
message(FATAL_ERROR "wslc CLI not found. Install WSL by running: wsl --install --no-distribution")
endif()
endif()

set(_image_ref "${ARG_IMAGE}:${ARG_TAG}")
set(_marker "${CMAKE_CURRENT_BINARY_DIR}/wslc_${ARG_NAME}.marker")
set(_tar_output "${ARG_OUTPUT}/${ARG_NAME}.tar")

# Resolve source globs to file lists
file(GLOB_RECURSE _resolved_sources CONFIGURE_DEPENDS ${ARG_SOURCES})
Comment on lines +129 to +130
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

file(GLOB_RECURSE ...) requires at least one glob expression. Since SOURCES is not validated as required, calling wslc_add_image() without SOURCES will make configuration fail at this line. Handle the empty case (e.g., skip the GLOB when ARG_SOURCES is empty and rely on DOCKERFILE/explicit deps).

Suggested change
# Resolve source globs to file lists
file(GLOB_RECURSE _resolved_sources CONFIGURE_DEPENDS ${ARG_SOURCES})
# Resolve source globs to file lists (if any were provided)
if(ARG_SOURCES)
file(GLOB_RECURSE _resolved_sources CONFIGURE_DEPENDS ${ARG_SOURCES})
else()
set(_resolved_sources)
endif()

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SOURCES is optional, but incremental rebuild correctness depends on it. If callers omit SOURCES (as in the second usage example), this glob resolves to an empty list and the build won’t rerun when other context files change. Either require SOURCES or default it to include the full CONTEXT directory contents.

Suggested change
file(GLOB_RECURSE _resolved_sources CONFIGURE_DEPENDS ${ARG_SOURCES})
if(ARG_SOURCES)
file(GLOB_RECURSE _resolved_sources CONFIGURE_DEPENDS ${ARG_SOURCES})
else()
# If SOURCES is not provided, default to the full CONTEXT directory contents
file(GLOB_RECURSE _resolved_sources CONFIGURE_DEPENDS "${ARG_CONTEXT}/*")
endif()

Copilot uses AI. Check for mistakes.

add_custom_command(
OUTPUT "${_marker}"
COMMAND "${WSLC_CLI_PATH}" image build -t "${_image_ref}" -f "${ARG_DOCKERFILE}" "${ARG_CONTEXT}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to save the image to the output folder.
TODO: Which compression method to use for the tar file? Is ztsd supported in the wslc session?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to save the digest file along with the image if we decide to provide a fast path for image id check.

COMMAND ${CMAKE_COMMAND} -E make_directory "${ARG_OUTPUT}"
COMMAND "${WSLC_CLI_PATH}" image save -o "${_tar_output}" "${_image_ref}"
COMMAND ${CMAKE_COMMAND} -E touch "${_marker}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to use the flag file for cmake. We can just use the produced files (the image tar file).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, once we confirmed how to export the image file to build folder, then this line can be removed.

DEPENDS ${_resolved_sources} "${ARG_DOCKERFILE}"
Comment on lines +129 to +138
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The custom command invokes wslc ... -f "${ARG_DOCKERFILE}" "${ARG_CONTEXT}" without setting WORKING_DIRECTORY or converting these to absolute paths. With common usage like DOCKERFILE container/Dockerfile and CONTEXT container/, the build tool typically runs commands from the build directory, so these relative paths can fail to resolve. Consider setting WORKING_DIRECTORY to ${CMAKE_CURRENT_SOURCE_DIR} (or the context dir) and/or normalizing ARG_DOCKERFILE/ARG_CONTEXT to absolute paths before use.

Suggested change
# Resolve source globs to file lists
file(GLOB_RECURSE _resolved_sources CONFIGURE_DEPENDS ${ARG_SOURCES})
add_custom_command(
OUTPUT "${_marker}"
COMMAND "${WSLC_CLI_PATH}" image build -t "${_image_ref}" -f "${ARG_DOCKERFILE}" "${ARG_CONTEXT}"
# Uncomment when wslc image save is available:
# COMMAND ${CMAKE_COMMAND} -E make_directory "${ARG_OUTPUT}"
# COMMAND "${WSLC_CLI_PATH}" image save -o "${_tar_output}" "${_image_ref}"
COMMAND ${CMAKE_COMMAND} -E touch "${_marker}"
DEPENDS ${_resolved_sources} "${ARG_DOCKERFILE}"
# Normalize paths to be independent of the build directory.
get_filename_component(_dockerfile_path "${ARG_DOCKERFILE}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}")
get_filename_component(_context_path "${ARG_CONTEXT}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}")
# Resolve source globs to file lists
file(GLOB_RECURSE _resolved_sources CONFIGURE_DEPENDS ${ARG_SOURCES})
add_custom_command(
OUTPUT "${_marker}"
COMMAND "${WSLC_CLI_PATH}" image build -t "${_image_ref}" -f "${_dockerfile_path}" "${_context_path}"
# Uncomment when wslc image save is available:
# COMMAND ${CMAKE_COMMAND} -E make_directory "${ARG_OUTPUT}"
# COMMAND "${WSLC_CLI_PATH}" image save -o "${_tar_output}" "${_image_ref}"
COMMAND ${CMAKE_COMMAND} -E touch "${_marker}"
DEPENDS ${_resolved_sources} "${_dockerfile_path}"

Copilot uses AI. Check for mistakes.
COMMENT "WSLC: Building and saving image '${_image_ref}' to '${_tar_output}'..."
VERBATIM
)

add_custom_target(wslc_image_${ARG_NAME} ALL
DEPENDS "${_marker}"
)
Comment on lines +143 to +145
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAME is used verbatim in the generated custom target name (wslc_image_${ARG_NAME}), so names containing spaces or path separators can make CMake error out during generation. Consider validating NAME against a safe pattern (or normalizing it) and emitting a clear FATAL_ERROR when it isn’t usable as a target name.

Copilot uses AI. Check for mistakes.
endfunction()