| File: Utils\DockerUtils.cs | Web Access |
| Project: src\tests\Aspire.Hosting.TestUtilities\Aspire.Hosting.TestUtilities.csproj (Aspire.Hosting.TestUtilities) |
// 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.Hosting.Utils; public sealed class DockerUtils { // Bounds the one-off runtime probe below. ContainerRuntimeDetector already caps each individual // process at 10s, but a machine with several wedged CLIs could still stall test cleanup for a while. private static readonly TimeSpan s_runtimeDetectionTimeout = TimeSpan.FromSeconds(30); // `volume rm` / `volume inspect` are spelled identically by Docker and Podman, so the only thing that // varies is the executable. Resolved once because every functional test that uses a volume calls this. private static readonly Lazy<RuntimeResolution> s_runtimeResolution = new(ResolveRuntimeExecutable); /// <summary> /// Resolves the container runtime the app host itself would have used, so volumes are removed with the /// same engine that created them. /// </summary> /// <remarks> /// Picking the first CLI on PATH is not good enough: with both CLIs installed and /// <c>ASPIRE_CONTAINER_RUNTIME=podman</c>, Aspire creates a Podman volume, and the same happens when /// Docker is installed but unhealthy and detection falls back to Podman. Either way a PATH-order guess /// would run <c>docker volume rm</c> against a volume Docker does not have, silently leaking it. /// <see cref="ContainerRuntimeDetector"/> is exactly what <c>ContainerRuntimeResolver</c> uses, and the /// configured-runtime precedence mirrors <c>DcpOptions</c>. The in-memory /// <c>DcpPublisher:ContainerRuntime</c> key is deliberately not honoured — this is a static helper with /// no <c>IConfiguration</c>, and no test that sets that key creates volumes. /// </remarks> private static RuntimeResolution ResolveRuntimeExecutable() => ResolveRuntimeExecutable(static (configuredRuntime, cancellationToken) => ContainerRuntimeDetector.FindAvailableRuntimeAsync(configuredRuntime, cancellationToken: cancellationToken)); internal static RuntimeResolution ResolveRuntimeExecutable( Func<string?, CancellationToken, Task<ContainerRuntimeInfo?>> runtimeDetector) { ArgumentNullException.ThrowIfNull(runtimeDetector); string? configuredRuntime = null; try { configuredRuntime = GetConfiguredRuntime(); // Detection is async and spawns processes. Task.Run keeps the blocking wait off any ambient // synchronization context, since callers are synchronous test teardown paths. using var cts = new CancellationTokenSource(s_runtimeDetectionTimeout); var detected = Task.Run( () => runtimeDetector(configuredRuntime, cts.Token), cts.Token).GetAwaiter().GetResult(); if (detected is not { IsInstalled: true }) { return RuntimeResolution.Failed(DescribeMissingRuntime(configuredRuntime)); } return PathLookupHelper.FindFullPathFromPath(detected.Executable) is { } executable ? RuntimeResolution.Succeeded(executable) : RuntimeResolution.Failed(DescribeMissingRuntime(configuredRuntime)); } catch (OperationCanceledException exception) { return RuntimeResolution.Failed("container runtime detection timed out.", exception); } catch (Exception exception) { // This is a best-effort cleanup boundary. Preserve unexpected probe failures as data so the // Lazy does not cache a thrown exception that can later replace the test's original failure. return RuntimeResolution.Failed( $"container runtime detection failed with {exception.GetType().Name}: {exception.Message}", exception); } } /// <summary> /// Reads the explicitly configured container runtime, using the same precedence as <c>DcpOptions</c>. /// </summary> private static string? GetConfiguredRuntime() { var configuredRuntime = Environment.GetEnvironmentVariable(KnownConfigNames.ContainerRuntime) ?? Environment.GetEnvironmentVariable(KnownConfigNames.Legacy.ContainerRuntime); return string.IsNullOrEmpty(configuredRuntime) ? null : configuredRuntime; } /// <summary> /// Explains why no runtime could be used, so a failing cleanup does not look like a missing install /// when the runtime was actually pinned by configuration. /// </summary> private static string DescribeMissingRuntime(string? configuredRuntime) => configuredRuntime is not null ? $"the container runtime configured by {KnownConfigNames.ContainerRuntime} ('{configuredRuntime}') is not available." : "no container runtime was found on PATH."; public static void AttemptDeleteDockerVolume(string volumeName, bool throwOnFailure = false) { var runtimeResolution = s_runtimeResolution.Value; if (runtimeResolution.Executable is not string runtime) { var message = $"Failed to delete the volume named '{volumeName}': {runtimeResolution.FailureReason}"; if (throwOnFailure) { throw new InvalidOperationException(message, runtimeResolution.DetectionException); } Console.WriteLine(message); return; } for (var i = 0; i < 3; i++) { if (i != 0) { Thread.Sleep(1000); } if (Process.Start(runtime, $"volume rm {volumeName}") is { } process) { var exited = process.WaitForExit(TimeSpan.FromSeconds(3)); var done = exited && process.ExitCode == 0; process.Kill(entireProcessTree: true); process.Dispose(); if (done) { break; } } } if (throwOnFailure) { if (Process.Start(runtime, $"volume inspect {volumeName}") is { } process) { var exited = process.WaitForExit(TimeSpan.FromSeconds(3)); var exitCode = process.ExitCode; process.Kill(entireProcessTree: true); process.Dispose(); if (!exited) { throw new InvalidOperationException($"Failed to inspect the deleted volume named '{volumeName}', the inspect process did not exit."); } if (exitCode == 0) { throw new InvalidOperationException($"Failed to delete the volume named '{volumeName}'. Attempted to inspect the volume and it still exists."); } } else { throw new InvalidOperationException($"Failed to inspect the deleted volume named '{volumeName}', the inspect process did not start."); } } } internal readonly record struct RuntimeResolution( string? Executable, string? FailureReason, Exception? DetectionException) { public static RuntimeResolution Succeeded(string executable) => new(executable, FailureReason: null, DetectionException: null); public static RuntimeResolution Failed(string failureReason, Exception? detectionException = null) => new(Executable: null, FailureReason: failureReason, DetectionException: detectionException); } }