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..6b129f391cb
--- /dev/null
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs
@@ -0,0 +1,458 @@
+// 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.");
+ }
+ }
+
+ using 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
+ {
+ await CleanupResourceGroupAsync(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);
+
+ // 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.
+ 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")
+ .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);
+
+ 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; " +
+ "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));
+ }
+
+ 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 async Task CleanupResourceGroupAsync(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();
+ 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 cleanup resource group: {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