// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.Publishing;
/// <summary>
/// Base class for container runtime implementations that provides common process execution,
/// logging, and error handling patterns.
/// </summary>
internal abstract class ContainerRuntimeBase<TLogger> : IContainerRuntime where TLogger : class
{
private readonly ILogger<TLogger> _logger;
private readonly IProcessRunner _processRunner;
protected ContainerRuntimeBase(ILogger<TLogger> logger, IProcessRunner processRunner)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner));
}
/// <summary>
/// Gets the logger instance for use in derived classes.
/// </summary>
protected ILogger<TLogger> Logger => _logger;
/// <summary>
/// Gets the process runner used for container runtime commands.
/// </summary>
protected IProcessRunner ProcessRunner => _processRunner;
/// <summary>
/// Gets the name of the container runtime executable (e.g., "docker", "podman").
/// </summary>
protected abstract string RuntimeExecutable { get; }
public abstract string Name { get; }
public abstract Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken);
public abstract Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken);
public virtual async Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken)
{
var arguments = $"tag \"{localImageName}\" \"{targetImageName}\"";
await ExecuteContainerCommandAsync(
arguments,
$"{Name} tag for {{LocalImageName}} -> {{TargetImageName}} failed with exit code {{ExitCode}}.",
$"{Name} tag for {{LocalImageName}} -> {{TargetImageName}} succeeded.",
$"{Name} tag failed with exit code {{0}}.",
cancellationToken,
localImageName, targetImageName).ConfigureAwait(false);
}
public virtual async Task RemoveImageAsync(string imageName, CancellationToken cancellationToken)
{
var arguments = $"rmi \"{imageName}\"";
await ExecuteContainerCommandAsync(
arguments,
$"{Name} rmi for {{ImageName}} failed with exit code {{ExitCode}}.",
$"{Name} rmi for {{ImageName}} succeeded.",
$"{Name} rmi failed with exit code {{0}}.",
cancellationToken,
imageName).ConfigureAwait(false);
}
public virtual async Task PushImageAsync(IResource resource, CancellationToken cancellationToken)
{
var localImageName = resource.TryGetContainerImageName(out var imageName)
? imageName
: resource.Name.ToLowerInvariant();
var remoteImageName = await resource.GetFullRemoteImageNameAsync(cancellationToken).ConfigureAwait(false);
await TagImageAsync(localImageName, remoteImageName, cancellationToken).ConfigureAwait(false);
var arguments = $"push \"{remoteImageName}\"";
await ExecuteContainerCommandAsync(
arguments,
$"{Name} push for {{ImageName}} failed with exit code {{ExitCode}}.",
$"{Name} push for {{ImageName}} succeeded.",
$"{Name} push failed with exit code {{0}}.",
cancellationToken,
remoteImageName).ConfigureAwait(false);
}
public virtual async Task<ContainerImageConfigInspectionResult> InspectImageConfigAsync(string imageName, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imageName);
string output;
try
{
output = await ExecuteContainerCommandForOutputAsync(
[
"image",
"inspect",
imageName,
"--format",
"""{"Entrypoint":{{json .Config.Entrypoint}},"Cmd":{{json .Config.Cmd}},"WorkingDir":{{json .Config.WorkingDir}}}"""
],
"inspect image config",
imageName,
cancellationToken).ConfigureAwait(false);
}
catch (DistributedApplicationException ex)
{
return new ContainerImageConfigInspectionResult(
ContainerImageInspectionStatus.Failed,
rawJson: null,
ex.Message,
configAccessor: null);
}
if (!TryParseImageConfig(output, out var config))
{
return new ContainerImageConfigInspectionResult(
ContainerImageInspectionStatus.Failed,
output,
$"Container runtime returned invalid image configuration for '{imageName}'.",
configAccessor: null);
}
return new ContainerImageConfigInspectionResult(
ContainerImageInspectionStatus.Succeeded,
output,
errorMessage: null,
() => config);
}
public virtual async Task<ContainerImageManifestInspectionResult> InspectImageManifestAsync(string imageName, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imageName);
string output;
try
{
output = await ExecuteContainerCommandForOutputAsync(
["manifest", "inspect", "--verbose", imageName],
"inspect image manifest",
imageName,
cancellationToken).ConfigureAwait(false);
}
catch (DistributedApplicationException ex)
{
return new ContainerImageManifestInspectionResult(
ContainerImageInspectionStatus.Failed,
rawJson: null,
ex.Message,
manifestAccessor: null);
}
if (!IsJsonObjectOrArray(output))
{
return new ContainerImageManifestInspectionResult(
ContainerImageInspectionStatus.Failed,
output,
$"Container runtime returned invalid image manifest for '{imageName}'.",
manifestAccessor: null);
}
return new ContainerImageManifestInspectionResult(
ContainerImageInspectionStatus.Succeeded,
output,
errorMessage: null,
(operatingSystem, architecture) => FindManifest(output, operatingSystem, architecture));
}
private static bool TryParseImageConfig(string output, [NotNullWhen(true)] out ContainerImageConfig? config)
{
config = null;
try
{
var root = JsonNode.Parse(output) as JsonObject;
if (root is null)
{
return false;
}
config = new ContainerImageConfig(
ReadStringArray(root["Entrypoint"]),
ReadStringArray(root["Cmd"]),
root["WorkingDir"]?.GetValue<string>());
return true;
}
catch (JsonException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
private static IReadOnlyList<string> ReadStringArray(JsonNode? node)
{
if (node is not JsonArray array)
{
return [];
}
var values = new List<string>(array.Count);
foreach (var item in array)
{
if (item?.GetValue<string>() is { } value)
{
values.Add(value);
}
}
return values;
}
protected static bool IsJsonObjectOrArray(string output)
{
try
{
using var document = JsonDocument.Parse(output);
return document.RootElement.ValueKind is JsonValueKind.Object or JsonValueKind.Array;
}
catch (JsonException)
{
return false;
}
}
protected static ContainerImageManifest? FindManifest(string output, string operatingSystem, string architecture)
{
try
{
var root = JsonNode.Parse(output);
if (root is JsonArray verboseManifests)
{
foreach (var item in verboseManifests.OfType<JsonObject>())
{
if (TryCreateManifest(item["Descriptor"] as JsonObject, operatingSystem, architecture, out var manifest))
{
return manifest;
}
}
return null;
}
if (root is not JsonObject manifestObject)
{
return null;
}
if (manifestObject["manifests"] is JsonArray manifests)
{
foreach (var item in manifests.OfType<JsonObject>())
{
if (TryCreateManifest(item, operatingSystem, architecture, out var manifest))
{
return manifest;
}
}
return null;
}
var descriptor = manifestObject["Descriptor"] as JsonObject ??
manifestObject["descriptor"] as JsonObject ??
manifestObject;
return TryCreateManifest(descriptor, operatingSystem, architecture, out var singleManifest)
? singleManifest
: null;
}
catch (JsonException)
{
return null;
}
catch (InvalidOperationException)
{
return null;
}
}
private static bool TryCreateManifest(
JsonObject? descriptor,
string operatingSystem,
string architecture,
[NotNullWhen(true)] out ContainerImageManifest? manifest)
{
manifest = null;
if (descriptor is null)
{
return false;
}
var platform = descriptor["platform"] as JsonObject;
var actualOperatingSystem = platform?["os"]?.GetValue<string>();
var actualArchitecture = platform?["architecture"]?.GetValue<string>();
var digest = descriptor["digest"]?.GetValue<string>();
if (digest is null ||
!ContainerImageManifest.IsValidDigest(digest) ||
actualOperatingSystem is null ||
actualArchitecture is null ||
!string.Equals(actualOperatingSystem, operatingSystem, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(actualArchitecture, architecture, StringComparison.OrdinalIgnoreCase))
{
return false;
}
manifest = new ContainerImageManifest(digest, actualOperatingSystem, actualArchitecture);
return true;
}
public virtual async Task LoginToRegistryAsync(string registryServer, string username, string password, CancellationToken cancellationToken)
{
// Escape quotes in arguments to prevent command injection
var escapedRegistryServer = EscapeArgument(registryServer);
var escapedUsername = EscapeArgument(username);
var arguments = $"login --username \"{escapedUsername}\" --password-stdin \"{escapedRegistryServer}\"";
var spec = new ProcessSpec(RuntimeExecutable)
{
Arguments = arguments,
StandardInputContent = password,
RetainedOutputLineCount = ProcessSpec.DefaultRetainedOutputLineCount,
OnOutputData = output =>
{
_logger.LogDebug("{RuntimeName} (stdout): {Output}", RuntimeExecutable, output);
},
OnErrorData = error =>
{
_logger.LogDebug("{RuntimeName} (stderr): {Error}", RuntimeExecutable, error);
},
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
};
_logger.LogDebug("Running {RuntimeName} with arguments: {Arguments}", RuntimeExecutable, arguments);
_logger.LogDebug("Password length being passed to stdin: {PasswordLength}", password?.Length ?? 0);
var (pendingProcessResult, processDisposable) = _processRunner.Run(spec);
await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
if (processResult.ExitCode != 0)
{
_logger.LogError("{RuntimeName} login to {RegistryServer} failed with exit code {ExitCode}.", Name, registryServer, processResult.ExitCode);
var message = $"{Name} login failed with exit code {processResult.ExitCode}.";
if (processResult.TotalProcessOutputLineCount > 0)
{
message = $"{message}{Environment.NewLine}{processResult.GetFormattedOutput()}";
}
throw new DistributedApplicationException(message);
}
_logger.LogInformation("{RuntimeName} login to {RegistryServer} succeeded.", Name, registryServer);
}
}
/// <summary>
/// Executes a container runtime command with standard logging and error handling.
/// </summary>
/// <param name="arguments">The command arguments to pass to the container runtime.</param>
/// <param name="errorLogTemplate">Log template for error messages (must include {ExitCode} placeholder).</param>
/// <param name="successLogTemplate">Log template for success messages.</param>
/// <param name="exceptionMessageTemplate">Exception message template (must include {ExitCode} placeholder).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="logArguments">Arguments to pass to the log templates.</param>
protected async Task ExecuteContainerCommandAsync(
string arguments,
string errorLogTemplate,
string successLogTemplate,
string exceptionMessageTemplate,
CancellationToken cancellationToken,
params object[] logArguments)
{
var spec = CreateProcessSpec(arguments, retainOutput: true);
_logger.LogDebug("Running {RuntimeName} with arguments: {ArgumentList}", Name, spec.Arguments);
var (pendingProcessResult, processDisposable) = _processRunner.Run(spec);
await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
if (processResult.ExitCode != 0)
{
var errorArgs = logArguments.Concat(new object[] { processResult.ExitCode }).ToArray();
_logger.LogError(errorLogTemplate, errorArgs);
var message = string.Format(System.Globalization.CultureInfo.InvariantCulture, exceptionMessageTemplate, processResult.ExitCode);
if (processResult.TotalProcessOutputLineCount > 0)
{
message = $"{message}{Environment.NewLine}{processResult.GetFormattedOutput(outputDescription: "Command output")}";
}
throw new DistributedApplicationException(message);
}
_logger.LogInformation(successLogTemplate, logArguments);
}
}
/// <summary>
/// Executes a container runtime command and returns the exit code without throwing exceptions.
/// </summary>
/// <param name="arguments">The command arguments to pass to the container runtime.</param>
/// <param name="errorLogTemplate">Log template for error messages (must include {ExitCode} placeholder).</param>
/// <param name="successLogTemplate">Log template for success messages.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="logArguments">Arguments to pass to the log templates.</param>
/// <param name="environmentVariables">Optional environment variables to set for the process.</param>
/// <returns>The exit code of the process.</returns>
protected async Task<int> ExecuteContainerCommandWithExitCodeAsync(
string arguments,
string errorLogTemplate,
string successLogTemplate,
CancellationToken cancellationToken,
object[] logArguments,
Dictionary<string, string>? environmentVariables = null)
{
var processResult = await ExecuteContainerCommandWithResultAsync(
arguments,
errorLogTemplate,
successLogTemplate,
cancellationToken,
logArguments,
environmentVariables).ConfigureAwait(false);
return processResult.ExitCode;
}
/// <summary>
/// Builds a string of build arguments for container build commands.
/// </summary>
/// <param name="buildArguments">The build arguments to include.</param>
/// <returns>A string containing the formatted build arguments.</returns>
protected static string BuildArgumentsString(Dictionary<string, string?> buildArguments)
{
var result = string.Empty;
foreach (var buildArg in buildArguments)
{
result += buildArg.Value is not null
? $" --build-arg \"{buildArg.Key}={buildArg.Value}\""
: $" --build-arg \"{buildArg.Key}\"";
}
return result;
}
/// <summary>
/// Builds a string of build secrets for container build commands.
/// </summary>
/// <param name="buildSecrets">The build secrets to include.</param>
/// <param name="requireValue">Whether to require a non-null value for secrets (default: false).</param>
/// <returns>A string containing the formatted build secrets.</returns>
internal static string BuildSecretsString(Dictionary<string, BuildImageSecretValue> buildSecrets, bool requireValue = false)
{
var result = string.Empty;
foreach (var buildSecret in buildSecrets)
{
if (buildSecret.Value.Type == BuildImageSecretType.File)
{
result += $" --secret \"id={buildSecret.Key},type=file,src={buildSecret.Value.Value}\"";
}
else if (requireValue && buildSecret.Value.Value is null)
{
result += $" --secret \"id={buildSecret.Key},type=env\"";
}
else
{
result += $" --secret \"id={buildSecret.Key},type=env,env={buildSecret.Key.ToUpperInvariant()}\"";
}
}
return result;
}
/// <summary>
/// Builds a string for the target stage in container build commands.
/// </summary>
/// <param name="stage">The target stage to include.</param>
/// <returns>A string containing the formatted target stage, or empty string if stage is null or empty.</returns>
protected static string BuildStageString(string? stage)
{
return !string.IsNullOrEmpty(stage) ? $" --target \"{stage}\"" : string.Empty;
}
/// <summary>
/// Executes a container runtime command and returns the process result without throwing for non-zero exit codes.
/// </summary>
protected async Task<ProcessResult> ExecuteContainerCommandWithResultAsync(
string arguments,
string errorLogTemplate,
string successLogTemplate,
CancellationToken cancellationToken,
object[] logArguments,
Dictionary<string, string>? environmentVariables = null,
bool retainOutput = false)
{
var spec = CreateProcessSpec(arguments, retainOutput);
if (environmentVariables is not null)
{
foreach (var (key, value) in environmentVariables)
{
spec.EnvironmentVariables[key] = value;
}
}
_logger.LogDebug("Running {RuntimeName} with arguments: {ArgumentList}", Name, spec.Arguments);
var (pendingProcessResult, processDisposable) = _processRunner.Run(spec);
await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
if (processResult.ExitCode != 0)
{
var errorArgs = logArguments.Concat(new object[] { processResult.ExitCode }).ToArray();
_logger.LogError(errorLogTemplate, errorArgs);
}
else
{
_logger.LogDebug(successLogTemplate, logArguments);
}
return processResult;
}
}
protected async Task<string> ExecuteContainerCommandForOutputAsync(
string arguments,
string operationName,
string imageName,
CancellationToken cancellationToken)
{
var stdout = new List<string>();
var spec = CreateProcessSpec(arguments, retainOutput: true, onOutputData: output =>
{
stdout.Add(output);
_logger.LogDebug("{RuntimeName} (stdout): {Output}", RuntimeExecutable, output);
});
return await ExecuteContainerCommandForOutputAsync(spec, stdout, operationName, imageName, cancellationToken).ConfigureAwait(false);
}
protected async Task<string> ExecuteContainerCommandForOutputAsync(
IReadOnlyList<string> argumentList,
string operationName,
string imageName,
CancellationToken cancellationToken)
{
var stdout = new List<string>();
var spec = CreateProcessSpec(argumentList, retainOutput: true, onOutputData: output =>
{
stdout.Add(output);
_logger.LogDebug("{RuntimeName} (stdout): {Output}", RuntimeExecutable, output);
});
return await ExecuteContainerCommandForOutputAsync(spec, stdout, operationName, imageName, cancellationToken).ConfigureAwait(false);
}
private async Task<string> ExecuteContainerCommandForOutputAsync(
ProcessSpec spec,
List<string> stdout,
string operationName,
string imageName,
CancellationToken cancellationToken)
{
_logger.LogDebug("Running {RuntimeName} with arguments: {ArgumentList}", Name, spec.ArgumentList ?? (object?)spec.Arguments);
var (pendingProcessResult, processDisposable) = _processRunner.Run(spec);
ProcessResult processResult;
await using (processDisposable)
{
processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
}
if (processResult.ExitCode != 0)
{
_logger.LogError("{RuntimeName} {OperationName} for {ImageName} failed with exit code {ExitCode}.", Name, operationName, imageName, processResult.ExitCode);
throw new DistributedApplicationException($"{Name} {operationName} for '{imageName}' failed with exit code {processResult.ExitCode}.{Environment.NewLine}{processResult.GetFormattedOutput()}");
}
_logger.LogDebug("{RuntimeName} {OperationName} for {ImageName} succeeded.", Name, operationName, imageName);
return string.Join(Environment.NewLine, stdout);
}
private ProcessSpec CreateProcessSpec(string arguments, bool retainOutput = false, Action<string>? onOutputData = null)
{
return CreateProcessSpecCore(arguments, argumentList: null, retainOutput, onOutputData);
}
private ProcessSpec CreateProcessSpec(IReadOnlyList<string> argumentList, bool retainOutput = false, Action<string>? onOutputData = null)
{
return CreateProcessSpecCore(arguments: null, argumentList, retainOutput, onOutputData);
}
private ProcessSpec CreateProcessSpecCore(
string? arguments,
IReadOnlyList<string>? argumentList,
bool retainOutput,
Action<string>? onOutputData)
{
return new ProcessSpec(RuntimeExecutable)
{
Arguments = arguments,
ArgumentList = argumentList,
RetainedOutputLineCount = retainOutput ? ProcessSpec.DefaultRetainedOutputLineCount : null,
OnOutputData = onOutputData ?? (output =>
{
_logger.LogDebug("{RuntimeName} (stdout): {Output}", RuntimeExecutable, output);
}),
OnErrorData = error =>
{
_logger.LogDebug("{RuntimeName} (stderr): {Error}", RuntimeExecutable, error);
},
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
};
}
protected static string EscapeArgument(string value) => value.Replace("\"", "\\\"", StringComparison.Ordinal);
public virtual async Task ComposeUpAsync(ComposeOperationContext context, CancellationToken cancellationToken)
{
await EnsureRuntimeAvailableAsync().ConfigureAwait(false);
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)
{
Arguments = arguments,
WorkingDirectory = context.WorkingDirectory,
ThrowOnNonZeroReturnCode = false,
RetainedOutputLineCount = ProcessSpec.DefaultRetainedOutputLineCount,
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) = _processRunner.Run(spec);
await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
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').";
var message =
$"'{RuntimeExecutable} compose up' failed with exit code {processResult.ExitCode}. " +
$"Ensure '{RuntimeExecutable}' is installed and available on PATH. " +
envHint;
if (processResult.TotalProcessOutputLineCount > 0)
{
message = $"{message}{Environment.NewLine}{processResult.GetFormattedOutput()}";
}
throw new DistributedApplicationException(message);
}
}
}
public virtual async Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken)
{
await EnsureRuntimeAvailableAsync().ConfigureAwait(false);
var arguments = BuildComposeArguments(context);
arguments += " down";
_logger.LogDebug("Running {Runtime} compose down with arguments: {Arguments}", RuntimeExecutable, arguments);
var stderrLines = new List<string>();
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);
if (!string.IsNullOrWhiteSpace(error))
{
stderrLines.Add(error);
}
},
};
var (pendingProcessResult, processDisposable) = _processRunner.Run(spec);
await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
if (processResult.ExitCode != 0)
{
var stderrOutput = stderrLines.Count > 0
? " " + string.Join(" ", stderrLines)
: "";
throw new DistributedApplicationException(
$"'{RuntimeExecutable} compose down' failed with exit code {processResult.ExitCode}.{stderrOutput}");
}
}
}
public virtual async Task<IReadOnlyList<ComposeServiceInfo>?> ComposeListServicesAsync(ComposeOperationContext context, CancellationToken cancellationToken)
{
await EnsureRuntimeAvailableAsync().ConfigureAwait(false);
var arguments = BuildComposeArguments(context);
arguments += " ps --format json";
var outputLines = new List<string>();
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) = _processRunner.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);
}
/// <summary>
/// Parses Docker Compose ps JSON output, handling both NDJSON (one object per line) and JSON array formats.
/// </summary>
/// <example>
/// NDJSON (Docker Compose v2+):
/// <code>
/// {"Service":"web","Publishers":[{"URL":"","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"}]}
/// {"Service":"cache","Publishers":[{"TargetPort":6379,"PublishedPort":6379}]}
/// </code>
/// JSON array (older versions):
/// <code>
/// [{"Service":"web","Publishers":[{"TargetPort":80,"PublishedPort":8080}]}]
/// </code>
/// </example>
internal static List<ComposeServiceInfo> ParseComposeServiceEntries(List<string> outputLines)
{
var results = new List<ComposeServiceInfo>();
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()
};
}
/// <summary>
/// Builds the compose CLI arguments from a <see cref="ComposeOperationContext"/>.
/// </summary>
private static string BuildComposeArguments(ComposeOperationContext context)
{
var arguments = context.ComposeFilePath is not null
? $"compose -f \"{context.ComposeFilePath}\" --project-name \"{context.ProjectName}\""
: $"compose --project-name \"{context.ProjectName}\"";
if (context.EnvFilePath is not null && File.Exists(context.EnvFilePath))
{
arguments += $" --env-file \"{context.EnvFilePath}\"";
}
return arguments;
}
/// <summary>
/// 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.
/// </summary>
protected async Task EnsureRuntimeAvailableAsync()
{
try
{
var whichCommand = OperatingSystem.IsWindows() ? "where" : "which";
var spec = new ProcessSpec(whichCommand)
{
Arguments = RuntimeExecutable,
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
};
var (pendingResult, processDisposable) = _processRunner.Run(spec);
await using (processDisposable)
{
var result = await pendingResult.ConfigureAwait(false);
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);
}
}
}
/// <summary>
/// Internal DTO for deserializing Docker Compose ps JSON output.
/// </summary>
internal sealed class DockerComposePsEntry
{
public string? Service { get; set; }
public List<DockerComposePsPublisher>? Publishers { get; set; }
}
/// <summary>
/// Internal DTO for deserializing Docker Compose ps publisher entries.
/// </summary>
internal sealed class DockerComposePsPublisher
{
public int? PublishedPort { get; set; }
public int? TargetPort { get; set; }
}
[JsonSerializable(typeof(DockerComposePsEntry))]
[JsonSerializable(typeof(List<DockerComposePsEntry>))]
internal sealed partial class ComposeJsonContext : JsonSerializerContext
{
}