File: src\Shared\ContainerRuntimeProbe.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// 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 Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Shared;
 
/// <summary>
/// Describes the availability of a single container runtime (e.g., Docker, Podman, or WSLc).
/// </summary>
internal sealed class ContainerRuntimeInfo
{
    /// <summary>
    /// The registered runtime key used by configuration and service resolution.
    /// </summary>
    public required string Key { get; init; }
 
    /// <summary>
    /// The executable name (e.g., "docker", "podman").
    /// </summary>
    public required string Executable { get; init; }
 
    /// <summary>
    /// Display name (e.g., "Docker", "Podman").
    /// </summary>
    public required string Name { get; init; }
 
    /// <summary>
    /// Whether the runtime CLI was found on PATH.
    /// </summary>
    public bool IsInstalled { get; init; }
 
    /// <summary>
    /// Whether the runtime daemon/service is responding.
    /// </summary>
    public bool IsRunning { get; init; }
 
    /// <summary>
    /// Whether the runtime supports the host operating system.
    /// </summary>
    public bool IsSupported { get; init; } = true;
 
    /// <summary>
    /// Discovery priority when availability is equal. Lower values are preferred.
    /// </summary>
    public int Priority { get; init; } = int.MaxValue;
 
    /// <summary>
    /// Error message if detection failed.
    /// </summary>
    public string? Error { get; init; }
 
    /// <summary>
    /// The client (CLI) version, if detected.
    /// </summary>
    public Version? ClientVersion { get; init; }
 
    /// <summary>
    /// The server (daemon/engine) version, if detected.
    /// </summary>
    public Version? ServerVersion { get; init; }
 
    public bool IsClientPrerelease { get; init; }
    public bool IsServerPrerelease { get; init; }
    public string? VersionError { get; init; }
    public string? ClientVersionText { get; init; }
    public string? ServerVersionText { get; init; }
 
    /// <summary>
    /// Whether this is Docker Desktop (vs Docker Engine).
    /// </summary>
    public bool IsDockerDesktop { get; init; }
 
    /// <summary>
    /// The server OS (e.g., "linux", "windows"). Relevant for Docker's Windows container mode.
    /// </summary>
    public string? ServerOs { get; init; }
 
    /// <summary>
    /// Whether the runtime is fully operational.
    /// </summary>
    public bool IsHealthy => IsInstalled && IsRunning;
}
 
/// <summary>
/// Shares availability probing and Docker-compatible version parsing between container runtimes.
/// </summary>
internal class ContainerRuntimeProbe(string key, string executable, string name, int priority)
{
    private static readonly TimeSpan s_processTimeout = TimeSpan.FromSeconds(10);
 
    public string Key => key;
    public string Executable => executable;
    public string Name => name;
    public int Priority => priority;
 
    /// <summary>
    /// Determines whether the runtime supports the host operating system.
    /// </summary>
    public virtual bool IsSupportedHost(bool isWindows) => true;
 
    /// <summary>
    /// Checks CLI installation and runtime health, then gathers available version metadata.
    /// </summary>
    public async Task<ContainerRuntimeInfo> CheckAsync(ILogger? logger, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        if (!IsSupportedHost(OperatingSystem.IsWindows()))
        {
            logger?.LogDebug("{Name} is not supported on this host.", name);
            return new ContainerRuntimeInfo
            {
                Key = key,
                Executable = executable,
                Name = name,
                Priority = priority,
                IsSupported = false
            };
        }
 
        try
        {
            logger?.LogDebug("Probing container runtime '{Name}' ({Executable})...", name, executable);
            // Check CLI availability and runtime health with `<runtime> container ls -n 1`.
            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
                {
                    Key = key,
                    Executable = executable,
                    Name = name,
                    IsInstalled = false,
                    IsRunning = false,
                    Priority = priority,
                    Error = $"{name} CLI not found on PATH."
                };
            }
 
            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            timeoutCts.CancelAfter(s_processTimeout);
 
            var errorTask = process.StandardError.ReadToEndAsync(timeoutCts.Token);
            try
            {
                // Consume both pipes while waiting so child writes cannot block process completion.
                await Task.WhenAll(
                    process.StandardOutput.BaseStream.CopyToAsync(Stream.Null, timeoutCts.Token),
                    errorTask,
                    process.WaitForExitAsync(timeoutCts.Token)).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                try { process.Kill(); } catch { /* best effort */ }
                cancellationToken.ThrowIfCancellationRequested();
 
                return new ContainerRuntimeInfo
                {
                    Key = key,
                    Executable = executable,
                    Name = name,
                    IsInstalled = true,
                    IsRunning = false,
                    Priority = priority,
                    Error = $"{name} CLI timed out while checking status."
                };
            }
 
            if (process.ExitCode == 0)
            {
                // Runtime is running — gather version metadata
                logger?.LogDebug("{Name} is running, gathering version info...", name);
                var versionInfo = await ReadVersionInfoAsync(isRunning: true, logger, cancellationToken).ConfigureAwait(false);
                logger?.LogDebug("{Name}: client={ClientVersion}, server={ServerVersion}, desktop={IsDesktop}", name, versionInfo.ClientVersion, versionInfo.ServerVersion, versionInfo.IsDockerDesktop);
 
                return new ContainerRuntimeInfo
                {
                    Key = key,
                    Executable = executable,
                    Name = name,
                    IsInstalled = true,
                    IsRunning = true,
                    Priority = priority,
                    ClientVersion = versionInfo.ClientVersion,
                    ServerVersion = versionInfo.ServerVersion,
                    IsClientPrerelease = versionInfo.IsClientPrerelease,
                    IsServerPrerelease = versionInfo.IsServerPrerelease,
                    VersionError = versionInfo.Error,
                    ClientVersionText = versionInfo.ClientVersionText,
                    ServerVersionText = versionInfo.ServerVersionText,
                    IsDockerDesktop = versionInfo.IsDockerDesktop,
                    ServerOs = versionInfo.ServerOs
                };
            }
 
            // Non-zero exit code: CLI exists (we started it) but daemon may not be running.
            var isInstalled = await IsCliInstalledAsync(logger, cancellationToken).ConfigureAwait(false);
            logger?.LogDebug("{Name}: exit code {ExitCode}, installed={IsInstalled}", name, process.ExitCode, isInstalled);
 
            var partialVersionInfo = isInstalled
                ? await ReadVersionInfoAsync(isRunning: false, logger, cancellationToken).ConfigureAwait(false)
                : default;
 
            var error = isInstalled
                ? $"{name} is installed but the daemon is not running."
                : $"{name} CLI not found on PATH.";
            var stderr = (await errorTask.ConfigureAwait(false)).Trim();
            if (stderr.Length > 0)
            {
                error += $"{Environment.NewLine}{stderr}";
            }
            logger?.LogDebug("{Name}: {Error}", name, error);
 
            return new ContainerRuntimeInfo
            {
                Key = key,
                Executable = executable,
                Name = name,
                IsInstalled = isInstalled,
                IsRunning = false,
                Priority = priority,
                ClientVersion = partialVersionInfo.ClientVersion,
                IsDockerDesktop = partialVersionInfo.IsDockerDesktop,
                Error = error
            };
        }
        catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
        {
            logger?.LogDebug("{Name}: not found on PATH ({ExceptionMessage})", name, ex.Message);
            return new ContainerRuntimeInfo
            {
                Key = key,
                Executable = executable,
                Name = name,
                IsInstalled = false,
                IsRunning = false,
                Priority = priority,
                Error = $"{name} CLI not found on PATH."
            };
        }
    }
 
    private async Task<bool> IsCliInstalledAsync(ILogger? logger, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        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
            {
                var errorTask = process.StandardError.ReadToEndAsync(timeoutCts.Token);
                await Task.WhenAll(
                    process.StandardOutput.BaseStream.CopyToAsync(Stream.Null, timeoutCts.Token),
                    errorTask,
                    process.WaitForExitAsync(timeoutCts.Token)).ConfigureAwait(false);
                if (process.ExitCode != 0)
                {
                    logger?.LogDebug("{Name} installation probe failed with exit code {ExitCode}: {Error}",
                        name, process.ExitCode, await errorTask.ConfigureAwait(false));
                }
                return process.ExitCode == 0;
            }
            catch (OperationCanceledException)
            {
                try { process.Kill(); } catch { /* best effort */ }
                cancellationToken.ThrowIfCancellationRequested();
 
                return false;
            }
        }
        catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
        {
            logger?.LogDebug(ex, "Failed to check {Name} CLI installation.", name);
            return false;
        }
    }
 
    /// <summary>
    /// Reads the Docker/Podman version protocol, including partial output when the service is unavailable.
    /// </summary>
    protected virtual async Task<RuntimeVersionInfo> ReadVersionInfoAsync(bool isRunning, ILogger? logger, CancellationToken cancellationToken)
    {
        var nonJsonOutput = false;
        var info = await ReadVersionOutputAsync("version -f json", async (stream, token) =>
        {
            try
            {
                var json = await JsonSerializer.DeserializeAsync(stream, ContainerRuntimeJsonContext.Default.ContainerRuntimeVersionJson, token).ConfigureAwait(false);
                return NormalizeVersionInfo(json);
            }
            catch (JsonException ex)
            {
                logger?.LogDebug(ex, "{Name} did not return JSON version metadata.", name);
                nonJsonOutput = true;
                return default;
            }
        }, logger, cancellationToken).ConfigureAwait(false);
 
        if (nonJsonOutput)
        {
            // Streaming JSON consumes its input before rejecting text. Retrieve the existing
            // text-version fallback separately with `<runtime> --version` (e.g. "podman version 5.0.0").
            return await ReadVersionOutputAsync("--version", async (stream, token) =>
            {
                using var reader = new StreamReader(stream, leaveOpen: true);
                return ParseVersionOutput(await reader.ReadToEndAsync(token).ConfigureAwait(false));
            }, logger, cancellationToken).ConfigureAwait(false);
        }
 
        return info;
    }
 
    /// <summary>
    /// Parses streamed metadata while capturing stderr and waiting for process completion.
    /// </summary>
    protected async Task<RuntimeVersionInfo> ReadVersionOutputAsync(
        string arguments,
        Func<Stream, CancellationToken, Task<RuntimeVersionInfo>> parseOutput,
        ILogger? logger,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        try
        {
            var startInfo = new ProcessStartInfo
            {
                FileName = executable,
                Arguments = arguments,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            };
 
            using var process = Process.Start(startInfo);
            if (process is null)
            {
                return default;
            }
 
            try
            {
                using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
                timeoutCts.CancelAfter(s_processTimeout);
 
                var errorTask = process.StandardError.ReadToEndAsync(timeoutCts.Token);
                async Task<RuntimeVersionInfo> ParseAndDrainAsync()
                {
                    try
                    {
                        return await parseOutput(process.StandardOutput.BaseStream, timeoutCts.Token).ConfigureAwait(false);
                    }
                    finally
                    {
                        // A parser can reject malformed input before EOF; keep draining so the
                        // child can finish writing and stderr collection can complete.
                        await process.StandardOutput.BaseStream.CopyToAsync(Stream.Null, timeoutCts.Token).ConfigureAwait(false);
                    }
                }
 
                var parseTask = ParseAndDrainAsync();
                try
                {
                    await Task.WhenAll(parseTask, errorTask, process.WaitForExitAsync(timeoutCts.Token)).ConfigureAwait(false);
                }
                catch (OperationCanceledException)
                {
                    cancellationToken.ThrowIfCancellationRequested();
 
                    logger?.LogDebug("{Name} metadata command timed out.", name);
                    return default;
                }
                catch (JsonException ex)
                {
                    cancellationToken.ThrowIfCancellationRequested();
                    logger?.LogDebug(ex, "Invalid {Name} version metadata. Stderr: {Error}", name, await errorTask.ConfigureAwait(false));
                    return default;
                }
 
                if (process.ExitCode != 0)
                {
                    // Docker can return usable client metadata together with a daemon connection error.
                    logger?.LogDebug("{Name} metadata command failed with exit code {ExitCode}: {Error}",
                        name, process.ExitCode, await errorTask.ConfigureAwait(false));
                }
 
                return await parseTask.ConfigureAwait(false);
            }
            finally
            {
                // Process.Dispose closes handles but does not terminate a still-running child.
                // Cleanup must not depend on which exception wins when concurrent tasks fail.
                try
                {
                    if (!process.HasExited)
                    {
                        process.Kill();
                    }
                }
                catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception)
                {
                    logger?.LogDebug(ex, "Failed to terminate {Name} metadata process.", name);
                }
 
                cancellationToken.ThrowIfCancellationRequested();
            }
        }
        catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
        {
            logger?.LogDebug(ex, "Failed to read {Name} version metadata.", name);
            return default;
        }
    }
 
    /// <summary>
    /// Parses Docker-compatible version output using source-generated JSON serialization.
    /// </summary>
    /// <example>
    /// Docker:
    /// <code>
    /// {"Client":{"Version":"28.0.1","Context":"desktop-linux"},"Server":{"Version":"27.5.0","Os":"linux"}}
    /// </code>
    /// Podman:
    /// <code>
    /// {"Client":{"Version":"4.9.3"},"Server":null}
    /// </code>
    /// </example>
    internal static RuntimeVersionInfo ParseVersionOutput(string? output)
    {
        if (string.IsNullOrWhiteSpace(output))
        {
            return default;
        }
 
        try
        {
            var json = JsonSerializer.Deserialize(output, ContainerRuntimeJsonContext.Default.ContainerRuntimeVersionJson);
            return NormalizeVersionInfo(json);
        }
        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;
        }
    }
 
    private static RuntimeVersionInfo NormalizeVersionInfo(ContainerRuntimeVersionJson? json)
    {
        var clientVersion = ParseRuntimeVersion(json?.Client?.Version, out var clientPrerelease);
        var serverVersion = ParseRuntimeVersion(json?.Server?.Version, out var serverPrerelease);
        var isDockerDesktop = json?.Client?.Context?.Contains("desktop", StringComparison.OrdinalIgnoreCase) == true;
 
        return new RuntimeVersionInfo(clientVersion, serverVersion, isDockerDesktop, json?.Server?.Os)
        {
            IsClientPrerelease = clientPrerelease,
            IsServerPrerelease = serverPrerelease,
            ClientVersionText = json?.Client?.Version,
            ServerVersionText = json?.Server?.Version
        };
    }
 
    internal static Version? ParseRuntimeVersion(string? value, out bool isPrerelease)
    {
        isPrerelease = false;
        if (Version.TryParse(value, out var nativeVersion))
        {
            return nativeVersion;
        }
 
        // Native WSLc uses four numbers (3.0.3.0). Docker distribution builds and WSLc
        // SemVer releases can instead use 26.1.5+dfsg1 or 3.0.2-rc.1.
        if (value is not null && SemVersion.TryParse(value, SemVersionStyles.Strict, out var semanticVersion) &&
            semanticVersion.Major <= int.MaxValue && semanticVersion.Minor <= int.MaxValue && semanticVersion.Patch <= int.MaxValue)
        {
            isPrerelease = semanticVersion.IsPrerelease;
            return new Version((int)semanticVersion.Major, (int)semanticVersion.Minor, (int)semanticVersion.Patch);
        }
 
        return null;
    }
 
    internal readonly record struct RuntimeVersionInfo(
        Version? ClientVersion,
        Version? ServerVersion,
        bool IsDockerDesktop,
        string? ServerOs)
    {
        public bool IsClientPrerelease { get; init; }
        public bool IsServerPrerelease { get; init; }
        public string? Error { get; init; }
        public string? ClientVersionText { get; init; }
        public string? ServerVersionText { get; init; }
    }
}
 
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
{
}