File: Utils\EnvironmentChecker\ContainerRuntimeCheck.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using Aspire.Cli.Resources;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Utils.EnvironmentChecker;
 
/// <summary>
/// Checks if a supported container runtime is available and running.
/// </summary>
internal sealed class ContainerRuntimeCheck(ILogger<ContainerRuntimeCheck> logger, IEnvironment environment) : IEnvironmentCheck
{
    internal const string CheckName = "container-runtime";
 
    /// <summary>
    /// Minimum Docker version required for Aspire.
    /// </summary>
    public const string MinimumDockerVersion = "28.0.0";
 
    /// <summary>
    /// Minimum Podman version required for Aspire.
    /// </summary>
    public const string MinimumPodmanVersion = "5.0.0";
 
    // Matches DCP's minimum supported WSLc client and session manager version.
    public const string MinimumWslcVersion = "3.0.2.0";
 
    public int Order => 40; // Process check - more expensive
 
    public async Task<IReadOnlyList<EnvironmentCheckResult>> CheckAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            var configuredRuntime = environment.GetEnvironmentVariable("ASPIRE_CONTAINER_RUNTIME")
                ?? environment.GetEnvironmentVariable("DOTNET_ASPIRE_CONTAINER_RUNTIME");
            configuredRuntime = string.IsNullOrEmpty(configuredRuntime) ? null : configuredRuntime;
 
            if (!environment.IsWindows() &&
                string.Equals(configuredRuntime, KnownContainerRuntimes.Wslc, StringComparisons.ContainerRuntime))
            {
                return [new EnvironmentCheckResult
                {
                    Category = EnvironmentCheckCategories.Container,
                    Name = KnownContainerRuntimes.Wslc,
                    Status = EnvironmentCheckStatus.Fail,
                    Message = DoctorCommandStrings.ContainerRuntimeWslcUnsupportedHostMessage + DoctorCommandStrings.ContainerRuntimeExplicitSelectionSuffix,
                    Fix = DoctorCommandStrings.ContainerRuntimeSelectAlternativeFix,
                    Link = "https://aka.ms/aspire/containers"
                }];
            }
 
            if (configuredRuntime is not null)
            {
                var runtime = await ContainerRuntimeDetector.FindAvailableRuntimeAsync(configuredRuntime, logger, cancellationToken)
                    ?? throw new InvalidOperationException($"No detection result was returned for configured container runtime '{configuredRuntime}'.");
 
                return [BuildRuntimeResult(runtime, isSelected: true, hasHealthyRuntime: false, configuredRuntime, cancellationToken)];
            }
 
            var dockerTask = ContainerRuntimeDetector.CheckRuntimeAsync(KnownContainerRuntimes.Docker, logger, cancellationToken);
            var podmanTask = ContainerRuntimeDetector.CheckRuntimeAsync(KnownContainerRuntimes.Podman, logger, cancellationToken);
            var runtimes = environment.IsWindows()
                ? await Task.WhenAll(dockerTask, podmanTask, ContainerRuntimeDetector.CheckRuntimeAsync(KnownContainerRuntimes.Wslc, logger, cancellationToken))
                : await Task.WhenAll(dockerTask, podmanTask);
 
            // Select best from already-probed results (no re-probing)
            var healthyRuntime = ContainerRuntimeDetector.FindBestRuntime(
                runtimes.Where(info => info.IsHealthy && CheckRuntimePolicy(info)?.Status != EnvironmentCheckStatus.Fail));
            var selected = ContainerRuntimeDetector.FindBestRuntime(runtimes);
            var results = new List<EnvironmentCheckResult>();
            foreach (var info in runtimes.OrderBy(info => info.Priority))
            {
                if (!info.IsInstalled)
                {
                    continue;
                }
 
                var isSelected = selected is not null &&
                    string.Equals(info.Key, selected.Key, StringComparisons.ContainerRuntime);
 
                results.Add(BuildRuntimeResult(info, isSelected, healthyRuntime is not null, configuredRuntime, cancellationToken));
            }
 
            if (results.Count > 0)
            {
                return results;
            }
 
            return [new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Container,
                Name = CheckName,
                Status = EnvironmentCheckStatus.Fail,
                Message = DoctorCommandStrings.ContainerRuntimeNotDetectedMessage,
                Fix = environment.IsWindows()
                    ? DoctorCommandStrings.ContainerRuntimeInstallWindowsFix
                    : DoctorCommandStrings.ContainerRuntimeInstallFix,
                Link = "https://aka.ms/aspire/containers"
            }];
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Error checking container runtime");
            return [new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Container,
                Name = CheckName,
                Status = EnvironmentCheckStatus.Fail,
                Message = DoctorCommandStrings.ContainerRuntimeCheckFailedMessage,
                Details = ex.Message
            }];
        }
    }
 
    /// <summary>
    /// Applies Aspire-specific policy checks (minimum version, Windows containers)
    /// using version info already gathered by the detector. No process spawning.
    /// </summary>
    internal static EnvironmentCheckResult? CheckRuntimePolicy(ContainerRuntimeInfo info)
    {
        if (info.VersionError is not null)
        {
            return new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Container,
                Name = info.Key,
                Status = EnvironmentCheckStatus.Fail,
                Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeVersionUnavailableMessageFormat, info.Name),
                Details = info.VersionError,
                Fix = GetContainerRuntimeUpgradeAdvice(info.Key, info.Name),
                Link = "https://aka.ms/aspire/containers"
            };
        }
 
        // Blocking compatibility failures take precedence over advisory version warnings.
        if (string.Equals(info.Key, KnownContainerRuntimes.Docker, StringComparisons.ContainerRuntime) &&
            string.Equals(info.ServerOs, "windows", StringComparison.OrdinalIgnoreCase))
        {
            var runtimeName = info.IsDockerDesktop ? "Docker Desktop" : info.Name;
            return new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Container,
                Name = CheckName,
                Status = EnvironmentCheckStatus.Fail,
                Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeWindowsModeMessageFormat, runtimeName),
                Details = DoctorCommandStrings.ContainerRuntimeWindowsModeDetails,
                Fix = DoctorCommandStrings.ContainerRuntimeWindowsModeFix,
                Link = "https://aka.ms/aspire/containers"
            };
        }
 
        var minimumVersion = GetMinimumVersion(info.Key);
 
        // Check minimum client version
        if (info.ClientVersion is not null && minimumVersion is not null &&
            (info.ClientVersion < minimumVersion || IsUnsupportedPrerelease(info.ClientVersion, info.IsClientPrerelease, minimumVersion)))
        {
            return VersionPolicyResult(info.Key,
                string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeClientVersionWarningFormat,
                    info.Name, info.ClientVersionText ?? info.ClientVersion.ToString(), GetMinimumVersionString(info.Key)),
                GetContainerRuntimeUpgradeAdvice(info.Key, info.Name));
        }
 
        // Podman does not always expose a separate server version.
        if (info.Key is KnownContainerRuntimes.Docker or KnownContainerRuntimes.Wslc && info.ServerVersion is not null && minimumVersion is not null &&
            (info.ServerVersion < minimumVersion || IsUnsupportedPrerelease(info.ServerVersion, info.IsServerPrerelease, minimumVersion)))
        {
            return VersionPolicyResult(info.Key,
                string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeServerVersionWarningFormat,
                    info.Name, info.ServerVersionText ?? info.ServerVersion.ToString(), GetMinimumVersionString(info.Key)),
                GetContainerRuntimeUpgradeAdvice(info.Key, info.Name));
        }
 
        return null; // No issues
    }
 
    private static bool IsUnsupportedPrerelease(Version version, bool isPrerelease, Version minimumVersion)
    {
        return isPrerelease &&
            new Version(version.Major, version.Minor, Math.Max(version.Build, 0), Math.Max(version.Revision, 0)) <=
            new Version(minimumVersion.Major, minimumVersion.Minor, Math.Max(minimumVersion.Build, 0), Math.Max(minimumVersion.Revision, 0));
    }
 
    private static EnvironmentCheckResult VersionPolicyResult(string runtimeKey, string message, string fix) => new()
    {
        Category = EnvironmentCheckCategories.Container,
        Name = CheckName,
        Status = string.Equals(runtimeKey, KnownContainerRuntimes.Wslc, StringComparisons.ContainerRuntime)
            ? EnvironmentCheckStatus.Fail
            : EnvironmentCheckStatus.Warning,
        Message = message,
        Fix = fix,
        Link = "https://aka.ms/aspire/containers"
    };
 
    private static EnvironmentCheckResult BuildRuntimeResult(
        ContainerRuntimeInfo info,
        bool isSelected,
        bool hasHealthyRuntime,
        string? configuredRuntime,
        CancellationToken _)
    {
        var selectedSuffix = isSelected
            ? configuredRuntime is not null ? DoctorCommandStrings.ContainerRuntimeExplicitSelectionSuffix : DoctorCommandStrings.ContainerRuntimeActiveSuffix
            : "";
 
        if (!info.IsInstalled)
        {
            // Only reached for explicitly configured runtimes
            return new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Container,
                Name = info.Key,
                Status = EnvironmentCheckStatus.Fail,
                Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeNotFoundMessageFormat,
                    info.Name, configuredRuntime, selectedSuffix),
                Fix = GetContainerRuntimeInstallationLink(info.Key, info.Name)
            };
        }
 
        if (!info.IsRunning)
        {
            return new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Container,
                Name = info.Key,
                Status = EnvironmentCheckStatus.Warning,
                Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeStoppedMessageFormat,
                    info.Name, selectedSuffix),
                Details = info.Error,
                Fix = GetContainerRuntimeStartupAdvice(info.Key, info.Name, info.IsDockerDesktop)
            };
        }
 
        // 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 policy result message
            return new EnvironmentCheckResult
            {
                Category = policyResult.Category,
                Name = policyResult.Name,
                Status = !isSelected && hasHealthyRuntime && policyResult.Status == EnvironmentCheckStatus.Fail
                    ? EnvironmentCheckStatus.Warning
                    : policyResult.Status,
                Message = policyResult.Message + selectedSuffix,
                Fix = policyResult.Fix,
                Details = policyResult.Details,
                Link = policyResult.Link
            };
        }
 
        var versionSuffix = info.ClientVersion is not null
            ? string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeVersionSuffixFormat, info.ClientVersionText ?? info.ClientVersion.ToString())
            : "";
 
        return new EnvironmentCheckResult
        {
            Category = EnvironmentCheckCategories.Container,
            Name = info.Key,
            Status = EnvironmentCheckStatus.Pass,
            Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeRunningMessageFormat,
                info.Name, versionSuffix, selectedSuffix)
        };
    }
 
    private static string GetContainerRuntimeInstallationLink(string runtimeKey, string runtimeName)
    {
        return runtimeKey switch
        {
            KnownContainerRuntimes.Docker => DoctorCommandStrings.ContainerRuntimeInstallDockerFix,
            KnownContainerRuntimes.Podman => DoctorCommandStrings.ContainerRuntimeInstallPodmanFix,
            KnownContainerRuntimes.Wslc => DoctorCommandStrings.ContainerRuntimeInstallWslcFix,
            _ => string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeInstallGenericFixFormat, runtimeName)
        };
    }
 
    /// <summary>
    /// Gets the minimum required version for the specified container runtime.
    /// </summary>
    private static Version? GetMinimumVersion(string runtime)
    {
        var versionString = GetMinimumVersionString(runtime);
 
        if (versionString is not null && Version.TryParse(versionString, out var version))
        {
            if (string.Equals(runtime, KnownContainerRuntimes.Wslc, StringComparisons.ContainerRuntime) && version.Revision == 0)
            {
                // WSLc reports clients as 3.0.2.0 and session managers as 3.0.2.
                // Omit a zero revision from the minimum so either form compares correctly.
                version = new Version(version.Major, version.Minor, version.Build);
            }
 
            return version;
        }
 
        return null;
    }
 
    /// <summary>
    /// Gets the minimum required version string for the specified container runtime.
    /// </summary>
    private static string? GetMinimumVersionString(string runtime)
    {
        return runtime switch
        {
            KnownContainerRuntimes.Docker => MinimumDockerVersion,
            KnownContainerRuntimes.Podman => MinimumPodmanVersion,
            KnownContainerRuntimes.Wslc => MinimumWslcVersion,
            _ => null
        };
    }
 
    private static string GetContainerRuntimeUpgradeAdvice(string runtimeKey, string runtimeName)
    {
        return runtimeKey switch
        {
            KnownContainerRuntimes.Docker => string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeUpgradeFixFormat,
                runtimeName, MinimumDockerVersion, "https://www.docker.com/products/docker-desktop"),
            KnownContainerRuntimes.Podman => string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeUpgradeFixFormat,
                runtimeName, MinimumPodmanVersion, "https://podman.io/getting-started/installation"),
            KnownContainerRuntimes.Wslc => string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeUpgradeWslcFixFormat, MinimumWslcVersion),
            _ => string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeUpgradeGenericFixFormat, runtimeName)
        };
    }
 
    private static string GetContainerRuntimeStartupAdvice(string runtimeKey, string runtimeName, bool isDockerDesktop)
    {
        return runtimeKey switch
        {
            KnownContainerRuntimes.Docker when isDockerDesktop => DoctorCommandStrings.ContainerRuntimeStartDockerDesktopFix,
            KnownContainerRuntimes.Podman => DoctorCommandStrings.ContainerRuntimeStartPodmanFix,
            KnownContainerRuntimes.Wslc => DoctorCommandStrings.ContainerRuntimeStartWslcFix,
            _ => string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ContainerRuntimeStartDaemonFixFormat, runtimeName)
        };
    }
}