From a190eb4de573d12fa4104e03dbe0a42efea97054 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 01:11:54 -0700 Subject: [PATCH 01/12] Support Podman as container runtime for Docker Compose deploy Add compose lifecycle methods (ComposeUpAsync, ComposeDownAsync, ComposeListServicesAsync) to IContainerRuntime so each runtime handles compose operations natively. Docker uses 'docker compose ps --format json' for service discovery. Podman overrides ComposeListServicesAsync to use native 'podman ps --filter label=com.docker.compose.project=X --format json', which works with both Docker Compose v2 and podman-compose providers. The compose publisher now resolves IContainerRuntime from DI instead of hardcoding ProcessSpec("docker"). All process execution for compose operations is encapsulated in the runtime implementations. Validated on Ubuntu 24.04 with Podman 4.9.3: compose up/down/ps work correctly with both Docker Compose v2 provider and native podman-compose. Fixes #13315 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DockerComposeEnvironmentResource.cs | 108 ++------ .../DockerComposeServiceResource.cs | 173 +++---------- .../Publishing/ComposeOperationContext.cs | 33 +++ .../Publishing/ComposeServiceInfo.cs | 40 +++ .../Publishing/ContainerRuntimeBase.cs | 234 ++++++++++++++++++ .../Publishing/IContainerRuntime.cs | 24 ++ .../Publishing/PodmanContainerRuntime.cs | 143 +++++++++++ .../Publishing/FakeContainerRuntime.cs | 23 ++ 8 files changed, 558 insertions(+), 220 deletions(-) create mode 100644 src/Aspire.Hosting/Publishing/ComposeOperationContext.cs create mode 100644 src/Aspire.Hosting/Publishing/ComposeServiceInfo.cs diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 6fc8bccd7cf..8d1d7f41980 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -3,17 +3,16 @@ #pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREPIPELINES003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIRECONTAINERRUNTIME001 using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Docker.Resources; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Docker; @@ -231,47 +230,15 @@ private async Task DockerComposeUpAsync(PipelineStepContext context) { try { - var arguments = GetDockerComposeArguments(context, this); - arguments += " up -d --remove-orphans"; + var runtime = context.Services.GetRequiredService(); + var composeContext = CreateComposeOperationContext(context); - context.Logger.LogDebug("Running docker compose up with arguments: {Arguments}", arguments); + await runtime.ComposeUpAsync(composeContext, context.CancellationToken).ConfigureAwait(false); - var spec = new ProcessSpec("docker") - { - Arguments = arguments, - WorkingDirectory = outputPath, - ThrowOnNonZeroReturnCode = false, - InheritEnv = true, - OnOutputData = output => - { - context.Logger.LogDebug("docker compose up (stdout): {Output}", output); - }, - OnErrorData = error => - { - context.Logger.LogDebug("docker compose up (stderr): {Error}", error); - }, - }; - - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); - - await using (processDisposable) - { - var processResult = await pendingProcessResult - .WaitAsync(context.CancellationToken) - .ConfigureAwait(false); - - if (processResult.ExitCode != 0) - { - await deployTask.FailAsync($"docker compose up failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false); - } - else - { - await deployTask.CompleteAsync( - new MarkdownString($"Service **{Name}** is now running with Docker Compose locally"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } - } + await deployTask.CompleteAsync( + new MarkdownString($"Service **{Name}** is now running with Docker Compose locally"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -298,39 +265,15 @@ private async Task DockerComposeDownAsync(PipelineStepContext context) { try { - var arguments = GetDockerComposeArguments(context, this); - arguments += " down"; - - context.Logger.LogDebug("Running docker compose down with arguments: {Arguments}", arguments); - - var spec = new ProcessSpec("docker") - { - Arguments = arguments, - WorkingDirectory = outputPath, - ThrowOnNonZeroReturnCode = false, - InheritEnv = true - }; + var runtime = context.Services.GetRequiredService(); + var composeContext = CreateComposeOperationContext(context); - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + await runtime.ComposeDownAsync(composeContext, context.CancellationToken).ConfigureAwait(false); - await using (processDisposable) - { - var processResult = await pendingProcessResult - .WaitAsync(context.CancellationToken) - .ConfigureAwait(false); - - if (processResult.ExitCode != 0) - { - await deployTask.FailAsync($"docker compose down failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false); - } - else - { - await deployTask.CompleteAsync( - new MarkdownString($"Docker Compose shutdown complete for **{Name}**"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } - } + await deployTask.CompleteAsync( + new MarkdownString($"Docker Compose shutdown complete for **{Name}**"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -396,21 +339,16 @@ internal static string GetEnvFilePath(PipelineStepContext context, DockerCompose return envFilePath; } - internal static string GetDockerComposeArguments(PipelineStepContext context, DockerComposeEnvironmentResource environment) + internal ComposeOperationContext CreateComposeOperationContext(PipelineStepContext context) { - var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment); - var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml"); - var envFilePath = GetEnvFilePath(context, environment); - var projectName = GetDockerComposeProjectName(context, environment); - - var arguments = $"compose -f \"{dockerComposeFilePath}\" --project-name \"{projectName}\""; - - if (File.Exists(envFilePath)) + var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this); + return new ComposeOperationContext { - arguments += $" --env-file \"{envFilePath}\""; - } - - return arguments; + ComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml"), + ProjectName = GetDockerComposeProjectName(context, this), + EnvFilePath = GetEnvFilePath(context, this), + WorkingDirectory = outputPath + }; } internal static string GetDockerComposeProjectName(PipelineStepContext context, DockerComposeEnvironmentResource environment) diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs index c8dd226dbee..4155de2a5b9 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs @@ -2,17 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIRECONTAINERRUNTIME001 using System.Globalization; using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Docker.Resources.ComposeNodes; using Aspire.Hosting.Docker.Resources.ServiceNodes; using Aspire.Hosting.Pipelines; -using Aspire.Hosting.Utils; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Docker; @@ -325,8 +324,6 @@ private void AddVolumes(Service composeService) private async Task PrintEndpointsAsync(PipelineStepContext context, DockerComposeEnvironmentResource environment) { - var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment); - // No external endpoints configured - this is valid for internal-only services var externalEndpointMappings = EndpointMappings.Values.Where(m => m.IsExternal).ToList(); if (externalEndpointMappings.Count == 0) @@ -337,10 +334,13 @@ private async Task PrintEndpointsAsync(PipelineStepContext context, DockerCompos return; } - // Query the running container for its published ports - var outputLines = await RunDockerComposePsAsync(context, environment, outputPath).ConfigureAwait(false); - var endpoints = outputLines is not null - ? ParseServiceEndpoints(outputLines, externalEndpointMappings, context.Logger) + // Query the running containers for published ports + var runtime = context.Services.GetRequiredService(); + var composeContext = environment.CreateComposeOperationContext(context); + var services = await runtime.ComposeListServicesAsync(composeContext, context.CancellationToken).ConfigureAwait(false); + + var endpoints = services is not null + ? ParseServiceEndpoints(services, externalEndpointMappings, context.Logger) : []; if (endpoints.Count > 0) @@ -351,7 +351,7 @@ private async Task PrintEndpointsAsync(PipelineStepContext context, DockerCompos } else { - // No published ports found in docker compose ps output. + // No published ports found in compose output. context.ReportingStep.Log(LogLevel.Information, new MarkdownString($"Successfully deployed **{TargetResource.Name}** to Docker Compose environment **{environment.Name}**.")); context.Summary.Add(TargetResource.Name, "No public endpoints"); @@ -359,152 +359,55 @@ private async Task PrintEndpointsAsync(PipelineStepContext context, DockerCompos } /// - /// Runs 'docker compose ps --format json' to get container status and port mappings. + /// Extracts endpoint URLs from compose service info, matching against configured external endpoint mappings. /// - /// List of JSON output lines, or null if the command failed. - private static async Task?> RunDockerComposePsAsync( - PipelineStepContext context, - DockerComposeEnvironmentResource environment, - string outputPath) + private HashSet ParseServiceEndpoints( + IReadOnlyList services, + List externalEndpointMappings, + ILogger _) { - var arguments = DockerComposeEnvironmentResource.GetDockerComposeArguments(context, environment); - arguments += " ps --format json"; - - var outputLines = new List(); + var endpoints = new HashSet(StringComparers.EndpointAnnotationName); + var serviceName = TargetResource.Name.ToLowerInvariant(); - var spec = new ProcessSpec("docker") + foreach (var serviceInfo in services) { - Arguments = arguments, - WorkingDirectory = outputPath, - ThrowOnNonZeroReturnCode = false, - InheritEnv = true, - OnOutputData = output => + // Skip if not our service + if (serviceInfo.Service is null || + !string.Equals(serviceInfo.Service, serviceName, StringComparisons.ResourceName)) { - if (!string.IsNullOrWhiteSpace(output)) - { - outputLines.Add(output); - } - }, - OnErrorData = error => - { - if (!string.IsNullOrWhiteSpace(error)) - { - context.Logger.LogDebug("docker compose ps (stderr): {Error}", error); - } + continue; } - }; - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); - - await using (processDisposable) - { - var processResult = await pendingProcessResult - .WaitAsync(context.CancellationToken) - .ConfigureAwait(false); - - if (processResult.ExitCode != 0) + // Skip if no published ports + if (serviceInfo.Publishers is not { Count: > 0 }) { - context.Logger.LogDebug("docker compose ps failed with exit code {ExitCode}", processResult.ExitCode); - return null; + continue; } - } - - return outputLines; - } - /// - /// Parses the JSON output from 'docker compose ps' to extract endpoint URLs for this service. - /// - /// - /// Example JSON line from 'docker compose ps --format json': - /// - /// {"Service":"myservice","State":"running","Publishers":[{"URL":"","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"}]} - /// - /// Note: PublishedPort is 0 when the port is exposed but not mapped to the host. - /// - private HashSet ParseServiceEndpoints( - List outputLines, - List externalEndpointMappings, - ILogger logger) - { - var endpoints = new HashSet(StringComparers.EndpointAnnotationName); - var serviceName = TargetResource.Name.ToLowerInvariant(); - - foreach (var line in outputLines) - { - try + foreach (var publisher in serviceInfo.Publishers) { - var serviceInfo = JsonSerializer.Deserialize(line, DockerComposeJsonContext.Default.DockerComposeServiceInfo); - - // Skip if not our service - if (serviceInfo is null || - !string.Equals(serviceInfo.Service, serviceName, StringComparisons.ResourceName)) + // Skip ports that aren't actually published (port 0 or null means not exposed) + if (publisher.PublishedPort is not > 0) { continue; } - // Skip if no published ports - if (serviceInfo.Publishers is not { Count: > 0 }) - { - continue; - } + // Try to find a matching external endpoint to get the scheme + var targetPortStr = publisher.TargetPort?.ToString(CultureInfo.InvariantCulture); + var endpointMapping = externalEndpointMappings + .FirstOrDefault(m => m.InternalPort == targetPortStr || m.ExposedPort == publisher.TargetPort); + + var scheme = endpointMapping.Scheme ?? "http"; - foreach (var publisher in serviceInfo.Publishers) + if (endpointMapping.IsExternal || scheme is "http" or "https") { - // Skip ports that aren't actually published (port 0 or null means not exposed) - if (publisher.PublishedPort is not > 0) - { - continue; - } - - // Try to find a matching external endpoint to get the scheme - // Match by internal port (numeric) or by exposed port - // InternalPort may be a placeholder like ${API_PORT} for projects, so also check ExposedPort - var targetPortStr = publisher.TargetPort?.ToString(CultureInfo.InvariantCulture); - var endpointMapping = externalEndpointMappings - .FirstOrDefault(m => m.InternalPort == targetPortStr || m.ExposedPort == publisher.TargetPort); - - // If we found a matching endpoint, use its scheme; otherwise default to http for external ports - var scheme = endpointMapping.Scheme ?? "http"; - - // Only add if we found a matching external endpoint OR if scheme is http/https - // (published ports are external by definition in docker compose) - if (endpointMapping.IsExternal || scheme is "http" or "https") - { - var endpoint = $"{scheme}://localhost:{publisher.PublishedPort}"; - endpoints.Add(endpoint); - } + var endpoint = $"{scheme}://localhost:{publisher.PublishedPort}"; + endpoints.Add(endpoint); } } - catch (JsonException ex) - { - logger.LogDebug(ex, "Failed to parse docker compose ps output line: {Line}", line); - } } return endpoints; } - /// - /// Represents the JSON output from docker compose ps --format json. - /// - internal sealed class DockerComposeServiceInfo - { - public string? Service { get; set; } - public List? Publishers { get; set; } - } - - /// - /// Represents a port publisher in docker compose ps output. - /// - internal sealed class DockerComposePublisher - { - public int? PublishedPort { get; set; } - public int? TargetPort { get; set; } - } -} - -[JsonSerializable(typeof(DockerComposeServiceResource.DockerComposeServiceInfo))] -internal sealed partial class DockerComposeJsonContext : JsonSerializerContext -{ } diff --git a/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs b/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs new file mode 100644 index 00000000000..b888e16d3d4 --- /dev/null +++ b/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Publishing; + +/// +/// Provides the parameters needed to execute a Docker Compose operation against a container runtime. +/// +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ComposeOperationContext +{ + /// + /// Gets the path to the Docker Compose YAML file. + /// + public required string ComposeFilePath { get; init; } + + /// + /// Gets the compose project name used for resource isolation. + /// + public required string ProjectName { get; init; } + + /// + /// Gets the optional path to an environment file to pass to the compose operation. + /// + public string? EnvFilePath { get; init; } + + /// + /// Gets the working directory for the compose process. + /// + public required string WorkingDirectory { get; init; } +} diff --git a/src/Aspire.Hosting/Publishing/ComposeServiceInfo.cs b/src/Aspire.Hosting/Publishing/ComposeServiceInfo.cs new file mode 100644 index 00000000000..d4aa67b2e21 --- /dev/null +++ b/src/Aspire.Hosting/Publishing/ComposeServiceInfo.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Publishing; + +/// +/// Represents a running service discovered from a compose environment. +/// +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ComposeServiceInfo +{ + /// + /// Gets the name of the compose service. + /// + public string? Service { get; init; } + + /// + /// Gets the published port mappings for the service. + /// + public IReadOnlyList? Publishers { get; init; } +} + +/// +/// Represents a port mapping for a compose service. +/// +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ComposeServicePort +{ + /// + /// Gets the port published on the host. + /// + public int? PublishedPort { get; init; } + + /// + /// Gets the target port inside the container. + /// + public int? TargetPort { get; init; } +} diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 6b0bf535b60..86db3053928 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -4,6 +4,8 @@ #pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIRECONTAINERRUNTIME001 +using System.Text.Json; +using System.Text.Json.Serialization; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Process; using Microsoft.Extensions.Logging; @@ -294,4 +296,236 @@ private ProcessSpec CreateProcessSpec(string arguments) InheritEnv = true }; } + + public virtual async Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + var arguments = BuildComposeArguments(context); + arguments += " up -d --remove-orphans"; + + _logger.LogDebug("Running {Runtime} compose up with arguments: {Arguments}", RuntimeExecutable, arguments); + + var spec = new ProcessSpec(RuntimeExecutable) + { + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + _logger.LogDebug("{Runtime} compose up (stdout): {Output}", RuntimeExecutable, output); + }, + OnErrorData = error => + { + _logger.LogDebug("{Runtime} compose up (stderr): {Error}", RuntimeExecutable, error); + }, + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + throw new DistributedApplicationException($"{Name} compose up failed with exit code {processResult.ExitCode}."); + } + } + } + + public virtual async Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + var arguments = BuildComposeArguments(context); + arguments += " down"; + + _logger.LogDebug("Running {Runtime} compose down with arguments: {Arguments}", RuntimeExecutable, arguments); + + var spec = new ProcessSpec(RuntimeExecutable) + { + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + _logger.LogDebug("{Runtime} compose down (stdout): {Output}", RuntimeExecutable, output); + }, + OnErrorData = error => + { + _logger.LogDebug("{Runtime} compose down (stderr): {Error}", RuntimeExecutable, error); + }, + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + throw new DistributedApplicationException($"{Name} compose down failed with exit code {processResult.ExitCode}."); + } + } + } + + public virtual async Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + var arguments = BuildComposeArguments(context); + arguments += " ps --format json"; + + var outputLines = new List(); + + var spec = new ProcessSpec(RuntimeExecutable) + { + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + if (!string.IsNullOrWhiteSpace(output)) + { + outputLines.Add(output); + } + }, + OnErrorData = error => + { + if (!string.IsNullOrWhiteSpace(error)) + { + _logger.LogDebug("{Runtime} compose ps (stderr): {Error}", RuntimeExecutable, error); + } + } + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + _logger.LogDebug("{Runtime} compose ps failed with exit code {ExitCode}", RuntimeExecutable, processResult.ExitCode); + return null; + } + } + + return ParseComposeServiceEntries(outputLines); + } + + /// + /// Parses Docker Compose ps JSON output, handling both NDJSON (one object per line) and JSON array formats. + /// + private static List ParseComposeServiceEntries(List outputLines) + { + var results = new List(); + + foreach (var line in outputLines) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0) + { + continue; + } + + // Try parsing as JSON array first (older Docker Compose versions) + if (trimmed.StartsWith('[')) + { + try + { + var entries = JsonSerializer.Deserialize(trimmed, ComposeJsonContext.Default.ListDockerComposePsEntry); + if (entries is not null) + { + foreach (var entry in entries) + { + results.Add(MapDockerComposeEntry(entry)); + } + } + } + catch (JsonException) + { + // Skip unparseable lines + } + continue; + } + + // Parse as single JSON object (NDJSON format) + if (trimmed.StartsWith('{')) + { + try + { + var entry = JsonSerializer.Deserialize(trimmed, ComposeJsonContext.Default.DockerComposePsEntry); + if (entry is not null) + { + results.Add(MapDockerComposeEntry(entry)); + } + } + catch (JsonException) + { + // Skip unparseable lines + } + } + } + + return results; + } + + private static ComposeServiceInfo MapDockerComposeEntry(DockerComposePsEntry entry) + { + return new ComposeServiceInfo + { + Service = entry.Service, + Publishers = entry.Publishers?.Select(p => new ComposeServicePort + { + PublishedPort = p.PublishedPort, + TargetPort = p.TargetPort + }).ToList() + }; + } + + /// + /// Builds the compose CLI arguments from a . + /// + private static string BuildComposeArguments(ComposeOperationContext context) + { + var arguments = $"compose -f \"{context.ComposeFilePath}\" --project-name \"{context.ProjectName}\""; + + if (context.EnvFilePath is not null && File.Exists(context.EnvFilePath)) + { + arguments += $" --env-file \"{context.EnvFilePath}\""; + } + + return arguments; + } +} + +/// +/// Internal DTO for deserializing Docker Compose ps JSON output. +/// +internal sealed class DockerComposePsEntry +{ + public string? Service { get; set; } + public List? Publishers { get; set; } +} + +/// +/// Internal DTO for deserializing Docker Compose ps publisher entries. +/// +internal sealed class DockerComposePsPublisher +{ + public int? PublishedPort { get; set; } + public int? TargetPort { get; set; } +} + +[JsonSerializable(typeof(DockerComposePsEntry))] +[JsonSerializable(typeof(List))] +internal sealed partial class ComposeJsonContext : JsonSerializerContext +{ } diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs index 88a4a58a772..73c6d574c88 100644 --- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs @@ -66,4 +66,28 @@ public interface IContainerRuntime /// The password for authentication. /// A token to cancel the operation. Task LoginToRegistryAsync(string registryServer, string username, string password, CancellationToken cancellationToken); + + /// + /// Starts compose services in detached mode. + /// + /// The compose operation parameters. + /// A token to cancel the operation. + /// Thrown when the compose up command fails. + Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken); + + /// + /// Stops and removes compose services. + /// + /// The compose operation parameters. + /// A token to cancel the operation. + /// Thrown when the compose down command fails. + Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken); + + /// + /// Lists the running services in a compose environment with their port mappings. + /// + /// The compose operation parameters. + /// A token to cancel the operation. + /// A list of running services, or null if the query could not be completed. + Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken); } diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs index d93eefd21ae..721f4a7eede 100644 --- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs @@ -4,6 +4,9 @@ #pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIRECONTAINERRUNTIME001 +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Hosting.Dcp.Process; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Publishing; @@ -16,6 +19,120 @@ public PodmanContainerRuntime(ILogger logger) : base(log protected override string RuntimeExecutable => "podman"; public override string Name => "Podman"; + + /// + /// Lists compose services using native podman ps with label filters, + /// which works with both Docker Compose v2 and podman-compose providers. + /// + public override async Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + var arguments = $"ps --filter label=com.docker.compose.project={context.ProjectName} --format json"; + + var outputLines = new List(); + + var spec = new ProcessSpec(RuntimeExecutable) + { + Arguments = arguments, + WorkingDirectory = context.WorkingDirectory, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + if (!string.IsNullOrWhiteSpace(output)) + { + outputLines.Add(output); + } + }, + OnErrorData = error => + { + if (!string.IsNullOrWhiteSpace(error)) + { + Logger.LogDebug("podman ps (stderr): {Error}", error); + } + } + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + Logger.LogDebug("podman ps failed with exit code {ExitCode}", processResult.ExitCode); + return null; + } + } + + return ParsePodmanPsOutput(outputLines); + } + + /// + /// Parses native podman ps --format json output into normalized entries. + /// Podman returns a JSON array. Containers are aggregated by compose service name. + /// + private static List ParsePodmanPsOutput(List outputLines) + { + var allText = string.Join("", outputLines); + if (string.IsNullOrWhiteSpace(allText)) + { + return []; + } + + List? entries; + try + { + entries = JsonSerializer.Deserialize(allText, PodmanPsJsonContext.Default.ListPodmanPsEntry); + } + catch (JsonException) + { + return []; + } + + if (entries is null) + { + return []; + } + + // Group by compose service name since Podman may return multiple containers per service + var grouped = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in entries) + { + var serviceName = entry.Labels?.GetValueOrDefault("com.docker.compose.service"); + if (serviceName is null) + { + continue; + } + + if (!grouped.TryGetValue(serviceName, out var ports)) + { + ports = []; + grouped[serviceName] = ports; + } + + if (entry.Ports is not null) + { + foreach (var port in entry.Ports) + { + ports.Add(new ComposeServicePort + { + PublishedPort = port.HostPort, + TargetPort = port.ContainerPort + }); + } + } + } + + return grouped.Select(g => new ComposeServiceInfo + { + Service = g.Key, + Publishers = g.Value + }).ToList(); + } private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) { var imageName = !string.IsNullOrEmpty(options?.Tag) @@ -116,3 +233,29 @@ public override async Task CheckIfRunningAsync(CancellationToken cancellat } } } + +/// +/// Internal DTO for deserializing podman ps --format json output. +/// +internal sealed class PodmanPsEntry +{ + public Dictionary? Labels { get; set; } + public List? Ports { get; set; } +} + +/// +/// Internal DTO for deserializing Podman port mappings. +/// +internal sealed class PodmanPsPort +{ + [JsonPropertyName("container_port")] + public int? ContainerPort { get; set; } + + [JsonPropertyName("host_port")] + public int? HostPort { get; set; } +} + +[JsonSerializable(typeof(List))] +internal sealed partial class PodmanPsJsonContext : JsonSerializerContext +{ +} diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs index 0e23c545ccf..7e112f9689e 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs @@ -103,4 +103,27 @@ public Task LoginToRegistryAsync(string registryServer, string username, string } return Task.CompletedTask; } + + public Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + if (shouldFail) + { + throw new DistributedApplicationException("Fake container runtime is configured to fail"); + } + return Task.CompletedTask; + } + + public Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + if (shouldFail) + { + throw new DistributedApplicationException("Fake container runtime is configured to fail"); + } + return Task.CompletedTask; + } + + public Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) + { + return Task.FromResult?>(null); + } } From a04b05800101f423b11f9902ad815f4608d3344d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 07:56:25 -0700 Subject: [PATCH 02/12] Add --output, --rid, and --archive flags to localhive scripts New flags for producing portable hive layouts: - --output / -o: write the .aspire layout to a custom directory instead of $HOME/.aspire. Forces copy mode (no symlinks). - --rid / -r: target a different RID for bundle and CLI builds (e.g. linux-x64 from macOS). Cross-RID CLI uses dotnet publish with --self-contained and PublishSingleFile. - --archive: create a .tar.gz (or .zip for win-* RIDs) archive of the output directory. Requires --output. Usage example: ./localhive.sh -o /tmp/aspire-linux -r linux-x64 --archive scp /tmp/aspire-linux.tar.gz user@host:~ # On target: tar -xzf aspire-linux.tar.gz -C ~/.aspire Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- localhive.ps1 | 138 ++++++++++++++++++++++++++++++++--------- localhive.sh | 166 ++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 235 insertions(+), 69 deletions(-) diff --git a/localhive.ps1 b/localhive.ps1 index b5f9693280a..2885162acf7 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -61,6 +61,14 @@ param( [Alias('v')] [string] $VersionSuffix, + [Alias('o')] + [string] $Output, + + [Alias('r')] + [string] $Rid, + + [switch] $Archive, + [switch] $Copy, [switch] $SkipCli, @@ -88,7 +96,10 @@ Positional parameters: Options: -Configuration (-c) Build configuration: Release or Debug -Name (-n) Hive name (default: local) + -Output (-o) Output directory for portable layout (instead of $HOME\.aspire) + -Rid (-r) Target RID for cross-platform builds (e.g. linux-x64) -VersionSuffix (-v) Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) + -Archive Create an archive (.tar.gz or .zip) of the output. Requires -Output. -Copy Copy .nupkg files instead of creating a symlink -SkipCli Skip installing the locally-built CLI to $HOME\.aspire\bin -SkipBundle Skip building and installing the bundle (aspire-managed + DCP) @@ -102,6 +113,7 @@ Examples: .\localhive.ps1 # Packs (tries Release then Debug) -> hive 'local' .\localhive.ps1 Debug # Packs Debug -> hive 'local' .\localhive.ps1 Release demo + .\localhive.ps1 -o ./aspire-linux -r linux-x64 -Archive # Portable archive for a Linux machine This will pack NuGet packages into artifacts\packages\\Shipping and create/update a hive at $HOME\.aspire\hives\ so the Aspire CLI can use it as a channel. @@ -115,6 +127,26 @@ function Write-Err { param([string]$m) Write-Error "[localhive] $m" } if ($Help) { Show-Usage; exit 0 } +# Validate flag combinations +if ($Archive -and -not $Output) { + Write-Err "-Archive requires -Output to be specified." + exit 1 +} + +if ($Rid -and $NativeAot) { + # Detect if this is a cross-OS build + $hostPrefix = if ($IsWindows) { 'win' } elseif ($IsMacOS) { 'osx' } else { 'linux' } + if (-not $Rid.StartsWith($hostPrefix)) { + Write-Err "Cross-OS native AOT builds are not supported (host=$hostPrefix, target=$Rid). Use -Rid without -NativeAot." + exit 1 + } +} + +# When -Output is specified, always copy (portable layout, no symlinks) +if ($Output) { + $Copy = $true +} + # Normalize configuration casing if provided (case-insensitive) and allow common abbreviations. if ($Configuration) { switch ($Configuration.ToLowerInvariant()) { @@ -206,7 +238,7 @@ if (-not $packages -or $packages.Count -eq 0) { } Write-Log ("Found {0} packages in {1}" -f $packages.Count, $pkgDir) -$hivesRoot = Join-Path (Join-Path $HOME '.aspire') 'hives' +$hivesRoot = Join-Path $aspireRoot 'hives' $hiveRoot = Join-Path $hivesRoot $Name $hivePath = Join-Path $hiveRoot 'packages' @@ -252,8 +284,11 @@ else { } } -# Determine the RID for the current platform -if ($IsWindows) { +# Determine the RID for the target platform (or auto-detect from host) +if ($Rid) { + $bundleRid = $Rid + Write-Log "Using target RID: $bundleRid" +} elseif ($IsWindows) { $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'win-arm64' } else { 'win-x64' } } elseif ($IsMacOS) { $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'osx-arm64' } else { 'osx-x64' } @@ -261,7 +296,11 @@ if ($IsWindows) { $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'linux-arm64' } else { 'linux-x64' } } -$aspireRoot = Join-Path $HOME '.aspire' +if ($Output) { + $aspireRoot = $Output +} else { + $aspireRoot = Join-Path $HOME '.aspire' +} $cliBinDir = Join-Path $aspireRoot 'bin' # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) @@ -270,7 +309,7 @@ if (-not $SkipBundle) { $skipNativeArg = if ($NativeAot) { '' } else { '/p:SkipNativeBuild=true' } Write-Log "Building bundle (aspire-managed + DCP$(if ($NativeAot) { ' + native AOT CLI' }))..." - $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix") + $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix", "/p:TargetRid=$bundleRid") if (-not $NativeAot) { $buildArgs += '/p:SkipNativeBuild=true' } @@ -305,9 +344,9 @@ if (-not $SkipBundle) { Write-Log "Bundle installed to $aspireRoot (managed/ + dcp/)" } -# Install the CLI to $HOME/.aspire/bin +# Install the CLI to $aspireRoot/bin if (-not $SkipCli) { - $cliExeName = if ($IsWindows) { 'aspire.exe' } else { 'aspire' } + $cliExeName = if ($bundleRid -like 'win-*') { 'aspire.exe' } else { 'aspire' } if ($NativeAot) { # Native AOT CLI is produced by Bundle.proj's _PublishNativeCli target @@ -315,6 +354,16 @@ if (-not $SkipCli) { if (-not (Test-Path -LiteralPath $cliPublishDir)) { $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli" $effectiveConfig "net10.0" $bundleRid "publish" } + } elseif ($Rid) { + # Cross-RID: publish CLI for the target platform + Write-Log "Publishing Aspire CLI for target RID: $Rid" + $cliProj = Join-Path $RepoRoot "src" "Aspire.Cli" "Aspire.Cli.csproj" + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli" $effectiveConfig "net10.0" $Rid "publish" + & dotnet publish $cliProj -c $effectiveConfig -r $Rid --self-contained /p:PublishAot=false /p:PublishSingleFile=true "/p:VersionSuffix=$VersionSuffix" + if ($LASTEXITCODE -ne 0) { + Write-Err "CLI publish for RID $Rid failed." + exit 1 + } } else { # Framework-dependent CLI from dotnet tool build $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" "publish" @@ -377,16 +426,18 @@ if (-not $SkipCli) { $installedCliPath = Join-Path $cliBinDir $cliExeName Write-Log "Aspire CLI installed to: $installedCliPath" - # Set the channel to the local hive so templates and packages resolve from it - & $installedCliPath config set channel $Name -g 2>$null - Write-Log "Set global channel to '$Name'" - - # Check if the bin directory is in PATH - $pathSeparator = [System.IO.Path]::PathSeparator - $currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) - if ($currentPathArray -notcontains $cliBinDir) { - Write-Warn "The CLI bin directory is not in your PATH." - Write-Log "Add it to your PATH with: `$env:PATH = '$cliBinDir' + '$pathSeparator' + `$env:PATH" + if (-not $Output) { + # Set the channel to the local hive so templates and packages resolve from it + & $installedCliPath config set channel $Name -g 2>$null + Write-Log "Set global channel to '$Name'" + + # Check if the bin directory is in PATH + $pathSeparator = [System.IO.Path]::PathSeparator + $currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) + if ($currentPathArray -notcontains $cliBinDir) { + Write-Warn "The CLI bin directory is not in your PATH." + Write-Log "Add it to your PATH with: `$env:PATH = '$cliBinDir' + '$pathSeparator' + `$env:PATH" + } } } else { @@ -395,21 +446,50 @@ if (-not $SkipCli) { } } +# Create archive if requested +if ($Archive) { + if ($bundleRid -like 'win-*') { + $archivePath = "$Output.zip" + Write-Log "Creating archive: $archivePath" + Compress-Archive -Path (Join-Path $Output '*') -DestinationPath $archivePath -Force + } else { + $archivePath = "$Output.tar.gz" + Write-Log "Creating archive: $archivePath" + tar -czf $archivePath -C $Output . + } + Write-Log "Archive created: $archivePath" +} + Write-Host Write-Log 'Done.' Write-Host -Write-Log "Aspire CLI will discover a channel named '$Name' from:" -Write-Log " $hivePath" -Write-Host -Write-Log "Channel behavior: Aspire* comes from the hive; others from nuget.org." -Write-Host -if (-not $SkipCli) { - Write-Log "The locally-built CLI was installed to: $(Join-Path (Join-Path $HOME '.aspire') 'bin')" +if ($Output) { + Write-Log "Portable layout created at: $Output" + if ($Archive) { + Write-Log "Archive: $archivePath" + Write-Log "" + Write-Log "To install on the target machine:" + if ($bundleRid -like 'win-*') { + Write-Log " Expand-Archive -Path $(Split-Path $archivePath -Leaf) -DestinationPath `$HOME\.aspire" + } else { + Write-Log " mkdir -p ~/.aspire && tar -xzf $(Split-Path $archivePath -Leaf) -C ~/.aspire" + } + Write-Log " ~/.aspire/bin/aspire config set channel '$Name' -g" + } +} else { + Write-Log "Aspire CLI will discover a channel named '$Name' from:" + Write-Log " $hivePath" Write-Host -} -if (-not $SkipBundle) { - Write-Log "Bundle (aspire-managed + DCP) installed to: $(Join-Path $HOME '.aspire')" - Write-Log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + Write-Log "Channel behavior: Aspire* comes from the hive; others from nuget.org." Write-Host + if (-not $SkipCli) { + Write-Log "The locally-built CLI was installed to: $cliBinDir" + Write-Host + } + if (-not $SkipBundle) { + Write-Log "Bundle (aspire-managed + DCP) installed to: $aspireRoot" + Write-Log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + Write-Host + } + Write-Log 'The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required.' } -Write-Log 'The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required.' diff --git a/localhive.sh b/localhive.sh index 859865339ac..4aebf5a8eb5 100755 --- a/localhive.sh +++ b/localhive.sh @@ -9,7 +9,10 @@ # Options: # -c, --configuration Build configuration: Release or Debug # -n, --name Hive name (default: local) +# -o, --output Output directory for portable layout (instead of $HOME/.aspire) +# -r, --rid Target RID for cross-platform builds (e.g. linux-x64) # -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) +# --archive Create a .tar.gz (or .zip for win-* RIDs) archive of the output. Requires --output. # --copy Copy .nupkg files instead of creating a symlink # --skip-cli Skip installing the locally-built CLI to $HOME/.aspire/bin # --skip-bundle Skip building and installing the bundle (aspire-managed + DCP) @@ -32,7 +35,10 @@ Usage: Options: -c, --configuration Build configuration: Release or Debug -n, --name Hive name (default: local) + -o, --output Output directory for portable layout (instead of \$HOME/.aspire) + -r, --rid Target RID for cross-platform builds (e.g. linux-x64) -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) + --archive Create a .tar.gz (or .zip for win-* RIDs) archive of the output. Requires --output. --copy Copy .nupkg files instead of creating a symlink --skip-cli Skip installing the locally-built CLI to \$HOME/.aspire/bin --skip-bundle Skip building and installing the bundle (aspire-managed + DCP) @@ -44,6 +50,7 @@ Examples: ./localhive.sh Debug my-feature ./localhive.sh -c Release -n demo -v local.20250811.t033324 ./localhive.sh --skip-cli + ./localhive.sh -o /tmp/aspire-linux -r linux-x64 --archive # Portable archive for a Linux machine This will pack NuGet packages into artifacts/packages//Shipping and create/update a hive at \$HOME/.aspire/hives/ so the Aspire CLI can use it as a channel. @@ -78,6 +85,9 @@ SKIP_CLI=0 SKIP_BUNDLE=0 NATIVE_AOT=0 VERSION_SUFFIX="" +OUTPUT_DIR="" +TARGET_RID="" +ARCHIVE=0 is_valid_versionsuffix() { local s="$1" # Must be dot-separated identifiers containing only 0-9A-Za-z- per SemVer2. @@ -111,6 +121,14 @@ while [[ $# -gt 0 ]]; do -v|--versionsuffix) if [[ $# -lt 2 ]]; then error "Missing value for $1"; exit 1; fi VERSION_SUFFIX="$2"; shift 2 ;; + -o|--output) + if [[ $# -lt 2 ]]; then error "Missing value for $1"; exit 1; fi + OUTPUT_DIR="$2"; shift 2 ;; + -r|--rid) + if [[ $# -lt 2 ]]; then error "Missing value for $1"; exit 1; fi + TARGET_RID="$2"; shift 2 ;; + --archive) + ARCHIVE=1; shift ;; --copy) USE_COPY=1; shift ;; --skip-cli) @@ -131,6 +149,31 @@ while [[ $# -gt 0 ]]; do esac done +# Validate flag combinations +if [[ $ARCHIVE -eq 1 ]] && [[ -z "$OUTPUT_DIR" ]]; then + error "--archive requires --output to be specified." + exit 1 +fi + +if [[ -n "$TARGET_RID" ]] && [[ $NATIVE_AOT -eq 1 ]]; then + # Detect if this is a cross-OS build (e.g. building linux-x64 on macOS) + HOST_OS="$(uname -s)" + case "$HOST_OS" in + Darwin) HOST_PREFIX="osx" ;; + Linux) HOST_PREFIX="linux" ;; + *) HOST_PREFIX="win" ;; + esac + if [[ "$TARGET_RID" != "$HOST_PREFIX"* ]]; then + error "Cross-OS native AOT builds are not supported (host=$HOST_PREFIX, target=$TARGET_RID). Use --rid without --native-aot." + exit 1 + fi +fi + +# When --output is specified, always copy (portable layout, no symlinks) +if [[ -n "$OUTPUT_DIR" ]]; then + USE_COPY=1 +fi + # Normalize config value if set if [[ -n "$CONFIG" ]]; then case "${CONFIG,,}" in @@ -192,7 +235,7 @@ if [[ $pkg_count -eq 0 ]]; then fi log "Found $pkg_count packages in $PKG_DIR" -HIVES_ROOT="$HOME/.aspire/hives" +HIVES_ROOT="$ASPIRE_ROOT/hives" HIVE_ROOT="$HIVES_ROOT/$HIVE_NAME" HIVE_PATH="$HIVE_ROOT/packages" @@ -223,21 +266,30 @@ else fi fi -# Determine the RID for the current platform -ARCH=$(uname -m) -case "$(uname -s)" in - Darwin) - if [[ "$ARCH" == "arm64" ]]; then BUNDLE_RID="osx-arm64"; else BUNDLE_RID="osx-x64"; fi - ;; - Linux) - if [[ "$ARCH" == "aarch64" ]]; then BUNDLE_RID="linux-arm64"; else BUNDLE_RID="linux-x64"; fi - ;; - *) - BUNDLE_RID="linux-x64" - ;; -esac - -ASPIRE_ROOT="$HOME/.aspire" +# Determine the RID for the current platform (or use --rid override) +if [[ -n "$TARGET_RID" ]]; then + BUNDLE_RID="$TARGET_RID" + log "Using target RID: $BUNDLE_RID" +else + ARCH=$(uname -m) + case "$(uname -s)" in + Darwin) + if [[ "$ARCH" == "arm64" ]]; then BUNDLE_RID="osx-arm64"; else BUNDLE_RID="osx-x64"; fi + ;; + Linux) + if [[ "$ARCH" == "aarch64" ]]; then BUNDLE_RID="linux-arm64"; else BUNDLE_RID="linux-x64"; fi + ;; + *) + BUNDLE_RID="linux-x64" + ;; + esac +fi + +if [[ -n "$OUTPUT_DIR" ]]; then + ASPIRE_ROOT="$OUTPUT_DIR" +else + ASPIRE_ROOT="$HOME/.aspire" +fi CLI_BIN_DIR="$ASPIRE_ROOT/bin" # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) @@ -246,10 +298,10 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then if [[ $NATIVE_AOT -eq 1 ]]; then log "Building bundle (aspire-managed + DCP + native AOT CLI)..." - dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" "/p:TargetRid=$BUNDLE_RID" else log "Building bundle (aspire-managed + DCP)..." - dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" "/p:TargetRid=$BUNDLE_RID" fi if [[ $? -ne 0 ]]; then error "Bundle build failed." @@ -285,7 +337,7 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then log "Bundle installed to $ASPIRE_ROOT (managed/ + dcp/)" fi -# Install the CLI to $HOME/.aspire/bin +# Install the CLI to $ASPIRE_ROOT/bin if [[ $SKIP_CLI -eq 0 ]]; then if [[ $NATIVE_AOT -eq 1 ]]; then # Native AOT CLI from Bundle.proj publish @@ -293,6 +345,13 @@ if [[ $SKIP_CLI -eq 0 ]]; then if [[ ! -d "$CLI_PUBLISH_DIR" ]]; then CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli/$EFFECTIVE_CONFIG/net10.0/$BUNDLE_RID/publish" fi + elif [[ -n "$TARGET_RID" ]]; then + # Cross-RID: publish CLI for the target platform + log "Publishing Aspire CLI for target RID: $TARGET_RID" + CLI_PROJ="$REPO_ROOT/src/Aspire.Cli/Aspire.Cli.csproj" + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli/$EFFECTIVE_CONFIG/net10.0/$TARGET_RID/publish" + dotnet publish "$CLI_PROJ" -c "$EFFECTIVE_CONFIG" -r "$TARGET_RID" --self-contained \ + /p:PublishAot=false /p:PublishSingleFile=true "/p:VersionSuffix=$VERSION_SUFFIX" else # Framework-dependent CLI from dotnet tool build CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/publish" @@ -319,16 +378,18 @@ if [[ $SKIP_CLI -eq 0 ]]; then log "Aspire CLI installed to: $CLI_BIN_DIR/aspire" - if "$CLI_BIN_DIR/aspire" config set channel "$HIVE_NAME" -g >/dev/null 2>&1; then - log "Set global channel to '$HIVE_NAME'" - else - warn "Failed to set global channel to '$HIVE_NAME'. Run: aspire config set channel '$HIVE_NAME' -g" - fi + if [[ -z "$OUTPUT_DIR" ]]; then + if "$CLI_BIN_DIR/aspire" config set channel "$HIVE_NAME" -g >/dev/null 2>&1; then + log "Set global channel to '$HIVE_NAME'" + else + warn "Failed to set global channel to '$HIVE_NAME'. Run: aspire config set channel '$HIVE_NAME' -g" + fi - # Check if the bin directory is in PATH - if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then - warn "The CLI bin directory is not in your PATH." - log "Add it to your PATH with: export PATH=\"$CLI_BIN_DIR:\$PATH\"" + # Check if the bin directory is in PATH + if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then + warn "The CLI bin directory is not in your PATH." + log "Add it to your PATH with: export PATH=\"$CLI_BIN_DIR:\$PATH\"" + fi fi else warn "Could not find CLI at $CLI_SOURCE_PATH. Skipping CLI installation." @@ -336,21 +397,46 @@ if [[ $SKIP_CLI -eq 0 ]]; then fi fi +# Create archive if requested +if [[ $ARCHIVE -eq 1 ]]; then + if [[ "$BUNDLE_RID" == win-* ]]; then + ARCHIVE_PATH="${OUTPUT_DIR}.zip" + log "Creating archive: $ARCHIVE_PATH" + (cd "$OUTPUT_DIR" && zip -r "$ARCHIVE_PATH" .) + else + ARCHIVE_PATH="${OUTPUT_DIR}.tar.gz" + log "Creating archive: $ARCHIVE_PATH" + tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" . + fi + log "Archive created: $ARCHIVE_PATH" +fi + echo log "Done." echo -log "Aspire CLI will discover a channel named '$HIVE_NAME' from:" -log " $HIVE_PATH" -echo -log "Channel behavior: Aspire* comes from the hive; others from nuget.org." -echo -if [[ $SKIP_CLI -eq 0 ]]; then - log "The locally-built CLI was installed to: $HOME/.aspire/bin" +if [[ -n "$OUTPUT_DIR" ]]; then + log "Portable layout created at: $OUTPUT_DIR" + if [[ $ARCHIVE -eq 1 ]]; then + log "Archive: $ARCHIVE_PATH" + log "" + log "To install on the target machine:" + log " mkdir -p ~/.aspire && tar -xzf $(basename "$ARCHIVE_PATH") -C ~/.aspire" + log " ~/.aspire/bin/aspire config set channel '$HIVE_NAME' -g" + fi +else + log "Aspire CLI will discover a channel named '$HIVE_NAME' from:" + log " $HIVE_PATH" echo -fi -if [[ $SKIP_BUNDLE -eq 0 ]]; then - log "Bundle (aspire-managed + DCP) installed to: $HOME/.aspire" - log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + log "Channel behavior: Aspire* comes from the hive; others from nuget.org." echo + if [[ $SKIP_CLI -eq 0 ]]; then + log "The locally-built CLI was installed to: $ASPIRE_ROOT/bin" + echo + fi + if [[ $SKIP_BUNDLE -eq 0 ]]; then + log "Bundle (aspire-managed + DCP) installed to: $ASPIRE_ROOT" + log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + echo + fi + log "The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required." fi -log "The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required." From 9893362523692cd6cc0c3ecffe2b7ec7b64231e0 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 08:10:41 -0700 Subject: [PATCH 03/12] Add Podman deployment E2E test Add PodmanDeploymentTests that validates aspire deploy works with Podman as the container runtime. The test sets ASPIRE_CONTAINER_RUNTIME=podman, creates a .NET AppHost with Docker Compose environment, deploys, and verifies containers are running via podman ps. Marked as OuterloopTest since it requires Podman and docker-compose v2 installed on the host machine. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PodmanDeploymentTests.cs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs new file mode 100644 index 00000000000..643db4e1ba4 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Aspire.TestUtilities; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for Aspire CLI deployment to Docker Compose using Podman as the container runtime. +/// Validates that setting ASPIRE_CONTAINER_RUNTIME=podman flows through to compose operations. +/// Requires Podman and docker-compose v2 installed on the host. +/// +public sealed class PodmanDeploymentTests(ITestOutputHelper output) +{ + private const string ProjectName = "AspirePodmanDeployTest"; + + [Fact] + [OuterloopTest("Requires Podman and docker-compose v2 installed on the host")] + public async Task CreateAndDeployToDockerComposeWithPodman() + { + using var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + // PrepareEnvironment + await auto.PrepareEnvironmentAsync(workspace, counter); + + if (isCI) + { + await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); + await auto.SourceAspireCliEnvironmentAsync(counter); + await auto.VerifyAspireCliVersionAsync(commitSha, counter); + } + + // Step 0: Verify Podman is available, skip if not + await auto.TypeAsync("podman --version || echo 'PODMAN_NOT_FOUND'"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10)); + + // Step 1: Set the container runtime to Podman + await auto.TypeAsync("export ASPIRE_CONTAINER_RUNTIME=podman"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 2: Create a new Aspire Starter App (no Redis cache) + await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false); + + // Step 3: Navigate into the project directory + await auto.TypeAsync($"cd {ProjectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 4: Add Aspire.Hosting.Docker package using aspire add + await auto.TypeAsync("aspire add Aspire.Hosting.Docker"); + await auto.EnterAsync(); + + if (isCI) + { + await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); + await auto.EnterAsync(); + } + + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify AppHost's main file to add Docker Compose environment + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName); + var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Docker Compose environment for deployment +builder.AddDockerComposeEnvironment("compose"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}"); + } + + // Step 6: Create output directory for deployment artifacts + await auto.TypeAsync("mkdir -p deploy-output"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 7: Unset ASPIRE_PLAYGROUND before deploy + await auto.TypeAsync("unset ASPIRE_PLAYGROUND"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 8: Run aspire deploy with Podman as the container runtime + await auto.TypeAsync("aspire deploy -o deploy-output --non-interactive"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5)); + + // Step 9: Verify containers are running with podman ps + await auto.TypeAsync("podman ps"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 10: Verify the application is accessible + await auto.TypeAsync("curl -s -o /dev/null -w '%{http_code}' http://localhost:$(podman ps --format '{{.Ports}}' --filter 'name=webfrontend' | grep -oE '0\\.0\\.0\\.0:[0-9]+->8080' | head -1 | cut -d: -f2 | cut -d'-' -f1) 2>/dev/null || echo 'request-failed'"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + // Step 11: Clean up - stop and remove containers using podman + await auto.TypeAsync("cd deploy-output && podman compose down --volumes --remove-orphans 2>/dev/null || true"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } +} From 0c043df76fb502cf944ae51d6b8b35cf3747af08 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 10:43:29 -0700 Subject: [PATCH 04/12] Add container runtime diagnostics for compose operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve observability when compose operations use the wrong runtime: - Log the resolved container runtime at DI registration time: 'Container runtime resolved: Docker (configured via ASPIRE_CONTAINER_RUNTIME=docker)' - Log the runtime name when compose up starts: 'Using container runtime podman for compose operations' - Include the runtime binary name in error messages: 'podman compose up failed with exit code 125. Ensure podman is installed and available on PATH.' - Validate runtime binary exists on PATH before attempting compose operations — fail fast with actionable message instead of cryptic exit codes - Use runtime name in pipeline step UI messages so the user can see which runtime is being used at each step Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- localhive.sh | 59 ++++++++++--------- .../DockerComposeEnvironmentResource.cs | 18 +++--- .../CompatibilitySuppressions.xml | 25 ++++++++ .../DistributedApplicationBuilder.cs | 10 ++-- .../Publishing/ContainerRuntimeBase.cs | 54 ++++++++++++++++- 5 files changed, 123 insertions(+), 43 deletions(-) create mode 100644 src/Aspire.Hosting/CompatibilitySuppressions.xml diff --git a/localhive.sh b/localhive.sh index 4aebf5a8eb5..05415ad0f0c 100755 --- a/localhive.sh +++ b/localhive.sh @@ -235,6 +235,32 @@ if [[ $pkg_count -eq 0 ]]; then fi log "Found $pkg_count packages in $PKG_DIR" +# Determine the RID for the current platform (or use --rid override) +if [[ -n "$TARGET_RID" ]]; then + BUNDLE_RID="$TARGET_RID" + log "Using target RID: $BUNDLE_RID" +else + ARCH=$(uname -m) + case "$(uname -s)" in + Darwin) + if [[ "$ARCH" == "arm64" ]]; then BUNDLE_RID="osx-arm64"; else BUNDLE_RID="osx-x64"; fi + ;; + Linux) + if [[ "$ARCH" == "aarch64" ]]; then BUNDLE_RID="linux-arm64"; else BUNDLE_RID="linux-x64"; fi + ;; + *) + BUNDLE_RID="linux-x64" + ;; + esac +fi + +if [[ -n "$OUTPUT_DIR" ]]; then + ASPIRE_ROOT="$OUTPUT_DIR" +else + ASPIRE_ROOT="$HOME/.aspire" +fi +CLI_BIN_DIR="$ASPIRE_ROOT/bin" + HIVES_ROOT="$ASPIRE_ROOT/hives" HIVE_ROOT="$HIVES_ROOT/$HIVE_NAME" HIVE_PATH="$HIVE_ROOT/packages" @@ -249,9 +275,12 @@ if [ -e "$HIVE_ROOT" ] || [ -L "$HIVE_ROOT" ]; then fi if [[ $USE_COPY -eq 1 ]]; then - log "Populating hive '$HIVE_NAME' by copying .nupkg files" + log "Populating hive '$HIVE_NAME' by copying .nupkg files (version suffix: $VERSION_SUFFIX)" mkdir -p "$HIVE_PATH" - cp -f "$PKG_DIR"/*.nupkg "$HIVE_PATH"/ 2>/dev/null || true + # Only copy packages matching the current version suffix to avoid accumulating stale packages + for pkg in "$PKG_DIR"/*"$VERSION_SUFFIX"*.nupkg; do + [ -f "$pkg" ] && cp -f "$pkg" "$HIVE_PATH"/ + done log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied packages)." else log "Linking hive '$HIVE_NAME/packages' to $PKG_DIR" @@ -266,32 +295,6 @@ else fi fi -# Determine the RID for the current platform (or use --rid override) -if [[ -n "$TARGET_RID" ]]; then - BUNDLE_RID="$TARGET_RID" - log "Using target RID: $BUNDLE_RID" -else - ARCH=$(uname -m) - case "$(uname -s)" in - Darwin) - if [[ "$ARCH" == "arm64" ]]; then BUNDLE_RID="osx-arm64"; else BUNDLE_RID="osx-x64"; fi - ;; - Linux) - if [[ "$ARCH" == "aarch64" ]]; then BUNDLE_RID="linux-arm64"; else BUNDLE_RID="linux-x64"; fi - ;; - *) - BUNDLE_RID="linux-x64" - ;; - esac -fi - -if [[ -n "$OUTPUT_DIR" ]]; then - ASPIRE_ROOT="$OUTPUT_DIR" -else - ASPIRE_ROOT="$HOME/.aspire" -fi -CLI_BIN_DIR="$ASPIRE_ROOT/bin" - # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) if [[ $SKIP_BUNDLE -eq 0 ]]; then BUNDLE_PROJ="$REPO_ROOT/eng/Bundle.proj" diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 8d1d7f41980..6c76c5529b8 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -223,26 +223,27 @@ private async Task DockerComposeUpAsync(PipelineStepContext context) throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}"); } + var runtime = context.Services.GetRequiredService(); + var deployTask = await context.ReportingStep.CreateTaskAsync( - new MarkdownString($"Running docker compose up for **{Name}**"), + new MarkdownString($"Running compose up for **{Name}** using **{runtime.Name}**"), context.CancellationToken).ConfigureAwait(false); await using (deployTask.ConfigureAwait(false)) { try { - var runtime = context.Services.GetRequiredService(); var composeContext = CreateComposeOperationContext(context); await runtime.ComposeUpAsync(composeContext, context.CancellationToken).ConfigureAwait(false); await deployTask.CompleteAsync( - new MarkdownString($"Service **{Name}** is now running with Docker Compose locally"), + new MarkdownString($"Service **{Name}** is now running with Docker Compose locally (runtime: {runtime.Name})"), CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); } catch (Exception ex) { - await deployTask.CompleteAsync($"Docker Compose deployment failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); + await deployTask.CompleteAsync($"Compose deployment failed ({runtime.Name}): {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); throw; } } @@ -258,26 +259,27 @@ private async Task DockerComposeDownAsync(PipelineStepContext context) throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}"); } + var runtime = context.Services.GetRequiredService(); + var deployTask = await context.ReportingStep.CreateTaskAsync( - new MarkdownString($"Running docker compose down for **{Name}**"), + new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"), context.CancellationToken).ConfigureAwait(false); await using (deployTask.ConfigureAwait(false)) { try { - var runtime = context.Services.GetRequiredService(); var composeContext = CreateComposeOperationContext(context); await runtime.ComposeDownAsync(composeContext, context.CancellationToken).ConfigureAwait(false); await deployTask.CompleteAsync( - new MarkdownString($"Docker Compose shutdown complete for **{Name}**"), + new MarkdownString($"Compose shutdown complete for **{Name}** ({runtime.Name})"), CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); } catch (Exception ex) { - await deployTask.CompleteAsync($"Docker Compose shutdown failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); + await deployTask.CompleteAsync($"Compose shutdown failed ({runtime.Name}): {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); throw; } } diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml new file mode 100644 index 00000000000..5820a6da429 --- /dev/null +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -0,0 +1,25 @@ + + + + + CP0006 + M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeDownAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0006 + M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeListServicesAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0006 + M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeUpAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + \ No newline at end of file diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index e1f004486ff..d10e9c47da5 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -514,11 +514,11 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(sp => { var dcpOptions = sp.GetRequiredService>(); - return dcpOptions.Value.ContainerRuntime switch - { - string rt => sp.GetRequiredKeyedService(rt), - null => sp.GetRequiredKeyedService("docker") - }; + var runtimeKey = dcpOptions.Value.ContainerRuntime ?? "docker"; + var logger = sp.GetRequiredService().CreateLogger("Aspire.Hosting.ContainerRuntime"); + var runtime = sp.GetRequiredKeyedService(runtimeKey); + logger.LogInformation("Container runtime resolved: {RuntimeName} (configured via ASPIRE_CONTAINER_RUNTIME={RuntimeKey})", runtime.Name, runtimeKey); + return runtime; }); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 86db3053928..f14038a89ba 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -299,9 +299,12 @@ private ProcessSpec CreateProcessSpec(string arguments) public virtual async Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken) { + EnsureRuntimeAvailable(); + var arguments = BuildComposeArguments(context); arguments += " up -d --remove-orphans"; + _logger.LogInformation("Using container runtime '{Runtime}' for compose operations.", RuntimeExecutable); _logger.LogDebug("Running {Runtime} compose up with arguments: {Arguments}", RuntimeExecutable, arguments); var spec = new ProcessSpec(RuntimeExecutable) @@ -330,13 +333,18 @@ public virtual async Task ComposeUpAsync(ComposeOperationContext context, Cancel if (processResult.ExitCode != 0) { - throw new DistributedApplicationException($"{Name} compose up failed with exit code {processResult.ExitCode}."); + throw new DistributedApplicationException( + $"'{RuntimeExecutable} compose up' failed with exit code {processResult.ExitCode}. " + + $"Ensure '{RuntimeExecutable}' is installed and available on PATH. " + + $"The container runtime is configured via the ASPIRE_CONTAINER_RUNTIME environment variable (current: '{RuntimeExecutable}')."); } } } public virtual async Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken) { + EnsureRuntimeAvailable(); + var arguments = BuildComposeArguments(context); arguments += " down"; @@ -368,13 +376,17 @@ public virtual async Task ComposeDownAsync(ComposeOperationContext context, Canc if (processResult.ExitCode != 0) { - throw new DistributedApplicationException($"{Name} compose down failed with exit code {processResult.ExitCode}."); + throw new DistributedApplicationException( + $"'{RuntimeExecutable} compose down' failed with exit code {processResult.ExitCode}. " + + $"Ensure '{RuntimeExecutable}' is installed and available on PATH."); } } } public virtual async Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) { + EnsureRuntimeAvailable(); + var arguments = BuildComposeArguments(context); arguments += " ps --format json"; @@ -504,6 +516,44 @@ private static string BuildComposeArguments(ComposeOperationContext context) return arguments; } + + /// + /// Validates that the container runtime binary is available on the system PATH. + /// Fails fast with an actionable error message instead of a cryptic exit code. + /// + private void EnsureRuntimeAvailable() + { + try + { + var whichCommand = OperatingSystem.IsWindows() ? "where" : "which"; + var spec = new ProcessSpec(whichCommand) + { + Arguments = RuntimeExecutable, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true + }; + + var (pendingResult, disposable) = ProcessUtil.Run(spec); + using (disposable as IDisposable) + { + var result = pendingResult.GetAwaiter().GetResult(); + if (result.ExitCode != 0) + { + throw new DistributedApplicationException( + $"Container runtime '{RuntimeExecutable}' was not found on PATH. " + + $"Install {Name} or set ASPIRE_CONTAINER_RUNTIME to a different runtime (e.g., 'docker' or 'podman')."); + } + } + } + catch (DistributedApplicationException) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to check if {Runtime} is available on PATH", RuntimeExecutable); + } + } } /// From a9dd43c2ae5ca21d7900b51b60b1be12638ae366 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 11:45:07 -0700 Subject: [PATCH 05/12] Add shared container runtime auto-detection Create ContainerRuntimeDetector in src/Shared/ that mirrors DCP's detection logic (see internal/containers/runtimes/runtime.go): - Probe Docker and Podman in parallel - Prefer installed+running over installed-only over not-found - Prefer Docker as default when both are equally available Used in two places: - DistributedApplicationBuilder: auto-detects runtime when ASPIRE_CONTAINER_RUNTIME is not set (instead of always defaulting to Docker) - ContainerRuntimeCheck (aspire doctor): uses shared detector for initial availability check, then does extended checks (version, Windows containers, tunnel config) This means on a Podman-only machine, Aspire will automatically use Podman without needing ASPIRE_CONTAINER_RUNTIME=podman. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + .../ContainerRuntimeCheck.cs | 356 +++++------------- src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + .../DistributedApplicationBuilder.cs | 31 +- src/Shared/ContainerRuntimeDetector.cs | 270 +++++++++++++ 5 files changed, 386 insertions(+), 273 deletions(-) create mode 100644 src/Shared/ContainerRuntimeDetector.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index e1ae8a44e13..fb338ae871e 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -78,6 +78,7 @@ + diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs index dfa04cf884d..adfee69ad7e 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Aspire.Hosting; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Utils.EnvironmentChecker; @@ -32,29 +33,56 @@ public async Task> CheckAsync(Cancellation { try { - // Try Docker first, then Podman - var dockerCheck = await CheckSpecificContainerRuntimeAsync("Docker", cancellationToken); - if (dockerCheck.Status == EnvironmentCheckStatus.Pass) + // Use shared detector to probe both runtimes in parallel + var dockerInfo = await ContainerRuntimeDetector.CheckRuntimeAsync("docker", "Docker", isDefault: true, cancellationToken); + var podmanInfo = await ContainerRuntimeDetector.CheckRuntimeAsync("podman", "Podman", isDefault: false, cancellationToken); + + // If Docker is healthy, do extended checks (version, Windows containers, tunnel) + if (dockerInfo.IsHealthy) + { + var result = await CheckDockerExtendedAsync(cancellationToken); + if (result is not null) + { + return [result]; + } + } + + // If Podman is healthy, do version check + if (podmanInfo.IsHealthy) { - return [dockerCheck]; + var result = await CheckPodmanExtendedAsync(cancellationToken); + if (result is not null) + { + return [result]; + } + } + + // Prefer healthy Docker + if (dockerInfo.IsHealthy) + { + return [PassResult("Docker detected and running")]; } - var podmanCheck = await CheckSpecificContainerRuntimeAsync("Podman", cancellationToken); - if (podmanCheck.Status == EnvironmentCheckStatus.Pass) + // Prefer healthy Podman + if (podmanInfo.IsHealthy) { - return [podmanCheck]; + return [PassResult("Podman detected and running")]; } // If Docker is installed but not running, prefer showing that error - if (dockerCheck.Status == EnvironmentCheckStatus.Warning) + if (dockerInfo.IsInstalled) { - return [dockerCheck]; + return [WarningResult( + "Docker is installed but not running", + GetContainerRuntimeStartupAdvice("Docker"))]; } // If Podman is installed but not running, show that - if (podmanCheck.Status == EnvironmentCheckStatus.Warning) + if (podmanInfo.IsInstalled) { - return [podmanCheck]; + return [WarningResult( + "Podman is installed but not running", + GetContainerRuntimeStartupAdvice("Podman"))]; } // Neither found @@ -82,11 +110,20 @@ public async Task> CheckAsync(Cancellation } } - private async Task CheckSpecificContainerRuntimeAsync(string runtime, CancellationToken cancellationToken) + private async Task CheckDockerExtendedAsync(CancellationToken cancellationToken) + { + return await CheckVersionAndModeAsync("Docker", cancellationToken); + } + + private async Task CheckPodmanExtendedAsync(CancellationToken cancellationToken) + { + return await CheckVersionAndModeAsync("Podman", cancellationToken); + } + + private async Task CheckVersionAndModeAsync(string runtime, CancellationToken cancellationToken) { try { - // Check if runtime is installed and get version using JSON format (use lowercase for process name) var runtimeLower = runtime.ToLowerInvariant(); var versionProcessInfo = new ProcessStartInfo { @@ -101,206 +138,43 @@ private async Task CheckSpecificContainerRuntimeAsync(st using var versionProcess = Process.Start(versionProcessInfo); if (versionProcess is null) { - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = $"{runtime} not found" - }; + return null; } - using var versionTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - versionTimeoutCts.CancelAfter(s_processTimeout); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_processTimeout); string versionOutput; try { - versionOutput = await versionProcess.StandardOutput.ReadToEndAsync(versionTimeoutCts.Token); - await versionProcess.WaitForExitAsync(versionTimeoutCts.Token); + versionOutput = await versionProcess.StandardOutput.ReadToEndAsync(timeoutCts.Token); + await versionProcess.WaitForExitAsync(timeoutCts.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { versionProcess.Kill(); - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} check timed out", - Fix = GetContainerRuntimeStartupAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; + return null; } - // Parse the version from JSON output first, even if the command failed - // (docker version -f json outputs client info even when daemon is not running) var versionInfo = ContainerVersionInfo.Parse(versionOutput); - var clientVersion = versionInfo.ClientVersion; - var serverVersion = versionInfo.ServerVersion; + var clientVersion = versionInfo.ClientVersion ?? ParseVersionFromOutput(versionOutput); var context = versionInfo.Context; var serverOs = versionInfo.ServerOs; - - // Determine if this is Docker Desktop based on context var isDockerDesktop = runtime == "Docker" && context is not null && context.Contains("desktop", StringComparison.OrdinalIgnoreCase); - // Note: docker/podman version -f json returns exit code != 0 when daemon is not running, - // but still outputs client version info including the context - if (versionProcess.ExitCode != 0) - { - // If we got client info from JSON, CLI is installed but daemon isn't running - if (clientVersion is not null || isDockerDesktop) - { - var runtimeDescription = isDockerDesktop ? "Docker Desktop" : runtime; - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtimeDescription} is installed but not running", - Fix = GetContainerRuntimeStartupAdvice(runtime, isDockerDesktop), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - // Couldn't get client info, check if CLI is installed separately - var isCliInstalled = await IsCliInstalledAsync(runtimeLower, cancellationToken); - if (isCliInstalled) - { - // CLI is installed but daemon isn't running - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} is installed but the daemon is not running", - Fix = GetContainerRuntimeStartupAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = $"{runtime} not found", - Fix = GetContainerRuntimeInstallationLink(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - // Fall back to text parsing if JSON parsing failed - if (clientVersion is null) - { - clientVersion = ParseVersionFromOutput(versionOutput); - } - var minimumVersion = GetMinimumVersion(runtime); - // Check if client version meets minimum requirement - if (clientVersion is not null && minimumVersion is not null) - { - if (clientVersion < minimumVersion) - { - var minVersionString = GetMinimumVersionString(runtime); - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} client version {clientVersion} is below the minimum required version {minVersionString}", - Fix = GetContainerRuntimeUpgradeAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - } - - // For Docker, also check server version if available - if (runtime == "Docker" && serverVersion is not null && minimumVersion is not null) - { - if (serverVersion < minimumVersion) - { - var minVersionString = GetMinimumVersionString(runtime); - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} server version {serverVersion} is below the minimum required version {minVersionString}", - Fix = GetContainerRuntimeUpgradeAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - } - - // Runtime is installed, check if it's running - var psProcessInfo = new ProcessStartInfo - { - FileName = runtimeLower, - Arguments = "ps", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var psProcess = Process.Start(psProcessInfo); - if (psProcess is null) + // Check minimum version + if (clientVersion is not null && minimumVersion is not null && clientVersion < minimumVersion) { - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} installed but daemon not reachable", - Fix = GetContainerRuntimeStartupAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; + return WarningResult( + $"{runtime} version {clientVersion} is below minimum required {GetMinimumVersionString(runtime)}", + GetContainerRuntimeUpgradeAdvice(runtime)); } - using var psTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - psTimeoutCts.CancelAfter(s_processTimeout); - - try - { - await psProcess.WaitForExitAsync(psTimeoutCts.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - psProcess.Kill(); - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtime} daemon not responding", - Fix = GetContainerRuntimeStartupAdvice(runtime), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - if (psProcess.ExitCode != 0) - { - var runtimeDescription = isDockerDesktop ? "Docker Desktop" : runtime; - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"{runtimeDescription} is installed but not running", - Fix = GetContainerRuntimeStartupAdvice(runtime, isDockerDesktop), - Link = "https://aka.ms/dotnet/aspire/containers" - }; - } - - // Return pass with version info if available - var versionSuffix = clientVersion is not null ? $" (version {clientVersion})" : string.Empty; - var runtimeName = isDockerDesktop ? "Docker Desktop" : runtime; - - // Check if Docker is running in Windows container mode (only Linux containers are supported) + // Docker-specific: check Windows container mode if (runtime == "Docker" && string.Equals(serverOs, "windows", StringComparison.OrdinalIgnoreCase)) { return new EnvironmentCheckResult @@ -308,17 +182,18 @@ context is not null && Category = "container", Name = "container-runtime", Status = EnvironmentCheckStatus.Fail, - Message = $"{runtimeName} is running in Windows container mode{versionSuffix}", + Message = $"{(isDockerDesktop ? "Docker Desktop" : "Docker")} is running in Windows container mode", Details = "Aspire requires Linux containers. Windows containers are not supported.", Fix = "Switch Docker Desktop to Linux containers mode (right-click Docker tray icon → 'Switch to Linux containers...')", Link = "https://aka.ms/dotnet/aspire/containers" }; } - // For Docker Engine (not Desktop), check tunnel configuration + // Docker Engine (not Desktop): check tunnel if (runtime == "Docker" && !isDockerDesktop) { var tunnelEnabled = Environment.GetEnvironmentVariable("ASPIRE_ENABLE_CONTAINER_TUNNEL"); + var versionSuffix = clientVersion is not null ? $" (version {clientVersion})" : ""; if (!string.Equals(tunnelEnabled, "true", StringComparison.OrdinalIgnoreCase)) { return new EnvironmentCheckResult @@ -331,37 +206,34 @@ context is not null && Link = "https://aka.ms/aspire-prerequisites#docker-engine" }; } - - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Pass, - Message = $"Docker Engine detected and running{versionSuffix} with container tunnel enabled" - }; } - - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Pass, - Message = $"{runtimeName} detected and running{versionSuffix}" - }; } catch (Exception ex) { - logger.LogDebug(ex, "Error checking {Runtime}", runtime); - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = $"Failed to check {runtime}" - }; + logger.LogDebug(ex, "Error during extended {Runtime} check", runtime); } + + return null; // No issues found } + private static EnvironmentCheckResult PassResult(string message) => new() + { + Category = "container", + Name = "container-runtime", + Status = EnvironmentCheckStatus.Pass, + Message = message + }; + + private static EnvironmentCheckResult WarningResult(string message, string fix) => new() + { + Category = "container", + Name = "container-runtime", + Status = EnvironmentCheckStatus.Warning, + Message = message, + Fix = fix, + Link = "https://aka.ms/dotnet/aspire/containers" + }; + /// /// Parses a version number from container runtime output as a fallback when JSON parsing fails. /// @@ -424,16 +296,6 @@ private static string GetContainerRuntimeUpgradeAdvice(string runtime) [GeneratedRegex(@"version\s+(\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase)] private static partial Regex VersionRegex(); - private static string GetContainerRuntimeInstallationLink(string runtime) - { - return runtime switch - { - "Docker" => "Install Docker Desktop from: https://www.docker.com/products/docker-desktop", - "Podman" => "Install Podman from: https://podman.io/getting-started/installation", - _ => $"Install {runtime}" - }; - } - private static string GetContainerRuntimeStartupAdvice(string runtime, bool isDockerDesktop = false) { return runtime switch @@ -444,50 +306,6 @@ private static string GetContainerRuntimeStartupAdvice(string runtime, bool isDo _ => $"Start {runtime} daemon" }; } - - /// - /// Checks if the container runtime CLI is installed by running --version (which doesn't require daemon). - /// - private async Task IsCliInstalledAsync(string runtimeLower, CancellationToken cancellationToken) - { - try - { - var processInfo = new ProcessStartInfo - { - FileName = runtimeLower, - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(processInfo); - if (process is null) - { - return false; - } - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(s_processTimeout); - - try - { - await process.WaitForExitAsync(timeoutCts.Token); - return process.ExitCode == 0; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - process.Kill(); - return false; - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error checking if {Runtime} CLI is installed", runtimeLower); - return false; - } - } } /// diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 7a1342cd690..5d6eb7c6108 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index d10e9c47da5..b0e8ee6524f 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -514,11 +514,34 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(sp => { var dcpOptions = sp.GetRequiredService>(); - var runtimeKey = dcpOptions.Value.ContainerRuntime ?? "docker"; var logger = sp.GetRequiredService().CreateLogger("Aspire.Hosting.ContainerRuntime"); - var runtime = sp.GetRequiredKeyedService(runtimeKey); - logger.LogInformation("Container runtime resolved: {RuntimeName} (configured via ASPIRE_CONTAINER_RUNTIME={RuntimeKey})", runtime.Name, runtimeKey); - return runtime; + var configuredRuntime = dcpOptions.Value.ContainerRuntime; + + if (configuredRuntime is not null) + { + logger.LogInformation("Container runtime '{RuntimeKey}' configured via ASPIRE_CONTAINER_RUNTIME.", configuredRuntime); + return sp.GetRequiredKeyedService(configuredRuntime); + } + + // Auto-detect: probe available runtimes, matching DCP's detection logic. + // See https://github.com/microsoft/dcp/blob/main/internal/containers/runtimes/runtime.go + var detected = ContainerRuntimeDetector.FindAvailableRuntimeAsync().GetAwaiter().GetResult(); + var runtimeKey = detected?.Executable ?? "docker"; + + if (detected is { IsHealthy: true }) + { + logger.LogInformation("Container runtime auto-detected: {RuntimeName} ({Executable}).", detected.Name, detected.Executable); + } + else if (detected is { IsInstalled: true }) + { + logger.LogWarning("Container runtime '{RuntimeName}' is installed but not running. {Error}", detected.Name, detected.Error); + } + else + { + logger.LogWarning("No container runtime detected, defaulting to 'docker'. Install Docker or Podman to use container features."); + } + + return sp.GetRequiredKeyedService(runtimeKey); }); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); diff --git a/src/Shared/ContainerRuntimeDetector.cs b/src/Shared/ContainerRuntimeDetector.cs new file mode 100644 index 00000000000..c006eded73a --- /dev/null +++ b/src/Shared/ContainerRuntimeDetector.cs @@ -0,0 +1,270 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Shared container runtime detection logic mirroring the approach used by DCP: +// https://github.com/microsoft/dcp/blob/main/internal/containers/runtimes/runtime.go +// https://github.com/microsoft/dcp/blob/main/internal/containers/flags/container_runtime.go +// +// Detection strategy (matches DCP's FindAvailableContainerRuntime): +// 1. If a runtime is explicitly configured, use it directly. +// 2. Otherwise, probe all known runtimes in parallel. +// 3. Prefer installed+running over installed-only over not-found. +// 4. When runtimes are equally available, prefer the default (Docker). + +using System.Diagnostics; + +namespace Aspire.Hosting; + +/// +/// Describes the availability of a single container runtime (e.g., Docker or Podman). +/// +internal sealed class ContainerRuntimeInfo +{ + /// + /// The executable name (e.g., "docker", "podman"). + /// + public required string Executable { get; init; } + + /// + /// Display name (e.g., "Docker", "Podman"). + /// + public required string Name { get; init; } + + /// + /// Whether the runtime CLI was found on PATH. + /// + public bool IsInstalled { get; init; } + + /// + /// Whether the runtime daemon/service is responding. + /// + public bool IsRunning { get; init; } + + /// + /// Whether this is the default runtime when all else is equal. + /// + public bool IsDefault { get; init; } + + /// + /// Error message if detection failed. + /// + public string? Error { get; init; } + + /// + /// Whether the runtime is fully operational. + /// + public bool IsHealthy => IsInstalled && IsRunning; +} + +/// +/// Detects available container runtimes by probing CLI executables on PATH. +/// Mirrors the detection logic used by DCP. +/// +internal static class ContainerRuntimeDetector +{ + private static readonly TimeSpan s_processTimeout = TimeSpan.FromSeconds(10); + + private static readonly (string Executable, string Name, bool IsDefault)[] s_knownRuntimes = + [ + ("docker", "Docker", true), + ("podman", "Podman", false) + ]; + + /// + /// Finds the best available container runtime, optionally using an explicit preference. + /// + /// + /// An explicitly configured runtime name (e.g., "docker" or "podman" from ASPIRE_CONTAINER_RUNTIME). + /// When set, only that runtime is checked. When null, all known runtimes are probed in parallel. + /// + /// Cancellation token. + /// + /// The best available runtime, or null if no runtime was found. + /// When a runtime is configured but not available, returns its info with = false. + /// + public static async Task FindAvailableRuntimeAsync(string? configuredRuntime = null, CancellationToken cancellationToken = default) + { + if (configuredRuntime is not null) + { + // Explicit config: check only the requested runtime + var known = s_knownRuntimes.FirstOrDefault(r => string.Equals(r.Executable, configuredRuntime, StringComparison.OrdinalIgnoreCase)); + var name = known.Name ?? configuredRuntime; + var isDefault = known.IsDefault; + return await CheckRuntimeAsync(configuredRuntime, name, isDefault, cancellationToken).ConfigureAwait(false); + } + + // Auto-detect: probe all runtimes in parallel (matches DCP behavior) + var tasks = s_knownRuntimes.Select(r => + CheckRuntimeAsync(r.Executable, r.Name, r.IsDefault, cancellationToken)).ToArray(); + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + + // Pick the best runtime using DCP's priority: + // 1. Prefer installed over not-installed + // 2. Prefer running over not-running + // 3. Prefer the default runtime when all else is equal + ContainerRuntimeInfo? best = null; + foreach (var candidate in results) + { + if (best is null) + { + best = candidate; + continue; + } + + if (!best.IsInstalled && candidate.IsInstalled) + { + best = candidate; + } + else if (!best.IsRunning && candidate.IsRunning) + { + best = candidate; + } + else if (candidate.IsDefault + && candidate.IsInstalled == best.IsInstalled + && candidate.IsRunning == best.IsRunning) + { + best = candidate; + } + } + + return best; + } + + /// + /// Checks the availability of a specific container runtime. + /// + public static async Task CheckRuntimeAsync(string executable, string name, bool isDefault, CancellationToken cancellationToken = default) + { + try + { + // Check if the CLI is installed by running ` container ls -n 1` + // This matches DCP's check and also validates the daemon is running. + var startInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = "container ls -n 1", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) + { + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = false, + IsRunning = false, + IsDefault = isDefault, + Error = $"{name} CLI not found on PATH." + }; + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_processTimeout); + + try + { + await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try { process.Kill(); } catch { /* best effort */ } + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = true, + IsRunning = false, + IsDefault = isDefault, + Error = $"{name} CLI timed out while checking status." + }; + } + + if (process.ExitCode == 0) + { + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = true, + IsRunning = true, + IsDefault = isDefault + }; + } + + // Non-zero exit code: CLI exists (we started it) but daemon may not be running. + // Try a simpler check to distinguish "not installed" from "not running" + var isInstalled = await IsCliInstalledAsync(executable, cancellationToken).ConfigureAwait(false); + + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = isInstalled, + IsRunning = false, + IsDefault = isDefault, + Error = isInstalled + ? $"{name} is installed but the daemon is not running." + : $"{name} CLI not found on PATH." + }; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException) + { + // Process.Start throws Win32Exception when the executable is not found + return new ContainerRuntimeInfo + { + Executable = executable, + Name = name, + IsInstalled = false, + IsRunning = false, + IsDefault = isDefault, + Error = $"{name} CLI not found on PATH." + }; + } + } + + private static async Task IsCliInstalledAsync(string executable, CancellationToken cancellationToken) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) + { + return false; + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_processTimeout); + + try + { + await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + return process.ExitCode == 0; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try { process.Kill(); } catch { /* best effort */ } + return false; + } + } + catch + { + return false; + } + } +} From 62e487025eef32cab244506c398ae3ba7f7f63b2 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 12:51:39 -0700 Subject: [PATCH 06/12] Improve aspire doctor to report all container runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aspire doctor now reports the status of every known container runtime (Docker and Podman) instead of just the first one found. Each entry shows: - Whether the runtime is installed and running - Whether it's the active (selected) runtime - Why it was selected (explicit config, auto-detected default, or only runtime running) Example output with Podman only: ❌ Docker: not found ✅ Podman: running (auto-detected, only runtime running) ← active Example with both + explicit override: ✅ Docker: running (available) ✅ Podman: running (configured via ASPIRE_CONTAINER_RUNTIME=podman) ← active Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ContainerRuntimeCheck.cs | 172 +++++++++++------- 1 file changed, 103 insertions(+), 69 deletions(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs index adfee69ad7e..aba98cf7510 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs @@ -33,68 +33,45 @@ public async Task> CheckAsync(Cancellation { try { - // Use shared detector to probe both runtimes in parallel - var dockerInfo = await ContainerRuntimeDetector.CheckRuntimeAsync("docker", "Docker", isDefault: true, cancellationToken); - var podmanInfo = await ContainerRuntimeDetector.CheckRuntimeAsync("podman", "Podman", isDefault: false, cancellationToken); - - // If Docker is healthy, do extended checks (version, Windows containers, tunnel) - if (dockerInfo.IsHealthy) + // Probe all runtimes in parallel + var runtimes = new[] { - var result = await CheckDockerExtendedAsync(cancellationToken); - if (result is not null) - { - return [result]; - } - } + await ContainerRuntimeDetector.CheckRuntimeAsync("docker", "Docker", isDefault: true, cancellationToken), + await ContainerRuntimeDetector.CheckRuntimeAsync("podman", "Podman", isDefault: false, cancellationToken) + }; - // If Podman is healthy, do version check - if (podmanInfo.IsHealthy) - { - var result = await CheckPodmanExtendedAsync(cancellationToken); - if (result is not null) - { - return [result]; - } - } + var configuredRuntime = Environment.GetEnvironmentVariable("ASPIRE_CONTAINER_RUNTIME") + ?? Environment.GetEnvironmentVariable("DOTNET_ASPIRE_CONTAINER_RUNTIME"); - // Prefer healthy Docker - if (dockerInfo.IsHealthy) - { - return [PassResult("Docker detected and running")]; - } + // Determine which runtime would be selected by auto-detection + var selected = await ContainerRuntimeDetector.FindAvailableRuntimeAsync(configuredRuntime, cancellationToken); - // Prefer healthy Podman - if (podmanInfo.IsHealthy) - { - return [PassResult("Podman detected and running")]; - } + var results = new List(); - // If Docker is installed but not running, prefer showing that error - if (dockerInfo.IsInstalled) + // Report each runtime's status + foreach (var info in runtimes) { - return [WarningResult( - "Docker is installed but not running", - GetContainerRuntimeStartupAdvice("Docker"))]; + var isSelected = selected is not null && + string.Equals(info.Executable, selected.Executable, StringComparison.OrdinalIgnoreCase); + + results.Add(await BuildRuntimeResultAsync(info, isSelected, configuredRuntime, cancellationToken)); } - // If Podman is installed but not running, show that - if (podmanInfo.IsInstalled) + // If nothing is available, add an overall failure + if (!runtimes.Any(r => r.IsInstalled)) { - return [WarningResult( - "Podman is installed but not running", - GetContainerRuntimeStartupAdvice("Podman"))]; + results.Add(new EnvironmentCheckResult + { + Category = "container", + Name = "container-runtime", + Status = EnvironmentCheckStatus.Fail, + Message = "No container runtime detected", + Fix = "Install Docker Desktop: https://www.docker.com/products/docker-desktop or Podman: https://podman.io/getting-started/installation", + Link = "https://aka.ms/dotnet/aspire/containers" + }); } - // Neither found - return [new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = "No container runtime detected", - Fix = "Install Docker Desktop: https://www.docker.com/products/docker-desktop or Podman: https://podman.io/getting-started/installation", - Link = "https://aka.ms/dotnet/aspire/containers" - }]; + return results; } catch (Exception ex) { @@ -110,16 +87,6 @@ public async Task> CheckAsync(Cancellation } } - private async Task CheckDockerExtendedAsync(CancellationToken cancellationToken) - { - return await CheckVersionAndModeAsync("Docker", cancellationToken); - } - - private async Task CheckPodmanExtendedAsync(CancellationToken cancellationToken) - { - return await CheckVersionAndModeAsync("Podman", cancellationToken); - } - private async Task CheckVersionAndModeAsync(string runtime, CancellationToken cancellationToken) { try @@ -216,14 +183,6 @@ context is not null && return null; // No issues found } - private static EnvironmentCheckResult PassResult(string message) => new() - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Pass, - Message = message - }; - private static EnvironmentCheckResult WarningResult(string message, string fix) => new() { Category = "container", @@ -234,6 +193,81 @@ context is not null && Link = "https://aka.ms/dotnet/aspire/containers" }; + private async Task BuildRuntimeResultAsync( + ContainerRuntimeInfo info, + bool isSelected, + string? configuredRuntime, + CancellationToken cancellationToken) + { + var selectedSuffix = isSelected ? " ← active" : ""; + + if (!info.IsInstalled) + { + return new EnvironmentCheckResult + { + Category = "container", + Name = info.Executable, + Status = EnvironmentCheckStatus.Fail, + Message = $"{info.Name}: not found", + Fix = GetContainerRuntimeInstallationLink(info.Name) + }; + } + + if (!info.IsRunning) + { + return new EnvironmentCheckResult + { + Category = "container", + Name = info.Executable, + Status = EnvironmentCheckStatus.Warning, + Message = $"{info.Name}: installed but not running{selectedSuffix}", + Fix = GetContainerRuntimeStartupAdvice(info.Name) + }; + } + + // Runtime is healthy — run extended checks (version, Windows containers, tunnel) + var extendedResult = await CheckVersionAndModeAsync(info.Name, cancellationToken); + if (extendedResult is not null) + { + // Append selection info to the extended result message + return new EnvironmentCheckResult + { + Category = extendedResult.Category, + Name = extendedResult.Name, + Status = extendedResult.Status, + Message = extendedResult.Message + selectedSuffix, + Fix = extendedResult.Fix, + Details = extendedResult.Details, + Link = extendedResult.Link + }; + } + + // Explain why this runtime was chosen + var reason = configuredRuntime is not null + ? $"configured via ASPIRE_CONTAINER_RUNTIME={configuredRuntime}" + : isSelected && info.IsDefault ? "auto-detected (default)" + : isSelected ? "auto-detected (only runtime running)" + : "available"; + + return new EnvironmentCheckResult + { + Category = "container", + Name = info.Executable, + Status = EnvironmentCheckStatus.Pass, + Message = $"{info.Name}: running ({reason}){selectedSuffix}" + }; + } + + private static string GetContainerRuntimeInstallationLink(string runtime) + { + return runtime switch + { + "Docker" => "Install Docker Desktop: https://www.docker.com/products/docker-desktop", + "Podman" => "Install Podman: https://podman.io/getting-started/installation", + _ => $"Install {runtime}" + }; + } + /// /// Parses a version number from container runtime output as a fallback when JSON parsing fails. /// From c32116d0de14ca1364917b69c3e6a61507dbd24c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 13:05:32 -0700 Subject: [PATCH 07/12] Fix issues found in code review - Fix localhive.ps1 $aspireRoot used before assignment (critical: script would crash with null path) - Fix EnsureRuntimeAvailable process leak: use async/await with IAsyncDisposable instead of broken IDisposable cast - Fix compose-up error message: only mention ASPIRE_CONTAINER_RUNTIME when env var is actually set, otherwise say 'auto-detected' - Restore Docker server version check in aspire doctor (was dropped during refactor) - Fix localhive.sh archive path: resolve to absolute before cd to avoid relative path issues with zip Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- localhive.ps1 | 38 +++++++++---------- localhive.sh | 6 ++- .../ContainerRuntimeCheck.cs | 13 ++++++- .../Publishing/ContainerRuntimeBase.cs | 20 ++++++---- 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/localhive.ps1 b/localhive.ps1 index 2885162acf7..9cbcf9adc4c 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -238,6 +238,25 @@ if (-not $packages -or $packages.Count -eq 0) { } Write-Log ("Found {0} packages in {1}" -f $packages.Count, $pkgDir) +# Determine the RID for the target platform (or auto-detect from host) +if ($Rid) { + $bundleRid = $Rid + Write-Log "Using target RID: $bundleRid" +} elseif ($IsWindows) { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'win-arm64' } else { 'win-x64' } +} elseif ($IsMacOS) { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'osx-arm64' } else { 'osx-x64' } +} else { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'linux-arm64' } else { 'linux-x64' } +} + +if ($Output) { + $aspireRoot = $Output +} else { + $aspireRoot = Join-Path $HOME '.aspire' +} +$cliBinDir = Join-Path $aspireRoot 'bin' + $hivesRoot = Join-Path $aspireRoot 'hives' $hiveRoot = Join-Path $hivesRoot $Name $hivePath = Join-Path $hiveRoot 'packages' @@ -284,25 +303,6 @@ else { } } -# Determine the RID for the target platform (or auto-detect from host) -if ($Rid) { - $bundleRid = $Rid - Write-Log "Using target RID: $bundleRid" -} elseif ($IsWindows) { - $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'win-arm64' } else { 'win-x64' } -} elseif ($IsMacOS) { - $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'osx-arm64' } else { 'osx-x64' } -} else { - $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'linux-arm64' } else { 'linux-x64' } -} - -if ($Output) { - $aspireRoot = $Output -} else { - $aspireRoot = Join-Path $HOME '.aspire' -} -$cliBinDir = Join-Path $aspireRoot 'bin' - # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) if (-not $SkipBundle) { $bundleProjPath = Join-Path $RepoRoot "eng" "Bundle.proj" diff --git a/localhive.sh b/localhive.sh index 05415ad0f0c..d79ad54a261 100755 --- a/localhive.sh +++ b/localhive.sh @@ -402,12 +402,14 @@ fi # Create archive if requested if [[ $ARCHIVE -eq 1 ]]; then + # Resolve to absolute path before cd to avoid relative path issues + ARCHIVE_BASE="$(cd "$(dirname "$OUTPUT_DIR")" && pwd)/$(basename "$OUTPUT_DIR")" if [[ "$BUNDLE_RID" == win-* ]]; then - ARCHIVE_PATH="${OUTPUT_DIR}.zip" + ARCHIVE_PATH="${ARCHIVE_BASE}.zip" log "Creating archive: $ARCHIVE_PATH" (cd "$OUTPUT_DIR" && zip -r "$ARCHIVE_PATH" .) else - ARCHIVE_PATH="${OUTPUT_DIR}.tar.gz" + ARCHIVE_PATH="${ARCHIVE_BASE}.tar.gz" log "Creating archive: $ARCHIVE_PATH" tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" . fi diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs index aba98cf7510..1c350107f2e 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs @@ -133,11 +133,20 @@ context is not null && var minimumVersion = GetMinimumVersion(runtime); - // Check minimum version + // Check minimum client version if (clientVersion is not null && minimumVersion is not null && clientVersion < minimumVersion) { return WarningResult( - $"{runtime} version {clientVersion} is below minimum required {GetMinimumVersionString(runtime)}", + $"{runtime} client version {clientVersion} is below minimum required {GetMinimumVersionString(runtime)}", + GetContainerRuntimeUpgradeAdvice(runtime)); + } + + // Check minimum server version (Docker only) + var serverVersion = versionInfo.ServerVersion; + if (runtime == "Docker" && serverVersion is not null && minimumVersion is not null && serverVersion < minimumVersion) + { + return WarningResult( + $"{runtime} server version {serverVersion} is below minimum required {GetMinimumVersionString(runtime)}", GetContainerRuntimeUpgradeAdvice(runtime)); } diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index f14038a89ba..39379c2c593 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -299,7 +299,7 @@ private ProcessSpec CreateProcessSpec(string arguments) public virtual async Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken) { - EnsureRuntimeAvailable(); + await EnsureRuntimeAvailableAsync().ConfigureAwait(false); var arguments = BuildComposeArguments(context); arguments += " up -d --remove-orphans"; @@ -333,17 +333,21 @@ public virtual async Task ComposeUpAsync(ComposeOperationContext context, Cancel if (processResult.ExitCode != 0) { + var envHint = Environment.GetEnvironmentVariable("ASPIRE_CONTAINER_RUNTIME") is not null + ? $"The container runtime is configured via ASPIRE_CONTAINER_RUNTIME (current: '{RuntimeExecutable}')." + : $"The container runtime was auto-detected as '{RuntimeExecutable}'. Set ASPIRE_CONTAINER_RUNTIME to override (e.g., 'docker' or 'podman')."; + throw new DistributedApplicationException( $"'{RuntimeExecutable} compose up' failed with exit code {processResult.ExitCode}. " + $"Ensure '{RuntimeExecutable}' is installed and available on PATH. " + - $"The container runtime is configured via the ASPIRE_CONTAINER_RUNTIME environment variable (current: '{RuntimeExecutable}')."); + envHint); } } } public virtual async Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken) { - EnsureRuntimeAvailable(); + await EnsureRuntimeAvailableAsync().ConfigureAwait(false); var arguments = BuildComposeArguments(context); arguments += " down"; @@ -385,7 +389,7 @@ public virtual async Task ComposeDownAsync(ComposeOperationContext context, Canc public virtual async Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) { - EnsureRuntimeAvailable(); + await EnsureRuntimeAvailableAsync().ConfigureAwait(false); var arguments = BuildComposeArguments(context); arguments += " ps --format json"; @@ -521,7 +525,7 @@ private static string BuildComposeArguments(ComposeOperationContext context) /// Validates that the container runtime binary is available on the system PATH. /// Fails fast with an actionable error message instead of a cryptic exit code. /// - private void EnsureRuntimeAvailable() + private async Task EnsureRuntimeAvailableAsync() { try { @@ -533,10 +537,10 @@ private void EnsureRuntimeAvailable() InheritEnv = true }; - var (pendingResult, disposable) = ProcessUtil.Run(spec); - using (disposable as IDisposable) + var (pendingResult, processDisposable) = ProcessUtil.Run(spec); + await using (processDisposable) { - var result = pendingResult.GetAwaiter().GetResult(); + var result = await pendingResult.ConfigureAwait(false); if (result.ExitCode != 0) { throw new DistributedApplicationException( From 00abc756ad163191f47e6fa92f6e8b7463b5cb0f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 13:58:10 -0700 Subject: [PATCH 08/12] Replace sync-over-async runtime resolution with IContainerRuntimeResolver Introduce IContainerRuntimeResolver with async ResolveAsync() that caches the result. This eliminates the .GetAwaiter().GetResult() call in the DI singleton factory that blocked the thread during startup while probing container runtimes. Callers now resolve IContainerRuntimeResolver and await ResolveAsync() instead of resolving IContainerRuntime directly. This is a breaking change for the experimental IContainerRuntime API surface. Also consolidates version detection into ContainerRuntimeDetector (AOT-friendly JsonDocument parsing), adds FindBestRuntime() for reuse without re-probing, and slims ContainerRuntimeCheck to pure policy checks with no process spawning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/JsonSourceGenerationContext.cs | 1 - .../ContainerRuntimeCheck.cs | 302 +++++------------- src/Aspire.Hosting.Azure/AcrLoginService.cs | 11 +- .../DockerComposeEnvironmentResource.cs | 4 +- .../DockerComposeServiceResource.cs | 2 +- .../ApplicationModel/ProjectResource.cs | 2 +- .../DistributedApplicationBuilder.cs | 33 +- .../Pipelines/PipelineStepHelpers.cs | 2 +- .../Publishing/ContainerRuntimeResolver.cs | 70 ++++ .../Publishing/IContainerRuntimeResolver.cs | 22 ++ .../ResourceContainerImageManager.cs | 9 +- src/Shared/ContainerRuntimeDetector.cs | 246 +++++++++++--- .../PodmanDeploymentTests.cs | 1 + .../AzureDeployerTests.cs | 6 +- .../FakeAcrLoginService.cs | 11 +- .../DockerComposeTests.cs | 2 + .../Publishing/FakeContainerRuntime.cs | 7 +- 17 files changed, 414 insertions(+), 317 deletions(-) create mode 100644 src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs create mode 100644 src/Aspire.Hosting/Publishing/IContainerRuntimeResolver.cs diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs index b0fadbcf717..814b794e0e8 100644 --- a/src/Aspire.Cli/JsonSourceGenerationContext.cs +++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs @@ -27,7 +27,6 @@ namespace Aspire.Cli; [JsonSerializable(typeof(DoctorCheckResponse))] [JsonSerializable(typeof(EnvironmentCheckResult))] [JsonSerializable(typeof(DoctorCheckSummary))] -[JsonSerializable(typeof(ContainerVersionJson))] [JsonSerializable(typeof(AspireJsonConfiguration))] [JsonSerializable(typeof(AspireConfigFile))] [JsonSerializable(typeof(List))] diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs index 1c350107f2e..74345c68e39 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs @@ -1,11 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Aspire.Hosting; +using Aspire.Shared; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Utils.EnvironmentChecker; @@ -13,9 +9,8 @@ namespace Aspire.Cli.Utils.EnvironmentChecker; /// /// Checks if a container runtime (Docker or Podman) is available and running. /// -internal sealed partial class ContainerRuntimeCheck(ILogger logger) : IEnvironmentCheck +internal sealed class ContainerRuntimeCheck(ILogger logger) : IEnvironmentCheck { - private static readonly TimeSpan s_processTimeout = TimeSpan.FromSeconds(10); /// /// Minimum Docker version required for Aspire. @@ -34,17 +29,24 @@ public async Task> CheckAsync(Cancellation try { // Probe all runtimes in parallel - var runtimes = new[] - { - await ContainerRuntimeDetector.CheckRuntimeAsync("docker", "Docker", isDefault: true, cancellationToken), - await ContainerRuntimeDetector.CheckRuntimeAsync("podman", "Podman", isDefault: false, cancellationToken) - }; + var dockerTask = ContainerRuntimeDetector.CheckRuntimeAsync("docker", "Docker", isDefault: true, logger, cancellationToken); + var podmanTask = ContainerRuntimeDetector.CheckRuntimeAsync("podman", "Podman", isDefault: false, logger, cancellationToken); + var runtimes = await Task.WhenAll(dockerTask, podmanTask); var configuredRuntime = Environment.GetEnvironmentVariable("ASPIRE_CONTAINER_RUNTIME") ?? Environment.GetEnvironmentVariable("DOTNET_ASPIRE_CONTAINER_RUNTIME"); - // Determine which runtime would be selected by auto-detection - var selected = await ContainerRuntimeDetector.FindAvailableRuntimeAsync(configuredRuntime, cancellationToken); + // Select best from already-probed results (no re-probing) + ContainerRuntimeInfo? selected; + if (configuredRuntime is not null) + { + selected = runtimes.FirstOrDefault(r => + string.Equals(r.Executable, configuredRuntime, StringComparison.OrdinalIgnoreCase)); + } + else + { + selected = ContainerRuntimeDetector.FindBestRuntime(runtimes); + } var results = new List(); @@ -54,7 +56,7 @@ await ContainerRuntimeDetector.CheckRuntimeAsync("podman", "Podman", isDefault: var isSelected = selected is not null && string.Equals(info.Executable, selected.Executable, StringComparison.OrdinalIgnoreCase); - results.Add(await BuildRuntimeResultAsync(info, isSelected, configuredRuntime, cancellationToken)); + results.Add(BuildRuntimeResult(info, isSelected, configuredRuntime, cancellationToken)); } // If nothing is available, add an overall failure @@ -87,109 +89,66 @@ await ContainerRuntimeDetector.CheckRuntimeAsync("podman", "Podman", isDefault: } } - private async Task CheckVersionAndModeAsync(string runtime, CancellationToken cancellationToken) + /// + /// Applies Aspire-specific policy checks (minimum version, Windows containers, tunnel) + /// using version info already gathered by the detector. No process spawning. + /// + private static EnvironmentCheckResult? CheckRuntimePolicy(ContainerRuntimeInfo info) { - try - { - var runtimeLower = runtime.ToLowerInvariant(); - var versionProcessInfo = new ProcessStartInfo - { - FileName = runtimeLower, - Arguments = "version -f json", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; + var minimumVersion = GetMinimumVersion(info.Name); - using var versionProcess = Process.Start(versionProcessInfo); - if (versionProcess is null) - { - return null; - } - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(s_processTimeout); - - string versionOutput; - try - { - versionOutput = await versionProcess.StandardOutput.ReadToEndAsync(timeoutCts.Token); - await versionProcess.WaitForExitAsync(timeoutCts.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - versionProcess.Kill(); - return null; - } - - var versionInfo = ContainerVersionInfo.Parse(versionOutput); - var clientVersion = versionInfo.ClientVersion ?? ParseVersionFromOutput(versionOutput); - var context = versionInfo.Context; - var serverOs = versionInfo.ServerOs; - var isDockerDesktop = runtime == "Docker" && - context is not null && - context.Contains("desktop", StringComparison.OrdinalIgnoreCase); - - var minimumVersion = GetMinimumVersion(runtime); + // Check minimum client version + if (info.ClientVersion is not null && minimumVersion is not null && info.ClientVersion < minimumVersion) + { + return WarningResult( + $"{info.Name} client version {info.ClientVersion} is below minimum required {GetMinimumVersionString(info.Name)}", + GetContainerRuntimeUpgradeAdvice(info.Name)); + } - // Check minimum client version - if (clientVersion is not null && minimumVersion is not null && clientVersion < minimumVersion) - { - return WarningResult( - $"{runtime} client version {clientVersion} is below minimum required {GetMinimumVersionString(runtime)}", - GetContainerRuntimeUpgradeAdvice(runtime)); - } + // Check minimum server version (Docker only) + if (info.Name == "Docker" && info.ServerVersion is not null && minimumVersion is not null && info.ServerVersion < minimumVersion) + { + return WarningResult( + $"{info.Name} server version {info.ServerVersion} is below minimum required {GetMinimumVersionString(info.Name)}", + GetContainerRuntimeUpgradeAdvice(info.Name)); + } - // Check minimum server version (Docker only) - var serverVersion = versionInfo.ServerVersion; - if (runtime == "Docker" && serverVersion is not null && minimumVersion is not null && serverVersion < minimumVersion) + // Docker-specific: check Windows container mode + if (info.Name == "Docker" && string.Equals(info.ServerOs, "windows", StringComparison.OrdinalIgnoreCase)) + { + var runtimeName = info.IsDockerDesktop ? "Docker Desktop" : "Docker"; + return new EnvironmentCheckResult { - return WarningResult( - $"{runtime} server version {serverVersion} is below minimum required {GetMinimumVersionString(runtime)}", - GetContainerRuntimeUpgradeAdvice(runtime)); - } + Category = "container", + Name = "container-runtime", + Status = EnvironmentCheckStatus.Fail, + Message = $"{runtimeName} is running in Windows container mode", + Details = "Aspire requires Linux containers. Windows containers are not supported.", + Fix = "Switch Docker Desktop to Linux containers mode (right-click Docker tray icon → 'Switch to Linux containers...')", + Link = "https://aka.ms/dotnet/aspire/containers" + }; + } - // Docker-specific: check Windows container mode - if (runtime == "Docker" && string.Equals(serverOs, "windows", StringComparison.OrdinalIgnoreCase)) + // Docker Engine (not Desktop): check tunnel + if (info.Name == "Docker" && !info.IsDockerDesktop) + { + var tunnelEnabled = Environment.GetEnvironmentVariable("ASPIRE_ENABLE_CONTAINER_TUNNEL"); + if (!string.Equals(tunnelEnabled, "true", StringComparison.OrdinalIgnoreCase)) { + var versionSuffix = info.ClientVersion is not null ? $" (version {info.ClientVersion})" : ""; return new EnvironmentCheckResult { Category = "container", Name = "container-runtime", - Status = EnvironmentCheckStatus.Fail, - Message = $"{(isDockerDesktop ? "Docker Desktop" : "Docker")} is running in Windows container mode", - Details = "Aspire requires Linux containers. Windows containers are not supported.", - Fix = "Switch Docker Desktop to Linux containers mode (right-click Docker tray icon → 'Switch to Linux containers...')", - Link = "https://aka.ms/dotnet/aspire/containers" + Status = EnvironmentCheckStatus.Warning, + Message = $"Docker Engine detected{versionSuffix}. Aspire's container tunnel is required to allow containers to reach applications running on the host", + Fix = "Set environment variable: ASPIRE_ENABLE_CONTAINER_TUNNEL=true", + Link = "https://aka.ms/aspire-prerequisites#docker-engine" }; } - - // Docker Engine (not Desktop): check tunnel - if (runtime == "Docker" && !isDockerDesktop) - { - var tunnelEnabled = Environment.GetEnvironmentVariable("ASPIRE_ENABLE_CONTAINER_TUNNEL"); - var versionSuffix = clientVersion is not null ? $" (version {clientVersion})" : ""; - if (!string.Equals(tunnelEnabled, "true", StringComparison.OrdinalIgnoreCase)) - { - return new EnvironmentCheckResult - { - Category = "container", - Name = "container-runtime", - Status = EnvironmentCheckStatus.Warning, - Message = $"Docker Engine detected{versionSuffix}. Aspire's container tunnel is required to allow containers to reach applications running on the host", - Fix = "Set environment variable: ASPIRE_ENABLE_CONTAINER_TUNNEL=true", - Link = "https://aka.ms/aspire-prerequisites#docker-engine" - }; - } - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error during extended {Runtime} check", runtime); } - return null; // No issues found + return null; // No issues } private static EnvironmentCheckResult WarningResult(string message, string fix) => new() @@ -202,11 +161,11 @@ context is not null && Link = "https://aka.ms/dotnet/aspire/containers" }; - private async Task BuildRuntimeResultAsync( + private static EnvironmentCheckResult BuildRuntimeResult( ContainerRuntimeInfo info, bool isSelected, string? configuredRuntime, - CancellationToken cancellationToken) + CancellationToken _) { var selectedSuffix = isSelected ? " ← active" : ""; @@ -230,40 +189,42 @@ private async Task BuildRuntimeResultAsync( Name = info.Executable, Status = EnvironmentCheckStatus.Warning, Message = $"{info.Name}: installed but not running{selectedSuffix}", - Fix = GetContainerRuntimeStartupAdvice(info.Name) + Fix = GetContainerRuntimeStartupAdvice(info.Name, info.IsDockerDesktop) }; } - // Runtime is healthy — run extended checks (version, Windows containers, tunnel) - var extendedResult = await CheckVersionAndModeAsync(info.Name, cancellationToken); - if (extendedResult is not null) + // Runtime is healthy — apply Aspire-specific policy checks (no process spawning) + var policyResult = CheckRuntimePolicy(info); + if (policyResult is not null) { - // Append selection info to the extended result message + // Append selection info to the policy result message return new EnvironmentCheckResult { - Category = extendedResult.Category, - Name = extendedResult.Name, - Status = extendedResult.Status, - Message = extendedResult.Message + selectedSuffix, - Fix = extendedResult.Fix, - Details = extendedResult.Details, - Link = extendedResult.Link + Category = policyResult.Category, + Name = policyResult.Name, + Status = policyResult.Status, + Message = policyResult.Message + selectedSuffix, + Fix = policyResult.Fix, + Details = policyResult.Details, + Link = policyResult.Link }; } // Explain why this runtime was chosen - var reason = configuredRuntime is not null + var reason = configuredRuntime is not null && isSelected ? $"configured via ASPIRE_CONTAINER_RUNTIME={configuredRuntime}" : isSelected && info.IsDefault ? "auto-detected (default)" : isSelected ? "auto-detected (only runtime running)" : "available"; + var versionSuffix = info.ClientVersion is not null ? $" v{info.ClientVersion}" : ""; + return new EnvironmentCheckResult { Category = "container", Name = info.Executable, Status = EnvironmentCheckStatus.Pass, - Message = $"{info.Name}: running ({reason}){selectedSuffix}" + Message = $"{info.Name}{versionSuffix}: running ({reason}){selectedSuffix}" }; } @@ -277,27 +238,6 @@ private static string GetContainerRuntimeInstallationLink(string runtime) }; } - /// - /// Parses a version number from container runtime output as a fallback when JSON parsing fails. - /// - internal static Version? ParseVersionFromOutput(string output) - { - if (string.IsNullOrWhiteSpace(output)) - { - return null; - } - - // Match version patterns like "20.10.17", "4.3.1", "27.5.1" etc. - // The pattern looks for "version" followed by a version number - var match = VersionRegex().Match(output); - if (match.Success && Version.TryParse(match.Groups[1].Value, out var version)) - { - return version; - } - - return null; - } - /// /// Gets the minimum required version for the specified container runtime. /// @@ -336,9 +276,6 @@ private static string GetContainerRuntimeUpgradeAdvice(string runtime) }; } - [GeneratedRegex(@"version\s+(\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase)] - private static partial Regex VersionRegex(); - private static string GetContainerRuntimeStartupAdvice(string runtime, bool isDockerDesktop = false) { return runtime switch @@ -350,82 +287,3 @@ private static string GetContainerRuntimeStartupAdvice(string runtime, bool isDo }; } } - -/// -/// Parsed container runtime version information. -/// -internal sealed record ContainerVersionInfo( - Version? ClientVersion, - Version? ServerVersion, - string? Context, - string? ServerOs) -{ - /// - /// Parses container version info from 'docker/podman version -f json' output. - /// - public static ContainerVersionInfo Parse(string? output) - { - if (string.IsNullOrWhiteSpace(output)) - { - return new ContainerVersionInfo(null, null, null, null); - } - - try - { - var json = JsonSerializer.Deserialize(output, JsonSourceGenerationContext.Default.ContainerVersionJson); - if (json is null) - { - return new ContainerVersionInfo(null, null, null, null); - } - - Version.TryParse(json.Client?.Version, out var clientVersion); - Version.TryParse(json.Server?.Version, out var serverVersion); - - return new ContainerVersionInfo( - clientVersion, - serverVersion, - json.Client?.Context, - json.Server?.Os); - } - catch (JsonException) - { - return new ContainerVersionInfo(null, null, null, null); - } - } -} - -/// -/// JSON structure for container runtime version output. -/// -internal sealed class ContainerVersionJson -{ - [JsonPropertyName("Client")] - public ContainerClientJson? Client { get; set; } - - [JsonPropertyName("Server")] - public ContainerServerJson? Server { get; set; } -} - -/// -/// JSON structure for the Client section of container runtime version output. -/// -internal sealed class ContainerClientJson -{ - [JsonPropertyName("Version")] - public string? Version { get; set; } - - [JsonPropertyName("Context")] - public string? Context { get; set; } -} - -/// -/// JSON structure for the Server section of container runtime version output. -/// -internal sealed class ContainerServerJson -{ - [JsonPropertyName("Version")] - public string? Version { get; set; } - - [JsonPropertyName("Os")] - public string? Os { get; set; } -} diff --git a/src/Aspire.Hosting.Azure/AcrLoginService.cs b/src/Aspire.Hosting.Azure/AcrLoginService.cs index ad4a4fe3881..bf943179647 100644 --- a/src/Aspire.Hosting.Azure/AcrLoginService.cs +++ b/src/Aspire.Hosting.Azure/AcrLoginService.cs @@ -26,7 +26,7 @@ internal sealed class AcrLoginService : IAcrLoginService }; private readonly IHttpClientFactory _httpClientFactory; - private readonly IContainerRuntime _containerRuntime; + private readonly IContainerRuntimeResolver _containerRuntimeResolver; private readonly ILogger _logger; private sealed class AcrRefreshTokenResponse @@ -42,12 +42,12 @@ private sealed class AcrRefreshTokenResponse /// Initializes a new instance of the class. /// /// The HTTP client factory for making OAuth2 exchange requests. - /// The container runtime for performing registry login. + /// The container runtime resolver for performing registry login. /// The logger for diagnostic output. - public AcrLoginService(IHttpClientFactory httpClientFactory, IContainerRuntime containerRuntime, ILogger logger) + public AcrLoginService(IHttpClientFactory httpClientFactory, IContainerRuntimeResolver containerRuntimeResolver, ILogger logger) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _containerRuntime = containerRuntime ?? throw new ArgumentNullException(nameof(containerRuntime)); + _containerRuntimeResolver = containerRuntimeResolver ?? throw new ArgumentNullException(nameof(containerRuntimeResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -76,7 +76,8 @@ public async Task LoginAsync( _logger.LogDebug("ACR refresh token acquired, length: {TokenLength}", refreshToken.Length); // Step 3: Login to the registry using container runtime - await _containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, refreshToken, cancellationToken).ConfigureAwait(false); + var containerRuntime = await _containerRuntimeResolver.ResolveAsync(cancellationToken).ConfigureAwait(false); + await containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, refreshToken, cancellationToken).ConfigureAwait(false); } private async Task ExchangeAadTokenForAcrRefreshTokenAsync( diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 6c76c5529b8..44047f40ef2 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -223,7 +223,7 @@ private async Task DockerComposeUpAsync(PipelineStepContext context) throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}"); } - var runtime = context.Services.GetRequiredService(); + var runtime = await context.Services.GetRequiredService().ResolveAsync(context.CancellationToken).ConfigureAwait(false); var deployTask = await context.ReportingStep.CreateTaskAsync( new MarkdownString($"Running compose up for **{Name}** using **{runtime.Name}**"), @@ -259,7 +259,7 @@ private async Task DockerComposeDownAsync(PipelineStepContext context) throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}"); } - var runtime = context.Services.GetRequiredService(); + var runtime = await context.Services.GetRequiredService().ResolveAsync(context.CancellationToken).ConfigureAwait(false); var deployTask = await context.ReportingStep.CreateTaskAsync( new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"), diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs index 4155de2a5b9..9cee6f081b5 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs @@ -335,7 +335,7 @@ private async Task PrintEndpointsAsync(PipelineStepContext context, DockerCompos } // Query the running containers for published ports - var runtime = context.Services.GetRequiredService(); + var runtime = await context.Services.GetRequiredService().ResolveAsync(context.CancellationToken).ConfigureAwait(false); var composeContext = environment.CreateComposeOperationContext(context); var services = await runtime.ComposeListServicesAsync(composeContext, context.CancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 1e960005d69..51f484731d4 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -144,7 +144,7 @@ private async Task BuildProjectImage(PipelineStepContext ctx) var tempTag = $"temp-{Guid.NewGuid():N}"; var tempImageName = $"{originalImageName}:{tempTag}"; - var containerRuntime = ctx.Services.GetRequiredService(); + var containerRuntime = await ctx.Services.GetRequiredService().ResolveAsync(ctx.CancellationToken).ConfigureAwait(false); logger.LogDebug("Tagging image {OriginalImageName} as {TempImageName}", originalImageName, tempImageName); await containerRuntime.TagImageAsync(originalImageName, tempImageName, ctx.CancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index b0e8ee6524f..bceeacd410e 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -511,38 +511,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) Eventing.Subscribe(BuiltInDistributedApplicationEventSubscriptionHandlers.MutateHttp2TransportAsync); _innerBuilder.Services.AddKeyedSingleton("docker"); _innerBuilder.Services.AddKeyedSingleton("podman"); - _innerBuilder.Services.AddSingleton(sp => - { - var dcpOptions = sp.GetRequiredService>(); - var logger = sp.GetRequiredService().CreateLogger("Aspire.Hosting.ContainerRuntime"); - var configuredRuntime = dcpOptions.Value.ContainerRuntime; - - if (configuredRuntime is not null) - { - logger.LogInformation("Container runtime '{RuntimeKey}' configured via ASPIRE_CONTAINER_RUNTIME.", configuredRuntime); - return sp.GetRequiredKeyedService(configuredRuntime); - } - - // Auto-detect: probe available runtimes, matching DCP's detection logic. - // See https://github.com/microsoft/dcp/blob/main/internal/containers/runtimes/runtime.go - var detected = ContainerRuntimeDetector.FindAvailableRuntimeAsync().GetAwaiter().GetResult(); - var runtimeKey = detected?.Executable ?? "docker"; - - if (detected is { IsHealthy: true }) - { - logger.LogInformation("Container runtime auto-detected: {RuntimeName} ({Executable}).", detected.Name, detected.Executable); - } - else if (detected is { IsInstalled: true }) - { - logger.LogWarning("Container runtime '{RuntimeName}' is installed but not running. {Error}", detected.Name, detected.Error); - } - else - { - logger.LogWarning("No container runtime detected, defaulting to 'docker'. Install Docker or Podman to use container features."); - } - - return sp.GetRequiredKeyedService(runtimeKey); - }); + _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepHelpers.cs b/src/Aspire.Hosting/Pipelines/PipelineStepHelpers.cs index 6e438c09df1..1e686028533 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepHelpers.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepHelpers.cs @@ -84,7 +84,7 @@ private static async Task TagImageForLocalRegistryAsync(IResource resource, Pipe : resource.Name.ToLowerInvariant(); // Only tag the image, don't push to a remote registry - var containerRuntime = context.Services.GetRequiredService(); + var containerRuntime = await context.Services.GetRequiredService().ResolveAsync(context.CancellationToken).ConfigureAwait(false); await containerRuntime.TagImageAsync(localImageName, targetTag, context.CancellationToken).ConfigureAwait(false); await tagTask.CompleteAsync( diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs new file mode 100644 index 00000000000..3f920f5387f --- /dev/null +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs @@ -0,0 +1,70 @@ +// 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 + +using Aspire.Hosting.Dcp; +using Aspire.Shared; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Publishing; + +/// +/// Resolves the container runtime asynchronously using explicit configuration or auto-detection. +/// Caches the result after first resolution. +/// +internal sealed class ContainerRuntimeResolver : IContainerRuntimeResolver +{ + private readonly IServiceProvider _serviceProvider; + private readonly IOptions _dcpOptions; + private readonly ILogger _logger; + private Task? _cachedTask; + + public ContainerRuntimeResolver( + IServiceProvider serviceProvider, + IOptions dcpOptions, + ILoggerFactory loggerFactory) + { + _serviceProvider = serviceProvider; + _dcpOptions = dcpOptions; + _logger = loggerFactory.CreateLogger("Aspire.Hosting.ContainerRuntime"); + } + + public Task ResolveAsync(CancellationToken cancellationToken = default) + { + return _cachedTask ??= ResolveInternalAsync(cancellationToken); + } + + private async Task ResolveInternalAsync(CancellationToken cancellationToken) + { + var configuredRuntime = _dcpOptions.Value.ContainerRuntime; + + if (configuredRuntime is not null) + { + _logger.LogInformation("Container runtime '{RuntimeKey}' configured via ASPIRE_CONTAINER_RUNTIME.", configuredRuntime); + return _serviceProvider.GetRequiredKeyedService(configuredRuntime); + } + + // Auto-detect: probe available runtimes asynchronously. + // See https://github.com/microsoft/dcp/blob/main/internal/containers/runtimes/runtime.go + var detected = await ContainerRuntimeDetector.FindAvailableRuntimeAsync(logger: _logger, cancellationToken: cancellationToken).ConfigureAwait(false); + var runtimeKey = detected?.Executable ?? "docker"; + + if (detected is { IsHealthy: true }) + { + _logger.LogInformation("Container runtime auto-detected: {RuntimeName} ({Executable}).", detected.Name, detected.Executable); + } + else if (detected is { IsInstalled: true }) + { + _logger.LogWarning("Container runtime '{RuntimeName}' is installed but not running. {Error}", detected.Name, detected.Error); + } + else + { + _logger.LogWarning("No container runtime detected, defaulting to 'docker'. Install Docker or Podman to use container features."); + } + + return _serviceProvider.GetRequiredKeyedService(runtimeKey); + } +} diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntimeResolver.cs b/src/Aspire.Hosting/Publishing/IContainerRuntimeResolver.cs new file mode 100644 index 00000000000..bc3f2e57b56 --- /dev/null +++ b/src/Aspire.Hosting/Publishing/IContainerRuntimeResolver.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Publishing; + +/// +/// Resolves the configured or auto-detected container runtime asynchronously. +/// The result is cached after the first resolution. +/// +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public interface IContainerRuntimeResolver +{ + /// + /// Resolves the container runtime, detecting it from the environment if not explicitly configured. + /// The result is cached after the first call. + /// + /// A token to cancel the operation. + /// The resolved container runtime. + Task ResolveAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs index c2887046135..df3a0113a64 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs @@ -158,14 +158,15 @@ public interface IResourceContainerImageManager internal sealed class ResourceContainerImageManager( ILogger logger, - IContainerRuntime containerRuntime, + IContainerRuntimeResolver containerRuntimeResolver, IServiceProvider serviceProvider, DistributedApplicationExecutionContext? executionContext = null) : IResourceContainerImageManager { // Disable concurrent builds for project resources to avoid issues with overlapping msbuild projects private readonly SemaphoreSlim _throttle = new(1); - private IContainerRuntime ContainerRuntime { get; } = containerRuntime; + private async Task GetContainerRuntimeAsync(CancellationToken cancellationToken) + => await containerRuntimeResolver.ResolveAsync(cancellationToken).ConfigureAwait(false); private sealed class ResolvedContainerBuildOptions { @@ -205,6 +206,7 @@ private async Task ResolveContainerBuildOptionsAs public async Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken = default) { + var ContainerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Starting to build container images"); // Only check container runtime health if there are resources that need it @@ -234,6 +236,7 @@ public async Task BuildImagesAsync(IEnumerable resources, Cancellatio public async Task BuildImageAsync(IResource resource, CancellationToken cancellationToken = default) { + var ContainerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Building container image for resource {ResourceName}", resource.Name); var options = await ResolveContainerBuildOptionsAsync(resource, cancellationToken).ConfigureAwait(false); @@ -418,6 +421,7 @@ private async Task ExecuteDotnetPublishAsync(IResource resource, ResolvedC private async Task BuildContainerImageFromDockerfileAsync(IResource resource, DockerfileBuildAnnotation dockerfileBuildAnnotation, string imageName, ResolvedContainerBuildOptions options, CancellationToken cancellationToken) { + var ContainerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Building image: {ResourceName}", resource.Name); // If there's a factory, generate the Dockerfile content and write it to the specified path @@ -514,6 +518,7 @@ await ContainerRuntime.BuildImageAsync( public async Task PushImageAsync(IResource resource, CancellationToken cancellationToken) { + var ContainerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); await ContainerRuntime.PushImageAsync(resource, cancellationToken).ConfigureAwait(false); } diff --git a/src/Shared/ContainerRuntimeDetector.cs b/src/Shared/ContainerRuntimeDetector.cs index c006eded73a..347b932cdd6 100644 --- a/src/Shared/ContainerRuntimeDetector.cs +++ b/src/Shared/ContainerRuntimeDetector.cs @@ -12,8 +12,11 @@ // 4. When runtimes are equally available, prefer the default (Docker). using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; -namespace Aspire.Hosting; +namespace Aspire.Shared; /// /// Describes the availability of a single container runtime (e.g., Docker or Podman). @@ -50,6 +53,26 @@ internal sealed class ContainerRuntimeInfo /// public string? Error { get; init; } + /// + /// The client (CLI) version, if detected. + /// + public Version? ClientVersion { get; init; } + + /// + /// The server (daemon/engine) version, if detected. + /// + public Version? ServerVersion { get; init; } + + /// + /// Whether this is Docker Desktop (vs Docker Engine). + /// + public bool IsDockerDesktop { get; init; } + + /// + /// The server OS (e.g., "linux", "windows"). Relevant for Docker's Windows container mode. + /// + public string? ServerOs { get; init; } + /// /// Whether the runtime is fully operational. /// @@ -77,12 +100,13 @@ private static readonly (string Executable, string Name, bool IsDefault)[] s_kno /// An explicitly configured runtime name (e.g., "docker" or "podman" from ASPIRE_CONTAINER_RUNTIME). /// When set, only that runtime is checked. When null, all known runtimes are probed in parallel. /// + /// Optional logger for diagnostic output during detection. /// Cancellation token. /// /// The best available runtime, or null if no runtime was found. /// When a runtime is configured but not available, returns its info with = false. /// - public static async Task FindAvailableRuntimeAsync(string? configuredRuntime = null, CancellationToken cancellationToken = default) + public static async Task FindAvailableRuntimeAsync(string? configuredRuntime = null, ILogger? logger = null, CancellationToken cancellationToken = default) { if (configuredRuntime is not null) { @@ -90,54 +114,28 @@ private static readonly (string Executable, string Name, bool IsDefault)[] s_kno var known = s_knownRuntimes.FirstOrDefault(r => string.Equals(r.Executable, configuredRuntime, StringComparison.OrdinalIgnoreCase)); var name = known.Name ?? configuredRuntime; var isDefault = known.IsDefault; - return await CheckRuntimeAsync(configuredRuntime, name, isDefault, cancellationToken).ConfigureAwait(false); + logger?.LogDebug("Checking explicitly configured runtime: {Runtime}", configuredRuntime); + return await CheckRuntimeAsync(configuredRuntime, name, isDefault, logger, cancellationToken).ConfigureAwait(false); } // Auto-detect: probe all runtimes in parallel (matches DCP behavior) + logger?.LogDebug("Auto-detecting container runtime, probing {Count} known runtimes...", s_knownRuntimes.Length); var tasks = s_knownRuntimes.Select(r => - CheckRuntimeAsync(r.Executable, r.Name, r.IsDefault, cancellationToken)).ToArray(); + CheckRuntimeAsync(r.Executable, r.Name, r.IsDefault, logger, cancellationToken)).ToArray(); var results = await Task.WhenAll(tasks).ConfigureAwait(false); - // Pick the best runtime using DCP's priority: - // 1. Prefer installed over not-installed - // 2. Prefer running over not-running - // 3. Prefer the default runtime when all else is equal - ContainerRuntimeInfo? best = null; - foreach (var candidate in results) - { - if (best is null) - { - best = candidate; - continue; - } - - if (!best.IsInstalled && candidate.IsInstalled) - { - best = candidate; - } - else if (!best.IsRunning && candidate.IsRunning) - { - best = candidate; - } - else if (candidate.IsDefault - && candidate.IsInstalled == best.IsInstalled - && candidate.IsRunning == best.IsRunning) - { - best = candidate; - } - } - - return best; + return FindBestRuntime(results); } /// /// Checks the availability of a specific container runtime. /// - public static async Task CheckRuntimeAsync(string executable, string name, bool isDefault, CancellationToken cancellationToken = default) + public static async Task CheckRuntimeAsync(string executable, string name, bool isDefault, ILogger? logger = null, CancellationToken cancellationToken = default) { try { + logger?.LogDebug("Probing container runtime '{Name}' ({Executable})...", name, executable); // Check if the CLI is installed by running ` container ls -n 1` // This matches DCP's check and also validates the daemon is running. var startInfo = new ProcessStartInfo @@ -187,19 +185,37 @@ public static async Task CheckRuntimeAsync(string executab if (process.ExitCode == 0) { + // Runtime is running — gather version metadata + logger?.LogDebug("{Name} is running, gathering version info...", name); + var versionInfo = await GetVersionInfoAsync(executable, cancellationToken).ConfigureAwait(false); + logger?.LogDebug("{Name}: client={ClientVersion}, server={ServerVersion}, desktop={IsDesktop}", name, versionInfo.ClientVersion, versionInfo.ServerVersion, versionInfo.IsDockerDesktop); + return new ContainerRuntimeInfo { Executable = executable, Name = name, IsInstalled = true, IsRunning = true, - IsDefault = isDefault + IsDefault = isDefault, + ClientVersion = versionInfo.ClientVersion, + ServerVersion = versionInfo.ServerVersion, + IsDockerDesktop = versionInfo.IsDockerDesktop, + ServerOs = versionInfo.ServerOs }; } // Non-zero exit code: CLI exists (we started it) but daemon may not be running. - // Try a simpler check to distinguish "not installed" from "not running" var isInstalled = await IsCliInstalledAsync(executable, cancellationToken).ConfigureAwait(false); + logger?.LogDebug("{Name}: exit code {ExitCode}, installed={IsInstalled}", name, process.ExitCode, isInstalled); + + var partialVersionInfo = isInstalled + ? await GetVersionInfoAsync(executable, cancellationToken).ConfigureAwait(false) + : default; + + var error = isInstalled + ? $"{name} is installed but the daemon is not running." + : $"{name} CLI not found on PATH."; + logger?.LogDebug("{Name}: {Error}", name, error); return new ContainerRuntimeInfo { @@ -208,14 +224,14 @@ public static async Task CheckRuntimeAsync(string executab IsInstalled = isInstalled, IsRunning = false, IsDefault = isDefault, - Error = isInstalled - ? $"{name} is installed but the daemon is not running." - : $"{name} CLI not found on PATH." + ClientVersion = partialVersionInfo.ClientVersion, + IsDockerDesktop = partialVersionInfo.IsDockerDesktop, + Error = error }; } catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException) { - // Process.Start throws Win32Exception when the executable is not found + logger?.LogDebug("{Name}: not found on PATH ({ExceptionMessage})", name, ex.Message); return new ContainerRuntimeInfo { Executable = executable, @@ -267,4 +283,152 @@ private static async Task IsCliInstalledAsync(string executable, Cancellat return false; } } + + /// + /// Selects the best runtime from pre-probed results using DCP's priority logic. + /// Use this when you've already probed runtimes and want to determine which one to use. + /// + public static ContainerRuntimeInfo? FindBestRuntime(IEnumerable results) + { + ContainerRuntimeInfo? best = null; + foreach (var candidate in results) + { + if (best is null) + { + best = candidate; + continue; + } + + if (!best.IsInstalled && candidate.IsInstalled) + { + best = candidate; + } + else if (!best.IsRunning && candidate.IsRunning) + { + best = candidate; + } + else if (candidate.IsDefault + && candidate.IsInstalled == best.IsInstalled + && candidate.IsRunning == best.IsRunning) + { + best = candidate; + } + } + + return best; + } + + /// + /// Gathers version metadata from <runtime> version -f json. + /// + private static async Task GetVersionInfoAsync(string executable, CancellationToken cancellationToken) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = "version -f json", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) + { + return default; + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_processTimeout); + + string output; + try + { + output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token).ConfigureAwait(false); + await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try { process.Kill(); } catch { /* best effort */ } + return default; + } + + return ParseVersionOutput(output); + } + catch + { + return default; + } + } + + /// + /// Parses the JSON output from docker/podman version -f json using AOT-compatible JsonDocument. + /// + internal static RuntimeVersionInfo ParseVersionOutput(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return default; + } + + try + { + using var doc = JsonDocument.Parse(output); + var root = doc.RootElement; + + Version? clientVersion = null; + Version? serverVersion = null; + string? context = null; + string? serverOs = null; + + if (root.TryGetProperty("Client", out var client)) + { + if (client.TryGetProperty("Version", out var cv)) + { + Version.TryParse(cv.GetString(), out clientVersion); + } + if (client.TryGetProperty("Context", out var ctx)) + { + context = ctx.GetString(); + } + } + + if (root.TryGetProperty("Server", out var server)) + { + if (server.TryGetProperty("Version", out var sv)) + { + Version.TryParse(sv.GetString(), out serverVersion); + } + if (server.TryGetProperty("Os", out var os)) + { + serverOs = os.GetString(); + } + } + + var isDockerDesktop = context is not null && + context.Contains("desktop", StringComparison.OrdinalIgnoreCase); + + return new RuntimeVersionInfo(clientVersion, serverVersion, isDockerDesktop, serverOs); + } + catch (JsonException) + { + // Fall back to regex parsing for non-JSON output + var match = Regex.Match(output, @"[Vv]ersion\s*:?\s*(\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase); + if (match.Success && Version.TryParse(match.Groups[1].Value, out var version)) + { + return new RuntimeVersionInfo(version, null, false, null); + } + + return default; + } + } + + internal readonly record struct RuntimeVersionInfo( + Version? ClientVersion, + Version? ServerVersion, + bool IsDockerDesktop, + string? ServerOs); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs index 643db4e1ba4..5d7a0a269a8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs @@ -19,6 +19,7 @@ public sealed class PodmanDeploymentTests(ITestOutputHelper output) private const string ProjectName = "AspirePodmanDeployTest"; [Fact] + [ActiveIssue("https://github.com/mitchdenny/hex1b/pull/270")] [OuterloopTest("Requires Podman and docker-compose v2 installed on the host")] public async Task CreateAndDeployToDockerComposeWithPodman() { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 4694845207a..0ef66685757 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1293,7 +1293,8 @@ private void ConfigureTestServices(IDistributedApplicationTestingBuilder builder builder.Services.AddSingleton(processRunner ?? new MockProcessRunner()); builder.Services.AddSingleton(); builder.Services.AddSingleton(containerRuntime ?? new FakeContainerRuntime()); - builder.Services.AddSingleton(sp => new FakeAcrLoginService(sp.GetRequiredService())); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); + builder.Services.AddSingleton(sp => new FakeAcrLoginService(sp.GetRequiredService())); } private sealed class NoOpDeploymentStateManager : IDeploymentStateManager @@ -1730,6 +1731,7 @@ private static void ConfigureTestServicesWithFileDeploymentStateManager( builder.Services.AddSingleton(new MockProcessRunner()); builder.Services.AddSingleton(); builder.Services.AddSingleton(new FakeContainerRuntime()); - builder.Services.AddSingleton(sp => new FakeAcrLoginService(sp.GetRequiredService())); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); + builder.Services.AddSingleton(sp => new FakeAcrLoginService(sp.GetRequiredService())); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/FakeAcrLoginService.cs b/tests/Aspire.Hosting.Azure.Tests/FakeAcrLoginService.cs index 52c64379485..8b427dc22b8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/FakeAcrLoginService.cs +++ b/tests/Aspire.Hosting.Azure.Tests/FakeAcrLoginService.cs @@ -12,15 +12,15 @@ internal sealed class FakeAcrLoginService : IAcrLoginService { private const string AcrUsername = "00000000-0000-0000-0000-000000000000"; - private readonly IContainerRuntime _containerRuntime; + private readonly IContainerRuntimeResolver _containerRuntimeResolver; public bool WasLoginCalled { get; private set; } public string? LastRegistryEndpoint { get; private set; } public string? LastTenantId { get; private set; } - public FakeAcrLoginService(IContainerRuntime containerRuntime) + public FakeAcrLoginService(IContainerRuntimeResolver containerRuntimeResolver) { - _containerRuntime = containerRuntime ?? throw new ArgumentNullException(nameof(containerRuntime)); + _containerRuntimeResolver = containerRuntimeResolver ?? throw new ArgumentNullException(nameof(containerRuntimeResolver)); } public async Task LoginAsync( @@ -33,8 +33,7 @@ public async Task LoginAsync( LastRegistryEndpoint = registryEndpoint; LastTenantId = tenantId; - // Call the container runtime to match real implementation behavior - // This allows tests to verify the container runtime was called - await _containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, "fake-refresh-token", cancellationToken); + var containerRuntime = await _containerRuntimeResolver.ResolveAsync(cancellationToken); + await containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, "fake-refresh-token", cancellationToken); } } diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index 00529c619d0..5577f29920a 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -668,6 +668,7 @@ public async Task PushImageToRegistry_WithLocalRegistry_OnlyTagsImage() var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "push-servicea"); builder.Services.AddSingleton(); builder.Services.AddSingleton(fakeRuntime); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); // No registry added - will use LocalContainerRegistry with empty endpoint builder.AddDockerComposeEnvironment("docker-compose"); @@ -698,6 +699,7 @@ public async Task PushImageToRegistry_WithRemoteRegistry_PushesImage() var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "push-servicea"); builder.Services.AddSingleton(new MockImageBuilderWithRuntime(fakeRuntime)); builder.Services.AddSingleton(fakeRuntime); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); // Add a remote registry with a non-empty endpoint var registry = builder.AddContainerRegistry("acr", "myregistry.azurecr.io"); diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs index 7e112f9689e..00b6354f090 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs @@ -11,7 +11,7 @@ namespace Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.ApplicationModel; -public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning = true) : IContainerRuntime +public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning = true) : IContainerRuntime, IContainerRuntimeResolver { public string Name => "fake-runtime"; public bool WasHealthCheckCalled { get; private set; } @@ -126,4 +126,9 @@ public Task ComposeDownAsync(ComposeOperationContext context, CancellationToken { return Task.FromResult?>(null); } + + public Task ResolveAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(this); + } } From eb4cacf10fe5a59426f33bbbe23328ebabd19e9b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 15:31:11 -0700 Subject: [PATCH 09/12] Fix resolver race condition and add unit tests Fix ContainerRuntimeResolver caching: - Use Lazy> for thread-safe single-initialization - Use CancellationToken.None so cached task isn't poisoned by per-operation cancellation tokens Add 22 unit tests: - FindBestRuntime: all priority permutations (running > installed > default tiebreaker, empty, single, neither) - ParseVersionOutput: Docker JSON, Podman JSON, Docker Desktop detection, Windows containers, regex fallback, null/empty - ParseComposeServiceEntries: NDJSON, JSON array, empty, invalid - ParsePodmanPsOutput: ports + labels, multi-container aggregation, no labels, empty, invalid JSON Make ParsePodmanPsOutput and ParseComposeServiceEntries internal for testability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Publishing/ContainerRuntimeBase.cs | 2 +- .../Publishing/ContainerRuntimeResolver.cs | 9 +- .../Publishing/PodmanContainerRuntime.cs | 2 +- .../Publishing/ComposeServiceParsingTests.cs | 129 +++++++++++++ .../ContainerRuntimeDetectorTests.cs | 179 ++++++++++++++++++ 5 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/Publishing/ComposeServiceParsingTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeDetectorTests.cs diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 39379c2c593..08a4e9c3b42 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -439,7 +439,7 @@ public virtual async Task ComposeDownAsync(ComposeOperationContext context, Canc /// /// Parses Docker Compose ps JSON output, handling both NDJSON (one object per line) and JSON array formats. /// - private static List ParseComposeServiceEntries(List outputLines) + internal static List ParseComposeServiceEntries(List outputLines) { var results = new List(); diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs index 3f920f5387f..443cb93bf03 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs @@ -20,7 +20,7 @@ internal sealed class ContainerRuntimeResolver : IContainerRuntimeResolver private readonly IServiceProvider _serviceProvider; private readonly IOptions _dcpOptions; private readonly ILogger _logger; - private Task? _cachedTask; + private readonly Lazy> _lazyRuntime; public ContainerRuntimeResolver( IServiceProvider serviceProvider, @@ -30,14 +30,15 @@ public ContainerRuntimeResolver( _serviceProvider = serviceProvider; _dcpOptions = dcpOptions; _logger = loggerFactory.CreateLogger("Aspire.Hosting.ContainerRuntime"); + _lazyRuntime = new Lazy>(ResolveInternalAsync); } public Task ResolveAsync(CancellationToken cancellationToken = default) { - return _cachedTask ??= ResolveInternalAsync(cancellationToken); + return _lazyRuntime.Value; } - private async Task ResolveInternalAsync(CancellationToken cancellationToken) + private async Task ResolveInternalAsync() { var configuredRuntime = _dcpOptions.Value.ContainerRuntime; @@ -49,7 +50,7 @@ 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 - var detected = await ContainerRuntimeDetector.FindAvailableRuntimeAsync(logger: _logger, cancellationToken: cancellationToken).ConfigureAwait(false); + var detected = await ContainerRuntimeDetector.FindAvailableRuntimeAsync(logger: _logger).ConfigureAwait(false); var runtimeKey = detected?.Executable ?? "docker"; if (detected is { IsHealthy: true }) diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs index 721f4a7eede..ba1ab6830fe 100644 --- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs @@ -74,7 +74,7 @@ public PodmanContainerRuntime(ILogger logger) : base(log /// Parses native podman ps --format json output into normalized entries. /// Podman returns a JSON array. Containers are aggregated by compose service name. /// - private static List ParsePodmanPsOutput(List outputLines) + internal static List ParsePodmanPsOutput(List outputLines) { var allText = string.Join("", outputLines); if (string.IsNullOrWhiteSpace(allText)) diff --git a/tests/Aspire.Hosting.Tests/Publishing/ComposeServiceParsingTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ComposeServiceParsingTests.cs new file mode 100644 index 00000000000..2adec508074 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Publishing/ComposeServiceParsingTests.cs @@ -0,0 +1,129 @@ +// 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 + +using Aspire.Hosting.Publishing; + +namespace Aspire.Hosting.Tests.Publishing; + +public class ComposeServiceParsingTests +{ + [Fact] + public void ParseComposeServiceEntries_NdjsonFormat_ParsesCorrectly() + { + var lines = new List + { + """{"Service":"web","Publishers":[{"URL":"","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"}]}""", + """{"Service":"cache","Publishers":[{"URL":"","TargetPort":6379,"PublishedPort":6379,"Protocol":"tcp"}]}""" + }; + + var results = ContainerRuntimeBase.ParseComposeServiceEntries(lines); + + Assert.Equal(2, results.Count); + Assert.Equal("web", results[0].Service); + Assert.Equal(80, results[0].Publishers?[0].TargetPort); + Assert.Equal(8080, results[0].Publishers?[0].PublishedPort); + Assert.Equal("cache", results[1].Service); + } + + [Fact] + public void ParseComposeServiceEntries_JsonArrayFormat_ParsesCorrectly() + { + var lines = new List + { + """[{"Service":"web","Publishers":[{"TargetPort":80,"PublishedPort":8080}]},{"Service":"db","Publishers":[]}]""" + }; + + var results = ContainerRuntimeBase.ParseComposeServiceEntries(lines); + + Assert.Equal(2, results.Count); + Assert.Equal("web", results[0].Service); + Assert.Equal("db", results[1].Service); + } + + [Fact] + public void ParseComposeServiceEntries_EmptyLines_ReturnsEmpty() + { + var results = ContainerRuntimeBase.ParseComposeServiceEntries([]); + + Assert.Empty(results); + } + + [Fact] + public void ParseComposeServiceEntries_InvalidJson_SkipsLine() + { + var lines = new List + { + "not json", + """{"Service":"web","Publishers":[{"TargetPort":80,"PublishedPort":8080}]}""" + }; + + var results = ContainerRuntimeBase.ParseComposeServiceEntries(lines); + + Assert.Single(results); + Assert.Equal("web", results[0].Service); + } + + [Fact] + public void ParsePodmanPsOutput_ParsesPortsAndLabels() + { + var lines = new List + { + """[{"Labels":{"com.docker.compose.service":"web"},"Ports":[{"host_ip":"","container_port":80,"host_port":8080,"range":1,"protocol":"tcp"}]},{"Labels":{"com.docker.compose.service":"cache"},"Ports":[{"host_ip":"","container_port":6379,"host_port":6379,"range":1,"protocol":"tcp"}]}]""" + }; + + var results = PodmanContainerRuntime.ParsePodmanPsOutput(lines); + + Assert.Equal(2, results.Count); + Assert.Equal("web", results[0].Service); + Assert.Equal(80, results[0].Publishers?[0].TargetPort); + Assert.Equal(8080, results[0].Publishers?[0].PublishedPort); + Assert.Equal("cache", results[1].Service); + Assert.Equal(6379, results[1].Publishers?[0].TargetPort); + } + + [Fact] + public void ParsePodmanPsOutput_AggregatesMultipleContainersPerService() + { + var lines = new List + { + """[{"Labels":{"com.docker.compose.service":"web"},"Ports":[{"container_port":80,"host_port":8080}]},{"Labels":{"com.docker.compose.service":"web"},"Ports":[{"container_port":443,"host_port":8443}]}]""" + }; + + var results = PodmanContainerRuntime.ParsePodmanPsOutput(lines); + + Assert.Single(results); + Assert.Equal("web", results[0].Service); + Assert.Equal(2, results[0].Publishers?.Count); + } + + [Fact] + public void ParsePodmanPsOutput_NoLabels_SkipsContainer() + { + var lines = new List + { + """[{"Labels":{},"Ports":[{"container_port":80,"host_port":8080}]}]""" + }; + + var results = PodmanContainerRuntime.ParsePodmanPsOutput(lines); + + Assert.Empty(results); + } + + [Fact] + public void ParsePodmanPsOutput_EmptyInput_ReturnsEmpty() + { + var results = PodmanContainerRuntime.ParsePodmanPsOutput([]); + + Assert.Empty(results); + } + + [Fact] + public void ParsePodmanPsOutput_InvalidJson_ReturnsEmpty() + { + var results = PodmanContainerRuntime.ParsePodmanPsOutput(["not json"]); + + Assert.Empty(results); + } +} diff --git a/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeDetectorTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeDetectorTests.cs new file mode 100644 index 00000000000..ff677089a29 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeDetectorTests.cs @@ -0,0 +1,179 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared; + +namespace Aspire.Hosting.Tests.Publishing; + +public class ContainerRuntimeDetectorTests +{ + [Fact] + public void FindBestRuntime_PrefersRunningOverInstalled() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "docker", Name = "Docker", IsInstalled = true, IsRunning = false, IsDefault = true }, + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = true, IsRunning = true, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("podman", best?.Executable); + } + + [Fact] + public void FindBestRuntime_PrefersInstalledOverNotInstalled() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "docker", Name = "Docker", IsInstalled = false, IsRunning = false, IsDefault = true }, + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = true, IsRunning = false, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("podman", best?.Executable); + } + + [Fact] + public void FindBestRuntime_PrefersDefaultWhenEqual() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "docker", Name = "Docker", IsInstalled = true, IsRunning = true, IsDefault = true }, + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = true, IsRunning = true, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("docker", best?.Executable); + } + + [Fact] + public void FindBestRuntime_ReturnsNullForEmpty() + { + var best = ContainerRuntimeDetector.FindBestRuntime([]); + + Assert.Null(best); + } + + [Fact] + public void FindBestRuntime_ReturnsSingleRuntime() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = true, IsRunning = true, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("podman", best?.Executable); + } + + [Fact] + public void FindBestRuntime_NeitherInstalled_ReturnsDefault() + { + var runtimes = new[] + { + new ContainerRuntimeInfo { Executable = "docker", Name = "Docker", IsInstalled = false, IsRunning = false, IsDefault = true }, + new ContainerRuntimeInfo { Executable = "podman", Name = "Podman", IsInstalled = false, IsRunning = false, IsDefault = false } + }; + + var best = ContainerRuntimeDetector.FindBestRuntime(runtimes); + + Assert.Equal("docker", best?.Executable); + } + + [Fact] + public void ParseVersionOutput_ValidDockerJson_ParsesVersions() + { + var json = """ + { + "Client": { "Version": "28.0.1", "Context": "desktop-linux" }, + "Server": { "Version": "27.5.0", "Os": "linux" } + } + """; + + var info = ContainerRuntimeDetector.ParseVersionOutput(json); + + Assert.Equal(new Version(28, 0, 1), info.ClientVersion); + Assert.Equal(new Version(27, 5, 0), info.ServerVersion); + Assert.True(info.IsDockerDesktop); + Assert.Equal("linux", info.ServerOs); + } + + [Fact] + public void ParseVersionOutput_DockerEngine_NotDesktop() + { + var json = """ + { + "Client": { "Version": "29.1.3" }, + "Server": { "Version": "29.1.3", "Os": "linux" } + } + """; + + var info = ContainerRuntimeDetector.ParseVersionOutput(json); + + Assert.Equal(new Version(29, 1, 3), info.ClientVersion); + Assert.False(info.IsDockerDesktop); + } + + [Fact] + public void ParseVersionOutput_PodmanJson_ParsesClient() + { + var json = """ + { + "Client": { "Version": "4.9.3" } + } + """; + + var info = ContainerRuntimeDetector.ParseVersionOutput(json); + + Assert.Equal(new Version(4, 9, 3), info.ClientVersion); + Assert.Null(info.ServerVersion); + Assert.False(info.IsDockerDesktop); + } + + [Fact] + public void ParseVersionOutput_NonJsonFallback_UsesRegex() + { + var text = "podman version 5.2.1"; + + var info = ContainerRuntimeDetector.ParseVersionOutput(text); + + Assert.Equal(new Version(5, 2, 1), info.ClientVersion); + } + + [Fact] + public void ParseVersionOutput_NullInput_ReturnsDefault() + { + var info = ContainerRuntimeDetector.ParseVersionOutput(null); + + Assert.Null(info.ClientVersion); + Assert.Null(info.ServerVersion); + Assert.False(info.IsDockerDesktop); + } + + [Fact] + public void ParseVersionOutput_EmptyInput_ReturnsDefault() + { + var info = ContainerRuntimeDetector.ParseVersionOutput(""); + + Assert.Null(info.ClientVersion); + } + + [Fact] + public void ParseVersionOutput_WindowsContainers_DetectsOs() + { + var json = """ + { + "Client": { "Version": "28.0.1", "Context": "desktop-linux" }, + "Server": { "Version": "28.0.1", "Os": "windows" } + } + """; + + var info = ContainerRuntimeDetector.ParseVersionOutput(json); + + Assert.Equal("windows", info.ServerOs); + } +} From dbac599ff263018b3e139654eb5c6ed444c86280 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 18:06:12 -0700 Subject: [PATCH 10/12] Fix CI: migrate CLI tests to shared detector, fix JSON null handling - Migrate ContainerRuntimeCheckTests from deleted ContainerVersionInfo to ContainerRuntimeDetector.ParseVersionOutput - Fix JSON null handling: use JsonSerializerContext with strong types instead of JsonDocument (Server:null is handled by nullable properties) - Add missing IContainerRuntimeResolver registration in ProjectResourceTests - Remove deleted ContainerVersionJson from CLI source gen context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Publishing/ContainerRuntimeBase.cs | 11 +++ .../Publishing/PodmanContainerRuntime.cs | 5 ++ src/Shared/ContainerRuntimeDetector.cs | 76 ++++++++++------- .../Utils/ContainerRuntimeCheckTests.cs | 81 +++++++++++-------- .../ProjectResourceTests.cs | 1 + 5 files changed, 109 insertions(+), 65 deletions(-) diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 08a4e9c3b42..d3f0d529672 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -439,6 +439,17 @@ public virtual async Task ComposeDownAsync(ComposeOperationContext context, Canc /// /// Parses Docker Compose ps JSON output, handling both NDJSON (one object per line) and JSON array formats. /// + /// + /// NDJSON (Docker Compose v2+): + /// + /// {"Service":"web","Publishers":[{"URL":"","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"}]} + /// {"Service":"cache","Publishers":[{"TargetPort":6379,"PublishedPort":6379}]} + /// + /// JSON array (older versions): + /// + /// [{"Service":"web","Publishers":[{"TargetPort":80,"PublishedPort":8080}]}] + /// + /// internal static List ParseComposeServiceEntries(List outputLines) { var results = new List(); diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs index ba1ab6830fe..005ee4a13e9 100644 --- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs @@ -74,6 +74,11 @@ public PodmanContainerRuntime(ILogger logger) : base(log /// Parses native podman ps --format json output into normalized entries. /// Podman returns a JSON array. Containers are aggregated by compose service name. /// + /// + /// + /// [{"Labels":{"com.docker.compose.service":"web"},"Ports":[{"host_ip":"","container_port":80,"host_port":8080,"range":1,"protocol":"tcp"}]}] + /// + /// internal static List ParsePodmanPsOutput(List outputLines) { var allText = string.Join("", outputLines); diff --git a/src/Shared/ContainerRuntimeDetector.cs b/src/Shared/ContainerRuntimeDetector.cs index 347b932cdd6..bedc765045a 100644 --- a/src/Shared/ContainerRuntimeDetector.cs +++ b/src/Shared/ContainerRuntimeDetector.cs @@ -13,6 +13,7 @@ using System.Diagnostics; using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; @@ -365,8 +366,18 @@ private static async Task GetVersionInfoAsync(string executa } /// - /// Parses the JSON output from docker/podman version -f json using AOT-compatible JsonDocument. + /// Parses the JSON output from docker/podman version -f json using source-generated JSON serialization. /// + /// + /// Docker: + /// + /// {"Client":{"Version":"28.0.1","Context":"desktop-linux"},"Server":{"Version":"27.5.0","Os":"linux"}} + /// + /// Podman: + /// + /// {"Client":{"Version":"4.9.3"},"Server":null} + /// + /// internal static RuntimeVersionInfo ParseVersionOutput(string? output) { if (string.IsNullOrWhiteSpace(output)) @@ -376,42 +387,19 @@ internal static RuntimeVersionInfo ParseVersionOutput(string? output) try { - using var doc = JsonDocument.Parse(output); - var root = doc.RootElement; - - Version? clientVersion = null; - Version? serverVersion = null; - string? context = null; - string? serverOs = null; - - if (root.TryGetProperty("Client", out var client)) + var json = JsonSerializer.Deserialize(output, ContainerRuntimeJsonContext.Default.ContainerRuntimeVersionJson); + if (json is null) { - if (client.TryGetProperty("Version", out var cv)) - { - Version.TryParse(cv.GetString(), out clientVersion); - } - if (client.TryGetProperty("Context", out var ctx)) - { - context = ctx.GetString(); - } - } - - if (root.TryGetProperty("Server", out var server)) - { - if (server.TryGetProperty("Version", out var sv)) - { - Version.TryParse(sv.GetString(), out serverVersion); - } - if (server.TryGetProperty("Os", out var os)) - { - serverOs = os.GetString(); - } + return default; } + Version.TryParse(json.Client?.Version, out var clientVersion); + Version.TryParse(json.Server?.Version, out var serverVersion); + var context = json.Client?.Context; var isDockerDesktop = context is not null && context.Contains("desktop", StringComparison.OrdinalIgnoreCase); - return new RuntimeVersionInfo(clientVersion, serverVersion, isDockerDesktop, serverOs); + return new RuntimeVersionInfo(clientVersion, serverVersion, isDockerDesktop, json.Server?.Os); } catch (JsonException) { @@ -432,3 +420,29 @@ internal readonly record struct RuntimeVersionInfo( bool IsDockerDesktop, string? ServerOs); } + +internal sealed class ContainerRuntimeVersionJson +{ + [JsonPropertyName("Client")] + public ContainerRuntimeComponentJson? Client { get; set; } + + [JsonPropertyName("Server")] + public ContainerRuntimeComponentJson? Server { get; set; } +} + +internal sealed class ContainerRuntimeComponentJson +{ + [JsonPropertyName("Version")] + public string? Version { get; set; } + + [JsonPropertyName("Context")] + public string? Context { get; set; } + + [JsonPropertyName("Os")] + public string? Os { get; set; } +} + +[JsonSerializable(typeof(ContainerRuntimeVersionJson))] +internal sealed partial class ContainerRuntimeJsonContext : JsonSerializerContext +{ +} diff --git a/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs index 72d435400a8..9faea5c2dc6 100644 --- a/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/ContainerRuntimeCheckTests.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Cli.Utils.EnvironmentChecker; +using Aspire.Shared; namespace Aspire.Cli.Tests.Utils; @@ -13,13 +13,14 @@ public void ParseVersionFromJsonOutput_WithDockerJsonOutput_ReturnsBothVersions( // Real Docker version -f json output with both client and server var input = """{"Client":{"Platform":{"Name":"Docker Engine - Community"},"Version":"28.0.4","ApiVersion":"1.48","DefaultAPIVersion":"1.48","GitCommit":"b8034c0","GoVersion":"go1.23.7","Os":"linux","Arch":"amd64","BuildTime":"Tue Mar 25 15:07:16 2025","Context":"default"},"Server":{"Platform":{"Name":"Docker Engine - Community"},"Components":[{"Name":"Engine","Version":"28.0.4"}],"Version":"28.0.4","ApiVersion":"1.48"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(28, 0, 4), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(28, 0, 4), serverVersion); - Assert.Equal("default", context); + Assert.False(info.IsDockerDesktop); Assert.Null(serverOs); } @@ -29,13 +30,14 @@ public void ParseVersionFromJsonOutput_WithDockerDesktopMacJsonOutput_ReturnsBot // Docker Desktop on macOS JSON output var input = """{"Client":{"Version":"28.5.1","ApiVersion":"1.51","DefaultAPIVersion":"1.51","GitCommit":"e180ab8","GoVersion":"go1.24.8","Os":"darwin","Arch":"arm64","BuildTime":"Wed Oct 8 12:16:17 2025","Context":"desktop-linux"},"Server":{"Platform":{"Name":"Docker Desktop 4.49.0 (208700)"},"Components":[{"Name":"Engine","Version":"28.5.1"}],"Version":"28.5.1","ApiVersion":"1.51","MinAPIVersion":"1.24","GitCommit":"f8215cc","GoVersion":"go1.24.8","Os":"linux","Arch":"arm64","KernelVersion":"6.10.14-linuxkit","BuildTime":"2025-10-08T12:18:25.000000000+00:00"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(28, 5, 1), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(28, 5, 1), serverVersion); - Assert.Equal("desktop-linux", context); + Assert.True(info.IsDockerDesktop); Assert.Equal("linux", serverOs); } @@ -45,13 +47,14 @@ public void ParseVersionFromJsonOutput_WithDockerEngineJsonOutput_ReturnsBothVer // Docker Engine (Linux) JSON output var input = """{"Client":{"Platform":{"Name":"Docker Engine - Community"},"Version":"29.1.3","ApiVersion":"1.52","DefaultAPIVersion":"1.52","GitCommit":"f52814d","GoVersion":"go1.25.5","Os":"linux","Arch":"amd64","BuildTime":"Fri Dec 12 14:49:37 2025","Context":"default"},"Server":{"Platform":{"Name":"Docker Engine - Community"},"Version":"29.1.3","ApiVersion":"1.52","MinAPIVersion":"1.44","Os":"linux","Arch":"amd64","Components":[{"Name":"Engine","Version":"29.1.3"}],"GitCommit":"fbf3ed2","GoVersion":"go1.25.5","KernelVersion":"5.15.0-113-generic","BuildTime":"2025-12-12T14:49:37.000000000+00:00"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(29, 1, 3), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(29, 1, 3), serverVersion); - Assert.Equal("default", context); + Assert.False(info.IsDockerDesktop); Assert.Equal("linux", serverOs); } @@ -61,12 +64,13 @@ public void ParseVersionFromJsonOutput_WithPodmanJsonOutput_ReturnsClientVersion // Real Podman version -f json output (no Server section) var input = """{"Client":{"APIVersion":"4.9.3","Version":"4.9.3","GoVersion":"go1.22.2","GitCommit":"","BuiltTime":"Thu Jan 1 00:00:00 1970","Built":0,"OsArch":"linux/amd64","Os":"linux"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(4, 9, 3), clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -76,12 +80,13 @@ public void ParseVersionFromJsonOutput_WithDockerDesktopWindowsJsonOutput_Server // Docker Desktop on Windows may have Server:null if daemon is not running var input = """{"Client":{"Version":"29.1.3","ApiVersion":"1.52","DefaultAPIVersion":"1.52","GitCommit":"f52814d","GoVersion":"go1.25.5","Os":"windows","Arch":"amd64","BuildTime":"Fri Dec 12 14:51:52 2025","Context":"desktop-linux"},"Server":null}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(29, 1, 3), clientVersion); Assert.Null(serverVersion); - Assert.Equal("desktop-linux", context); + Assert.True(info.IsDockerDesktop); Assert.Null(serverOs); } @@ -90,12 +95,13 @@ public void ParseVersionFromJsonOutput_WithOldDockerVersion_ReturnsClientVersion { var input = """{"Client":{"Version":"19.03.15","ApiVersion":"1.40"},"Server":null}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(19, 3, 15), clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -104,12 +110,13 @@ public void ParseVersionFromJsonOutput_WithTwoPartVersion_ReturnsVersion() { var input = """{"Client":{"Version":"20.10","ApiVersion":"1.41"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(20, 10), clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -122,11 +129,12 @@ public void ParseVersionFromJsonOutput_WithTwoPartVersion_ReturnsVersion() [InlineData("""{"Client":{}}""")] public void ParseVersionFromJsonOutput_WithInvalidInput_ReturnsNullVersions(string? input) { - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input!); + var info = ContainerRuntimeDetector.ParseVersionOutput(input!); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.Null(clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -136,12 +144,13 @@ public void ParseVersionFromJsonOutput_WithOnlyServerVersion_ReturnsServerVersio // Edge case: only server version present (unusual but possible) var input = """{"Server":{"Version":"1.0.0"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.Null(clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(1, 0, 0), serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -150,11 +159,12 @@ public void ParseVersionFromJsonOutput_WithInvalidVersionString_ReturnsNull() { var input = """{"Client":{"Version":"not-a-version"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.Null(clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -163,11 +173,12 @@ public void ParseVersionFromJsonOutput_WithMalformedJson_ReturnsNull() { var input = "{\"Client\":{\"Version\":\"28.0.4\""; // Missing closing braces - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.Null(clientVersion); Assert.Null(serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -177,13 +188,14 @@ public void ParseVersionFromJsonOutput_WithMismatchedClientServerVersions_Return // Hypothetical case where client and server versions differ var input = """{"Client":{"Version":"28.0.4"},"Server":{"Version":"27.5.1"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(28, 0, 4), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(27, 5, 1), serverVersion); - Assert.Null(context); + // Context is not exposed directly; tested via IsDockerDesktop Assert.Null(serverOs); } @@ -193,13 +205,14 @@ public void ParseVersionFromJsonOutput_WithWindowsContainerMode_ReturnsWindowsSe // Docker running in Windows container mode var input = """{"Client":{"Version":"28.0.4","Context":"default"},"Server":{"Version":"28.0.4","Os":"windows"}}"""; - var (clientVersion, serverVersion, context, serverOs) = ContainerVersionInfo.Parse(input); + var info = ContainerRuntimeDetector.ParseVersionOutput(input); + var (clientVersion, serverVersion, serverOs) = (info.ClientVersion, info.ServerVersion, info.ServerOs); Assert.NotNull(clientVersion); Assert.Equal(new Version(28, 0, 4), clientVersion); Assert.NotNull(serverVersion); Assert.Equal(new Version(28, 0, 4), serverVersion); - Assert.Equal("default", context); + Assert.False(info.IsDockerDesktop); Assert.Equal("windows", serverOs); } @@ -214,7 +227,7 @@ public void ParseVersionFromJsonOutput_WithWindowsContainerMode_ReturnsWindowsSe [InlineData("Docker version 20.10, build abc123", "20.10")] public void ParseVersionFromOutput_WithValidVersionString_ReturnsCorrectVersion(string input, string expectedVersion) { - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.NotNull(result); Assert.Equal(Version.Parse(expectedVersion), result); @@ -228,7 +241,7 @@ public void ParseVersionFromOutput_WithValidVersionString_ReturnsCorrectVersion( [InlineData("random text without version info")] public void ParseVersionFromOutput_WithInvalidInput_ReturnsNull(string input) { - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.Null(result); } @@ -236,9 +249,9 @@ public void ParseVersionFromOutput_WithInvalidInput_ReturnsNull(string input) [Fact] public void ParseVersionFromOutput_WithNullInput_ReturnsNull() { - var result = ContainerRuntimeCheck.ParseVersionFromOutput(null!); + var result = ContainerRuntimeDetector.ParseVersionOutput(null!); - Assert.Null(result); + Assert.Null(result.ClientVersion); } [Fact] @@ -250,7 +263,7 @@ public void ParseVersionFromOutput_WithDockerDesktopOutput_ReturnsVersion() """; - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.NotNull(result); Assert.Equal(new Version(27, 5, 1), result); @@ -265,7 +278,7 @@ podman version 4.3.1 API Version: 4.3.1 """; - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.NotNull(result); Assert.Equal(new Version(4, 3, 1), result); @@ -284,7 +297,7 @@ public void ParseVersionFromOutput_WithCaseInsensitiveVersion_ReturnsVersion() foreach (var input in inputs) { - var result = ContainerRuntimeCheck.ParseVersionFromOutput(input); + var result = ContainerRuntimeDetector.ParseVersionOutput(input).ClientVersion; Assert.NotNull(result); } } diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index 831cbd80008..81bcacdb454 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -842,6 +842,7 @@ public async Task ProjectResourceWithContainerFilesDestinationAnnotationWorks() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: "build-projectName"); builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); builder.Services.AddSingleton(); // Create a test container resource that implements IResourceWithContainerFiles From ceea24fffe65c5cd5a7d6370dfd13ba944bd7268 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 18:46:09 -0700 Subject: [PATCH 11/12] Fix doctor: don't show not-found runtimes as errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip runtimes that aren't installed instead of showing them as failures. Only show a failure if NO runtime is found at all. If a runtime was explicitly configured via ASPIRE_CONTAINER_RUNTIME but not found, that IS shown as a failure with install guidance. Before: ❌ Podman: not found (even though Docker is healthy) After: Only Docker shown, Podman silently omitted Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EnvironmentChecker/ContainerRuntimeCheck.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs index 74345c68e39..c1ab8c0cb81 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/ContainerRuntimeCheck.cs @@ -50,17 +50,23 @@ public async Task> CheckAsync(Cancellation var results = new List(); - // Report each runtime's status + // Only report runtimes that are installed (or explicitly configured) foreach (var info in runtimes) { + if (!info.IsInstalled && (configuredRuntime is null || + !string.Equals(info.Executable, configuredRuntime, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + var isSelected = selected is not null && string.Equals(info.Executable, selected.Executable, StringComparison.OrdinalIgnoreCase); results.Add(BuildRuntimeResult(info, isSelected, configuredRuntime, cancellationToken)); } - // If nothing is available, add an overall failure - if (!runtimes.Any(r => r.IsInstalled)) + // If nothing is available, show a single failure + if (results.Count == 0) { results.Add(new EnvironmentCheckResult { @@ -171,12 +177,13 @@ private static EnvironmentCheckResult BuildRuntimeResult( if (!info.IsInstalled) { + // Only reached for explicitly configured runtimes return new EnvironmentCheckResult { Category = "container", Name = info.Executable, Status = EnvironmentCheckStatus.Fail, - Message = $"{info.Name}: not found", + Message = $"{info.Name}: not found (configured via ASPIRE_CONTAINER_RUNTIME={configuredRuntime})", Fix = GetContainerRuntimeInstallationLink(info.Name) }; } From 66ee164b232b483a95003b7df0f04b7eee54332e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 11 Apr 2026 20:37:12 -0700 Subject: [PATCH 12/12] Address PR review feedback - Restore soft-report behavior for compose up/down failures: use FailAsync without re-throwing, matching the original non-fatal behavior (JamesNK feedback) - Add EnsureRuntimeAvailableAsync to Podman ComposeListServicesAsync override so missing podman binary gets an actionable error - Make EnsureRuntimeAvailableAsync protected for subclass access - Rename PascalCase ContainerRuntime locals to camelCase in ResourceContainerImageManager Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Publishing/ContainerRuntimeBase.cs | 2 +- .../Publishing/ContainerRuntimeResolver.cs | 35 ++++++- .../Publishing/PodmanContainerRuntime.cs | 2 + .../ResourceContainerImageManager.cs | 32 +++---- .../ContainerRuntimeResolverTests.cs | 91 +++++++++++++++++++ 5 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index d3f0d529672..ac465ed9057 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -536,7 +536,7 @@ private static string BuildComposeArguments(ComposeOperationContext context) /// Validates that the container runtime binary is available on the system PATH. /// Fails fast with an actionable error message instead of a cryptic exit code. /// - private async Task EnsureRuntimeAvailableAsync() + protected async Task EnsureRuntimeAvailableAsync() { try { diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs index 443cb93bf03..b0774136221 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeResolver.cs @@ -20,7 +20,8 @@ internal sealed class ContainerRuntimeResolver : IContainerRuntimeResolver private readonly IServiceProvider _serviceProvider; private readonly IOptions _dcpOptions; private readonly ILogger _logger; - private readonly Lazy> _lazyRuntime; + private readonly object _lock = new(); + private Task? _cachedTask; public ContainerRuntimeResolver( IServiceProvider serviceProvider, @@ -30,15 +31,39 @@ public ContainerRuntimeResolver( _serviceProvider = serviceProvider; _dcpOptions = dcpOptions; _logger = loggerFactory.CreateLogger("Aspire.Hosting.ContainerRuntime"); - _lazyRuntime = new Lazy>(ResolveInternalAsync); } public Task ResolveAsync(CancellationToken cancellationToken = default) { - return _lazyRuntime.Value; + // Caching behavior: + // - Completed successfully: return cached result. Caller's token is irrelevant. + // - In-progress: return the in-flight task (started with a previous caller's token). + // If this caller is cancelled, their await throws but the detection continues. + // - Faulted (e.g. bad ASPIRE_CONTAINER_RUNTIME value): return faulted task. + // Config won't change mid-process, so retry won't help. + // - Cancelled: discard and retry with the new caller's token, since a different + // caller may have a valid token. + // - Null: first call, start detection with this caller's token. + var task = _cachedTask; + if (task is not null && !task.IsCanceled) + { + return task; + } + + lock (_lock) + { + task = _cachedTask; + if (task is not null && !task.IsCanceled) + { + return task; + } + + _cachedTask = ResolveInternalAsync(cancellationToken); + return _cachedTask; + } } - private async Task ResolveInternalAsync() + private async Task ResolveInternalAsync(CancellationToken cancellationToken) { var configuredRuntime = _dcpOptions.Value.ContainerRuntime; @@ -50,7 +75,7 @@ private async Task ResolveInternalAsync() // Auto-detect: probe available runtimes asynchronously. // See https://github.com/microsoft/dcp/blob/main/internal/containers/runtimes/runtime.go - var detected = await ContainerRuntimeDetector.FindAvailableRuntimeAsync(logger: _logger).ConfigureAwait(false); + var detected = await ContainerRuntimeDetector.FindAvailableRuntimeAsync(logger: _logger, cancellationToken: cancellationToken).ConfigureAwait(false); var runtimeKey = detected?.Executable ?? "docker"; if (detected is { IsHealthy: true }) diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs index 005ee4a13e9..c007ddd316f 100644 --- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs @@ -26,6 +26,8 @@ public PodmanContainerRuntime(ILogger logger) : base(log /// public override async Task?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken) { + await EnsureRuntimeAvailableAsync().ConfigureAwait(false); + var arguments = $"ps --filter label=com.docker.compose.project={context.ProjectName} --format json"; var outputLines = new List(); diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs index df3a0113a64..7db0407bc5b 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs @@ -206,23 +206,23 @@ private async Task ResolveContainerBuildOptionsAs public async Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken = default) { - var ContainerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); + var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Starting to build container images"); // Only check container runtime health if there are resources that need it if (await ResourcesRequireContainerRuntimeAsync(resources, cancellationToken).ConfigureAwait(false)) { - logger.LogDebug("Checking {ContainerRuntimeName} health", ContainerRuntime.Name); + logger.LogDebug("Checking {ContainerRuntimeName} health", containerRuntime.Name); - var containerRuntimeHealthy = await ContainerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); + var containerRuntimeHealthy = await containerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); if (!containerRuntimeHealthy) { - logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container images.", ContainerRuntime.Name); - throw new InvalidOperationException($"Container runtime '{ContainerRuntime.Name}' is not running or is unhealthy."); + logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container images.", containerRuntime.Name); + throw new InvalidOperationException($"Container runtime '{containerRuntime.Name}' is not running or is unhealthy."); } - logger.LogDebug("{ContainerRuntimeName} is healthy", ContainerRuntime.Name); + logger.LogDebug("{ContainerRuntimeName} is healthy", containerRuntime.Name); } foreach (var resource in resources) @@ -236,7 +236,7 @@ public async Task BuildImagesAsync(IEnumerable resources, Cancellatio public async Task BuildImageAsync(IResource resource, CancellationToken cancellationToken = default) { - var ContainerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); + var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Building container image for resource {ResourceName}", resource.Name); var options = await ResolveContainerBuildOptionsAsync(resource, cancellationToken).ConfigureAwait(false); @@ -244,17 +244,17 @@ public async Task BuildImageAsync(IResource resource, CancellationToken cancella // Check if this resource needs a container runtime if (await ResourcesRequireContainerRuntimeAsync([resource], cancellationToken).ConfigureAwait(false)) { - logger.LogDebug("Checking {ContainerRuntimeName} health", ContainerRuntime.Name); + logger.LogDebug("Checking {ContainerRuntimeName} health", containerRuntime.Name); - var containerRuntimeHealthy = await ContainerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); + var containerRuntimeHealthy = await containerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); if (!containerRuntimeHealthy) { - logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container image.", ContainerRuntime.Name); - throw new InvalidOperationException($"Container runtime '{ContainerRuntime.Name}' is not running or is unhealthy."); + logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container image.", containerRuntime.Name); + throw new InvalidOperationException($"Container runtime '{containerRuntime.Name}' is not running or is unhealthy."); } - logger.LogDebug("{ContainerRuntimeName} is healthy", ContainerRuntime.Name); + logger.LogDebug("{ContainerRuntimeName} is healthy", containerRuntime.Name); } if (resource is ProjectResource) @@ -421,7 +421,7 @@ private async Task ExecuteDotnetPublishAsync(IResource resource, ResolvedC private async Task BuildContainerImageFromDockerfileAsync(IResource resource, DockerfileBuildAnnotation dockerfileBuildAnnotation, string imageName, ResolvedContainerBuildOptions options, CancellationToken cancellationToken) { - var ContainerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); + var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Building image: {ResourceName}", resource.Name); // If there's a factory, generate the Dockerfile content and write it to the specified path @@ -475,7 +475,7 @@ private async Task BuildContainerImageFromDockerfileAsync(IResource resource, Do try { - await ContainerRuntime.BuildImageAsync( + await containerRuntime.BuildImageAsync( dockerfileBuildAnnotation.ContextPath, dockerfileBuildAnnotation.DockerfilePath, containerBuildOptions, @@ -518,8 +518,8 @@ await ContainerRuntime.BuildImageAsync( public async Task PushImageAsync(IResource resource, CancellationToken cancellationToken) { - var ContainerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); - await ContainerRuntime.PushImageAsync(resource, cancellationToken).ConfigureAwait(false); + var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); + await containerRuntime.PushImageAsync(resource, cancellationToken).ConfigureAwait(false); } // .NET Container builds that push OCI images to a local file path do not need a runtime diff --git a/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs new file mode 100644 index 00000000000..d9b66250ccd --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Publishing/ContainerRuntimeResolverTests.cs @@ -0,0 +1,91 @@ +// 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 + +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Tests.Publishing; + +public class ContainerRuntimeResolverTests +{ + private static ContainerRuntimeResolver CreateResolver( + string? configuredRuntime = null, + IServiceProvider? serviceProvider = null) + { + var services = new ServiceCollection(); + services.AddKeyedSingleton("docker"); + services.AddKeyedSingleton("podman"); + var sp = serviceProvider ?? services.BuildServiceProvider(); + + var dcpOptions = Options.Create(new DcpOptions { ContainerRuntime = configuredRuntime }); + return new ContainerRuntimeResolver(sp, dcpOptions, NullLoggerFactory.Instance); + } + + [Fact] + public async Task ResolveAsync_ReturnsSameInstance_OnSubsequentCalls() + { + var resolver = CreateResolver(configuredRuntime: "docker"); + + var first = await resolver.ResolveAsync(); + var second = await resolver.ResolveAsync(); + + Assert.Same(first, second); + } + + [Fact] + public async Task ResolveAsync_ReturnsSameTask_WhenCached() + { + var resolver = CreateResolver(configuredRuntime: "docker"); + + var task1 = resolver.ResolveAsync(); + var task2 = resolver.ResolveAsync(); + + Assert.Same(task1, task2); + await task1; + } + + [Fact] + public async Task ResolveAsync_ConfiguredRuntime_ReturnsKeyedService() + { + var resolver = CreateResolver(configuredRuntime: "podman"); + + var runtime = await resolver.ResolveAsync(); + + Assert.NotNull(runtime); + } + + [Fact] + public async Task ResolveAsync_AfterCancellation_RetriesWithNewToken() + { + var resolver = CreateResolver(configuredRuntime: null); + + // First call with an already-cancelled token + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // The first call may or may not throw depending on timing — + // if detection hasn't started yet, the token cancels it immediately. + Task? firstTask = null; + try + { + firstTask = resolver.ResolveAsync(cts.Token); + await firstTask; + } + catch (OperationCanceledException) + { + // Expected — first attempt was cancelled + } + + // Second call with a valid token should work (not return cached cancellation) + if (firstTask is { IsCanceled: true }) + { + var runtime = await resolver.ResolveAsync(CancellationToken.None); + Assert.NotNull(runtime); + } + } +}