File: TestcontainersPodmanConfigurationTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Containers.Tests\Aspire.Hosting.Containers.Tests.csproj (Aspire.Hosting.Containers.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.TestUtilities;
using Microsoft.DotNet.RemoteExecutor;
 
namespace Aspire.Hosting.Containers.Tests;
 
/// <summary>
/// Covers <see cref="TestcontainersPodmanConfiguration.Decide"/>, which decides how to point
/// Testcontainers at a container runtime. CI always has a Docker socket, so it only ever exercises the
/// no-op branch; without these tests every Podman branch would ship unverified.
/// </summary>
/// <remarks>
/// Socket paths that the production code assembles with <see cref="Path.Combine(string[])"/> are built the
/// same way here, because the separator it inserts is platform dependent and these tests run everywhere.
/// Paths the production code hard-codes are hard-coded here too.
/// </remarks>
public class TestcontainersPodmanConfigurationTests
{
    private const string DockerHostVariable = "DOCKER_HOST";
    private const string RyukDisabledVariable = "TESTCONTAINERS_RYUK_DISABLED";
 
    private const string HomeDirectory = "/home/tester";
    private const string XdgRuntimeDirectory = "/run/user/1000";
 
    // The macOS `podman machine` layout, which is what the CLI fallback reports.
    private const string PodmanMachineSocket = "/var/folders/_h/xxxx/T/podman/podman-machine-default-api.sock";
 
    [Fact]
    public void IsFeatureSupported_ContainerRuntime_DoesNotInitializeTestcontainersConfiguration()
    {
        RemoteExecutor.Invoke(static () =>
        {
            Assert.False(TestcontainersPodmanConfiguration.IsConfigurationInitialized);
 
            _ = RequiresFeatureAttribute.IsFeatureSupported(TestFeature.ContainerRuntime);
 
            Assert.False(TestcontainersPodmanConfiguration.IsConfigurationInitialized);
        }).Dispose();
    }
 
    [Fact]
    public void Decide_OnWindowsWithAPodmanSocket_ReportsNoEndpoint()
    {
        // The socket is one the non-Windows path would happily adopt, so this fails if the Windows guard
        // is ever dropped. Testcontainers 4.8.1 cannot drive Podman over a named pipe, and there is no
        // Unix socket to point it at, so the fixtures have to be skipped.
        var decision = Decide(isWindows: true, environment: [], existingSockets: ["/run/podman/podman.sock"]);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: false), decision);
    }
 
    [Fact]
    public void Decide_OnWindowsWithDockerHostPointingAtDocker_ReportsEndpointWithoutChangingEnvironment()
    {
        var decision = Decide(
            isWindows: true,
            environment: new() { [DockerHostVariable] = "npipe://./pipe/docker_engine" },
            existingSockets: []);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: true), decision);
    }
 
    [Fact]
    public void Decide_OnWindowsWithDockerHostPointingAtPodman_ReportsNoEndpoint()
    {
        // Off Windows this same value would be adopted and Ryuk turned off; on Windows Testcontainers
        // cannot use it at all, so claiming an endpoint would just trade a skip for a hard failure.
        var decision = Decide(
            isWindows: true,
            environment: new() { [DockerHostVariable] = "npipe:////./pipe/podman-machine-default" },
            existingSockets: []);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: false), decision);
    }
 
    [Fact]
    public void Decide_WithDockerHostPointingAtDocker_LeavesRyukEnabled()
    {
        var decision = Decide(
            isWindows: false,
            environment: new() { [DockerHostVariable] = "unix:///var/run/docker.sock" },
            existingSockets: []);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: true), decision);
    }
 
    [Theory]
    [InlineData("unix:///run/user/1000/podman/podman.sock")]
    [InlineData("unix:///run/podman/podman.sock")]
    [InlineData("unix:///var/folders/_h/xxxx/T/podman/podman-machine-default-api.sock")]
    [InlineData("unix:///home/tester/.local/share/containers/Podman/machine/podman.sock")]
    public void Decide_WithDockerHostPointingAtPodman_DisablesRyuk(string dockerHost)
    {
        // `podman machine start` prints an `export DOCKER_HOST=...` hint, so this is the normal way a
        // developer ends up on Podman - and Ryuk cannot bind-mount a rootless Podman socket.
        var decision = Decide(
            isWindows: false,
            environment: new() { [DockerHostVariable] = dockerHost },
            existingSockets: []);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: true, DockerHost: null, DisableRyuk: true), decision);
    }
 
    [Theory]
    [InlineData("true")]
    [InlineData("false")]
    public void Decide_WithDockerHostPointingAtPodman_DoesNotOverrideAnExplicitRyukSetting(string ryukDisabled)
    {
        var decision = Decide(
            isWindows: false,
            environment: new()
            {
                [DockerHostVariable] = "unix:///run/podman/podman.sock",
                [RyukDisabledVariable] = ryukDisabled,
            },
            existingSockets: []);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: true), decision);
    }
 
    // Attribute arguments cannot contain a nested array, so the multi-segment candidates come through
    // MemberData rather than InlineData.
    public static TheoryData<string[]> DockerSocketCandidates => new()
    {
        new[] { "/var/run/docker.sock" },
        new[] { XdgRuntimeDirectory, "docker.sock" },
        new[] { HomeDirectory, ".docker", "run", "docker.sock" },
        new[] { HomeDirectory, ".docker", "desktop", "docker.sock" },
    };
 
    [Theory]
    [MemberData(nameof(DockerSocketCandidates))]
    public void Decide_WithADockerSocket_LeavesTestcontainersAlone(string[] socketPathParts)
    {
        var decision = Decide(isWindows: false, environment: [], existingSockets: [Path.Combine(socketPathParts)]);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: true), decision);
    }
 
    public static TheoryData<string[]> PodmanSocketCandidates => new()
    {
        new[] { XdgRuntimeDirectory, "podman", "podman.sock" },
        new[] { "/run/podman/podman.sock" },
        new[] { HomeDirectory, ".local", "share", "containers", "podman", "machine", "podman.sock" },
    };
 
    [Theory]
    [MemberData(nameof(PodmanSocketCandidates))]
    public void Decide_WithAPodmanSocket_PointsTestcontainersAtIt(string[] socketPathParts)
    {
        var podmanSocket = Path.Combine(socketPathParts);
 
        var decision = Decide(isWindows: false, environment: [], existingSockets: [podmanSocket]);
 
        Assert.Equal(
            new PodmanConfigurationDecision(HasUsableEndpoint: true, DockerHost: $"unix://{podmanSocket}", DisableRyuk: true),
            decision);
    }
 
    [Fact]
    public void Decide_WithAPodmanSocket_DoesNotOverrideAnExplicitRyukSetting()
    {
        var decision = Decide(
            isWindows: false,
            environment: new() { [RyukDisabledVariable] = "false" },
            existingSockets: ["/run/podman/podman.sock"]);
 
        Assert.Equal(
            new PodmanConfigurationDecision(HasUsableEndpoint: true, DockerHost: "unix:///run/podman/podman.sock"),
            decision);
    }
 
    [Fact]
    public void Decide_WithBothSockets_PrefersDocker()
    {
        var decision = Decide(
            isWindows: false,
            environment: [],
            existingSockets: ["/var/run/docker.sock", "/run/podman/podman.sock"]);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: true), decision);
    }
 
    [Fact]
    public void Decide_WithNoWellKnownSocket_FallsBackToThePodmanCli()
    {
        var decision = Decide(
            isWindows: false,
            environment: [],
            existingSockets: [],
            podmanSocketFromCli: PodmanMachineSocket);
 
        Assert.Equal(
            new PodmanConfigurationDecision(HasUsableEndpoint: true, DockerHost: $"unix://{PodmanMachineSocket}", DisableRyuk: true),
            decision);
    }
 
    [Fact]
    public void Decide_WithAWellKnownSocket_DoesNotConsultThePodmanCli()
    {
        // Shelling out costs a process launch on every test run, so the well-known paths have to win.
        var cliInvoked = false;
 
        var decision = TestcontainersPodmanConfiguration.Decide(
            isWindows: false,
            HomeDirectory,
            _ => null,
            path => path == "/run/podman/podman.sock",
            () =>
            {
                cliInvoked = true;
                return null;
            });
 
        Assert.Equal(
            new PodmanConfigurationDecision(HasUsableEndpoint: true, DockerHost: "unix:///run/podman/podman.sock", DisableRyuk: true),
            decision);
        Assert.False(cliInvoked);
    }
 
    [Fact]
    public void Decide_WithNoRuntimeAtAll_ReportsNoEndpoint()
    {
        var decision = Decide(isWindows: false, environment: [], existingSockets: []);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: false), decision);
    }
 
    [Fact]
    public void Decide_WithoutAHomeDirectory_SkipsTheHomeRelativeCandidates()
    {
        // Path.Combine throws on a null root, so a home directory that cannot be resolved has to be
        // dropped from the candidate list rather than fed into it.
        var dockerDesktopSocket = Path.Combine(HomeDirectory, ".docker", "desktop", "docker.sock");
 
        var decision = TestcontainersPodmanConfiguration.Decide(
            isWindows: false,
            homeDirectory: null,
            _ => null,
            path => path == dockerDesktopSocket,
            () => null);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: false), decision);
    }
 
    [Fact]
    public void Decide_WithoutXdgRuntimeDir_SkipsTheXdgRelativeCandidates()
    {
        var rootlessPodmanSocket = Path.Combine(XdgRuntimeDirectory, "podman", "podman.sock");
 
        var decision = TestcontainersPodmanConfiguration.Decide(
            isWindows: false,
            HomeDirectory,
            _ => null,
            path => path == rootlessPodmanSocket,
            () => null);
 
        Assert.Equal(new PodmanConfigurationDecision(HasUsableEndpoint: false), decision);
    }
 
    private static PodmanConfigurationDecision Decide(
        bool isWindows,
        Dictionary<string, string> environment,
        string[] existingSockets,
        string? podmanSocketFromCli = null)
    {
        environment.TryAdd("XDG_RUNTIME_DIR", XdgRuntimeDirectory);
 
        return TestcontainersPodmanConfiguration.Decide(
            isWindows,
            HomeDirectory,
            variable => environment.GetValueOrDefault(variable),
            path => path is not null && existingSockets.Contains(path),
            () => podmanSocketFromCli);
    }
}