From 254d583750ffa541b332500c0b186a337814da4c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 11 Aug 2026 18:57:43 +1000 Subject: [PATCH 1/5] Add AKS persistent volume support Expose the Kubernetes persistent volume API through Azure Kubernetes environments and document the managed disk default. Add unit, polyglot, and deployment coverage for persistence across redeployments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 650be0fb-e3a4-44d8-81d1-1bf5ae2b5274 --- .../Aspire.Hosting.Azure.Kubernetes.csproj | 1 + ...ureKubernetesPersistentVolumeExtensions.cs | 63 +++ src/Aspire.Hosting.Azure.Kubernetes/README.md | 24 + .../AksPersistentVolumeDeploymentTests.cs | 429 ++++++++++++++++++ .../AzureKubernetesPersistentVolumeTests.cs | 42 ++ ...tesClaimUsingClusterDefaults.verified.yaml | 11 + .../Aspire.Hosting/Java/AppHost.java | 2 + .../Aspire.Hosting/TypeScript/apphost.mts | 2 + 8 files changed, 574 insertions(+) create mode 100644 src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesPersistentVolumeExtensions.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesPersistentVolumeTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Kubernetes.Tests/Snapshots/AzureKubernetesPersistentVolumeTests.AksAddPersistentVolume_GeneratesClaimUsingClusterDefaults.verified.yaml diff --git a/src/Aspire.Hosting.Azure.Kubernetes/Aspire.Hosting.Azure.Kubernetes.csproj b/src/Aspire.Hosting.Azure.Kubernetes/Aspire.Hosting.Azure.Kubernetes.csproj index d58a1c8436a..4b9181136a0 100644 --- a/src/Aspire.Hosting.Azure.Kubernetes/Aspire.Hosting.Azure.Kubernetes.csproj +++ b/src/Aspire.Hosting.Azure.Kubernetes/Aspire.Hosting.Azure.Kubernetes.csproj @@ -6,6 +6,7 @@ true true false + true aspire integration hosting azure kubernetes aks Azure Kubernetes Service (AKS) resource types for Aspire. $(SharedDir)Azure_256x.png diff --git a/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesPersistentVolumeExtensions.cs b/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesPersistentVolumeExtensions.cs new file mode 100644 index 00000000000..b7e133dfbf0 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesPersistentVolumeExtensions.cs @@ -0,0 +1,63 @@ +// 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.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure.Kubernetes; +using Aspire.Hosting.Kubernetes; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Kubernetes persistent volumes to an +/// . +/// +[Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public static class AzureKubernetesPersistentVolumeExtensions +{ + /// + /// Adds a Kubernetes PersistentVolumeClaim resource to the application model for the + /// specified AKS environment. + /// + /// Adds a Kubernetes PersistentVolumeClaim resource to an AKS environment + /// The AKS environment resource builder. + /// The name of the persistent volume resource. + /// A builder for the new . + /// The resource builder. + /// + /// + /// The persistent volume is associated with the AKS environment's underlying Kubernetes + /// environment and generates a v1.PersistentVolumeClaim in the Helm chart output. + /// + /// + /// When no storage class is configured, the generated claim omits + /// spec.storageClassName so the cluster's default storage class is used. A standard + /// AKS cluster dynamically provisions an Azure managed disk for such claims. Use + /// + /// to select a different storage class explicitly. + /// + /// + /// + /// + /// + /// var aks = builder.AddAzureKubernetesEnvironment("aks"); + /// + /// var data = aks.AddPersistentVolume("data") + /// .WithCapacity("20Gi"); + /// + /// builder.AddProject<Projects.Api>("api") + /// .WithPersistentVolume(data, "/data"); + /// + /// + [AspireExport] + public static IResourceBuilder AddPersistentVolume( + this IResourceBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var k8sEnvBuilder = builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.KubernetesEnvironment); + return k8sEnvBuilder.AddPersistentVolume(name); + } +} diff --git a/src/Aspire.Hosting.Azure.Kubernetes/README.md b/src/Aspire.Hosting.Azure.Kubernetes/README.md index 467eec3dc03..88208e54abe 100644 --- a/src/Aspire.Hosting.Azure.Kubernetes/README.md +++ b/src/Aspire.Hosting.Azure.Kubernetes/README.md @@ -41,6 +41,30 @@ const myService = await builder.addNodeApp("myService", "../my-service", "server .withComputeEnvironment(aks); ``` +### Persistent volumes + +Add a persistent volume to the AKS environment and mount it into a workload: + +**C#** + +```csharp +var data = aks.AddPersistentVolume("data") + .WithCapacity("20Gi"); + +myService.WithPersistentVolume(data, "/data"); +``` + +**TypeScript** + +```typescript +const data = await aks.addPersistentVolume("data"); +await data.withCapacity("20Gi"); + +await myService.withKubernetesPersistentVolumeMount(data, "/data"); +``` + +When no storage class is specified, the generated claim uses the cluster's default storage class. A standard AKS cluster dynamically provisions an Azure managed disk. To request Premium SSD storage explicitly, call `WithStorageClass("managed-csi-premium")` in C# or `withStorageClass("managed-csi-premium")` in TypeScript. + ## Additional documentation * https://aspire.dev/integrations/gallery/ diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs new file mode 100644 index 00000000000..c7c0127ec7b --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs @@ -0,0 +1,429 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +[Trait("Partition", "Deployment")] +[Trait("category", "deployment")] +[Trait("provider", "azure")] +public sealed class AksPersistentVolumeDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(60); + + [Fact] + public async Task DeployAksPersistentVolumeSurvivesRedeploy() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + + await DeployAksPersistentVolumeSurvivesRedeployCore(linkedCts.Token); + } + + private async Task DeployAksPersistentVolumeSurvivesRedeployCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("akspv"); + var projectName = "AksPersistentVolume"; + var deploymentUrls = new Dictionary(); + + // Cleanup is deliberately redundant with Aspire destroy. If the test fails during deployment, + // deleting the resource group still removes the AKS cluster and its managed disk. + try + { + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareEnvironmentAsync(workspace, counter); + await auto.InstallCurrentBuildAspireCliAsync(counter, output); + + await auto.AspireNewAsync(projectName, counter, useRedisCache: false); + + await auto.TypeAsync($"cd {projectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Kubernetes"); + await auto.EnterAsync(); + await auto.WaitForAspireAddCompletionAsync(counter); + + var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDirectory = Path.Combine(projectDirectory, $"{projectName}.AppHost"); + var appHostPath = Path.Combine(appHostDirectory, "AppHost.cs"); + var apiProgramPath = Path.Combine( + projectDirectory, + $"{projectName}.ApiService", + "Program.cs"); + + ConfigureAppHost(appHostPath); + ConfigureApi(apiProgramPath); + + await auto.RunCommandAsync( + $"dotnet build {projectName}.AppHost/{projectName}.AppHost.csproj --nologo", + counter, + TimeSpan.FromMinutes(5)); + + await auto.TypeAsync($"cd {projectName}.AppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Linux environment variables are case-sensitive. Remove the job-level mixed-case value + // before setting the value consumed by the deployment pipeline. + await auto.TypeAsync( + $"unset ASPIRE_PLAYGROUND && unset Azure__Location && " + + $"export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await DeployAsync(auto, counter, waitForPipelineSuccess: true); + + await auto.RunCommandAsync( + $"AKS_NAME=$(az aks list --resource-group {resourceGroupName} --query '[0].name' --output tsv) && " + + "test -n \"$AKS_NAME\" && echo \"AKS cluster: $AKS_NAME\"", + counter, + TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync( + $"az aks get-credentials --resource-group {resourceGroupName} --name \"$AKS_NAME\" --overwrite-existing", + counter, + TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync( + "NS=$(kubectl get service --all-namespaces " + + "-o jsonpath='{range .items[?(@.metadata.name==\"apiservice-service\")]}{.metadata.namespace}{end}') && " + + "test -n \"$NS\" && echo \"Kubernetes namespace: $NS\"", + counter, + TimeSpan.FromMinutes(2)); + + await WaitForStatefulSetAndManagedDiskAsync(auto, counter); + + await auto.RunCommandAsync( + "PVC_UID_BEFORE=$(kubectl get persistentvolumeclaim data --namespace \"$NS\" -o jsonpath='{.metadata.uid}') && " + + "POD_UID_BEFORE=$(kubectl get pod apiservice-statefulset-0 --namespace \"$NS\" -o jsonpath='{.metadata.uid}') && " + + "test -n \"$PVC_UID_BEFORE\" && test -n \"$POD_UID_BEFORE\" && " + + "echo \"First PVC UID: $PVC_UID_BEFORE\" && echo \"First pod UID: $POD_UID_BEFORE\"", + counter); + + var apiPort = GetAvailablePort(); + await StartPortForwardAsync(auto, counter, apiPort); + await VerifyApiResponseAsync( + auto, + counter, + apiPort, + "?action=write", + "PASSED: wrote aks-pv-marker-42 revision first"); + await StopPortForwardAsync(auto, counter); + + UpdateDeploymentRevision(appHostPath); + + await DeployAsync(auto, counter, waitForPipelineSuccess: false); + + await auto.RunCommandAsync( + "kubectl rollout status statefulset/apiservice-statefulset --namespace \"$NS\" --timeout=10m", + counter, + TimeSpan.FromMinutes(11)); + await auto.RunCommandAsync( + "kubectl wait --for=condition=Ready pod/apiservice-statefulset-0 --namespace \"$NS\" --timeout=5m", + counter, + TimeSpan.FromMinutes(6)); + await auto.RunCommandAsync( + "PVC_UID_AFTER=$(kubectl get persistentvolumeclaim data --namespace \"$NS\" -o jsonpath='{.metadata.uid}') && " + + "POD_UID_AFTER=$(kubectl get pod apiservice-statefulset-0 --namespace \"$NS\" -o jsonpath='{.metadata.uid}') && " + + "test \"$PVC_UID_AFTER\" = \"$PVC_UID_BEFORE\" && " + + "test \"$POD_UID_AFTER\" != \"$POD_UID_BEFORE\" && " + + "echo \"Redeploy reused PVC $PVC_UID_AFTER and replaced pod $POD_UID_BEFORE with $POD_UID_AFTER\"", + counter); + + apiPort = GetAvailablePort(); + await StartPortForwardAsync(auto, counter, apiPort); + await VerifyApiResponseAsync( + auto, + counter, + apiPort, + "?action=read", + "PASSED: read aks-pv-marker-42 revision second"); + await StopPortForwardAsync(auto, counter); + + await auto.AspireDestroyAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAksPersistentVolumeSurvivesRedeploy), + resourceGroupName, + deploymentUrls, + duration); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Test failed after {duration}: {ex.Message}"); + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAksPersistentVolumeSurvivesRedeploy), + resourceGroupName, + ex.Message, + ex.StackTrace); + throw; + } + finally + { + TriggerCleanupResourceGroup(resourceGroupName); + } + } + + private static void ConfigureAppHost(string appHostPath) + { + var content = File.ReadAllText(appHostPath); + + content = ReplaceRequired( + content, + "var builder = DistributedApplication.CreateBuilder(args);", + """ + #pragma warning disable ASPIREAZURE003 + #pragma warning disable ASPIRECOMPUTE002 + + var builder = DistributedApplication.CreateBuilder(args); + + var aks = builder.AddAzureKubernetesEnvironment("aks") + .WithSystemNodePool("Standard_D2as_v5"); + + // Omitting WithStorageClass exercises the standard AKS default StorageClass, + // which dynamically provisions an Azure Managed Disk. + var data = aks.AddPersistentVolume("data") + .WithCapacity("1Gi"); + """, + appHostPath); + + content = ReplaceRequired( + content, + """builder.AddProject("apiservice")""", + """ + builder.AddProject("apiservice") + .WithPersistentVolume(data, "/srv/data") + .WithEnvironment("DEPLOYMENT_REVISION", "first") + """, + appHostPath); + + File.WriteAllText(appHostPath, content); + } + + private static void UpdateDeploymentRevision(string appHostPath) + { + var content = File.ReadAllText(appHostPath); + content = ReplaceRequired( + content, + """.WithEnvironment("DEPLOYMENT_REVISION", "first")""", + """.WithEnvironment("DEPLOYMENT_REVISION", "second")""", + appHostPath); + File.WriteAllText(appHostPath, content); + } + + private static void ConfigureApi(string apiProgramPath) + { + File.WriteAllText( + apiProgramPath, + """ + var builder = WebApplication.CreateBuilder(args); + + builder.AddServiceDefaults(); + + var app = builder.Build(); + + const string markerPath = "/srv/data/marker.txt"; + const string markerToken = "aks-pv-marker-42"; + var deploymentRevision = app.Configuration["DEPLOYMENT_REVISION"] + ?? throw new InvalidOperationException("DEPLOYMENT_REVISION is not configured."); + + app.MapGet("/", async (string action) => + { + if (action == "write") + { + Directory.CreateDirectory(Path.GetDirectoryName(markerPath)!); + await File.WriteAllTextAsync(markerPath, markerToken); + return Results.Ok($"PASSED: wrote {markerToken} revision {deploymentRevision}"); + } + + if (action == "read") + { + if (!File.Exists(markerPath)) + { + return Results.NotFound("FAILED: marker file was not found"); + } + + var persistedValue = await File.ReadAllTextAsync(markerPath); + if (persistedValue != markerToken) + { + return Results.Problem($"FAILED: expected {markerToken}, got {persistedValue}"); + } + + return Results.Ok($"PASSED: read {persistedValue} revision {deploymentRevision}"); + } + + return Results.BadRequest("FAILED: action must be write or read"); + }); + + app.MapDefaultEndpoints(); + app.Run(); + """); + } + + private static async Task WaitForStatefulSetAndManagedDiskAsync( + Hex1bTerminalAutomator auto, + SequenceCounter counter) + { + await auto.RunCommandAsync( + "kubectl get statefulset apiservice-statefulset --namespace \"$NS\"", + counter, + TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync( + "phase=''; for i in $(seq 1 60); do " + + "phase=$(kubectl get persistentvolumeclaim data --namespace \"$NS\" -o jsonpath='{.status.phase}' 2>/dev/null || true); " + + "if [ \"$phase\" = \"Bound\" ]; then break; fi; sleep 5; done; " + + "test \"$phase\" = \"Bound\" && " + + "STORAGE_CLASS=$(kubectl get persistentvolumeclaim data --namespace \"$NS\" -o jsonpath='{.spec.storageClassName}') && " + + "PROVISIONER=$(kubectl get storageclass \"$STORAGE_CLASS\" -o jsonpath='{.provisioner}') && " + + "test \"$PROVISIONER\" = \"disk.csi.azure.com\" && " + + "echo \"PVC data is Bound using $STORAGE_CLASS ($PROVISIONER)\"", + counter, + TimeSpan.FromMinutes(6)); + await auto.RunCommandAsync( + "kubectl wait --for=condition=Ready pod/apiservice-statefulset-0 --namespace \"$NS\" --timeout=5m", + counter, + TimeSpan.FromMinutes(6)); + } + + private static async Task DeployAsync( + Hex1bTerminalAutomator auto, + SequenceCounter counter, + bool waitForPipelineSuccess) + { + await auto.TypeAsync("aspire deploy --clear-cache"); + await auto.EnterAsync(); + + if (waitForPipelineSuccess) + { + await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(30)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + } + else + { + // The first deploy's "Pipeline succeeded" marker can still be in the terminal viewport. + // The sequence-numbered prompt belongs only to this redeploy and therefore cannot match + // stale output from the first deployment. + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(30)); + } + } + + private static async Task StartPortForwardAsync( + Hex1bTerminalAutomator auto, + SequenceCounter counter, + int port) + { + await auto.RunCommandAsync( + $"kubectl port-forward service/apiservice-service {port}:8080 --namespace \"$NS\" >/tmp/aks-pv-port-forward.log 2>&1 & PORT_FORWARD_PID=$!; " + + "test -n \"$PORT_FORWARD_PID\"", + counter); + } + + private static async Task StopPortForwardAsync( + Hex1bTerminalAutomator auto, + SequenceCounter counter) + { + await auto.RunCommandAsync( + "kill \"$PORT_FORWARD_PID\" 2>/dev/null || true; wait \"$PORT_FORWARD_PID\" 2>/dev/null || true; unset PORT_FORWARD_PID", + counter); + } + + private static async Task VerifyApiResponseAsync( + Hex1bTerminalAutomator auto, + SequenceCounter counter, + int port, + string query, + string expectedResponse) + { + await auto.RunCommandAsync( + $"verified=0; for i in $(seq 1 60); do " + + $"response=$(curl --silent --fail 'http://localhost:{port}/{query}' 2>/dev/null || true); " + + $"if printf '%s' \"$response\" | grep --fixed-strings --quiet '{expectedResponse}'; then " + + "echo \"API response: $response\"; verified=1; break; fi; sleep 2; done; test \"$verified\" = \"1\"", + counter, + TimeSpan.FromMinutes(3)); + } + + private static string ReplaceRequired( + string content, + string oldValue, + string newValue, + string filePath) + { + if (!content.Contains(oldValue, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Expected content was not found while updating '{filePath}'."); + } + + return content.Replace(oldValue, newValue, StringComparison.Ordinal); + } + + private static int GetAvailablePort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } + + private void TriggerCleanupResourceGroup(string resourceGroupName) + { + using var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, ex.Message); + } + } +} diff --git a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesPersistentVolumeTests.cs b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesPersistentVolumeTests.cs new file mode 100644 index 00000000000..f61f01e97b7 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesPersistentVolumeTests.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREAZURE003, ASPIRECOMPUTE002 + +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureKubernetesPersistentVolumeTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void AksAddPersistentVolume_HasCorrectParent() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var aks = builder.AddAzureKubernetesEnvironment("aks"); + + var volume = aks.AddPersistentVolume("data"); + + Assert.Same(aks.Resource.KubernetesEnvironment, volume.Resource.Parent); + } + + [Fact] + public async Task AksAddPersistentVolume_GeneratesClaimUsingClusterDefaults() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + var aks = builder.AddAzureKubernetesEnvironment("aks"); + aks.AddPersistentVolume("data") + .WithCapacity("20Gi"); + + var app = builder.Build(); + app.Run(); + + var claimPath = Path.Combine(workspace.Path, "templates", "data", "data.yaml"); + Assert.True(File.Exists(claimPath), $"Expected persistent volume claim YAML at {claimPath}."); + + var content = await File.ReadAllTextAsync(claimPath); + await Verify(content, "yaml"); + } +} diff --git a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/Snapshots/AzureKubernetesPersistentVolumeTests.AksAddPersistentVolume_GeneratesClaimUsingClusterDefaults.verified.yaml b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/Snapshots/AzureKubernetesPersistentVolumeTests.AksAddPersistentVolume_GeneratesClaimUsingClusterDefaults.verified.yaml new file mode 100644 index 00000000000..667c152ed9e --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/Snapshots/AzureKubernetesPersistentVolumeTests.AksAddPersistentVolume_GeneratesClaimUsingClusterDefaults.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "PersistentVolumeClaim" +metadata: + name: "data" +spec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: "20Gi" diff --git a/tests/PolyglotAppHosts/Aspire.Hosting/Java/AppHost.java b/tests/PolyglotAppHosts/Aspire.Hosting/Java/AppHost.java index b3a9ab9089a..abb872b7b99 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting/Java/AppHost.java +++ b/tests/PolyglotAppHosts/Aspire.Hosting/Java/AppHost.java @@ -109,6 +109,8 @@ void main() throws Exception { subnet.denyInbound(new DenyInboundOptions().from(AzureServiceTags.Internet)); var aks = builder.addAzureKubernetesEnvironment("aks"); aks.addNodePool("system", new AddNodePoolOptions().vmSize(AksNodeVmSizes.StandardDSv5.StandardD2sV5)); + var aksVolume = aks.addPersistentVolume("aks-data"); + aksVolume.withCapacity("20Gi"); var pipeline = builder.pipeline(); pipeline.addStep("custom-builder-step", (stepContext) -> { var builderSummary = stepContext.summary(); builderSummary.add("BuilderPipelineStep", "Validated"); }, new AddStepOptions().dependsOn(new String[] { WellKnownPipelineSteps.Build }).requiredBy(new String[] { WellKnownPipelineSteps.Publish })); pipeline.configure((configContext) -> { var builderPipeline = configContext.pipeline(); var _allSteps = builderPipeline.steps(); var _builderTaggedSteps = configContext.getSteps("custom-build"); }); diff --git a/tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.mts index 905d0114f76..491e0932807 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.mts @@ -219,6 +219,8 @@ await subnet.denyInbound({ from: AzureServiceTags.Internet }); const aks = await builder.addAzureKubernetesEnvironment("aks"); await aks.addNodePool("system", { vmSize: AksNodeVmSizes.StandardDSv5.StandardD2sV5 }); +const aksVolume = await aks.addPersistentVolume("aks-data"); +await aksVolume.withCapacity("20Gi"); // =================================================================== // Application pipeline on builder From 9ecd72b2689242305e1836608a557fb293892225 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 11 Aug 2026 21:39:19 +1000 Subject: [PATCH 2/5] Pin AKS volume test node pools Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 650be0fb-e3a4-44d8-81d1-1bf5ae2b5274 --- .../AksPersistentVolumeDeploymentTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs index c7c0127ec7b..2e5a2331d9b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs @@ -211,8 +211,11 @@ private static void ConfigureAppHost(string appHostPath) var builder = DistributedApplication.CreateBuilder(args); + // Pin both pools to the VM family provisioned by the deployment test subscription. + // Without the explicit workload pool, AKS creates it with the Standard_D2s_v5 default. var aks = builder.AddAzureKubernetesEnvironment("aks") .WithSystemNodePool("Standard_D2as_v5"); + aks.AddNodePool("workload", "Standard_D2as_v5", 1, 3); // Omitting WithStorageClass exercises the standard AKS default StorageClass, // which dynamically provisions an Azure Managed Disk. From 100b5306d2b98220702e93946c8e6ee4bb69e775 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 11 Aug 2026 22:02:17 +1000 Subject: [PATCH 3/5] Allow API writes to AKS managed disk Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 650be0fb-e3a4-44d8-81d1-1bf5ae2b5274 --- .../AksPersistentVolumeDeploymentTests.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs index 2e5a2331d9b..888f0a6a5a7 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs @@ -231,6 +231,17 @@ private static void ConfigureAppHost(string appHostPath) builder.AddProject("apiservice") .WithPersistentVolume(data, "/srv/data") .WithEnvironment("DEPLOYMENT_REVISION", "first") + .PublishAsKubernetesService(resource => + { + // .NET containers run as the non-root app user. Apply its group to the + // managed disk so the API can write to the filesystem mounted by kubelet. + var workload = resource.Workload + ?? throw new InvalidOperationException("The API Kubernetes workload was not generated."); + workload.PodTemplate.Spec.SecurityContext = new() + { + FsGroup = 1654 + }; + }) """, appHostPath); @@ -375,7 +386,11 @@ await auto.RunCommandAsync( $"verified=0; for i in $(seq 1 60); do " + $"response=$(curl --silent --fail 'http://localhost:{port}/{query}' 2>/dev/null || true); " + $"if printf '%s' \"$response\" | grep --fixed-strings --quiet '{expectedResponse}'; then " + - "echo \"API response: $response\"; verified=1; break; fi; sleep 2; done; test \"$verified\" = \"1\"", + "echo \"API response: $response\"; verified=1; break; fi; sleep 2; done; " + + "if [ \"$verified\" != \"1\" ]; then " + + "echo 'Port-forward log:'; cat /tmp/aks-pv-port-forward.log 2>/dev/null || true; " + + "echo 'API pod log:'; kubectl logs pod/apiservice-statefulset-0 --namespace \"$NS\" --tail=100 || true; " + + "fi; test \"$verified\" = \"1\"", counter, TimeSpan.FromMinutes(3)); } From 4b0cc667a42cd98bfc05b7863d84303df3d73f41 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 12 Aug 2026 10:47:59 +1000 Subject: [PATCH 4/5] Await AKS deployment cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 650be0fb-e3a4-44d8-81d1-1bf5ae2b5274 --- .../AksPersistentVolumeDeploymentTests.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs index 888f0a6a5a7..e1891b2e974 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs @@ -194,7 +194,7 @@ await VerifyApiResponseAsync( } finally { - TriggerCleanupResourceGroup(resourceGroupName); + await CleanupResourceGroupAsync(resourceGroupName); } } @@ -417,7 +417,7 @@ private static int GetAvailablePort() return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; } - private void TriggerCleanupResourceGroup(string resourceGroupName) + private async Task CleanupResourceGroupAsync(string resourceGroupName) { using var process = new System.Diagnostics.Process { @@ -435,12 +435,23 @@ private void TriggerCleanupResourceGroup(string resourceGroupName) try { process.Start(); - output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); - DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Deletion initiated"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, $"Exit code {process.ExitCode}: {error}"); + } } catch (Exception ex) { - output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, ex.Message); } } From a5da9c6f672fc0d0db60e295bfb499fa1245c914 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 12 Aug 2026 11:21:25 +1000 Subject: [PATCH 5/5] Dispose AKS deployment workspace Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 650be0fb-e3a4-44d8-81d1-1bf5ae2b5274 --- .../AksPersistentVolumeDeploymentTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs index e1891b2e974..6b129f391cb 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs @@ -44,7 +44,7 @@ private async Task DeployAksPersistentVolumeSurvivesRedeployCore(CancellationTok } } - var workspace = TemporaryWorkspace.Create(output); + using var workspace = TemporaryWorkspace.Create(output); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("akspv"); var projectName = "AksPersistentVolume";