File: Helpers\KubernetesDeployTestHelpersTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.Tests)
// 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 System.Net;
using System.Net.Sockets;
using System.Text;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests.Helpers;
 
public class KubernetesDeployTestHelpersTests
{
    [Fact]
    public void GetDeletePodCommand_WaitsForOldPodRemoval()
    {
        Assert.Equal(
            "kubectl delete pod app-statefulset-0 -n test-ns --wait=true",
            KubernetesDeployTestHelpers.GetDeletePodCommand("test-ns", "app-statefulset-0"));
    }
 
    [Fact]
    public void GetWaitForRecreatedPodReadyCommand_WaitsForCreationBeforeReadiness()
    {
        // Waiting for Ready on a StatefulSet pod that the controller has not recreated yet
        // fails immediately with NotFound, so readiness must be gated on creation.
        Assert.Equal(
            "kubectl wait --for=create pod/app-statefulset-0 -n test-ns --timeout=180s && " +
            "kubectl wait --for=condition=Ready pod/app-statefulset-0 -n test-ns --timeout=180s",
            KubernetesDeployTestHelpers.GetWaitForRecreatedPodReadyCommand("test-ns", "app-statefulset-0"));
    }
 
    [Fact]
    public async Task GetRetryingDownloadCommand_RetriesWhenServerReturnsError()
    {
        // A GitHub release download returning 504 must be retried, not saved as the file.
        await using var server = FakeDownloadServer.Start(static (attempt, stream, ct) => attempt == 1
            ? stream.WriteResponseAsync("504 Gateway Timeout", "gateway timeout", ct)
            : stream.WriteResponseAsync("200 OK", "payload", ct));
 
        var (exitCode, content) = await RunDownloadAsync(server.Url);
 
        Assert.Equal(0, exitCode);
        Assert.Equal("payload", content);
        Assert.Equal(2, server.RequestCount);
    }
 
    [Fact]
    public async Task GetRetryingDownloadCommand_RetriesWhenDownloadStalls()
    {
        // A connection that never responds must end the attempt so the next one can run
        // before the caller's 90s prompt timeout expires.
        await using var server = FakeDownloadServer.Start(static async (attempt, stream, ct) =>
        {
            if (attempt == 1)
            {
                await Task.Delay(Timeout.Infinite, ct);
            }
 
            await stream.WriteResponseAsync("200 OK", "payload", ct);
        });
 
        var stopwatch = Stopwatch.StartNew();
        var (exitCode, content) = await RunDownloadAsync(server.Url);
 
        Assert.Equal(0, exitCode);
        Assert.Equal("payload", content);
        Assert.Equal(2, server.RequestCount);
        Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(90), $"Download took {stopwatch.Elapsed}.");
    }
 
    private static async Task<(int ExitCode, string? Content)> RunDownloadAsync(string url)
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "The download command runs in a Linux shell.");
 
        var directory = Directory.CreateTempSubdirectory("aspire-download-test-");
        var destination = Path.Combine(directory.FullName, "download");
        var command = KubernetesDeployTestHelpers.GetRetryingDownloadCommand(
            "test", url, $"'{destination}'", $"test -s '{destination}'");
 
        var startInfo = new ProcessStartInfo("bash") { RedirectStandardOutput = true, RedirectStandardError = true };
        startInfo.ArgumentList.Add("-c");
        startInfo.ArgumentList.Add(command);
        using var process = Process.Start(startInfo)!;
        var stdout = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken);
        var stderr = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken);
 
        // Fail rather than hang when an attempt is not bounded (the original stalled-download behavior).
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        timeout.CancelAfter(TimeSpan.FromMinutes(2));
        try
        {
            await process.WaitForExitAsync(timeout.Token);
        }
        catch (OperationCanceledException)
        {
            process.Kill(entireProcessTree: true);
            throw new TimeoutException("The download command did not finish.");
        }
 
        TestContext.Current.TestOutputHelper?.WriteLine(await stdout + await stderr);
        try
        {
            return (process.ExitCode, File.Exists(destination) ? await File.ReadAllTextAsync(destination, TestContext.Current.CancellationToken) : null);
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    private sealed class FakeDownloadServer : IAsyncDisposable
    {
        private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
        private readonly CancellationTokenSource _cts = new();
        private readonly Func<int, NetworkStream, CancellationToken, Task> _respond;
        private readonly List<Task> _connections = [];
        private Task _acceptLoop = Task.CompletedTask;
        private int _requestCount;
 
        private FakeDownloadServer(Func<int, NetworkStream, CancellationToken, Task> respond) => _respond = respond;
 
        public string Url => $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/file";
 
        public int RequestCount => Volatile.Read(ref _requestCount);
 
        public static FakeDownloadServer Start(Func<int, NetworkStream, CancellationToken, Task> respond)
        {
            var server = new FakeDownloadServer(respond);
            server._listener.Start();
            server._acceptLoop = server.AcceptAsync();
            return server;
        }
 
        private async Task AcceptAsync()
        {
            try
            {
                while (true)
                {
                    var client = await _listener.AcceptTcpClientAsync(_cts.Token);
                    _connections.Add(HandleAsync(client));
                }
            }
            catch (OperationCanceledException)
            {
            }
        }
 
        private async Task HandleAsync(TcpClient client)
        {
            using (client)
            {
                try
                {
                    var stream = client.GetStream();
                    var buffer = new byte[4096];
                    var request = new StringBuilder();
                    while (!request.ToString().Contains("\r\n\r\n"))
                    {
                        var read = await stream.ReadAsync(buffer, _cts.Token);
                        if (read == 0)
                        {
                            return;
                        }
 
                        request.Append(Encoding.ASCII.GetString(buffer, 0, read));
                    }
 
                    await _respond(Interlocked.Increment(ref _requestCount), stream, _cts.Token);
                }
                catch (Exception ex) when (ex is OperationCanceledException or IOException)
                {
                }
            }
        }
 
        public async ValueTask DisposeAsync()
        {
            await _cts.CancelAsync();
            _listener.Stop();
            await _acceptLoop;
            await Task.WhenAll(_connections);
            _cts.Dispose();
        }
    }
}
 
internal static class FakeDownloadServerStreamExtensions
{
    public static Task WriteResponseAsync(this NetworkStream stream, string status, string body, CancellationToken cancellationToken)
    {
        var response = $"HTTP/1.1 {status}\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n{body}";
        return stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken).AsTask();
    }
}