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