From 4a6356060e6b99d43d095a4b90794653841341e0 Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:28:42 -0800 Subject: [PATCH] Add initial WSLc publish/deploy support with explicit capability limits (#21009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description WSLc can run Aspire apps on Windows, but publishing currently has no WSLc runtime implementation. This adds an initial, deliberately limited publishing implementation so supported image operations use the selected runtime and unsupported operations fail clearly instead of falling back or working around WSLc limitations. This PR targets `main` and builds on the runtime detection and doctor support from #21004, now incorporated into `main`. Supported on Windows: local Dockerfile builds without platform selection, image tagging/removal/push, registry login, native JSON image configuration inspection, and single-platform .NET SDK publishing to WSLc's local image store. Auto-discovery retains Docker → Podman → WSLc priority; explicit case-insensitive selection and custom keyed runtimes remain supported. Native SDK local publishing uses `LocalRegistry=Wslc` and requires an SDK containing the WSLc support introduced in [dotnet/sdk#55249](https://github.com/dotnet/sdk/pull/55249). It was verified with the repository-pinned SDK `11.0.100-rc.1.26425.128`; older SDKs without this support report an unsupported registry rather than falling back to another runtime. Aspire does not implement an archive/load bridge. Unsupported: Compose deployment/management, platform-selected Dockerfile builds, Dockerfile archives/OCI format selection, manifest inspection, multi-platform SDK publishing to WSLc's local image store, and container-file layering (which requires a platform-selected Dockerfile build). Multi-platform image loading is tracked by [microsoft/WSL#41123](https://github.com/microsoft/WSL/issues/41123). These paths reject explicitly using existing exception/inspection-result contracts. Daemon-free single- and multi-platform SDK archives remain supported without WSLc health checks or commands; archive publishing omits `LocalRegistry=Wslc`, preserving compatibility with SDKs that do not recognize it. No alternative builder or runtime fallback is introduced. Dockerfile builds in Docker, Podman, and WSLc now use `ProcessSpec.ArgumentList` instead of a manually quoted command line. Shared eager argument helpers preserve quotes, spaces, backslashes, empty values, and inherited environment values, retaining Podman's existing secret behavior. Platform-specific process argument handling remains owned by .NET, without invoking a shell. ### User-facing usage On Windows, select WSLc with `ASPIRE_CONTAINER_RUNTIME=wslc`. Single-platform .NET SDK publishing then uses WSLc's local store through the SDK's native support. Dockerfile publishing defaults to `linux/amd64`; explicitly opt into the WSLc host platform only when appropriate: ```csharp builder.AddDockerfile("program", "./program") .WithContainerBuildOptions(options => options.TargetPlatform = null); ``` For a .NET SDK archive without container-file layering: ```csharp project.WithContainerBuildOptions(options => { options.Destination = ContainerImageDestination.Archive; options.OutputPath = "./images"; }); ``` ### Validation - `.\restore.cmd /p:SkipNativeBuild=true` — passed during initial setup. - `.\.dotnet\dotnet.exe test --project tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj --no-launch-profile /p:SkipNativeBuild=true -- --filter-class '*.WslcContainerRuntimeTests' --filter-class '*.ContainerRuntimeResolverTests' --filter-class '*.ContainerRuntimeDetectorTests' --filter-class '*.DockerContainerRuntimeTests' --filter-class '*.ContainerRuntimeBaseTests' --filter-class '*.DotnetProgramArchiveTests' --filter-class '*.DotnetProgramPublishingTests' --filter-not-trait 'quarantined=true' --filter-not-trait 'outerloop=true'` — passed, 151 tests after merging `main`, before the argument-list correction. - `.\.dotnet\dotnet.exe test --project tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj --no-launch-profile /p:SkipNativeBuild=true -- --filter-class '*.ContainerRuntimeBaseTests' --filter-class '*.DockerContainerRuntimeTests' --filter-class '*.PodmanSaveArgumentsTests' --filter-class '*.WslcContainerRuntimeTests' --filter-not-trait 'quarantined=true' --filter-not-trait 'outerloop=true'` — passed, 95 tests after the argument-list correction and eager helper changes. The runtime-constant follow-up was rebuilt with `--filter-method '*.BuildImageAsync_PreservesArgumentBoundaries'` (same project and exclusions) — passed, 3 cases. The complete 95-test selection was rerun with `--no-build` immediately before pushing — passed. - `.\.dotnet\dotnet.exe test --project tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj --no-build --no-launch-profile -- --filter-method '*.BuildSecretsFormats*' --filter-not-trait 'quarantined=true' --filter-not-trait 'outerloop=true'` — passed, 4 tests after the argument-list correction. - `.\.dotnet\dotnet.exe test --project tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj --no-build --no-launch-profile -- --filter-class '*.DotnetProgramArchiveTests' --filter-class '*.DotnetProgramPublishingTests' --filter-not-trait 'quarantined=true' --filter-not-trait 'outerloop=true'` — passed, 48 tests after the argument-list correction. - `.\.dotnet\dotnet.exe test --project tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj --no-build --no-launch-profile -- --filter-method '*.BuildImageAsync_Dotnet*' --filter-method '*.BuildImageAsync_ProjectBuildPassesResolvedContainerRuntimeAsLocalRegistry' --filter-not-trait 'quarantined=true' --filter-not-trait 'outerloop=true'` — passed, 20 tests after the SDK correction and before the `main` merge. - `.\.dotnet\dotnet.exe publish '\probe.csproj' --configuration Release /t:PublishContainer --property:LocalRegistry=Wslc --property:ContainerRepository=aspire-wslc-sdk-probe-b35fa7a4 --property:ContainerImageTag=single --property:RuntimeIdentifier=linux-x64 --property:ContainerRuntimeIdentifier=linux-x64 --property:ContainerBaseImage=mcr.microsoft.com/dotnet/runtime:10.0 --source https://packagefeedproxy.microsoft.io/nuget/v3/index.json` — passed using a temporary minimal net10.0 console project and SDK `11.0.100-rc.1.26425.128`. SDK reported successful publication via Wslc; `wslc image inspect aspire-wslc-sdk-probe-b35fa7a4:single --format json` confirmed linux/amd64. Probe image and project cleaned up; the path above is a placeholder for that temporary directory. - Installed WSLc 3.0.3.0: verified local Dockerfile build and native `image inspect --format json`; inspected tag/remove/push/login/build help and DCP's WSLc adapter. Direct `--output type=docker` and `--output type=oci` probes both failed with the Docker-driver exporter limitation, confirming the explicit archive boundary. Probe image/files cleaned up. - Local E2E tests were not run; no E2E tests were added. Linux/macOS execution and live registry push/login were not validated locally. - Existing NU1902/NU1903 warnings for `System.Security.Cryptography.Pkcs` 10.0.12 remain; no dependency changes are included. ### Security considerations The selected WSLc executable is trusted in the same way as Docker/Podman. Image commands use the existing process runner; registry passwords remain on stdin and environment build secrets follow the existing runtime pattern. Native image inspection uses source-generated JSON deserialization and preserves the image reference as a single process argument. Dockerfile build invocations for all three runtimes now preserve argument boundaries through `ProcessSpec.ArgumentList`. No listener, elevated execution, archive extraction, or Aspire-managed archive/load bridge is added. A separate security review has not been performed. Fixes # (issue) ## Checklist - Is this feature complete? - [x] Yes. Ready to ship. - [ ] No. Follow-up changes expected. - Are you including unit tests for the changes and scenario tests if relevant? - [x] Yes - [ ] No - Did you add public API? - [ ] Yes - If yes, did you have an API Review for it? - [ ] Yes - [ ] No - Did you add `` and `` elements on your triple slash comments? - [ ] Yes - [ ] No - [x] No - Does the change make any security assumptions or guarantees? - [ ] Yes - If yes, have you done a threat model and had a security review? - [ ] Yes - [x] No - [x] No --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f85e4b06-371e-465a-8fd8-e635d14ec41f Copilot-Session: b35fa7a4-1433-4b70-9840-5f5866230ff4 (cherry picked from commit 64ba1ebd17de74ae3028fea46da5f96dd34da6c8) --- docs/machine-requirements.md | 4 +- .../ContainerRuntimeCheck.cs | 4 +- .../DistributedApplicationBuilder.cs | 1 + .../Publishing/ContainerRuntimeBase.cs | 95 +++-- .../Publishing/ContainerRuntimeResolver.cs | 13 +- .../Publishing/DockerContainerRuntime.cs | 18 +- .../Publishing/PodmanContainerRuntime.cs | 14 +- .../ResourceContainerImageManager.cs | 36 +- .../Publishing/WslcContainerRuntime.cs | 195 ++++++++++ src/Shared/ContainerRuntimeDetector.cs | 4 +- .../Utils/ContainerRuntimeCheckTests.cs | 32 +- .../WslcDotnetPublishingTests.cs | 171 +++++++++ .../Publishing/ContainerRuntimeBaseTests.cs | 107 ++++++ .../ContainerRuntimeResolverTests.cs | 26 ++ .../Publishing/DockerContainerRuntimeTests.cs | 32 +- .../Publishing/PodmanSaveArgumentsTests.cs | 6 +- .../ResourceContainerImageManagerTests.cs | 24 +- .../Publishing/WslcContainerRuntimeTests.cs | 352 ++++++++++++++++++ 18 files changed, 1033 insertions(+), 101 deletions(-) create mode 100644 src/Aspire.Hosting/Publishing/WslcContainerRuntime.cs create mode 100644 tests/Aspire.Hosting.Dotnet.Tests/WslcDotnetPublishingTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Publishing/WslcContainerRuntimeTests.cs diff --git a/docs/machine-requirements.md b/docs/machine-requirements.md index 86f06438797..74c2d352a4c 100644 --- a/docs/machine-requirements.md +++ b/docs/machine-requirements.md @@ -16,11 +16,11 @@ Whether you want to contribute to Aspire, or just use the latest build of Aspire ### WSLc (Windows only) -WSLc version 3.0.2.0 or later is supported for local development on Windows. `aspire doctor` checks the explicitly configured runtime, or reports all installed runtimes among Docker, Podman, and WSLc. Configured runtimes are marked "explicitly selected" in the report. Failures for non-selected alternatives are non-blocking when a healthy runtime is available; Docker in Windows-container mode remains an error when Docker is selected. When multiple healthy runtimes are available, Docker remains the default; set `ASPIRE_CONTAINER_RUNTIME=wslc` to select WSLc explicitly. WSLc is not supported when Aspire runs inside a Linux WSL distribution. +WSLc version 3.0.4.0 or later is supported for local development on Windows. `aspire doctor` checks the explicitly configured runtime, or reports all installed runtimes among Docker, Podman, and WSLc. Configured runtimes are marked "explicitly selected" in the report. Failures for non-selected alternatives are non-blocking when a healthy runtime is available; Docker in Windows-container mode remains an error when Docker is selected. When multiple healthy runtimes are available, Docker remains the default; set `ASPIRE_CONTAINER_RUNTIME=wslc` to select WSLc explicitly. WSLc is not supported when Aspire runs inside a Linux WSL distribution. WSLc is currently only supported for local development. Publish and deployment support will be added soon. See the [container runtime prerequisites](https://aka.ms/aspire/containers). -Doctor reports an error when the selected WSLc client or session manager is below version 3.0.2.0, including prereleases of that minimum release, or when required version metadata cannot be validated. +Doctor reports an error when the selected WSLc client or session manager is below version 3.0.4.0, including prereleases of that minimum release, or when required version metadata cannot be validated. `ASPIRE_CONTAINER_RUNTIME` selects a registered runtime (`docker`, `podman`, or `wslc`), not an executable path. Runtime names are case-insensitive. Leave the value blank to enable auto-detection. diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs index 9d5aea36d4c..3f9d9cb7f11 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs @@ -26,7 +26,7 @@ internal sealed class ContainerRuntimeCheck(ILogger logge public const string MinimumPodmanVersion = "5.0.0"; // Matches DCP's minimum supported WSLc client and session manager version. - public const string MinimumWslcVersion = "3.0.2.0"; + public const string MinimumWslcVersion = "3.0.4.0"; public int Order => 40; // Process check - more expensive @@ -290,7 +290,7 @@ private static string GetContainerRuntimeInstallationLink(string runtimeKey, str { if (string.Equals(runtime, KnownContainerRuntimes.Wslc, StringComparisons.ContainerRuntime) && version.Revision == 0) { - // WSLc reports clients as 3.0.2.0 and session managers as 3.0.2. + // WSLc reports clients as 3.0.4.0 and session managers as 3.0.4. // Omit a zero revision from the minimum so either form compares correctly. version = new Version(version.Major, version.Minor, version.Build); } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 111930eff43..f3aeb360dde 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -637,6 +637,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) Eventing.Subscribe(BuiltInDistributedApplicationEventSubscriptionHandlers.MutateHttp2TransportAsync); _innerBuilder.Services.AddKeyedSingleton(KnownContainerRuntimes.Docker); _innerBuilder.Services.AddKeyedSingleton(KnownContainerRuntimes.Podman); + _innerBuilder.Services.AddKeyedSingleton(KnownContainerRuntimes.Wslc); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index ab78f946e6c..6a9b7953119 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -449,63 +449,84 @@ protected async Task ExecuteContainerCommandWithExitCodeAsync( } /// - /// Builds a string of build arguments for container build commands. + /// Builds arguments for container build commands without command-line quoting. /// /// The build arguments to include. - /// A string containing the formatted build arguments. - protected static string BuildArgumentsString(Dictionary buildArguments) + /// The build argument switches and values. + protected static List BuildArguments(Dictionary buildArguments) { - var result = string.Empty; + var arguments = new List(); foreach (var buildArg in buildArguments) { - result += buildArg.Value is not null - ? $" --build-arg \"{buildArg.Key}={buildArg.Value}\"" - : $" --build-arg \"{buildArg.Key}\""; + arguments.Add("--build-arg"); + arguments.Add(buildArg.Value is not null + ? $"{buildArg.Key}={buildArg.Value}" + : buildArg.Key); } - return result; + + return arguments; } /// - /// Builds a string of build secrets for container build commands. + /// Builds secret arguments without command-line quoting. /// /// The build secrets to include. + /// Whether the runtime parses secret specifications as CSV records. /// Whether to require a non-null value for secrets (default: false). - /// A string containing the formatted build secrets. - internal static string BuildSecretsString(Dictionary buildSecrets, bool requireValue = false) + /// The secret switches and specifications. + internal static List BuildSecrets(Dictionary buildSecrets, bool useCsv, bool requireValue = false) { - var result = string.Empty; + var arguments = new List(); foreach (var buildSecret in buildSecrets) { + arguments.Add("--secret"); + var id = FormatSecretField($"id={buildSecret.Key}", useCsv); if (buildSecret.Value.Type == BuildImageSecretType.File) { - result += $" --secret \"id={buildSecret.Key},type=file,src={buildSecret.Value.Value}\""; + var source = FormatSecretField($"src={buildSecret.Value.Value}", useCsv); + arguments.Add($"{id},type=file,{source}"); } else if (requireValue && buildSecret.Value.Value is null) { - result += $" --secret \"id={buildSecret.Key},type=env\""; + arguments.Add($"{id},type=env"); } else { - result += $" --secret \"id={buildSecret.Key},type=env,env={buildSecret.Key.ToUpperInvariant()}\""; + var environment = FormatSecretField($"env={buildSecret.Key.ToUpperInvariant()}", useCsv); + arguments.Add($"{id},type=env,{environment}"); } } - return result; + + return arguments; + } + + private static string FormatSecretField(string field, bool useCsv) + { + // Docker Buildx and WSLc parse a CSV record: id=npmrc,type=file,"src=C:\team,a\.npmrc". + // These quotes belong to the secret specification, not to process argument escaping. + // https://github.com/docker/buildx/blob/master/util/buildflags/secrets.go + if (useCsv && field.AsSpan().ContainsAny(",\"\r\n")) + { + return $"\"{field.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + } + + return field; } /// - /// Builds a string for the target stage in container build commands. + /// Builds arguments for the target stage. /// /// The target stage to include. - /// A string containing the formatted target stage, or empty string if stage is null or empty. - protected static string BuildStageString(string? stage) + /// The target switches and value, or an empty collection. + protected static List BuildStage(string? stage) { - return !string.IsNullOrEmpty(stage) ? $" --target \"{stage}\"" : string.Empty; + return !string.IsNullOrEmpty(stage) ? ["--target", stage] : []; } /// /// Executes a container runtime command and returns the process result without throwing for non-zero exit codes. /// - protected async Task ExecuteContainerCommandWithResultAsync( + protected Task ExecuteContainerCommandWithResultAsync( string arguments, string errorLogTemplate, string successLogTemplate, @@ -514,8 +535,36 @@ protected async Task ExecuteContainerCommandWithResultAsync( Dictionary? environmentVariables = null, bool retainOutput = false) { - var spec = CreateProcessSpec(arguments, retainOutput); + return ExecuteContainerCommandWithResultAsync( + CreateProcessSpec(arguments, retainOutput), errorLogTemplate, successLogTemplate, + cancellationToken, logArguments, environmentVariables); + } + + /// + /// Executes a command with argument boundaries preserved by the process runner. + /// + protected Task ExecuteContainerCommandWithResultAsync( + IReadOnlyList arguments, + string errorLogTemplate, + string successLogTemplate, + CancellationToken cancellationToken, + object[] logArguments, + Dictionary? environmentVariables = null, + bool retainOutput = false) + { + return ExecuteContainerCommandWithResultAsync( + CreateProcessSpec(arguments, retainOutput), errorLogTemplate, successLogTemplate, + cancellationToken, logArguments, environmentVariables); + } + private async Task ExecuteContainerCommandWithResultAsync( + ProcessSpec spec, + string errorLogTemplate, + string successLogTemplate, + CancellationToken cancellationToken, + object[] logArguments, + Dictionary? environmentVariables) + { if (environmentVariables is not null) { foreach (var (key, value) in environmentVariables) @@ -524,7 +573,7 @@ protected async Task ExecuteContainerCommandWithResultAsync( } } - _logger.LogDebug("Running {RuntimeName} with arguments: {ArgumentList}", Name, spec.Arguments); + _logger.LogDebug("Running {RuntimeName} with arguments: {ArgumentList}", Name, spec.ArgumentList ?? (object?)spec.Arguments); var (pendingProcessResult, processDisposable) = _processRunner.Run(spec); await using (processDisposable) diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs index c87d9853966..b229d35ecb2 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs @@ -79,11 +79,9 @@ private async Task ResolveInternalAsync(CancellationToken can // Auto-detect: probe available runtimes asynchronously. // See https://github.com/microsoft/dcp/blob/main/internal/containers/runtimes/runtime.go - // Only Docker and Podman have publishing implementations and SDK local image-store support. var detected = await ContainerRuntimeDetector.FindAvailableRuntimeAsync( logger: _logger, - cancellationToken: cancellationToken, - supportedRuntimes: [KnownContainerRuntimes.Docker, KnownContainerRuntimes.Podman]).ConfigureAwait(false); + cancellationToken: cancellationToken).ConfigureAwait(false); var runtimeKey = detected?.Key ?? KnownContainerRuntimes.Docker; if (detected is { IsHealthy: true }) @@ -96,7 +94,14 @@ private async Task ResolveInternalAsync(CancellationToken can } else { - _logger.LogWarning("No container runtime detected, defaulting to '{DefaultRuntime}'. Install Docker or Podman to use container features.", KnownContainerRuntimes.Docker); + if (OperatingSystem.IsWindows()) + { + _logger.LogWarning("No container runtime detected, defaulting to '{DefaultRuntime}'. Install Docker, Podman, or WSLc to use container features.", KnownContainerRuntimes.Docker); + } + else + { + _logger.LogWarning("No container runtime detected, defaulting to '{DefaultRuntime}'. Install Docker or Podman to use container features.", KnownContainerRuntimes.Docker); + } } return _serviceProvider.GetRequiredKeyedService(runtimeKey); diff --git a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs index ae5fa185425..ba4d0a1bea3 100644 --- a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs @@ -64,17 +64,17 @@ private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath try { - var arguments = $"buildx build --file \"{dockerfilePath}\" --tag \"{imageName}\""; + var arguments = new List { "buildx", "build", "--file", dockerfilePath, "--tag", imageName }; if (!string.IsNullOrEmpty(selectedBuilderName)) { - arguments += $" --builder \"{selectedBuilderName}\""; + arguments.AddRange(["--builder", selectedBuilderName]); } // Add platform support if specified if (options?.TargetPlatform is not null) { - arguments += $" --platform \"{options.TargetPlatform.Value.ToRuntimePlatformString()}\""; + arguments.AddRange(["--platform", options.TargetPlatform.Value.ToRuntimePlatformString()]); } // Add output format support if specified @@ -95,19 +95,19 @@ private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath outputType += $",dest={archivePath}"; } - arguments += $" --output \"{outputType}\""; + arguments.AddRange(["--output", outputType]); } // Add build arguments if specified - arguments += BuildArgumentsString(buildArguments); + arguments.AddRange(BuildArguments(buildArguments)); // Add build secrets if specified - arguments += BuildSecretsString(buildSecrets); + arguments.AddRange(BuildSecrets(buildSecrets, useCsv: true)); // Add stage if specified - arguments += BuildStageString(stage); + arguments.AddRange(BuildStage(stage)); - arguments += $" \"{contextPath}\""; + arguments.Add(contextPath); // Prepare environment variables for build secrets var environmentVariables = new Dictionary(); @@ -179,7 +179,7 @@ public override async Task BuildImageAsync(string contextPath, string dockerfile } // Normalize the context path to handle trailing slashes and relative paths - var normalizedContextPath = Path.GetFullPath(contextPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedContextPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(contextPath)); await RunDockerBuildAsync( normalizedContextPath, diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs index 9e942bbdfd5..7286017af7d 100644 --- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs @@ -153,12 +153,12 @@ private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath throw new ArgumentException("OutputPath must be provided when ImageFormat is Oci.", nameof(options)); } - var arguments = $"build --file \"{dockerfilePath}\" --tag \"{imageName}\""; + var arguments = new List { "build", "--file", dockerfilePath, "--tag", imageName }; // Add platform support if specified if (options?.TargetPlatform is not null) { - arguments += $" --platform \"{options.TargetPlatform.Value.ToRuntimePlatformString()}\""; + arguments.AddRange(["--platform", options.TargetPlatform.Value.ToRuntimePlatformString()]); } // Add format support if specified @@ -170,7 +170,7 @@ private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath ContainerImageFormat.Docker => "docker", _ => throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format") }; - arguments += $" --format \"{format}\""; + arguments.AddRange(["--format", format]); } // Archive output is deliberately NOT handled here. @@ -186,15 +186,15 @@ private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath // https://docs.podman.io/en/latest/markdown/podman-save.1.html // Add build arguments if specified - arguments += BuildArgumentsString(buildArguments); + arguments.AddRange(BuildArguments(buildArguments)); // Add build secrets if specified - arguments += BuildSecretsString(buildSecrets, requireValue: true); + arguments.AddRange(BuildSecrets(buildSecrets, useCsv: false, requireValue: true)); // Add stage if specified - arguments += BuildStageString(stage); + arguments.AddRange(BuildStage(stage)); - arguments += $" \"{contextPath}\""; + arguments.Add(contextPath); // Prepare environment variables for build secrets (only for environment-type secrets) var environmentVariables = new Dictionary(); diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs index 41a7a4df773..3208f44524b 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs @@ -442,6 +442,29 @@ private async Task BuildDotnetProgramImageAsync( var hasContainerFiles = resource.TryGetAnnotationsOfType(out _); var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); + if (containerRuntime is WslcContainerRuntime) + { + if (hasContainerFiles) + { + // Layering selects a platform for its Dockerfile build even when the SDK image + // uses the host default. WSLc cannot honor that Dockerfile platform selection. + throw new DistributedApplicationException( + $"WSLc does not support the platform-selected Dockerfile build required by PublishWithContainerFiles for resource '{resource.Name}'. " + + "Use Docker or Podman for container-file layering."); + } + + if (string.IsNullOrEmpty(options.OutputPath) && + options.TargetPlatform is { } targetPlatform && + targetPlatform.ToMSBuildRuntimeIdentifierString().Contains(';')) + { + // WSLc cannot load the OCI image layout used by multi-platform SDK images. + // Track support at https://github.com/microsoft/WSL/issues/41123. + throw new DistributedApplicationException( + $"WSLc does not support loading multi-platform .NET SDK images into its local image store for resource '{resource.Name}'. " + + "Select a single TargetPlatform, use Docker or Podman, or publish an archive with Destination = Archive and OutputPath."); + } + } + if (hasContainerFiles || ResourceRequiresContainerRuntime(resource, options)) { await ensureContainerRuntimeRunning(containerRuntime, cancellationToken).ConfigureAwait(false); @@ -568,7 +591,12 @@ private async Task ExecuteDotnetPublishAsync( MsBuildResponseFileFactory.CreatePropertyArgument("ContainerImageTag", options.LocalImageTag) }; - if (GetLocalRegistryName(containerRuntime) is string localRegistry) + // Archives do not use a local registry. Omitting Wslc here also preserves archive-only + // publishing with older SDKs that do not recognize that registry name. + var localRegistryName = containerRuntime is WslcContainerRuntime && !string.IsNullOrEmpty(options.OutputPath) + ? null + : GetLocalRegistryName(containerRuntime); + if (localRegistryName is string localRegistry) { arguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("LocalRegistry", localRegistry)); } @@ -932,6 +960,11 @@ private static bool ResourceRequiresContainerRuntime( return "Podman"; } + if (containerRuntime is WslcContainerRuntime) + { + return "Wslc"; + } + return null; } @@ -978,6 +1011,7 @@ private async Task BuildContainerImageFromDockerfileAsync(IResource resource, Do { ImageName = imageNameOnly, Tag = imageTag, + Destination = options.Destination, OutputPath = options.OutputPath, ImageFormat = options.ImageFormat, TargetPlatform = options.TargetPlatform diff --git a/src/Aspire.Hosting/Publishing/WslcContainerRuntime.cs b/src/Aspire.Hosting/Publishing/WslcContainerRuntime.cs new file mode 100644 index 00000000000..9f502e29bf5 --- /dev/null +++ b/src/Aspire.Hosting/Publishing/WslcContainerRuntime.cs @@ -0,0 +1,195 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIRECONTAINERRUNTIME001 + +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Hosting.Dcp.Process; +using Aspire.Shared; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Publishing; + +internal sealed class WslcContainerRuntime : ContainerRuntimeBase +{ + public WslcContainerRuntime(ILogger logger, IProcessRunner processRunner) : base(logger, processRunner) + { + if (!OperatingSystem.IsWindows()) + { + throw new DistributedApplicationException( + "WSLc container publishing is only supported on Windows. Use Docker or Podman on this host."); + } + } + + protected override string RuntimeExecutable => KnownContainerRuntimes.Wslc; + public override string Name => "WSLc"; + + public override async Task CheckIfRunningAsync(CancellationToken cancellationToken) + { + try + { + return await ExecuteContainerCommandWithExitCodeAsync( + "container ls -n 1", + "WSLc is not running. Exit code: {ExitCode}.", + "WSLc is running.", + cancellationToken, + []).ConfigureAwait(false) == 0; + } + catch (Win32Exception ex) + { + Logger.LogDebug(ex, "Could not start WSLc. Ensure wslc.exe is installed and available on PATH."); + return false; + } + } + + public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (options?.TargetPlatform is not null) + { + throw new DistributedApplicationException( + "WSLc does not support selecting a platform for Dockerfile image builds. Use Docker or Podman, " + + "or set TargetPlatform to null with WithContainerBuildOptions to build for the WSLc host platform."); + } + + if (options?.Destination == ContainerImageDestination.Archive || + !string.IsNullOrEmpty(options?.OutputPath) || + options?.ImageFormat == ContainerImageFormat.Oci) + { + // WSLc exposes --output, but its Docker driver rejects Docker and OCI image exporters. + // Do not substitute save or create a different builder to work around this limitation. + throw new DistributedApplicationException( + "WSLc does not support Dockerfile image archive output or OCI image format selection. " + + "Use Docker or Podman for this publish operation."); + } + + if (options?.ImageFormat is not null and not ContainerImageFormat.Docker) + { + throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format"); + } + + var imageName = !string.IsNullOrEmpty(options?.Tag) + ? $"{options.ImageName}:{options.Tag}" + : options?.ImageName ?? throw new ArgumentException("ImageName must be provided in options.", nameof(options)); + var normalizedContextPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(contextPath)); + var arguments = new List { "build", "--file", dockerfilePath, "--tag", imageName }; + // WSLc runs buildx inside its VM, so bare --build-arg KEY cannot inherit the host environment. + foreach (var (key, value) in buildArguments) + { + if ((value ?? Environment.GetEnvironmentVariable(key)) is { } resolvedValue) + { + arguments.AddRange(["--build-arg", $"{key}={resolvedValue}"]); + } + } + arguments.AddRange(BuildSecrets(buildSecrets, useCsv: true)); + arguments.AddRange(BuildStage(stage)); + arguments.Add(normalizedContextPath); + + var environmentVariables = new Dictionary(); + foreach (var buildSecret in buildSecrets) + { + if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null) + { + environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value; + } + } + + var processResult = await ExecuteContainerCommandWithResultAsync( + arguments, + "WSLc build for {ImageName} failed with exit code {ExitCode}.", + "WSLc build for {ImageName} succeeded.", + cancellationToken, + [imageName], + environmentVariables, + retainOutput: true).ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + throw new ProcessFailedException( + $"WSLc build failed with exit code {processResult.ExitCode}.", + processResult.ExitCode, + processResult.ProcessOutput, + processResult.TotalProcessOutputLineCount); + } + } + + public override async Task InspectImageConfigAsync(string imageName, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(imageName); + + string output; + try + { + output = await ExecuteContainerCommandForOutputAsync( + ["image", "inspect", imageName, "--format", "json"], + "inspect image config", + imageName, + cancellationToken).ConfigureAwait(false); + } + catch (DistributedApplicationException ex) + { + return ContainerImageConfigInspectionResult.Failure(ex.Message); + } + + try + { + // `wslc image inspect --format json` returns an array, not a Go-template projection: + // [{"Config":{"Entrypoint":null,"Cmd":["/bin/sh"],"WorkingDir":"/"}}] + var images = JsonSerializer.Deserialize(output, WslcImageInspectionJsonContext.Default.ListWslcInspectedImage); + if (images is [{ Config: { } config }]) + { + return ContainerImageConfigInspectionResult.Success( + new ContainerImageConfig(config.Entrypoint ?? [], config.Cmd ?? [], config.WorkingDir), + output); + } + } + catch (JsonException) + { + // Report malformed or unexpected metadata through the shared inspection result contract. + } + + return ContainerImageConfigInspectionResult.Failure( + $"WSLc returned invalid image configuration for '{imageName}'.", + output); + } + + public override Task InspectImageManifestAsync(string imageName, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(imageName); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(ContainerImageManifestInspectionResult.Unsupported); + } + + public override Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken) + => throw CreateComposeNotSupportedException(); + + public override Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken) + => throw CreateComposeNotSupportedException(); + + public override Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) + => throw CreateComposeNotSupportedException(); + + private static DistributedApplicationException CreateComposeNotSupportedException() + => new("WSLc does not support Docker Compose deployment or management. Set ASPIRE_CONTAINER_RUNTIME to docker or podman for Compose operations."); +} + +internal sealed class WslcInspectedImage +{ + public WslcImageConfig? Config { get; set; } +} + +internal sealed class WslcImageConfig +{ + public List? Entrypoint { get; set; } + public List? Cmd { get; set; } + public string? WorkingDir { get; set; } +} + +[JsonSerializable(typeof(List))] +internal sealed partial class WslcImageInspectionJsonContext : JsonSerializerContext +{ +} diff --git a/src/Shared/ContainerRuntimeDetector.cs b/src/Shared/ContainerRuntimeDetector.cs index fff7753ec3b..8f4f04ab0a6 100644 --- a/src/Shared/ContainerRuntimeDetector.cs +++ b/src/Shared/ContainerRuntimeDetector.cs @@ -33,8 +33,7 @@ internal static IEnumerable GetKnownRuntimes(bool isWindo public static async Task FindAvailableRuntimeAsync( string? configuredRuntime = null, ILogger? logger = null, - CancellationToken cancellationToken = default, - IReadOnlyCollection? supportedRuntimes = null) + CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (!string.IsNullOrEmpty(configuredRuntime)) @@ -45,7 +44,6 @@ internal static IEnumerable GetKnownRuntimes(bool isWindo } var runtimes = GetKnownRuntimes(OperatingSystem.IsWindows()) - .Where(runtime => supportedRuntimes is null || supportedRuntimes.Contains(runtime.Key, StringComparers.ContainerRuntime)) .ToArray(); logger?.LogDebug("Auto-detecting container runtime, probing {Count} known runtimes...", runtimes.Length); var results = await Task.WhenAll(runtimes.Select(runtime => runtime.CheckAsync(logger, cancellationToken))).ConfigureAwait(false); diff --git a/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs index 764f60420ba..73f6ea8d73f 100644 --- a/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs @@ -40,14 +40,18 @@ public void RuntimeVersionPolicy_UsesRegisteredKeyRatherThanDisplayName(string k } [Theory] - [InlineData("3.0.2.0", "3.0.2", false)] - [InlineData("3.0.2", "3.0.2.0", false)] - [InlineData("3.0.2-rc.1", "3.0.3", true)] - [InlineData("3.0.3.0", "3.0.2-rc.1", true)] - [InlineData("3.0.3-rc.1", "3.0.3", false)] - [InlineData("3.0.1.9", "3.0.3", true)] - [InlineData("3.0.3.0", "3.0.1.9", true)] - [InlineData("3.0.3+build.1", "3.0.2+build.2", false)] + [InlineData("3.0.4.0", "3.0.4", false)] + [InlineData("3.0.4", "3.0.4.0", false)] + [InlineData("3.0.4-rc.1", "3.0.5", true)] + [InlineData("3.0.5.0", "3.0.4-rc.1", true)] + [InlineData("3.0.5-rc.1", "3.0.5", false)] + [InlineData("3.0.3.9", "3.0.5", true)] + [InlineData("3.0.5.0", "3.0.3.9", true)] + [InlineData("3.0.2.0", "3.0.5", true)] + [InlineData("3.0.5.0", "3.0.2", true)] + [InlineData("3.0.3.0", "3.0.5", true)] + [InlineData("3.0.5.0", "3.0.3", true)] + [InlineData("3.0.5+build.1", "3.0.4+build.2", false)] public void WslcVersionPolicy_RecognizesNativeAndSemanticVersions(string client, string server, bool expectFailure) { var metadata = WslcContainerRuntimeProbe.ParseInfoOutput( @@ -58,7 +62,7 @@ public void WslcVersionPolicy_RecognizesNativeAndSemanticVersions(string client, { Assert.NotNull(result); Assert.Equal(EnvironmentCheckStatus.Fail, result.Status); - var rejectedVersion = client.StartsWith("3.0.3", StringComparison.Ordinal) ? server : client; + var rejectedVersion = client.StartsWith("3.0.5", StringComparison.Ordinal) ? server : client; Assert.Contains(rejectedVersion, result.Message); } else @@ -241,16 +245,6 @@ public void GetKnownRuntimes_OnlyIncludesWslcOnWindows(bool isWindows) Assert.Equal(isWindows, includesWslc); } - [Fact] - public async Task FindAvailableRuntimeAsync_RestrictsDiscoveryToSupportedRuntimes() - { - var runtime = await ContainerRuntimeDetector.FindAvailableRuntimeAsync( - cancellationToken: TestContext.Current.CancellationToken, - supportedRuntimes: []); - - Assert.Null(runtime); - } - [Fact] public void ParseVersionFromJsonOutput_WithWslcInfo_ReturnsClientAndSessionManagerVersions() { diff --git a/tests/Aspire.Hosting.Dotnet.Tests/WslcDotnetPublishingTests.cs b/tests/Aspire.Hosting.Dotnet.Tests/WslcDotnetPublishingTests.cs new file mode 100644 index 00000000000..231af27938b --- /dev/null +++ b/tests/Aspire.Hosting.Dotnet.Tests/WslcDotnetPublishingTests.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIRECONTAINERRUNTIME001 +#pragma warning disable ASPIREPROJECTS001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Dcp.Process; +using Aspire.Hosting.Publishing; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Aspire.Shared; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Dotnet.Tests; + +[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "WSLc is Windows-only.")] +public class WslcDotnetPublishingTests(ITestOutputHelper output) +{ + [Theory] + [InlineData(ContainerTargetPlatform.AllLinux)] + [InlineData(ContainerTargetPlatform.LinuxAmd64 | ContainerTargetPlatform.WindowsAmd64)] + [InlineData(ContainerTargetPlatform.WindowsAmd64 | ContainerTargetPlatform.WindowsArm64)] + public async Task DotnetPublishing_RejectsMultiPlatformLocalStoreBeforeStartingProcess(ContainerTargetPlatform platform) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var runner = new TestProcessRunner(); + builder.Services.AddSingleton(runner); + builder.Services.Configure(options => options.ContainerRuntime = KnownContainerRuntimes.Wslc); + var resource = builder.AddDotnetProject("program", "program.csproj", options => options.ExcludeLaunchProfile = true) + .WithContainerBuildOptions(options => options.TargetPlatform = platform); + using var app = builder.Build(); + var manager = app.Services.GetRequiredService(); + + var exception = await Assert.ThrowsAsync( + () => manager.BuildImageAsync(resource.Resource, TestContext.Current.CancellationToken)); + + Assert.Contains("WSLc does not support loading multi-platform .NET SDK images into its local image store", exception.Message); + Assert.Empty(runner.ProcessSpecs); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DotnetPublishing_RejectsPlatformSelectedContainerFileLayering(bool archive) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var workspace = TemporaryWorkspace.Create(output); + var runner = new TestProcessRunner(); + builder.Services.AddSingleton(runner); + builder.Services.Configure(options => options.ContainerRuntime = KnownContainerRuntimes.Wslc); + var source = builder.AddContainer("assets", "assets-image"); + var resource = builder.AddDotnetProject("program", "program.csproj", options => options.ExcludeLaunchProfile = true) + .WithAnnotation(new ContainerFilesDestinationAnnotation { Source = source.Resource, DestinationPath = "wwwroot" }) + .WithContainerBuildOptions(options => + { + options.TargetPlatform = null; + if (archive) + { + options.Destination = ContainerImageDestination.Archive; + options.OutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar"); + } + }); + using var app = builder.Build(); + var manager = app.Services.GetRequiredService(); + + var exception = await Assert.ThrowsAsync( + () => manager.BuildImageAsync(resource.Resource, TestContext.Current.CancellationToken)); + + Assert.Contains("WSLc does not support the platform-selected Dockerfile build required by PublishWithContainerFiles", exception.Message); + Assert.Empty(runner.ProcessSpecs); + } + + [Theory] + [InlineData(null)] + [InlineData(ContainerTargetPlatform.LinuxAmd64)] + [InlineData(ContainerTargetPlatform.LinuxArm64)] + public async Task DotnetPublishing_SinglePlatformUsesSdkWslcLocalRegistry(ContainerTargetPlatform? platform) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var runner = new TestProcessRunner(); + builder.Services.AddSingleton(runner); + builder.Services.Configure(options => options.ContainerRuntime = KnownContainerRuntimes.Wslc); + var resource = builder.AddDotnetProject("program", "program.csproj", options => options.ExcludeLaunchProfile = true) + .WithContainerBuildOptions(options => options.TargetPlatform = platform); + using var app = builder.Build(); + var manager = app.Services.GetRequiredService(); + + await manager.BuildImageAsync(resource.Resource, TestContext.Current.CancellationToken); + + Assert.Collection(runner.ProcessSpecs, + spec => + { + Assert.Equal(KnownContainerRuntimes.Wslc, spec.ExecutablePath); + Assert.Equal("container ls -n 1", spec.Arguments); + }, + spec => + { + Assert.Equal("dotnet", spec.ExecutablePath); + var expectedArguments = new List + { + "publish", resource.Resource.GetProjectMetadata().ProjectPath, + "--configuration", "Release", "/t:PublishContainer", + "--property:ContainerRepository=program", "--property:ContainerImageTag=latest", + "--property:LocalRegistry=Wslc" + }; + if (platform is not null) + { + var rid = platform == ContainerTargetPlatform.LinuxArm64 ? "linux-arm64" : "linux-x64"; + expectedArguments.AddRange([$"--property:RuntimeIdentifier={rid}", $"--property:ContainerRuntimeIdentifier={rid}"]); + } + Assert.Equal(expectedArguments, spec.ArgumentList); + }); + } + + [Theory] + [InlineData(null, ContainerTargetPlatform.LinuxAmd64)] + [InlineData(ContainerImageFormat.Docker, ContainerTargetPlatform.LinuxAmd64)] + [InlineData(ContainerImageFormat.Oci, ContainerTargetPlatform.LinuxAmd64)] + [InlineData(ContainerImageFormat.Oci, ContainerTargetPlatform.AllLinux)] + public async Task DotnetPublishing_ArchiveDoesNotInvokeWslc(ContainerImageFormat? format, ContainerTargetPlatform platform) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var workspace = TemporaryWorkspace.Create(output); + var runner = new TestProcessRunner(); + builder.Services.AddSingleton(runner); + builder.Services.Configure(options => options.ContainerRuntime = KnownContainerRuntimes.Wslc); + var resource = builder.AddDotnetProject("program", "program.csproj", options => options.ExcludeLaunchProfile = true) + .WithContainerBuildOptions(options => + { + options.Destination = ContainerImageDestination.Archive; + options.OutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar"); + options.ImageFormat = format; + options.TargetPlatform = platform; + }); + using var app = builder.Build(); + var manager = app.Services.GetRequiredService(); + + await manager.BuildImagesAsync([resource.Resource], TestContext.Current.CancellationToken); + + var spec = Assert.Single(runner.ProcessSpecs); + Assert.Equal("dotnet", spec.ExecutablePath); + var expectedArguments = new List + { + "publish", + resource.Resource.GetProjectMetadata().ProjectPath, + "--configuration", + "Release", + "/t:PublishContainer", + "--property:ContainerRepository=program", + "--property:ContainerImageTag=latest", + MsBuildResponseFileFactory.CreatePropertyArgument("ContainerArchiveOutputPath", Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar")) + }; + if (format is not null) + { + expectedArguments.Add($"--property:ContainerImageFormat={(format == ContainerImageFormat.Oci ? "OCI" : "Docker")}"); + } + if (platform == ContainerTargetPlatform.AllLinux) + { + expectedArguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("RuntimeIdentifiers", "linux-x64;linux-arm64")); + expectedArguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("ContainerRuntimeIdentifiers", "linux-x64;linux-arm64")); + } + else + { + expectedArguments.AddRange(["--property:RuntimeIdentifier=linux-x64", "--property:ContainerRuntimeIdentifier=linux-x64"]); + } + Assert.Equal(expectedArguments, spec.ArgumentList); + } +} diff --git a/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeBaseTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeBaseTests.cs index 465f9533c44..991344edab9 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeBaseTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeBaseTests.cs @@ -6,6 +6,8 @@ using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Publishing; +using Aspire.Hosting.Tests.Utils; +using Aspire.Shared; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting.Tests.Publishing; @@ -13,6 +15,111 @@ namespace Aspire.Hosting.Tests.Publishing; [Trait("Partition", "4")] public class ContainerRuntimeBaseTests { + public static TheoryData BuildRuntimes => OperatingSystem.IsWindows() + ? new() { KnownContainerRuntimes.Docker, KnownContainerRuntimes.Podman, KnownContainerRuntimes.Wslc } + : new() { KnownContainerRuntimes.Docker, KnownContainerRuntimes.Podman }; + + [Theory] + [MemberData(nameof(BuildRuntimes))] + public async Task BuildImageAsync_PreservesRootContext(string runtimeName) + { + var runner = new TestProcessRunner(); + var runtime = CreateRuntime(runtimeName, runner); + var rootPath = Path.GetPathRoot(Path.GetFullPath("context"))!; + + await runtime.BuildImageAsync(rootPath, Path.Combine(rootPath, "Dockerfile"), + new ContainerImageBuildOptions { ImageName = "myapp" }, + [], [], null, TestContext.Current.CancellationToken); + + Assert.Equal(rootPath, runner.ProcessSpecs[^1].ArgumentList![^1]); + } + + [Theory] + [MemberData(nameof(BuildRuntimes))] + public async Task BuildImageAsync_PreservesArgumentBoundaries(string runtimeName) + { + var runner = new TestProcessRunner(); + var runtime = CreateRuntime(runtimeName, runner); + var contextPath = Path.GetFullPath("context with spaces"); + var dockerfile = Path.Combine(contextPath, "Dockerfile"); + const string secretPath = """C:\secrets with spaces\team,a\config "quoted".json"""; + const string stage = "stage with spaces"; + var values = new Dictionary + { + ["CONFIG"] = """{"mode":"prod"}""", + ["MESSAGE"] = """say "hello world" """, + ["PATH"] = """C:\path with spaces\trailing\""", + ["QUOTED_PATH"] = """C:\path\"quoted"\file""", + ["EMPTY"] = "", + [$"ASPIRE_BUILD_ARG_{Guid.NewGuid():N}"] = null + }; + + await runtime.BuildImageAsync(contextPath, dockerfile, + new ContainerImageBuildOptions { ImageName = "myapp", Tag = "latest" }, + values, + new() + { + ["file"] = new(secretPath, BuildImageSecretType.File), + ["token"] = new("secret-value", BuildImageSecretType.Environment), + ["inherited"] = new(null, BuildImageSecretType.Environment) + }, + stage, TestContext.Current.CancellationToken); + + var spec = runner.ProcessSpecs[^1]; + var expected = new List(); + if (runtimeName == KnownContainerRuntimes.Docker) + { + expected.Add("buildx"); + } + expected.AddRange(["build", "--file", dockerfile, "--tag", "myapp:latest"]); + foreach (var (key, value) in values) + { + if (runtimeName == KnownContainerRuntimes.Wslc && value is null) + { + continue; + } + expected.AddRange(["--build-arg", value is null ? key : $"{key}={value}"]); + } + expected.AddRange([ + "--secret", runtimeName == KnownContainerRuntimes.Podman + ? $"id=file,type=file,src={secretPath}" + : "id=file,type=file,\"src=C:\\secrets with spaces\\team,a\\config \"\"quoted\"\".json\"", + "--secret", "id=token,type=env,env=TOKEN", + "--secret", runtimeName == KnownContainerRuntimes.Podman ? "id=inherited,type=env" : "id=inherited,type=env,env=INHERITED", + "--target", stage, contextPath]); + Assert.Null(spec.Arguments); + Assert.Equal(expected, spec.ArgumentList); + Assert.Equal("secret-value", spec.EnvironmentVariables["TOKEN"]); + } + + [Theory] + [InlineData(@"C:\team,a\.npmrc", "\"src=C:\\team,a\\.npmrc\"")] + [InlineData("path\"quoted", "\"src=path\"\"quoted\"")] + [InlineData("path\r\nnext", "\"src=path\r\nnext\"")] + [InlineData(@"C:\plain\.npmrc", @"src=C:\plain\.npmrc")] + public void BuildSecrets_EncodesCsvFieldsOnlyWhenRequested(string path, string expectedSource) + { + var secrets = new Dictionary + { + ["npmrc"] = new(path, BuildImageSecretType.File) + }; + + Assert.Equal( + ["--secret", $"id=npmrc,type=file,{expectedSource}"], + ContainerRuntimeBase.BuildSecrets(secrets, useCsv: true)); + Assert.Equal( + ["--secret", $"id=npmrc,type=file,src={path}"], + ContainerRuntimeBase.BuildSecrets(secrets, useCsv: false)); + } + + private static IContainerRuntime CreateRuntime(string runtimeName, TestProcessRunner runner) => runtimeName switch + { + KnownContainerRuntimes.Docker => new DockerContainerRuntime(NullLogger.Instance, runner), + KnownContainerRuntimes.Podman => new PodmanContainerRuntime(NullLogger.Instance, runner), + KnownContainerRuntimes.Wslc => new WslcContainerRuntime(NullLogger.Instance, runner), + _ => throw new ArgumentOutOfRangeException(nameof(runtimeName)) + }; + [Fact] public async Task ExecuteContainerCommandAsync_IncludesCapturedOutputInFailureMessage() { diff --git a/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs index 9ed69d7acea..155461637f8 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.Dcp; using Aspire.Hosting.Publishing; +using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -20,6 +21,7 @@ private static ContainerRuntimeResolver CreateResolver( var services = new ServiceCollection(); services.AddKeyedSingleton("docker"); services.AddKeyedSingleton("podman"); + services.AddKeyedSingleton("wslc"); var sp = serviceProvider ?? services.BuildServiceProvider(); var dcpOptions = Options.Create(new DcpOptions { ContainerRuntime = configuredRuntime }); @@ -53,6 +55,8 @@ public async Task ResolveAsync_ReturnsSameTask_WhenCached() [InlineData("podman")] [InlineData("PODMAN")] [InlineData("Docker")] + [InlineData("wslc")] + [InlineData("WSLC")] public async Task ResolveAsync_ConfiguredRuntime_ReturnsKeyedService(string configuredRuntime) { var resolver = CreateResolver(configuredRuntime); @@ -62,6 +66,28 @@ public async Task ResolveAsync_ConfiguredRuntime_ReturnsKeyedService(string conf Assert.NotNull(runtime); } + [Fact] + public async Task ResolveAsync_BuiltInWslcIsWindowsOnly() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.Services.Configure(options => options.ContainerRuntime = "WSLC"); + using var app = builder.Build(); + var resolver = app.Services.GetRequiredService(); + + if (OperatingSystem.IsWindows()) + { + Assert.IsType(await resolver.ResolveAsync()); + } + else + { + var exception = await Assert.ThrowsAsync( + () => resolver.ResolveAsync()); + Assert.Equal( + "WSLc container publishing is only supported on Windows. Use Docker or Podman on this host.", + exception.Message); + } + } + [Fact] public async Task ResolveAsync_CustomConfiguredRuntime_PreservesKeyedRegistration() { diff --git a/tests/Aspire.Hosting.Tests/Publishing/DockerContainerRuntimeTests.cs b/tests/Aspire.Hosting.Tests/Publishing/DockerContainerRuntimeTests.cs index ebd9bcefed0..a79e0627004 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/DockerContainerRuntimeTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/DockerContainerRuntimeTests.cs @@ -54,9 +54,9 @@ await runtime.BuildImageAsync( Assert.Equal("buildx version", processSpecs[0].Arguments); var builderName = AssertAndGetBuilderName(processSpecs[1].Arguments); Assert.Equal( - $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" --builder \"{builderName}\" " + - $"--output \"{outputType},dest={archivePath}\" \"{GetNormalizedContextPath()}\"", - processSpecs[2].Arguments); + ["buildx", "build", "--file", "Dockerfile", "--tag", "myapp:latest", "--builder", builderName, + "--output", $"{outputType},dest={archivePath}", GetNormalizedContextPath()], + processSpecs[2].ArgumentList); Assert.Equal($"buildx rm \"{builderName}\"", processSpecs[3].Arguments); } @@ -206,15 +206,15 @@ public async Task BuildImageAsync_LocalImageArchiveUsesActiveContextBuilderThenD await BuildImageAsync(runtime, options); var archivePath = ResourceExtensions.GetContainerImageArchivePath("out", "myapp:latest"); - var platformArgument = expectedPlatforms is null ? string.Empty : $" --platform \"{expectedPlatforms}\""; + string[] platformArguments = expectedPlatforms is null ? [] : ["--platform", expectedPlatforms]; Assert.Collection( processRunner.ProcessSpecs, check => Assert.Equal("buildx version", check.Arguments), context => Assert.Equal("context show", context.Arguments), build => Assert.Equal( - $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" --builder \"desktop-linux\"" + - $"{platformArgument} \"{GetNormalizedContextPath()}\"", - build.Arguments), + ["buildx", "build", "--file", "Dockerfile", "--tag", "myapp:latest", "--builder", "desktop-linux", + .. platformArguments, GetNormalizedContextPath()], + build.ArgumentList), save => Assert.Equal( $"image save --output \"{archivePath}\" \"myapp:latest\"", save.Arguments)); @@ -258,9 +258,9 @@ public async Task BuildImageAsync_LocalMultiPlatformBuildFailurePreservesDiagnos check => Assert.Equal("buildx version", check.Arguments), context => Assert.Equal("context show", context.Arguments), build => Assert.Equal( - $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" --builder \"desktop-linux\" " + - $"--platform \"linux/amd64,linux/arm64\" \"{GetNormalizedContextPath()}\"", - build.Arguments)); + ["buildx", "build", "--file", "Dockerfile", "--tag", "myapp:latest", "--builder", "desktop-linux", + "--platform", "linux/amd64,linux/arm64", GetNormalizedContextPath()], + build.ArgumentList)); } [Fact] @@ -288,9 +288,9 @@ public async Task BuildImageAsync_LocalImageWithoutArchiveUsesActiveContextBuild check => Assert.Equal("buildx version", check.Arguments), context => Assert.Equal("context show", context.Arguments), build => Assert.Equal( - $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" --builder \"desktop-linux\" " + - $"--platform \"linux/amd64\" \"{GetNormalizedContextPath()}\"", - build.Arguments)); + ["buildx", "build", "--file", "Dockerfile", "--tag", "myapp:latest", "--builder", "desktop-linux", + "--platform", "linux/amd64", GetNormalizedContextPath()], + build.ArgumentList)); } [Fact] @@ -425,9 +425,9 @@ await runtime.BuildImageAsync( processRunner.ProcessSpecs, check => Assert.Equal("buildx version", check.Arguments), build => Assert.Equal( - $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" " + - $"--output \"type=docker\" \"{GetNormalizedContextPath()}\"", - build.Arguments)); + ["buildx", "build", "--file", "Dockerfile", "--tag", "myapp:latest", + "--output", "type=docker", GetNormalizedContextPath()], + build.ArgumentList)); } private static Task BuildImageAsync( diff --git a/tests/Aspire.Hosting.Tests/Publishing/PodmanSaveArgumentsTests.cs b/tests/Aspire.Hosting.Tests/Publishing/PodmanSaveArgumentsTests.cs index d96f77b526f..fcd098b8033 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/PodmanSaveArgumentsTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/PodmanSaveArgumentsTests.cs @@ -187,7 +187,7 @@ public async Task BuildImageAsync_WithoutOutputPath_DoesNotSave(string? outputPa await BuildImageAsync(runtime, options); var buildSpec = Assert.Single(processRunner.ProcessSpecs); - Assert.StartsWith("build ", Assert.IsType(buildSpec.Arguments), StringComparison.Ordinal); + Assert.Equal("build", buildSpec.ArgumentList![0]); } [Fact] @@ -209,7 +209,7 @@ public async Task BuildImageAsync_WithOutputPath_BuildsThenSaves() Assert.Collection( processRunner.ProcessSpecs, - buildSpec => Assert.StartsWith("build ", Assert.IsType(buildSpec.Arguments), StringComparison.Ordinal), + buildSpec => Assert.Equal("build", buildSpec.ArgumentList![0]), saveSpec => Assert.Equal(PodmanContainerRuntime.BuildSaveArguments("myapp:latest", options), saveSpec.Arguments)); } @@ -255,7 +255,7 @@ public async Task BuildImageAsync_WhenSaveFails_ThrowsProcessFailedException() Assert.Contains("save failed", exception.Message); Assert.Collection( processRunner.ProcessSpecs, - buildSpec => Assert.StartsWith("build ", Assert.IsType(buildSpec.Arguments), StringComparison.Ordinal), + buildSpec => Assert.Equal("build", buildSpec.ArgumentList![0]), saveSpec => Assert.StartsWith("save ", Assert.IsType(saveSpec.Arguments), StringComparison.Ordinal)); } diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs index cb99b9cf77a..456d6c96d94 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs @@ -1601,46 +1601,46 @@ public async Task CanResolveBuildSecretsWithFileType() } [Fact] - public void BuildSecretsStringFormatsEnvSecretCorrectly() + public void BuildSecretsFormatsEnvSecretCorrectly() { var secrets = new Dictionary { ["MY_SECRET"] = new BuildImageSecretValue("secret-value", BuildImageSecretType.Environment) }; - var result = ContainerRuntimeBase.BuildSecretsString(secrets); + var result = ContainerRuntimeBase.BuildSecrets(secrets, useCsv: true); - Assert.Equal(" --secret \"id=MY_SECRET,type=env,env=MY_SECRET\"", result); + Assert.Equal(["--secret", "id=MY_SECRET,type=env,env=MY_SECRET"], result); } [Fact] - public void BuildSecretsStringFormatsFileSecretCorrectly() + public void BuildSecretsFormatsFileSecretCorrectly() { var secrets = new Dictionary { ["npmrc"] = new BuildImageSecretValue("/path/to/.npmrc", BuildImageSecretType.File) }; - var result = ContainerRuntimeBase.BuildSecretsString(secrets); + var result = ContainerRuntimeBase.BuildSecrets(secrets, useCsv: true); - Assert.Equal(" --secret \"id=npmrc,type=file,src=/path/to/.npmrc\"", result); + Assert.Equal(["--secret", "id=npmrc,type=file,src=/path/to/.npmrc"], result); } [Fact] - public void BuildSecretsStringFormatsNullEnvSecretWithRequireValue() + public void BuildSecretsFormatsNullEnvSecretWithRequireValue() { var secrets = new Dictionary { ["MY_SECRET"] = new BuildImageSecretValue(null, BuildImageSecretType.Environment) }; - var result = ContainerRuntimeBase.BuildSecretsString(secrets, requireValue: true); + var result = ContainerRuntimeBase.BuildSecrets(secrets, useCsv: false, requireValue: true); - Assert.Equal(" --secret \"id=MY_SECRET,type=env\"", result); + Assert.Equal(["--secret", "id=MY_SECRET,type=env"], result); } [Fact] - public void BuildSecretsStringFormatsMixedSecretTypes() + public void BuildSecretsFormatsMixedSecretTypes() { var secrets = new Dictionary { @@ -1648,9 +1648,9 @@ public void BuildSecretsStringFormatsMixedSecretTypes() ["npmrc"] = new BuildImageSecretValue("/app/.npmrc", BuildImageSecretType.File) }; - var result = ContainerRuntimeBase.BuildSecretsString(secrets); + var result = ContainerRuntimeBase.BuildSecrets(secrets, useCsv: true); - Assert.Equal(" --secret \"id=ENV_TOKEN,type=env,env=ENV_TOKEN\" --secret \"id=npmrc,type=file,src=/app/.npmrc\"", result); + Assert.Equal(["--secret", "id=ENV_TOKEN,type=env,env=ENV_TOKEN", "--secret", "id=npmrc,type=file,src=/app/.npmrc"], result); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Publishing/WslcContainerRuntimeTests.cs b/tests/Aspire.Hosting.Tests/Publishing/WslcContainerRuntimeTests.cs new file mode 100644 index 00000000000..52bf6964847 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Publishing/WslcContainerRuntimeTests.cs @@ -0,0 +1,352 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRECONTAINERRUNTIME001 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPROJECTS001 +#pragma warning disable ASPIRECOMPUTE003 + +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Dcp.Process; +using Aspire.Hosting.Publishing; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Tests.Publishing; + +[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "WSLc is Windows-only.")] +public class WslcContainerRuntimeTests(ITestOutputHelper output) +{ + [Theory] + [InlineData(0, true)] + [InlineData(1, false)] + public async Task CheckIfRunningAsync_UsesContainerList(int exitCode, bool expected) + { + var runner = new TestProcessRunner(); + runner.EnqueueResult(exitCode); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + + Assert.Equal(expected, await runtime.CheckIfRunningAsync(TestContext.Current.CancellationToken)); + Assert.Equal("container ls -n 1", Assert.Single(runner.ProcessSpecs).Arguments); + } + + [Fact] + public async Task BuildImageAsync_PassesBuildArgumentsSecretsAndStage() + { + var runner = new TestProcessRunner(); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + var unsetVariable = $"ASPIRE_WSLC_BUILD_ARG_{Guid.NewGuid():N}"; + + await runtime.BuildImageAsync( + "context", + "Dockerfile", + new ContainerImageBuildOptions { ImageName = "myapp", Tag = "latest", ImageFormat = ContainerImageFormat.Docker }, + new() { ["VERSION"] = "1.0", [unsetVariable] = null }, + new() + { + ["token"] = new("secret-value", BuildImageSecretType.Environment), + ["file"] = new("secret.txt", BuildImageSecretType.File) + }, + "final", + TestContext.Current.CancellationToken); + + var spec = Assert.Single(runner.ProcessSpecs); + Assert.Equal("wslc", spec.ExecutablePath); + Assert.Equal( + ["build", "--file", "Dockerfile", "--tag", "myapp:latest", "--build-arg", "VERSION=1.0", + "--secret", "id=token,type=env,env=TOKEN", "--secret", "id=file,type=file,src=secret.txt", "--target", "final", + Path.GetFullPath("context")], + spec.ArgumentList); + Assert.Equal("secret-value", spec.EnvironmentVariables["TOKEN"]); + Assert.Equal(1, Assert.Single(runner.Disposables).DisposeCallCount); + } + + [Theory] + [InlineData(null, null, null)] + [InlineData("host-value", null, "host-value")] + [InlineData("", null, "")] + [InlineData("""say "hello world" C:\path with spaces\trailing\ a=b""", null, """say "hello world" C:\path with spaces\trailing\ a=b""")] + [InlineData("host-value", "explicit-value", "explicit-value")] + [InlineData("host-value", "", "")] + [InlineData(null, "", "")] + public async Task BuildImageAsync_ResolvesInheritedBuildArguments(string? hostValue, string? explicitValue, string? expectedValue) + { + var variable = $"ASPIRE_WSLC_BUILD_ARG_{Guid.NewGuid():N}"; + var runner = new TestProcessRunner(); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + try + { + Environment.SetEnvironmentVariable(variable, hostValue); + Assert.Equal(hostValue, Environment.GetEnvironmentVariable(variable)); + var buildArguments = new Dictionary { [variable] = explicitValue }; + + await runtime.BuildImageAsync( + "context", "Dockerfile", new ContainerImageBuildOptions { ImageName = "myapp" }, + buildArguments, [], null, TestContext.Current.CancellationToken); + + var expected = new List { "build", "--file", "Dockerfile", "--tag", "myapp" }; + if (expectedValue is not null) + { + expected.AddRange(["--build-arg", $"{variable}={expectedValue}"]); + } + expected.Add(Path.GetFullPath("context")); + var spec = Assert.Single(runner.ProcessSpecs); + Assert.Null(spec.Arguments); + Assert.Equal(expected, spec.ArgumentList); + Assert.Equal(explicitValue, buildArguments[variable]); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Theory] + [InlineData(ContainerTargetPlatform.LinuxAmd64)] + [InlineData(ContainerTargetPlatform.LinuxArm64)] + [InlineData(ContainerTargetPlatform.AllLinux)] + [InlineData(ContainerTargetPlatform.WindowsAmd64)] + public async Task BuildImageAsync_RejectsSelectedPlatformBeforeStartingProcess(ContainerTargetPlatform platform) + { + var runner = new TestProcessRunner(); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + + var exception = await Assert.ThrowsAsync(() => runtime.BuildImageAsync( + "context", "Dockerfile", + new ContainerImageBuildOptions { ImageName = "myapp", TargetPlatform = platform }, + [], [], null, TestContext.Current.CancellationToken)); + + Assert.Contains("WSLc does not support selecting a platform", exception.Message); + Assert.Empty(runner.ProcessSpecs); + } + + [Theory] + [InlineData(null, "out", null)] + [InlineData(ContainerImageFormat.Docker, "out", null)] + [InlineData(ContainerImageFormat.Oci, "out", null)] + [InlineData(ContainerImageFormat.Oci, null, null)] + [InlineData(null, null, ContainerImageDestination.Archive)] + public async Task BuildImageAsync_RejectsArchiveAndOciOutputBeforeStartingProcess( + ContainerImageFormat? format, string? outputPath, ContainerImageDestination? destination) + { + var runner = new TestProcessRunner(); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + + var exception = await Assert.ThrowsAsync(() => runtime.BuildImageAsync( + "context", "Dockerfile", + new ContainerImageBuildOptions { ImageName = "myapp", ImageFormat = format, OutputPath = outputPath, Destination = destination }, + [], [], null, TestContext.Current.CancellationToken)); + + Assert.Contains("WSLc does not support Dockerfile image archive output or OCI image format selection.", exception.Message); + Assert.Empty(runner.ProcessSpecs); + } + + [Fact] + public async Task BuildImageAsync_PreservesProcessFailureOutput() + { + var runner = new TestProcessRunner(); + runner.EnqueueResult(exitCode: 42, error: ["build failed"]); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + + var exception = await Assert.ThrowsAsync(() => runtime.BuildImageAsync( + "context", "Dockerfile", + new ContainerImageBuildOptions { ImageName = "myapp" }, + [], [], null, TestContext.Current.CancellationToken)); + + Assert.Equal(42, exception.ExitCode); + Assert.Equal(["build failed"], exception.ProcessOutput); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RuntimeOperations_PropagateCancellationAndDisposeProcess(bool build) + { + var runner = new TestProcessRunner(); + runner.EnqueuePending(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + using var cancellation = new CancellationTokenSource(); + + var operation = build + ? runtime.BuildImageAsync("context", "Dockerfile", new ContainerImageBuildOptions { ImageName = "myapp" }, [], [], null, cancellation.Token) + : runtime.CheckIfRunningAsync(cancellation.Token); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => operation); + Assert.Equal(1, Assert.Single(runner.Disposables).DisposeCallCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DockerfilePublishing_RequiresOptingOutOfDefaultPlatform(bool hostPlatform) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var workspace = TemporaryWorkspace.Create(output); + var runner = new TestProcessRunner(); + builder.Services.AddSingleton(runner); + builder.Services.Configure(options => options.ContainerRuntime = "wslc"); + var resource = builder.AddDockerfile("program", workspace.WorkspaceRoot.FullName); + if (hostPlatform) + { + resource.WithContainerBuildOptions(options => options.TargetPlatform = null); + } + using var app = builder.Build(); + var manager = app.Services.GetRequiredService(); + + if (hostPlatform) + { + await manager.BuildImageAsync(resource.Resource, TestContext.Current.CancellationToken); + Assert.Equal(2, runner.ProcessSpecs.Count); + Assert.Equal("build", runner.ProcessSpecs[1].ArgumentList![0]); + } + else + { + var exception = await Assert.ThrowsAsync( + () => manager.BuildImageAsync(resource.Resource, TestContext.Current.CancellationToken)); + Assert.Contains("WSLc does not support selecting a platform", exception.Message); + Assert.Single(runner.ProcessSpecs); + } + Assert.Equal("container ls -n 1", runner.ProcessSpecs[0].Arguments); + } + + [Fact] + public async Task DockerfilePublishing_RejectsArchiveDestinationWithoutOutputPath() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var workspace = TemporaryWorkspace.Create(output); + var runner = new TestProcessRunner(); + builder.Services.AddSingleton(runner); + builder.Services.Configure(options => options.ContainerRuntime = "wslc"); + var resource = builder.AddDockerfile("program", workspace.WorkspaceRoot.FullName) + .WithContainerBuildOptions(options => + { + options.TargetPlatform = null; + options.Destination = ContainerImageDestination.Archive; + }); + using var app = builder.Build(); + var manager = app.Services.GetRequiredService(); + + var exception = await Assert.ThrowsAsync( + () => manager.BuildImageAsync(resource.Resource, TestContext.Current.CancellationToken)); + + Assert.Contains("WSLc does not support Dockerfile image archive output", exception.Message); + Assert.Equal("container ls -n 1", Assert.Single(runner.ProcessSpecs).Arguments); + } + + [Fact] + public async Task ImageOperations_UseSharedWslcCommands() + { + var runner = new TestProcessRunner(); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var registry = builder.AddContainerRegistry("registry", "registry.example.com"); + var resource = builder.AddContainer("myapp", "myapp", "latest").WithContainerRegistry(registry); + + await runtime.TagImageAsync("myapp:latest", "myapp:next", TestContext.Current.CancellationToken); + await runtime.PushImageAsync(resource.Resource, TestContext.Current.CancellationToken); + await runtime.LoginToRegistryAsync("registry.example.com", "user", "password", TestContext.Current.CancellationToken); + await runtime.RemoveImageAsync("myapp:next", TestContext.Current.CancellationToken); + + Assert.Collection(runner.ProcessSpecs, + spec => Assert.Equal("tag \"myapp:latest\" \"myapp:next\"", spec.Arguments), + spec => Assert.Equal("tag \"myapp:latest\" \"registry.example.com/myapp:latest\"", spec.Arguments), + spec => Assert.Equal("push \"registry.example.com/myapp:latest\"", spec.Arguments), + spec => + { + Assert.Equal("login --username \"user\" --password-stdin \"registry.example.com\"", spec.Arguments); + Assert.Equal("password", spec.StandardInputContent); + }, + spec => Assert.Equal("rmi \"myapp:next\"", spec.Arguments)); + Assert.All(runner.ProcessSpecs, spec => Assert.Equal("wslc", spec.ExecutablePath)); + } + + [Fact] + public async Task InspectImageConfigAsync_ParsesNativeJson() + { + const string json = """[{"Config":{"Entrypoint":["dotnet","app.dll"],"Cmd":["--urls"],"WorkingDir":"/app"}}]"""; + var runner = new TestProcessRunner(); + runner.EnqueueResult(output: [json]); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + const string imageName = "registry/image\\\" --help"; + + var result = await runtime.InspectImageConfigAsync(imageName, TestContext.Current.CancellationToken); + + Assert.Equal(["image", "inspect", imageName, "--format", "json"], Assert.Single(runner.ProcessSpecs).ArgumentList); + Assert.Equal(ContainerImageInspectionStatus.Succeeded, result.Status); + Assert.Equal(json, result.RawJson); + Assert.True(result.TryGetConfig(out var config)); + Assert.Equal(["dotnet", "app.dll"], config.Entrypoint); + Assert.Equal(["--urls"], config.Command); + Assert.Equal("/app", config.WorkingDirectory); + } + + [Fact] + public async Task InspectImageConfigAsync_AllowsNullEntrypointAndCommand() + { + var runner = new TestProcessRunner(); + runner.EnqueueResult(output: ["""[{"Config":{"Entrypoint":null,"Cmd":null,"WorkingDir":""}}]"""]); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + + var result = await runtime.InspectImageConfigAsync("image", TestContext.Current.CancellationToken); + + Assert.True(result.TryGetConfig(out var config)); + Assert.Empty(config.Entrypoint); + Assert.Empty(config.Command); + Assert.Equal("", config.WorkingDirectory); + } + + [Theory] + [InlineData("not json")] + [InlineData("[]")] + [InlineData("[{}]")] + [InlineData("""[{"Config":{}},{"Config":{}}]""")] + [InlineData("""[{"Config":{"Cmd":42}}]""")] + public async Task InspectImageConfigAsync_ReportsInvalidMetadata(string json) + { + var runner = new TestProcessRunner(); + runner.EnqueueResult(output: [json]); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + + var result = await runtime.InspectImageConfigAsync("image", TestContext.Current.CancellationToken); + + Assert.Equal(ContainerImageInspectionStatus.Failed, result.Status); + Assert.Equal("WSLc returned invalid image configuration for 'image'.", result.ErrorMessage); + Assert.False(result.TryGetConfig(out _)); + } + + [Fact] + public async Task InspectImageConfigAsync_ReportsCommandFailure() + { + var runner = new TestProcessRunner(); + runner.EnqueueResult(exitCode: 1, error: ["image not found"]); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + + var result = await runtime.InspectImageConfigAsync("image", TestContext.Current.CancellationToken); + + Assert.Equal(ContainerImageInspectionStatus.Failed, result.Status); + Assert.Contains("image not found", result.ErrorMessage); + } + + [Fact] + public async Task UnsupportedOperations_DoNotStartProcesses() + { + var runner = new TestProcessRunner(); + var runtime = new WslcContainerRuntime(NullLogger.Instance, runner); + var context = new ComposeOperationContext { ProjectName = "app", WorkingDirectory = "out" }; + + var up = await Assert.ThrowsAsync(() => runtime.ComposeUpAsync(context, TestContext.Current.CancellationToken)); + var down = await Assert.ThrowsAsync(() => runtime.ComposeDownAsync(context, TestContext.Current.CancellationToken)); + var list = await Assert.ThrowsAsync(() => runtime.ComposeListServicesAsync(context, TestContext.Current.CancellationToken)); + Assert.Equal("WSLc does not support Docker Compose deployment or management. Set ASPIRE_CONTAINER_RUNTIME to docker or podman for Compose operations.", up.Message); + Assert.Equal(up.Message, down.Message); + Assert.Equal(up.Message, list.Message); + + var inspection = await runtime.InspectImageManifestAsync("image", TestContext.Current.CancellationToken); + Assert.Equal(ContainerImageInspectionStatus.Unsupported, inspection.Status); + Assert.Empty(runner.ProcessSpecs); + } + +}