File: Helpers\KubernetesDeployTestHelpers.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 Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests.Helpers;
 
/// <summary>
/// Shared helpers for Kubernetes deploy E2E tests that use KinD clusters with a local registry.
/// </summary>
internal static class KubernetesDeployTestHelpers
{
    private const string KindRegistryOwnerLabel = "aspire.e2e.kind-registry-owner";
 
    private static string KindVersion => KubernetesE2EVersions.KindVersion;
    private static string HelmVersion => KubernetesE2EVersions.HelmVersion;
    private static string KubectlVersion => KubernetesE2EVersions.KubectlVersion;
 
    /// <summary>
    /// Generates a unique KinD cluster name (max 32 chars).
    /// </summary>
    internal static string GenerateUniqueClusterName() =>
        $"aspire-e2e-{Guid.NewGuid():N}"[..32];
 
    /// <summary>
    /// Installs KinD, Helm, and kubectl binaries to ~/.local/bin and adds to PATH.
    /// Skips downloads for tools already on PATH (e.g., pre-installed in Dockerfile.e2e).
    /// Retries downloads up to 3 times to handle transient GitHub CDN failures.
    /// </summary>
    internal static async Task InstallKindAndHelmAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        await auto.TypeAsync("mkdir -p ~/.local/bin");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Download KinD if not already installed — GitHub CDN can transiently return HTML instead of a binary.
        await auto.TypeAsync($"command -v kind >/dev/null 2>&1 || {{ rm -f ~/.local/bin/kind; for i in 1 2 3; do curl -sSLo ~/.local/bin/kind \"https://github.com/kubernetes-sigs/kind/releases/download/{KindVersion}/kind-linux-amd64\" && chmod +x ~/.local/bin/kind && ~/.local/bin/kind version >/dev/null 2>&1 && break; echo \"Retry $i: KinD download failed, retrying in 5s...\"; rm -f ~/.local/bin/kind; sleep 5; done; test -x ~/.local/bin/kind && ~/.local/bin/kind version >/dev/null 2>&1; }}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90));
 
        // Download Helm if not already installed
        await auto.TypeAsync($"command -v helm >/dev/null 2>&1 || {{ for i in 1 2 3; do curl -sSL https://get.helm.sh/helm-{HelmVersion}-linux-amd64.tar.gz | tar xz -C /tmp && test -f /tmp/linux-amd64/helm && break; echo \"Retry $i: Helm download failed, retrying in 5s...\"; sleep 5; done && mv /tmp/linux-amd64/helm ~/.local/bin/helm && rm -rf /tmp/linux-amd64; }}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90));
 
        // Download kubectl if not already installed
        await auto.TypeAsync($"command -v kubectl >/dev/null 2>&1 || {{ rm -f ~/.local/bin/kubectl; for i in 1 2 3; do curl -sSLo ~/.local/bin/kubectl \"https://dl.k8s.io/release/{KubectlVersion}/bin/linux/amd64/kubectl\" && chmod +x ~/.local/bin/kubectl && ~/.local/bin/kubectl version --client >/dev/null 2>&1 && break; echo \"Retry $i: kubectl download failed, retrying in 5s...\"; rm -f ~/.local/bin/kubectl; sleep 5; done; test -x ~/.local/bin/kubectl && ~/.local/bin/kubectl version --client >/dev/null 2>&1; }}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90));
 
        await auto.TypeAsync("export PATH=\"$HOME/.local/bin:$PATH\"");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Verify all three binaries are functional
        await auto.TypeAsync("kind version && helm version --short && kubectl version --client --short 2>/dev/null || kubectl version --client");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Creates a KinD cluster with a local Docker registry at localhost:5001.
    /// Follows the KinD local registry guide pattern.
    /// </summary>
    internal static async Task CreateKindClusterWithRegistryAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string clusterName)
    {
        // Delete any leftover cluster with the same name
        await auto.TypeAsync($"kind delete cluster --name={clusterName} 2>/dev/null || true");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
        // Label only registries this test creates. A pre-existing registry can belong to a
        // developer or another cluster, so neither cleanup path may remove it. Do not start
        // a stopped pre-existing registry on its owner's behalf; fail setup explicitly.
        await auto.TypeAsync(
            "if docker container inspect kind-registry >/dev/null 2>&1; then " +
            "test \"$(docker inspect -f '{{.State.Running}}' kind-registry)\" = true || " +
            "{ echo 'Existing kind-registry is stopped; refusing to modify it.' >&2; false; }; " +
            "else docker run -d --restart=always -p 5001:5000 --network bridge --name kind-registry " +
            $"--label '{KindRegistryOwnerLabel}={clusterName}' registry:2; fi");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
        // Create the cluster (no containerd config patches — registry is configured post-creation via hosts.toml)
        await auto.TypeAsync($"kind create cluster --name={clusterName} --wait=120s");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
 
        // Connect registry to cluster network
        await auto.TypeAsync($"docker network connect \"kind\" kind-registry 2>/dev/null || true");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // The cluster is created by the host Docker daemon, so the default kubeconfig points kubectl at a
        // localhost-published API server port that is not reachable from inside the helper container. Join the
        // helper container to the kind network and switch kubectl to the cluster's internal control-plane endpoint.
        await auto.TypeAsync($"docker network connect \"kind\" \"$(hostname)\" 2>/dev/null || true && kind export kubeconfig --name={clusterName} --internal >/dev/null");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
        // Configure containerd on each node to resolve localhost:5001 via the registry container.
        // This uses the config_path approach required by containerd v2+ (shipped in KinD v0.31.0+).
        await auto.TypeAsync($"for node in $(kind get nodes --name={clusterName}); do " +
            "docker exec \"$node\" mkdir -p /etc/containerd/certs.d/localhost:5001 && " +
            "echo '[host.\"http://kind-registry:5000\"]' | docker exec -i \"$node\" tee /etc/containerd/certs.d/localhost:5001/hosts.toml > /dev/null && " +
            "echo '  capabilities = [\"pull\", \"resolve\"]' | docker exec -i \"$node\" tee -a /etc/containerd/certs.d/localhost:5001/hosts.toml > /dev/null; " +
            "done");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
        // Create a ConfigMap so KinD knows about the local registry
        await auto.TypeAsync("cat > /tmp/local-registry-cm.yaml << 'CMEOF'");
        await auto.EnterAsync();
        await auto.TypeAsync("apiVersion: v1");
        await auto.EnterAsync();
        await auto.TypeAsync("kind: ConfigMap");
        await auto.EnterAsync();
        await auto.TypeAsync("metadata:");
        await auto.EnterAsync();
        await auto.TypeAsync("  name: local-registry-hosting");
        await auto.EnterAsync();
        await auto.TypeAsync("  namespace: kube-public");
        await auto.EnterAsync();
        await auto.TypeAsync("data:");
        await auto.EnterAsync();
        await auto.TypeAsync("  localRegistryHosting.v1: |");
        await auto.EnterAsync();
        await auto.TypeAsync("    host: \"localhost:5001\"");
        await auto.EnterAsync();
        await auto.TypeAsync("CMEOF");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.TypeAsync("kubectl apply -f /tmp/local-registry-cm.yaml");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Verify cluster is ready
        await auto.TypeAsync($"kubectl cluster-info --context kind-{clusterName}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Installs the Radius <c>rad</c> CLI into ~/.local/bin (already added to PATH
    /// by <see cref="InstallKindAndHelmAsync"/>) using the official install script,
    /// pinned to <see cref="KubernetesE2EVersions.RadiusVersion"/>. Skips the
    /// download when <c>rad</c> is already on PATH, and retries up to 3 times to
    /// tolerate transient GitHub CDN failures (mirrors the KinD/Helm downloads).
    /// </summary>
    internal static async Task InstallRadCliAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        var radiusVersion = KubernetesE2EVersions.RadiusVersion;
 
        await auto.TypeAsync("mkdir -p ~/.local/bin");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // The install script is fetched from the matching release tag (which carries
        // the leading `v`), while its `--version` flag expects the bare number. rad
        // downloads its companion `bicep` to ~/.rad/bin on the first `rad deploy`.
        await auto.TypeAsync($"command -v rad >/dev/null 2>&1 || {{ rm -f ~/.local/bin/rad; for i in 1 2 3; do curl -fsSL \"https://raw.githubusercontent.com/radius-project/radius/v{radiusVersion}/deploy/install.sh\" | /bin/bash -s -- --version {radiusVersion} --install-dir \"$HOME/.local/bin\" && test -x \"$HOME/.local/bin/rad\" && break; echo \"Retry $i: rad download failed, retrying in 5s...\"; rm -f ~/.local/bin/rad; sleep 5; done; test -x \"$HOME/.local/bin/rad\"; }}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        await auto.TypeAsync("export PATH=\"$HOME/.local/bin:$PATH\" && rad version");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Installs the Radius control plane onto the KinD cluster and creates the
    /// resource group, environment, and workspace that <c>rad deploy</c> (driven by
    /// <c>aspire deploy</c>) resolves against. No Azure is involved.
    /// </summary>
    /// <remarks>
    /// Two non-obvious behaviors this sequence works around:
    /// <list type="bullet">
    ///   <item><description>
    ///     In rad 0.60.0, <c>rad</c> ignores <c>KUBECONFIG</c> and targets the current-context of
    ///     <c>~/.kube/config</c>. In this container <c>kind export kubeconfig
    ///     --internal</c> has already set that to <c>kind-&lt;cluster&gt;</c>, but we
    ///     still pass <c>--kubecontext</c>/<c>--context</c> explicitly so the
    ///     control-plane install and workspace are pinned to the intended cluster.
    ///   </description></item>
    ///   <item><description>
    ///     Default scope creation during <c>rad install kubernetes</c> varies by version:
    ///     rad 0.60.0 provisioned the <c>default</c> resource group and environment,
    ///     while 0.60.2 can finish installing without them. The explicit
    ///     <c>rad group create</c>/<c>rad env create</c> calls ensure both exist before
    ///     workspace creation; they are idempotent when installation already created them.
    ///     Installation does not persist a workspace, so we also create it explicitly
    ///     for <c>rad deploy</c> to resolve its scope.
    ///   </description></item>
    /// </list>
    /// </remarks>
    internal static async Task InstallRadiusControlPlaneAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string clusterName)
    {
        // Installing the control plane pulls several images and waits for the
        // radius-system pods to become ready, so allow a generous budget.
        await auto.TypeAsync($"rad install kubernetes --kubecontext kind-{clusterName}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(10));
 
        await auto.TypeAsync("rad group create default");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        await auto.TypeAsync("rad env create default --group default");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        await auto.TypeAsync($"rad workspace create kubernetes radius-e2e --context kind-{clusterName} --group default --environment default --force");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
    }
 
    /// <summary>
    /// Scaffolds an Aspire project using <c>aspire new</c> (Starter template, no Redis),
    /// then adds hosting/client packages and injects custom code into the existing source files.
    /// Asserts the "Using project templates version:" message appears with a prerelease suffix.
    /// </summary>
    internal static async Task ScaffoldK8sDeployProjectAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string projectName,
        string projectDir,
        string[] appHostHostingPackages,
        string[] apiClientPackages,
        string appHostCode,
        string apiProgramCode,
        ITestOutputHelper output)
    {
        // Step 1: Run aspire new inline (rather than AspireNewAsync) so we can assert on
        // the "Using project templates version:" message that appears during execution.
        await auto.TypeAsync("aspire new");
        await auto.EnterAsync();
        await auto.WaitUntilAsync(
            s => new CellPatternSearcher().Find("> Starter App").Search(s).Count > 0,
            timeout: TimeSpan.FromSeconds(60),
            description: "template selection list (> Starter App)");
        await auto.EnterAsync(); // Select Starter template
 
        await auto.WaitUntilAsync(
            s => new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0,
            timeout: TimeSpan.FromSeconds(10),
            description: "project name prompt");
        await auto.TypeAsync(projectName);
        await auto.EnterAsync();
 
        await auto.WaitUntilAsync(
            s => new CellPatternSearcher().Find("Enter the output path").Search(s).Count > 0,
            timeout: TimeSpan.FromSeconds(10),
            description: "output path prompt");
        await auto.EnterAsync();
 
        await auto.WaitUntilAsync(
            s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0,
            timeout: TimeSpan.FromSeconds(10),
            description: "URLs prompt");
        await auto.EnterAsync();
 
        await auto.WaitUntilAsync(
            s => new CellPatternSearcher().Find("Use Redis Cache").Search(s).Count > 0,
            timeout: TimeSpan.FromSeconds(10),
            description: "Redis cache prompt");
        await auto.TypeAsync("n");
 
        await auto.WaitUntilAsync(
            s => new CellPatternSearcher().Find("Do you want to create a test project?").Search(s).Count > 0,
            timeout: TimeSpan.FromSeconds(10),
            description: "test project prompt");
        await auto.EnterAsync();
 
        // === KEY ASSERTION: Wait for "Using project templates version:" ===
        // This message appears after all prompts, during template installation/project creation.
        var templateVersionSearcher = new CellPatternSearcher().Find("Using project templates version:");
        var agentInitSearcher = new CellPatternSearcher().Find("configure AI agent environments");
        var templateVersionFound = false;
 
        await auto.WaitUntilAsync(
            snapshot =>
            {
                if (templateVersionSearcher.Search(snapshot).Count > 0)
                {
                    templateVersionFound = true;
                }
 
                // Wait until the command finishes (agent init prompt or success prompt)
                if (agentInitSearcher.Search(snapshot).Count > 0)
                {
                    return true;
                }
                var successPrompt = new CellPatternSearcher()
                    .FindPattern(counter.Value.ToString())
                    .RightText(" OK] $ ");
                return successPrompt.Search(snapshot).Count > 0;
            },
            timeout: TimeSpan.FromMinutes(5),
            description: "template version message and aspire new completion");
 
        Assert.True(templateVersionFound,
            "Expected 'Using project templates version:' message during aspire new, but it was not found. " +
            "This may indicate the CLI is not using the expected development templates.");
        output.WriteLine("✅ Template version message found during aspire new");
 
        // Dismiss agent init prompt (same as DeclineAgentInitPromptAsync)
        await auto.WaitAsync(500);
        await auto.TypeAsync("n");
        await auto.WaitForAnyPromptAsync(counter);
 
        // Step 2: cd into the project
        await auto.TypeAsync($"cd {projectName}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Step 3: Add hosting packages via aspire add (handles version selection)
        foreach (var package in appHostHostingPackages)
        {
            await auto.TypeAsync($"aspire add {package}");
            await auto.EnterAsync();
            await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
        }
 
        // Step 4: Add client NuGet packages to ApiService (uses local hive version when available, otherwise falls back to --prerelease)
        foreach (var package in apiClientPackages)
        {
            await auto.TypeAsync(AspireCliShellCommandHelpers.GetDotnetAddPackageCommand($"{projectName}.ApiService", package));
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
        }
 
        // Step 5: Inject custom AppHost.cs and ApiService/Program.cs into the template-created project
        var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
        var apiDir = Path.Combine(projectDir, $"{projectName}.ApiService");
 
        Assert.True(File.Exists(Path.Combine(appHostDir, "AppHost.cs")));
        Assert.True(File.Exists(Path.Combine(apiDir, $"{projectName}.ApiService.csproj")));
        Assert.True(File.Exists(Path.Combine(apiDir, "Properties", "launchSettings.json")));
        output.WriteLine($"Writing AppHost.cs to: {Path.Combine(appHostDir, "AppHost.cs")}");
        File.WriteAllText(Path.Combine(appHostDir, "AppHost.cs"), appHostCode);
        File.WriteAllText(Path.Combine(apiDir, "Program.cs"), apiProgramCode);
    }
 
    /// <summary>
    /// Runs <c>aspire deploy</c> interactively, answering parameter prompts via terminal automation.
    /// </summary>
    /// <param name="auto">The terminal automator.</param>
    /// <param name="counter">Sequence counter for prompt tracking.</param>
    /// <param name="parameterResponses">
    /// Ordered list of (promptSubstring, valueToType) tuples.
    /// Each entry matches by the parameter name appearing in the prompt text.
    /// Entries are consumed in order — first match wins.
    /// </param>
    /// <param name="outputDir">Optional output directory for publish artifacts.</param>
    internal static async Task AspireDeployInteractiveAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        IReadOnlyList<(string PromptText, string Value)> parameterResponses,
        string? outputDir = null)
    {
        var outputArg = outputDir is not null ? $" -o {outputDir}" : "";
        await auto.TypeAsync($"aspire deploy{outputArg}");
        await auto.EnterAsync();
 
        // Answer each parameter prompt in order.
        // The CLI shows parameter prompts via Spectre.Console TextPrompt with the parameter name as the label.
        // For multi-input forms, each input appears on its own line as "paramname: ".
        for (var i = 0; i < parameterResponses.Count; i++)
        {
            var (promptText, value) = parameterResponses[i];
 
            await auto.WaitUntilTextAsync(promptText, timeout: TimeSpan.FromMinutes(5));
            await auto.TypeAsync(value);
            await auto.EnterAsync();
        }
 
        // Wait for pipeline completion
        await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(10));
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
    }
 
    /// <summary>
    /// Verifies a K8s deployment by port-forwarding and curling the test endpoint.
    /// An optional exact response can be supplied instead of the default PASSED marker.
    /// </summary>
    internal static async Task VerifyDeploymentAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string @namespace,
        string serviceName,
        int localPort,
        string testPath = "/test-deployment",
        string? expectedResponse = null)
    {
        // Wait for all pods to be ready in the namespace
        await auto.TypeAsync($"kubectl wait --for=condition=Ready pod --all -n {@namespace} --timeout=180s");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4));
 
        // Show pod status for debugging
        await auto.TypeAsync($"kubectl get pods -n {@namespace}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Show server pod logs for debugging connectivity issues
        await auto.TypeAsync($"kubectl logs -n {@namespace} -l app={serviceName} --tail=50 2>&1 || true");
        await auto.EnterAsync();
        await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30));
 
        // Show environment variables in server pod for connection string debugging
        await auto.TypeAsync($"kubectl exec -n {@namespace} deploy/{serviceName}-deployment -- env 2>&1 | grep -iE '(ConnectionStrings|services)' | head -10 || true");
        await auto.EnterAsync();
        await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30));
 
        await auto.VerifyForwardedEndpointAsync(
            counter, @namespace, $"svc/{serviceName}-service", localPort, 8080, testPath, expectedResponse);
    }
 
    /// <summary>
    /// Verifies a specific ready workload uses the image built for this deployment.
    /// </summary>
    internal static async Task VerifyPodImageAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string @namespace,
        string selector,
        string expectedImage)
    {
        await auto.RunCommandAsync(
            $"kubectl wait --for=condition=Ready pod -n {@namespace} -l {selector} --timeout=180s",
            counter, TimeSpan.FromMinutes(4));
 
        // jsonpath emits one image per line, e.g. localhost:5001/aspire-e2e-abc/server:project-v2.
        // Require exactly one pod/container, rather than accepting an unrelated ready workload.
        await auto.RunCommandAsync(
            $"test \"$(kubectl get pods -n {@namespace} -l {selector} " +
            "-o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{\"\\n\"}{end}{end}')\" " +
            $"= '{expectedImage}'",
            counter);
    }
 
    /// <summary>
    /// Probes a forwarded service or deployment with bounded HTTP retries and scoped cleanup.
    /// </summary>
    internal static async Task VerifyForwardedEndpointAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string @namespace,
        string target,
        int localPort,
        int targetPort,
        string testPath,
        string? expectedResponse)
    {
        var responseCheck = expectedResponse is null
            ? "printf '%s' \"$result\" | grep -q 'PASSED'"
            : $"[ \"$result\" = '{expectedResponse.Replace("'", "'\"'\"'")}' ]";
 
        // Run in a subshell so the EXIT trap owns only this forward, even on HTTP failure.
        // A prompt-success assertion checks the final `test`, not an echoed success sentinel.
        // Each curl is bounded; retries also absorb startup of the forward without a fixed sleep.
        await auto.RunCommandAsync(
            $"(kubectl port-forward -n {@namespace} {target} {localPort}:{targetPort} > port-forward.log 2>&1 & " +
            "forward_pid=$!; trap 'kill \"$forward_pid\" 2>/dev/null || true' EXIT; " +
            "found=0; for i in $(seq 1 30); do " +
            $"if result=$(curl --connect-timeout 2 --max-time 5 -fsS http://localhost:{localPort}{testPath}) && " +
            $"{responseCheck}; then found=1; printf '%s\\n' \"$result\"; break; fi; " +
            "echo \"Attempt $i: got [$result], retrying...\"; sleep 5; done; " +
            "test \"$found\" = 1)",
            counter, TimeSpan.FromMinutes(6));
    }
 
    /// <summary>
    /// Cleans up a KinD cluster and its owned registry (best-effort, in-terminal).
    /// </summary>
    internal static async Task CleanupKubernetesDeploymentAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string clusterName)
    {
        await auto.TypeAsync($"kind delete cluster --name={clusterName} 2>/dev/null || true");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
        // Resolve and remove by ID, not name, so a replacement registry is never deleted.
        await auto.TypeAsync(
            "registry_id=$(docker container ls -aq --filter 'name=^/kind-registry$' " +
            $"--filter 'label={KindRegistryOwnerLabel}={clusterName}') && " +
            "if [ -n \"$registry_id\" ]; then docker container rm -f -v \"$registry_id\"; " +
            "else echo 'Cleanup: no registry owned by this test; leaving any reused registry intact.'; fi");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Best-effort out-of-terminal cleanup for finally blocks.
    /// </summary>
    internal static async Task CleanupKindClusterOutOfBandAsync(string clusterName, ITestOutputHelper output)
    {
        // KinD is installed inside the helper container, not necessarily on the test host.
        // Delete only this cluster's nodes through the shared Docker daemon when the terminal
        // has failed; keep the shared kind network intact. Tests using kind-registry run serially.
        await LocalDeploymentTestHelpers.CleanupLabeledDockerResourcesAsync(
            $"io.x-k8s.kind.cluster={clusterName}", output);
        await LocalDeploymentTestHelpers.CleanupLabeledDockerResourcesAsync(
            $"{KindRegistryOwnerLabel}={clusterName}", output);
    }
}