File: Publishing\PodmanContainerRuntime.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.
 
#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 Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Publishing;
 
internal sealed class PodmanContainerRuntime : ContainerRuntimeBase<PodmanContainerRuntime>
{
    public PodmanContainerRuntime(ILogger<PodmanContainerRuntime> logger, IProcessRunner processRunner) : base(logger, processRunner)
    {
    }
 
    protected override string RuntimeExecutable => KnownContainerRuntimes.Podman;
    public override string Name => "Podman";
 
    /// <summary>
    /// Lists compose services using native <c>podman ps</c> with label filters,
    /// which works with both Docker Compose v2 and podman-compose providers.
    /// </summary>
    public override async Task<IReadOnlyList<ComposeServiceInfo>?> 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<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("podman ps (stderr): {Error}", error);
                }
            }
        };
 
        var (pendingProcessResult, processDisposable) = ProcessRunner.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);
    }
 
    /// <summary>
    /// Parses native <c>podman ps --format json</c> output into normalized <see cref="ComposeServiceInfo"/> entries.
    /// Podman returns a JSON array. Containers are aggregated by compose service name.
    /// </summary>
    /// <example>
    /// <code>
    /// [{"Labels":{"com.docker.compose.service":"web"},"Ports":[{"host_ip":"","container_port":80,"host_port":8080,"range":1,"protocol":"tcp"}]}]
    /// </code>
    /// </example>
    internal static List<ComposeServiceInfo> ParsePodmanPsOutput(List<string> outputLines)
    {
        var allText = string.Join("", outputLines);
        if (string.IsNullOrWhiteSpace(allText))
        {
            return [];
        }
 
        List<PodmanPsEntry>? 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<string, List<ComposeServicePort>>(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<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
    {
        var imageName = !string.IsNullOrEmpty(options?.Tag)
            ? $"{options.ImageName}:{options.Tag}"
            : options?.ImageName ?? throw new ArgumentException("ImageName must be provided in options.", nameof(options));
 
        if (options.ImageFormat == ContainerImageFormat.Oci && string.IsNullOrEmpty(options.OutputPath))
        {
            throw new ArgumentException("OutputPath must be provided when ImageFormat is Oci.", nameof(options));
        }
 
        var arguments = $"build --file \"{dockerfilePath}\" --tag \"{imageName}\"";
 
        // Add platform support if specified
        if (options?.TargetPlatform is not null)
        {
            arguments += $" --platform \"{options.TargetPlatform.Value.ToRuntimePlatformString()}\"";
        }
 
        // Add format support if specified
        if (options?.ImageFormat is not null)
        {
            var format = options.ImageFormat.Value switch
            {
                ContainerImageFormat.Oci => "oci",
                ContainerImageFormat.Docker => "docker",
                _ => throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format")
            };
            arguments += $" --format \"{format}\"";
        }
 
        // Archive output is deliberately NOT handled here.
        //
        // `podman build` has no image-archive output flag. Its `--output`/`-o` is a *filesystem* export
        // (`-o type=local,dest=...`), not the image-archive equivalent of
        // `docker buildx build --output type=oci,dest=...`, and it is rejected outright when talking to
        // a remote/machine-backed service:
        //   Error: '--output' option is not supported in remote mode
        // The image-archive equivalent is a normal tagged build followed by `podman save`, which
        // RunPodmanSaveAsync performs once the build below succeeds.
        // See https://docs.podman.io/en/latest/markdown/podman-build.1.html and
        // https://docs.podman.io/en/latest/markdown/podman-save.1.html
 
        // Add build arguments if specified
        arguments += BuildArgumentsString(buildArguments);
 
        // Add build secrets if specified
        arguments += BuildSecretsString(buildSecrets, requireValue: true);
 
        // Add stage if specified
        arguments += BuildStageString(stage);
 
        arguments += $" \"{contextPath}\"";
 
        // Prepare environment variables for build secrets (only for environment-type secrets)
        var environmentVariables = new Dictionary<string, string>();
        foreach (var buildSecret in buildSecrets)
        {
            if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null)
            {
                environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value;
            }
        }
 
        var processResult = await ExecuteContainerCommandWithResultAsync(
            arguments,
            "Podman build for {ImageName} failed with exit code {ExitCode}.",
            "Podman build for {ImageName} succeeded.",
            cancellationToken,
            new object[] { imageName },
            environmentVariables,
            retainOutput: true).ConfigureAwait(false);
 
        if (processResult.ExitCode != 0)
        {
            throw new ProcessFailedException(
                $"Podman build failed with exit code {processResult.ExitCode}.",
                processResult.ExitCode,
                processResult.ProcessOutput,
                processResult.TotalProcessOutputLineCount);
        }
 
        if (!string.IsNullOrEmpty(options?.OutputPath))
        {
            await RunPodmanSaveAsync(imageName, options, cancellationToken).ConfigureAwait(false);
        }
    }
 
    /// <summary>
    /// Exports an already-built image to an archive, which is how Podman produces the equivalent of
    /// <c>docker buildx build --output type=oci,dest=...</c>.
    /// </summary>
    private async Task RunPodmanSaveAsync(string imageName, ContainerImageBuildOptions options, CancellationToken cancellationToken)
    {
        var arguments = BuildSaveArguments(imageName, options);
 
        var processResult = await ExecuteContainerCommandWithResultAsync(
            arguments,
            "Podman save for {ImageName} failed with exit code {ExitCode}.",
            "Podman save for {ImageName} succeeded.",
            cancellationToken,
            new object[] { imageName },
            retainOutput: true).ConfigureAwait(false);
 
        if (processResult.ExitCode != 0)
        {
            throw new ProcessFailedException(
                $"Podman save failed with exit code {processResult.ExitCode}.",
                processResult.ExitCode,
                processResult.ProcessOutput,
                processResult.TotalProcessOutputLineCount);
        }
    }
 
    /// <summary>
    /// Builds the <c>podman save</c> arguments that export <paramref name="imageName"/> to an image archive.
    /// </summary>
    /// <example>
    /// <code>
    /// save --format "oci-archive" --output "/out/myapp-latest.tar" "myapp:latest"
    /// </code>
    /// </example>
    internal static string BuildSaveArguments(string imageName, ContainerImageBuildOptions options)
    {
        var format = options.ImageFormat switch
        {
            ContainerImageFormat.Oci => "oci-archive",
            // The .NET SDK and Docker both treat an unspecified format as the Docker manifest type.
            ContainerImageFormat.Docker or null => "docker-archive",
            _ => throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format")
        };
 
        // Derive the archive path through the shared helper so that this runtime, the Docker runtime, and
        // ContainerImageReference (which hands the path to consumers) all resolve the same file.
        var archivePath = ResourceExtensions.GetContainerImageArchivePath(options.OutputPath!, imageName);
 
        return $"save --format \"{format}\" --output \"{archivePath}\" \"{imageName}\"";
    }
 
    public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
    {
        await RunPodmanBuildAsync(
            contextPath,
            dockerfilePath,
            options,
            buildArguments,
            buildSecrets,
            stage,
            cancellationToken).ConfigureAwait(false);
    }
 
    public override async Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken)
    {
        try
        {
            var exitCode = await ExecuteContainerCommandWithExitCodeAsync(
                "container ls -n 1",
                "Podman container ls failed with exit code {ExitCode}.",
                "Podman is running and healthy.",
                cancellationToken,
                Array.Empty<object>()).ConfigureAwait(false);
 
            return exitCode == 0;
        }
        catch
        {
            return false;
        }
    }
 
    public override async Task<ContainerImageManifestInspectionResult> InspectImageManifestAsync(string imageName, CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(imageName);
        var remoteImageName = imageName.StartsWith("docker://", StringComparison.OrdinalIgnoreCase)
            ? imageName
            : $"docker://{imageName}";
 
        string manifest;
        try
        {
            manifest = await ExecuteContainerCommandForOutputAsync(
                ["manifest", "inspect", remoteImageName],
                "inspect image manifest",
                imageName,
                cancellationToken).ConfigureAwait(false);
        }
        catch (DistributedApplicationException ex)
        {
            return new ContainerImageManifestInspectionResult(
                ContainerImageInspectionStatus.Failed,
                rawJson: null,
                ex.Message,
                manifestAccessor: null);
        }
 
        if (!IsJsonObjectOrArray(manifest))
        {
            return new ContainerImageManifestInspectionResult(
                ContainerImageInspectionStatus.Failed,
                manifest,
                $"Podman returned an invalid image manifest for '{imageName}'.",
                manifestAccessor: null);
        }
 
        if (!IsPlainSingleImageManifest(manifest))
        {
            return new ContainerImageManifestInspectionResult(
                ContainerImageInspectionStatus.Succeeded,
                manifest,
                errorMessage: null,
                (operatingSystem, architecture) => FindManifest(manifest, operatingSystem, architecture));
        }
 
        // Podman returns a plain OCI/Docker manifest for a single-architecture tag:
        //   { "schemaVersion": 2, "config": { ... }, "layers": [ ... ] }
        // Unlike its manifest-list output, that shape has no digest. Query the local image
        // metadata so the sandbox deployer can still pin the tag to an immutable reference.
        var localImageName = imageName.StartsWith("docker://", StringComparison.OrdinalIgnoreCase)
            ? imageName["docker://".Length..]
            : imageName;
        string imageMetadata;
        try
        {
            await ExecuteContainerCommandForOutputAsync(
                ["pull", remoteImageName],
                "pull image for metadata inspection",
                imageName,
                cancellationToken).ConfigureAwait(false);
            imageMetadata = await ExecuteContainerCommandForOutputAsync(
                ["image", "inspect", "--format", """{"Digest":{{json .Digest}},"Os":{{json .Os}},"Architecture":{{json .Architecture}}}""", localImageName],
                "inspect image metadata",
                imageName,
                cancellationToken).ConfigureAwait(false);
        }
        catch (DistributedApplicationException ex)
        {
            return new ContainerImageManifestInspectionResult(
                ContainerImageInspectionStatus.Failed,
                manifest,
                ex.Message,
                manifestAccessor: null);
        }
 
        string? digest;
        string? os;
        string? architecture;
        try
        {
            using var metadataDocument = JsonDocument.Parse(imageMetadata);
            var metadata = metadataDocument.RootElement;
            digest = metadata.TryGetProperty("Digest", out var digestProperty) ? digestProperty.GetString() : null;
            os = metadata.TryGetProperty("Os", out var osProperty) ? osProperty.GetString() : null;
            architecture = metadata.TryGetProperty("Architecture", out var architectureProperty) ? architectureProperty.GetString() : null;
        }
        catch (JsonException ex)
        {
            return new ContainerImageManifestInspectionResult(
                ContainerImageInspectionStatus.Failed,
                imageMetadata,
                $"Podman returned invalid image metadata for '{imageName}': {ex.Message}",
                manifestAccessor: null);
        }
        catch (InvalidOperationException ex)
        {
            return new ContainerImageManifestInspectionResult(
                ContainerImageInspectionStatus.Failed,
                imageMetadata,
                $"Podman returned invalid image metadata for '{imageName}': {ex.Message}",
                manifestAccessor: null);
        }
 
        if (digest is null || !ContainerImageManifest.IsValidDigest(digest))
        {
            return new ContainerImageManifestInspectionResult(
                ContainerImageInspectionStatus.Failed,
                imageMetadata,
                $"Podman did not return an immutable digest for image '{imageName}'.",
                manifestAccessor: null);
        }
 
        if (string.IsNullOrWhiteSpace(os) || string.IsNullOrWhiteSpace(architecture))
        {
            return new ContainerImageManifestInspectionResult(
                ContainerImageInspectionStatus.Failed,
                imageMetadata,
                $"Podman did not return platform metadata for image '{imageName}'.",
                manifestAccessor: null);
        }
 
        var inspectedManifest = new ContainerImageManifest(digest, os, architecture);
        return new ContainerImageManifestInspectionResult(
            ContainerImageInspectionStatus.Succeeded,
            imageMetadata,
            errorMessage: null,
            (requestedOperatingSystem, requestedArchitecture) =>
                string.Equals(requestedOperatingSystem, inspectedManifest.OperatingSystem, StringComparison.OrdinalIgnoreCase) &&
                string.Equals(requestedArchitecture, inspectedManifest.Architecture, StringComparison.OrdinalIgnoreCase)
                    ? inspectedManifest
                    : null);
    }
 
    private static bool IsPlainSingleImageManifest(string manifest)
    {
        try
        {
            using var document = JsonDocument.Parse(manifest);
            var root = document.RootElement;
            return root.ValueKind == JsonValueKind.Object &&
                root.TryGetProperty("schemaVersion", out _) &&
                root.TryGetProperty("config", out _) &&
                root.TryGetProperty("layers", out _) &&
                !root.TryGetProperty("manifests", out _);
        }
        catch (JsonException)
        {
            return false;
        }
    }
}
 
/// <summary>
/// Internal DTO for deserializing <c>podman ps --format json</c> output.
/// </summary>
internal sealed class PodmanPsEntry
{
    public Dictionary<string, string>? Labels { get; set; }
    public List<PodmanPsPort>? Ports { get; set; }
}
 
/// <summary>
/// Internal DTO for deserializing Podman port mappings.
/// </summary>
internal sealed class PodmanPsPort
{
    [JsonPropertyName("container_port")]
    public int? ContainerPort { get; set; }
 
    [JsonPropertyName("host_port")]
    public int? HostPort { get; set; }
}
 
[JsonSerializable(typeof(List<PodmanPsEntry>))]
internal sealed partial class PodmanPsJsonContext : JsonSerializerContext
{
}