File: KubernetesDeployTypeScriptWithPersistentVolumeTests.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 Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// TypeScript analog of <see cref="KubernetesDeployWithProjectPersistentVolumeTests"/>.
/// Verifies the auto-generated TypeScript bindings for
/// <c>addPersistentVolume</c>/<c>withStorageClass</c>/<c>withCapacity</c>/<c>withKubernetesPersistentVolumeMount</c>
/// produce the same first-class persistent volume wiring as the C# API: the
/// node-app workload is auto-promoted to a <c>StatefulSet</c>, the generated PVC
/// binds to the rancher local-path-provisioner that ships with KinD, and a file
/// written to the mounted volume survives a pod restart.
///
/// This is the only TypeScript test exercising the persistent volume API end-to-end —
/// the C# Postgres counterpart already covers the name-match overload and the
/// connection-string-driven workload scenario, so a TS Postgres variant would
/// duplicate the C# durability proof while adding npm <c>pg</c> + connection
/// string wiring that is orthogonal to what this PR changes.
/// </summary>
public sealed class KubernetesDeployTypeScriptWithPersistentVolumeTests(ITestOutputHelper output)
{
    private const string ProjectName = "K8sTsPvTest";
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task DeployTypeScriptK8sWithPersistentVolumeSurvivesPodRestart()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        using var workspace = TemporaryWorkspace.Create(output);
 
        var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName();
        var k8sNamespace = $"test-{clusterName[..16]}";
 
        output.WriteLine($"Cluster name: {clusterName}");
        output.WriteLine($"Namespace: {k8sNamespace}");
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
        await auto.VerifyPullRequestCliVersionAsync(counter);
 
        try
        {
            // =====================================================================
            // Phase 1: Install KinD + Helm, create cluster with local registry
            // =====================================================================
 
            await auto.InstallKindAndHelmAsync(counter);
            await auto.CreateKindClusterWithRegistryAsync(counter, clusterName);
 
            // =====================================================================
            // Phase 2: Create TypeScript project from template + add Kubernetes
            // =====================================================================
 
            await auto.AspireNewAsync(ProjectName, counter, template: AspireTemplate.ExpressReact);
 
            await auto.TypeAsync($"cd {ProjectName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Add Kubernetes hosting so the persistent-volume APIs are projected
            // into the generated TypeScript SDK on the next aspire restore.
            await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes");
            await auto.EnterAsync();
            await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(2));
 
            // Regenerate the local TypeScript SDK shim (./.modules/aspire.js) so
            // addPersistentVolume + withKubernetesPersistentVolumeMount become
            // callable from apphost.mts. Without this step the TS imports below
            // would fail at type-check / runtime.
            await auto.TypeAsync("aspire restore");
            await auto.EnterAsync();
            await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
            await auto.WaitForSuccessPromptAsync(counter);
 
            // =====================================================================
            // Phase 3: Modify apphost.mts to wire the persistent volume to the app
            // =====================================================================
 
            var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName);
            var appHostFilePath = Path.Combine(projectDir, "apphost.mts");
            var apiIndexFilePath = Path.Combine(projectDir, "api", "src", "index.ts");
 
            output.WriteLine($"Patching apphost.mts at: {appHostFilePath}");
 
            // Replacing the trailing "await builder.build().run();" injects:
            //   - container registry param
            //   - K8s environment with Helm namespace/chartversion params
            //   - first-class PV "scratch" with KinD's default StorageClass
            //   - mount-path binding on the existing `app` (addNodeApp) at /srv/data
            // This forces StatefulSet promotion of the node app, since binding any
            // persistent volume promotes the workload from Deployment to StatefulSet.
            var appHostContent = File.ReadAllText(appHostFilePath);
            appHostContent = appHostContent.Replace(
                "await builder.build().run();",
                """
// Container registry param drives image push; K8s namespace/chartVersion drive Helm output.
const registryEndpoint = await builder.addParameter("registryendpoint");
await builder.addContainerRegistry("registry", registryEndpoint);
 
const k8sNamespace = await builder.addParameter("namespace");
const chartVersion = await builder.addParameter("chartversion");
 
const k8sEnv = await builder.addKubernetesEnvironment("env");
await k8sEnv.withHelm({
    configure: async (helm) => {
        await helm.withNamespace(k8sNamespace);
        await helm.withChartVersion(chartVersion);
    },
});
 
// First-class persistent volume — auto-generates a v1.PersistentVolumeClaim
// named "scratch" in the Helm chart. Storage class "standard" is KinD's
// default (rancher local-path-provisioner, RWO host-path, survives pod
// restarts within cluster lifetime).
const scratch = await k8sEnv.addPersistentVolume("scratch");
await scratch.withStorageClass("standard");
await scratch.withCapacity("256Mi");
 
// Mount-path overload — works for any IComputeResource (including node apps
// and projects) by adding the ContainerMount itself. Binding a workload to
// a PV auto-promotes it from Deployment to StatefulSet.
await app.withKubernetesPersistentVolumeMount(scratch, "/srv/data");
 
await builder.build().run();
""");
            File.WriteAllText(appHostFilePath, appHostContent);
 
            output.WriteLine($"Patching api/src/index.ts at: {apiIndexFilePath}");
 
            // Inject a /test-deployment endpoint that exercises the mounted volume.
            //
            // The endpoint speaks the same write/read action protocol as the C# tests
            // so the durability phases below (curl write -> kubectl delete pod -> curl
            // read) and their VERIFY_OK tokens match between C# and TS suites.
            var apiIndexContent = File.ReadAllText(apiIndexFilePath);
            apiIndexContent = apiIndexContent.Replace(
                "import { existsSync } from \"fs\";",
                "import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";")
                .Replace(
                    "app.get(\"/health\", (_req, res) => {",
                    """
const MARKER_PATH = "/srv/data/marker.txt";
const MARKER_TOKEN = "wrote-42";
 
app.get("/test-deployment", (req, res) => {
  const action = typeof req.query.action === "string" ? req.query.action : undefined;
  if (action === "write") {
    mkdirSync("/srv/data", { recursive: true });
    writeFileSync(MARKER_PATH, MARKER_TOKEN);
    res.send(`PASSED: wrote ${MARKER_TOKEN}`);
    return;
  }
  if (action === "read") {
    if (!existsSync(MARKER_PATH)) {
      res.status(500).send(`FAILED: marker file missing at ${MARKER_PATH}`);
      return;
    }
    const content = readFileSync(MARKER_PATH, "utf8");
    if (content === MARKER_TOKEN) {
      res.send(`PASSED: read ${content}`);
      return;
    }
    res.status(500).send(`FAILED: expected '${MARKER_TOKEN}', got '${content}'`);
    return;
  }
  res.status(400).send("missing or invalid 'action' query parameter (use write|read)");
});
 
app.get("/health", (_req, res) => {
""");
            File.WriteAllText(apiIndexFilePath, apiIndexContent);
 
            // =====================================================================
            // Phase 4: Run aspire deploy interactively
            // =====================================================================
 
            await auto.AspireDeployInteractiveAsync(
                counter,
                parameterResponses:
                [
                    ("registryendpoint", "localhost:5001"),
                    ("namespace", k8sNamespace),
                    ("chartversion", "0.1.0"),
                ]);
 
            // =====================================================================
            // Phase 5: Verify the generated K8s shape
            // =====================================================================
 
            // The node app is bound to a PV so the publisher must have rendered it
            // as a StatefulSet — there should be no app-deployment, only
            // app-statefulset. Naming comes from HelmExtensions.ToStatefulSetName.
            output.WriteLine("Verify: app StatefulSet exists (node app auto-promoted from Deployment)");
            await auto.TypeAsync($"kubectl get sts app-statefulset -n {k8sNamespace}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            output.WriteLine("Verify: scratch PVC exists and is Bound");
            await auto.TypeAsync($"kubectl get pvc scratch -n {k8sNamespace} -o jsonpath='{{.status.phase}}' | grep -q Bound && echo PVC_BOUND_OK || {{ echo PVC_NOT_BOUND; exit 1; }}");
            await auto.EnterAsync();
            await auto.WaitUntilTextAsync("PVC_BOUND_OK", timeout: TimeSpan.FromMinutes(2));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            await auto.TypeAsync($"kubectl wait --for=condition=Ready pod --all -n {k8sNamespace} --timeout=240s");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
            await auto.TypeAsync($"kubectl get pods -n {k8sNamespace} -o wide");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            // =====================================================================
            // Phase 6: Write marker file directly into the mounted PV via kubectl exec
            //
            // We bypass HTTP/port-forward because the Node.js template's HTTP listener
            // doesn't reliably surface through the K8s Service in CI (separate plumbing
            // issue, not a PV concern). What we actually want to prove here is that the
            // PV is mounted, writable, and survives pod restart — doing this via
            // `kubectl exec` is both simpler and a more direct test of the publisher's
            // PV wiring.
            // =====================================================================
 
            output.WriteLine("Phase 6: write marker file to /srv/data/marker.txt via kubectl exec");
            await auto.TypeAsync(
                $"kubectl exec app-statefulset-0 -n {k8sNamespace} -- sh -c " +
                "'echo wrote-42 > /srv/data/marker.txt && echo WROTE_OK_$(cat /srv/data/marker.txt)'");
            await auto.EnterAsync();
            // Single quotes around the sh -c arg are intentional: with double quotes, the
            // outer bash on the test runner expands $(cat ...) locally (producing empty,
            // since the marker file doesn't exist on the host) before the command ever
            // reaches the pod. Runtime-only sentinel "WROTE_OK_wrote-42" also avoids
            // racing against the typed echo, which only contains the literal
            // "WROTE_OK_$(cat ...)" substring.
            await auto.WaitUntilTextAsync("WROTE_OK_wrote-42", timeout: TimeSpan.FromMinutes(2));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            // =====================================================================
            // Phase 7: Pod restart — kill the node app pod, wait for K8s to recreate
            // =====================================================================
 
            // Deleting the StatefulSet pod forces K8s to recreate it; the controller
            // re-attaches the same PVC. If the publisher had rendered an emptyDir
            // (i.e. the binding annotation was lost in TS lowering) or generated a
            // fresh PVC name on each render, the marker file would be gone.
            output.WriteLine("Phase 7: delete app-statefulset-0 and wait for K8s to recreate it");
            await auto.TypeAsync($"kubectl delete pod app-statefulset-0 -n {k8sNamespace}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            await auto.TypeAsync($"kubectl wait --for=condition=Ready pod app-statefulset-0 -n {k8sNamespace} --timeout=180s");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4));
 
            // =====================================================================
            // Phase 8: Read marker — the durability proof
            // =====================================================================
 
            output.WriteLine("Phase 8: read marker file from new pod — proves data survived pod restart");
            await auto.TypeAsync(
                $"kubectl exec app-statefulset-0 -n {k8sNamespace} -- sh -c " +
                "'echo READ_OK_$(cat /srv/data/marker.txt)'");
            await auto.EnterAsync();
            await auto.WaitUntilTextAsync("READ_OK_wrote-42", timeout: TimeSpan.FromMinutes(2));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            // =====================================================================
            // Phase 9: Cleanup
            // =====================================================================
 
            await auto.CleanupKubernetesDeploymentAsync(counter, clusterName);
        }
        finally
        {
            await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output);
        }
    }
}