// 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 Aspire.Shared;
namespace Aspire.TestUtilities;
/// <summary>
/// Points Testcontainers at Podman on machines where Podman is the only container runtime, and reports
/// whether Testcontainers has an endpoint to talk to at all.
/// </summary>
/// <remarks>
/// <para>
/// Testcontainers 4.x cannot find Podman on its own. Its endpoint discovery only ever probes Docker
/// sockets: <c>UnixEndpointAuthenticationProvider</c> checks <c>/var/run/docker.sock</c>, and
/// <c>RootlessUnixEndpointAuthenticationProvider</c> is hard-coded to <c>docker.sock</c> file names
/// (<c>private const string DockerSocket = "docker.sock"</c>) and is additionally gated on
/// <c>IsOSPlatform(Linux)</c>, so on macOS it is never even tried. Nothing in the provider chain looks at
/// a path containing "podman". Without this, every Testcontainers-backed fixture fails on a Podman-only
/// host with <c>DockerUnavailableException</c> ("Docker is either not running or misconfigured...").
/// See https://github.com/testcontainers/testcontainers-dotnet/blob/4.8.1/src/Testcontainers/Configurations/TestcontainersSettings.cs
/// and https://github.com/testcontainers/testcontainers-dotnet/blob/4.8.1/src/Testcontainers/Builders/RootlessUnixEndpointAuthenticationProvider.cs
/// </para>
/// <para>
/// Configuration has to happen before the first <c>ContainerBuilder</c> is constructed, because
/// <c>TestcontainersSettings</c> resolves its endpoint and Ryuk switches once, in its static constructor.
/// The <see cref="TestFeature.Testcontainers"/> capability check is the hook for that, so every
/// Testcontainers fixture in the repo passes through it - either by calling
/// <see cref="RequiresFeatureAttribute.IsFeatureSupported"/> directly before building its container, or via
/// the trait attribute, which xUnit evaluates during discovery. That makes this strictly more reliable than
/// a module initializer, which would only cover the assemblies it happened to be compiled into.
/// </para>
/// <para>
/// Podman cannot always be reached, though: Testcontainers 4.8.1 cannot drive it over a Windows named pipe,
/// and on Linux <c>podman</c> runs daemonlessly with no API socket unless <c>podman system service</c> is
/// running. Those hosts still run containers perfectly well through DCP, so they keep
/// <see cref="TestFeature.ContainerRuntime"/>; it is <see cref="TestFeature.Testcontainers"/> that consults
/// <see cref="HasUsableEndpoint"/> and skips the fixtures which would otherwise throw.
/// </para>
/// </remarks>
internal static class TestcontainersPodmanConfiguration
{
private const string DockerHostVariable = "DOCKER_HOST";
private const string RyukDisabledVariable = "TESTCONTAINERS_RYUK_DISABLED";
// Give up rather than hang the test run if the Podman CLI is wedged.
private static readonly TimeSpan s_podmanInspectTimeout = TimeSpan.FromSeconds(10);
// Called once per test during trait evaluation, so the probing must happen at most once per process.
private static readonly Lazy<bool> s_hasUsableEndpoint = new(Configure);
/// <summary>
/// Reports whether configuration has already run, without triggering it.
/// </summary>
internal static bool IsConfigurationInitialized => s_hasUsableEndpoint.IsValueCreated;
/// <summary>
/// Configures Testcontainers for Podman if required. Safe to call repeatedly; the work happens once.
/// </summary>
internal static void EnsureConfigured() => _ = s_hasUsableEndpoint.Value;
/// <summary>
/// Reports whether Testcontainers has a Docker-compatible API endpoint to talk to, configuring it for
/// Podman first if that is what it takes. Safe to call repeatedly; the work happens once.
/// </summary>
internal static bool HasUsableEndpoint => s_hasUsableEndpoint.Value;
/// <returns><inheritdoc cref="HasUsableEndpoint" path="/summary"/></returns>
private static bool Configure()
{
var decision = Decide(
OperatingSystem.IsWindows(),
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
Environment.GetEnvironmentVariable,
SocketExists,
FindPodmanSocketFromCli);
// Both settings are published as environment variables rather than assigned on
// TestcontainersSettings, because they have to survive a process boundary. Microsoft.Testing.Platform
// runs the tests in a child process (for example when --hangdump is used), and that child inherits
// the environment but not managed static state, so an in-process assignment ends up applying to the
// wrong process. Going through the environment also keeps this file free of a compile-time
// dependency on the Testcontainers API, which Aspire.TestUtilities deliberately does not reference.
if (decision.DockerHost is { } dockerHost)
{
Environment.SetEnvironmentVariable(DockerHostVariable, dockerHost);
}
if (decision.DisableRyuk)
{
Environment.SetEnvironmentVariable(RyukDisabledVariable, "true");
}
return decision.HasUsableEndpoint;
}
/// <summary>
/// Works out what Testcontainers needs, without touching any ambient state. Every input is injected so
/// that the branches below - almost all of which are unreachable on CI, where Docker is always present -
/// can be exercised by unit tests.
/// </summary>
/// <param name="isWindows">Whether the host is Windows.</param>
/// <param name="homeDirectory">The current user's home directory, used to build socket candidates.</param>
/// <param name="getEnvironmentVariable">Reads an environment variable.</param>
/// <param name="socketExists">Reports whether a socket file exists at the given path.</param>
/// <param name="findPodmanSocketFromCli">Asks the Podman CLI for the machine's API socket path.</param>
internal static PodmanConfigurationDecision Decide(
bool isWindows,
string? homeDirectory,
Func<string, string?> getEnvironmentVariable,
Func<string?, bool> socketExists,
Func<string?> findPodmanSocketFromCli)
{
var dockerHost = getEnvironmentVariable(DockerHostVariable);
// Windows talks to the engine over a named pipe, and Podman-on-Windows needs fixes that are not in
// Testcontainers 4.8.1 (https://github.com/testcontainers/testcontainers-dotnet/issues/1438). There
// is nothing to configure, so the only question is whether the developer already pointed
// Testcontainers at something it can actually use. A Podman pipe is rejected rather than trusted,
// because running the fixtures against it only produces DockerUnavailableException instead of the
// skip the caller wants. A Docker Desktop install is handled by the caller, which additionally
// treats the `docker` CLI being on PATH as proof of a reachable endpoint.
if (isWindows)
{
return new PodmanConfigurationDecision(
HasUsableEndpoint: !string.IsNullOrEmpty(dockerHost) && !LooksLikePodmanEndpoint(dockerHost));
}
// An explicit DOCKER_HOST always wins: it is how a developer points the tests at a specific engine,
// and Testcontainers' EnvironmentEndpointAuthenticationProvider already honours it. This is also the
// branch a test host child process takes, because it inherits the values configured below.
if (!string.IsNullOrEmpty(dockerHost))
{
// Ryuk still has to be turned off when that endpoint is a Podman one. `podman machine start`
// prints an `export DOCKER_HOST=...` hint, so pointing DOCKER_HOST at Podman by hand is the
// normal setup rather than an exotic one.
return new PodmanConfigurationDecision(
HasUsableEndpoint: true,
DisableRyuk: LooksLikePodmanEndpoint(dockerHost) && !IsRyukSettingExplicit(getEnvironmentVariable));
}
// CI (GitHub Actions and Helix) always has Docker, so this leaves those runs on exactly the code
// path they use today.
if (DockerSocketExists(homeDirectory, getEnvironmentVariable, socketExists))
{
return new PodmanConfigurationDecision(HasUsableEndpoint: true);
}
if (FindPodmanSocket(homeDirectory, getEnvironmentVariable, socketExists, findPodmanSocketFromCli) is not { } podmanSocketPath)
{
return default;
}
return new PodmanConfigurationDecision(
HasUsableEndpoint: true,
DockerHost: $"unix://{podmanSocketPath}",
DisableRyuk: !IsRyukSettingExplicit(getEnvironmentVariable));
}
/// <summary>
/// Reports whether an endpoint is served by Podman rather than Docker.
/// </summary>
/// <remarks>
/// A substring match is the only option available without connecting: the value is an opaque URI and
/// Docker and Podman speak the same API over it. It holds for every layout Podman produces, all of
/// which name the product somewhere in the path:
/// <code>
/// unix:///run/user/1000/podman/podman.sock (Linux, rootless)
/// unix:///run/podman/podman.sock (Linux, rootful)
/// unix:///var/folders/_h/.../T/podman/podman-machine-default-api.sock (macOS machine)
/// npipe:////./pipe/podman-machine-default (Windows machine)
/// </code>
/// A false negative only costs the Ryuk workaround below, and a false positive only skips a container
/// reaper the fixtures do not rely on, so neither direction breaks a test run.
/// </remarks>
private static bool LooksLikePodmanEndpoint(string dockerHost)
=> dockerHost.Contains(KnownContainerRuntimes.Podman, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Reports whether the developer has taken ownership of the Ryuk setting, in which case their value
/// stands regardless of what the runtime is.
/// </summary>
private static bool IsRyukSettingExplicit(Func<string, string?> getEnvironmentVariable)
=> !string.IsNullOrEmpty(getEnvironmentVariable(RyukDisabledVariable));
/// <summary>
/// Reports whether a Docker socket that Testcontainers would discover on its own already exists.
/// </summary>
private static bool DockerSocketExists(string? homeDirectory, Func<string, string?> getEnvironmentVariable, Func<string?, bool> socketExists)
{
// Mirrors the socket paths Testcontainers 4.8.1 probes on Unix, plus the Docker Desktop socket.
string?[] candidates =
[
"/var/run/docker.sock",
CombineWithEnvironmentVariable(getEnvironmentVariable, "XDG_RUNTIME_DIR", "docker.sock"),
CombineWithHome(homeDirectory, ".docker", "run", "docker.sock"),
CombineWithHome(homeDirectory, ".docker", "desktop", "docker.sock"),
];
return candidates.Any(socketExists);
}
/// <summary>
/// Locates the Podman API socket, preferring well-known locations before shelling out to the CLI.
/// </summary>
private static string? FindPodmanSocket(
string? homeDirectory,
Func<string, string?> getEnvironmentVariable,
Func<string?, bool> socketExists,
Func<string?> findPodmanSocketFromCli)
{
string?[] candidates =
[
// Linux, rootless.
CombineWithEnvironmentVariable(getEnvironmentVariable, "XDG_RUNTIME_DIR", "podman", "podman.sock"),
// Linux, rootful.
"/run/podman/podman.sock",
// macOS: `podman machine` maintains this symlink to the current machine's API socket. Preferred
// over the link target because AF_UNIX paths are limited to ~104 bytes on macOS and the target
// lives under a long $TMPDIR path.
CombineWithHome(homeDirectory, ".local", "share", "containers", "podman", "machine", "podman.sock"),
];
return candidates.FirstOrDefault(socketExists) ?? findPodmanSocketFromCli();
}
/// <summary>
/// Asks the Podman CLI where the machine's API socket lives, as a fallback for layouts this file does
/// not know about.
/// </summary>
private static string? FindPodmanSocketFromCli()
{
// `podman machine inspect --format {{.ConnectionInfo.PodmanSocket.Path}}` prints one absolute path
// per inspected machine, e.g.
// /var/folders/_h/jnjmbyss12g40fn478_1k5rr0000gn/T/podman/podman-machine-default-api.sock
// It exits non-zero when no machine exists, and prints nothing on Linux where there is no VM.
var startInfo = new ProcessStartInfo(KnownContainerRuntimes.Podman)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
startInfo.ArgumentList.Add("machine");
startInfo.ArgumentList.Add("inspect");
startInfo.ArgumentList.Add("--format");
startInfo.ArgumentList.Add("{{.ConnectionInfo.PodmanSocket.Path}}");
try
{
using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}
// Drain both streams concurrently so a full pipe buffer cannot block podman before it exits.
var standardOutput = process.StandardOutput.ReadToEndAsync();
_ = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit((int)s_podmanInspectTimeout.TotalMilliseconds))
{
process.Kill(entireProcessTree: true);
return null;
}
// The overload that takes a timeout does not wait for the redirected streams to be flushed.
process.WaitForExit();
if (process.ExitCode != 0)
{
return null;
}
return standardOutput.GetAwaiter().GetResult()
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault(SocketExists);
}
catch (Exception)
{
// Podman is not installed, not on PATH, or otherwise unusable. Leave Testcontainers alone so it
// reports its own diagnostics.
return null;
}
}
private static bool SocketExists(string? path) => !string.IsNullOrEmpty(path) && File.Exists(path);
private static string? CombineWithEnvironmentVariable(Func<string, string?> getEnvironmentVariable, string variable, params string[] parts)
{
var root = getEnvironmentVariable(variable);
return string.IsNullOrEmpty(root) ? null : Path.Combine([root, .. parts]);
}
private static string? CombineWithHome(string? homeDirectory, params string[] parts)
=> string.IsNullOrEmpty(homeDirectory) ? null : Path.Combine([homeDirectory, .. parts]);
}
/// <summary>
/// What <see cref="TestcontainersPodmanConfiguration.Decide"/> concluded about the current machine.
/// </summary>
/// <param name="HasUsableEndpoint">
/// Whether Testcontainers ends up with a Docker-compatible API endpoint to talk to. When this is
/// <see langword="false"/> every Testcontainers fixture will throw <c>DockerUnavailableException</c>, so
/// those tests should be skipped rather than run.
/// </param>
/// <param name="DockerHost">
/// The value to publish as <c>DOCKER_HOST</c>, or <see langword="null"/> to leave it alone.
/// </param>
/// <param name="DisableRyuk">
/// Whether to publish <c>TESTCONTAINERS_RYUK_DISABLED=true</c>. Ryuk, the resource reaper, bind-mounts the
/// engine socket into a privileged container and always mounts it at <c>/var/run/docker.sock</c>
/// (<c>ResourceReaper.UnixSocketMount</c>). Under rootless Podman that fails outright - "statfs ...:
/// operation not supported" on a macOS podman machine, "permission denied" on Linux - so it has to be off.
/// See https://github.com/testcontainers/testcontainers-dotnet/issues/876. The fixtures dispose their own
/// containers, and Helix already disables Ryuk for Testcontainers work items
/// (tests/helix/send-to-helix-inner.proj).
/// </param>
internal readonly record struct PodmanConfigurationDecision(
bool HasUsableEndpoint,
string? DockerHost = null,
bool DisableRyuk = false);