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); + } + +}