diff --git a/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesPersistentVolumeExtensions.cs b/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesPersistentVolumeExtensions.cs index b7e133dfbf0..21158ac1d12 100644 --- a/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesPersistentVolumeExtensions.cs +++ b/src/Aspire.Hosting.Azure.Kubernetes/AzureKubernetesPersistentVolumeExtensions.cs @@ -46,7 +46,7 @@ public static class AzureKubernetesPersistentVolumeExtensions /// .WithCapacity("20Gi"); /// /// builder.AddProject<Projects.Api>("api") - /// .WithPersistentVolume(data, "/data"); + /// .WithPersistentVolume(data, "/data", env: "DATA_PATH"); /// /// [AspireExport] diff --git a/src/Aspire.Hosting.Azure.Kubernetes/README.md b/src/Aspire.Hosting.Azure.Kubernetes/README.md index 88208e54abe..5c85d56a32b 100644 --- a/src/Aspire.Hosting.Azure.Kubernetes/README.md +++ b/src/Aspire.Hosting.Azure.Kubernetes/README.md @@ -51,7 +51,7 @@ Add a persistent volume to the AKS environment and mount it into a workload: var data = aks.AddPersistentVolume("data") .WithCapacity("20Gi"); -myService.WithPersistentVolume(data, "/data"); +myService.WithPersistentVolume(data, "/data", env: "DATA_PATH"); ``` **TypeScript** @@ -60,10 +60,12 @@ myService.WithPersistentVolume(data, "/data"); const data = await aks.addPersistentVolume("data"); await data.withCapacity("20Gi"); -await myService.withKubernetesPersistentVolumeMount(data, "/data"); +await myService.withKubernetesPersistentVolumeMount(data, "/data", { env: "DATA_PATH" }); ``` -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. +When a project or executable runs locally, `DATA_PATH` points to a persistent directory in the AppHost's Aspire store. That store is normally under the AppHost intermediate-output directory, so cleaning build outputs can remove the local data. Local containers use a worktree-scoped container volume instead, and one persistent-volume resource cannot be shared between local containers and local projects or executables. + +In AKS, `DATA_PATH` contains `/data`, the mounted volume path. 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 diff --git a/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs index 649112dc8c4..6ee952732ba 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs @@ -117,6 +117,8 @@ private sealed record OptionVariation( List OptionalParameters, string? Experimental); + private sealed record ParameterMappingSignature(string[] RequiredParameters, string[] OptionalParameters); + /// /// Tracks the alternate capability ID for merged capabilities. /// Key: the merged capability ID (the "short" one without the extra param). @@ -131,6 +133,8 @@ private sealed record MergedCapabilityDispatch(string AlternateCapabilityId, str private PythonModuleBuilder _moduleBuilder = null!; + private readonly Dictionary _parameterMappingSignatures = new(StringComparer.Ordinal); + // Mapping of typeId -> wrapper class name for all generated wrapper types // Used to resolve parameter types to wrapper classes instead of handle types private readonly Dictionary _wrapperClassNames = new(StringComparer.Ordinal); @@ -690,12 +694,21 @@ private static string GetMethodParametersName(string methodName) } return methodName + "Parameters"; } + + private static string GetCapabilityName(string capabilityId) + { + var slashIndex = capabilityId.LastIndexOf('/'); + + return slashIndex >= 0 ? capabilityId[(slashIndex + 1)..] : capabilityId; + } + /// /// Generates the aspire.py SDK file with capability-based API. /// private string GenerateAspireSdk(AtsContext context) { _moduleBuilder = new PythonModuleBuilder(); + _parameterMappingSignatures.Clear(); var capabilities = context.Capabilities; var dtoTypes = context.DtoTypes; @@ -2354,7 +2367,6 @@ private List CreateOptionVariations( { var requiredParamsTypes = string.Join(", ", requiredParameters.Select(MapParameterToPython)); var optionalParamsTypes = string.Join(", ", optionalParameters.Select(MapParameterToPython)); - var parameterMappingName = GetMethodParametersName(capability.MethodName); string? experimental = null; // TODO: get experimental tag var variations = new List(); @@ -2383,7 +2395,7 @@ private List CreateOptionVariations( } else { - AddParameterMapping(parameterMappingName, requiredParameters, optionalParameters); + var parameterMappingName = AddParameterMapping(capability, requiredParameters, optionalParameters); variations.Add(new OptionVariation(requiredParamsTypes, requiredParameters, optionalParameters, experimental)); variations.Add(new OptionVariation(parameterMappingName, requiredParameters, optionalParameters, experimental)); } @@ -2392,7 +2404,7 @@ private List CreateOptionVariations( { if (optionalParameters.Count > 0) { - AddParameterMapping(parameterMappingName, requiredParameters, optionalParameters); + var parameterMappingName = AddParameterMapping(capability, requiredParameters, optionalParameters); variations.Add(new OptionVariation("(" + requiredParamsTypes + ")", requiredParameters, optionalParameters, experimental)); variations.Add(new OptionVariation(parameterMappingName, requiredParameters, optionalParameters, experimental)); } @@ -2403,7 +2415,7 @@ private List CreateOptionVariations( } else { - AddParameterMapping(parameterMappingName, requiredParameters, optionalParameters); + var parameterMappingName = AddParameterMapping(capability, requiredParameters, optionalParameters); if (requiredParameters.Count == 0) { variations.Add(new OptionVariation(parameterMappingName, requiredParameters, optionalParameters, experimental)); @@ -2418,12 +2430,14 @@ private List CreateOptionVariations( return variations; } - private void AddParameterMapping(string methodName, List requiredParameters, List optionalParameters) + private string AddParameterMapping(AtsCapabilityInfo capability, List requiredParameters, List optionalParameters) { + var methodName = ResolveParameterMappingName(capability, requiredParameters, optionalParameters); if (_moduleBuilder.MethodParameters.ContainsKey(methodName)) { - return; + return methodName; } + var parameters = new System.Text.StringBuilder(); parameters.AppendLine(); parameters.AppendLine(CultureInfo.InvariantCulture, $"class {methodName}(typing.TypedDict, total=False):"); @@ -2436,6 +2450,55 @@ private void AddParameterMapping(string methodName, List requi parameters.AppendLine(CultureInfo.InvariantCulture, $" {ToSnakeCase(optionalParam.Name!)}: {MapParameterToPython(optionalParam)}"); } _moduleBuilder.MethodParameters[methodName] = parameters; + _parameterMappingSignatures[methodName] = CreateParameterMappingSignature(requiredParameters, optionalParameters); + + return methodName; + } + + private string ResolveParameterMappingName(AtsCapabilityInfo capability, List requiredParameters, List optionalParameters) + { + var methodName = GetMethodParametersName(capability.MethodName); + var signature = CreateParameterMappingSignature(requiredParameters, optionalParameters); + + if (!_parameterMappingSignatures.TryGetValue(methodName, out var existingSignature)) + { + return methodName; + } + + if (AreParameterMappingSignaturesEqual(existingSignature, signature)) + { + return methodName; + } + + // Capabilities can share a projected method name while accepting different parameter shapes. + // Reusing the method-name TypedDict in that case makes one capability type-check against + // another capability's required/optional keys, so fall back to the capability ID. + var capabilityName = GetMethodParametersName(GetCapabilityName(capability.CapabilityId)); + if (_parameterMappingSignatures.TryGetValue(capabilityName, out var existingCapabilitySignature) + && !AreParameterMappingSignaturesEqual(existingCapabilitySignature, signature)) + { + throw new InvalidOperationException( + $"Parameter mapping '{capabilityName}' for capability '{capability.CapabilityId}' conflicts with an existing incompatible parameter shape."); + } + + return capabilityName; + } + + private static bool AreParameterMappingSignaturesEqual(ParameterMappingSignature left, ParameterMappingSignature right) + { + return left.RequiredParameters.SequenceEqual(right.RequiredParameters, StringComparer.Ordinal) + && left.OptionalParameters.SequenceEqual(right.OptionalParameters, StringComparer.Ordinal); + } + + private ParameterMappingSignature CreateParameterMappingSignature(List requiredParameters, List optionalParameters) + { + return new ParameterMappingSignature( + [.. requiredParameters + .OrderBy(p => p.Name, StringComparer.Ordinal) + .Select(p => $"{ToSnakeCase(p.Name!)}:{MapParameterToPython(p)}")], + [.. optionalParameters + .OrderBy(p => p.Name, StringComparer.Ordinal) + .Select(p => $"{ToSnakeCase(p.Name!)}:{MapParameterToPython(p)}")]); } /// diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs index 3d3f855f1cb..88853f5d554 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs @@ -1390,6 +1390,13 @@ private static string GetOptionsInterfaceName(string methodName) return $"{ToPascalCase(simpleName)}Options"; } + private static string GetCapabilityName(string capabilityId) + { + var slashIndex = capabilityId.LastIndexOf('/'); + + return slashIndex >= 0 ? capabilityId[(slashIndex + 1)..] : capabilityId; + } + /// /// Gets the options interface name for a specific capability, accounting for type conflicts. /// Falls back to the default method-name-based interface if no specific mapping exists. @@ -1456,8 +1463,7 @@ private static bool TryGetDirectOptionsParameter(List optional /// /// Registers an options interface to be generated later. /// Uses method name to create the interface name. When methods share a name but have - /// incompatible callback parameter types, separate options interfaces are created with - /// numeric suffixes (e.g., RunAsEmulatorOptions, RunAsEmulator1Options). + /// different option shapes, separate options interfaces are created from the capability ID. /// private void RegisterOptionsInterface(string capabilityId, string methodName, List optionalParams) { @@ -1471,6 +1477,17 @@ private void RegisterOptionsInterface(string capabilityId, string methodName, Li // Check if an existing interface with this name is compatible if (_optionsInterfacesToGenerate.TryGetValue(baseInterfaceName, out var existingParams)) { + var capabilityName = GetCapabilityName(capabilityId); + if (!string.Equals(capabilityName, methodName, StringComparison.Ordinal) + && !AreOptionsExactMatch(existingParams, optionalParams)) + { + // Capabilities can share a projected method name while accepting different options. + // Reusing the method-name interface would let callers pass options that the selected + // capability implementation never reads, so fall back to the capability ID. + RegisterDisambiguatedOptionsInterface(capabilityId, capabilityName, optionalParams); + return; + } + if (AreOptionsCompatible(existingParams, optionalParams)) { // Compatible - merge any new parameters and share the interface @@ -1486,34 +1503,7 @@ private void RegisterOptionsInterface(string capabilityId, string methodName, Li return; } - // Incompatible - find or create a suffixed interface - for (var suffix = 1; ; suffix++) - { - var suffixedName = GetOptionsInterfaceName($"{methodName}{suffix}"); - if (!_optionsInterfacesToGenerate.TryGetValue(suffixedName, out var suffixedParams)) - { - // Create a new interface with this suffix - _generatedOptionsInterfaces.Add(suffixedName); - _optionsInterfacesToGenerate[suffixedName] = [.. optionalParams]; - _capabilityOptionsInterfaceMap[capabilityId] = suffixedName; - return; - } - - if (AreOptionsCompatible(suffixedParams, optionalParams)) - { - // Compatible with this suffixed interface - share it - var existingNames2 = new HashSet(suffixedParams.Select(p => p.Name)); - foreach (var param in optionalParams) - { - if (existingNames2.Add(param.Name)) - { - suffixedParams.Add(param); - } - } - _capabilityOptionsInterfaceMap[capabilityId] = suffixedName; - return; - } - } + RegisterDisambiguatedOptionsInterface(capabilityId, capabilityName, optionalParams); } else { @@ -1524,6 +1514,42 @@ private void RegisterOptionsInterface(string capabilityId, string methodName, Li } } + private void RegisterDisambiguatedOptionsInterface(string capabilityId, string capabilityName, List optionalParams) + { + var capabilityInterfaceName = GetOptionsInterfaceName(capabilityName); + if (!_optionsInterfacesToGenerate.TryGetValue(capabilityInterfaceName, out var capabilityParameters)) + { + _generatedOptionsInterfaces.Add(capabilityInterfaceName); + _optionsInterfacesToGenerate[capabilityInterfaceName] = [.. optionalParams]; + _capabilityOptionsInterfaceMap[capabilityId] = capabilityInterfaceName; + return; + } + + if (AreOptionsCompatible(capabilityParameters, optionalParams)) + { + _capabilityOptionsInterfaceMap[capabilityId] = capabilityInterfaceName; + return; + } + + for (var suffix = 1; ; suffix++) + { + var suffixedName = GetOptionsInterfaceName($"{capabilityName}{suffix}"); + if (!_optionsInterfacesToGenerate.TryGetValue(suffixedName, out var suffixedParams)) + { + _generatedOptionsInterfaces.Add(suffixedName); + _optionsInterfacesToGenerate[suffixedName] = [.. optionalParams]; + _capabilityOptionsInterfaceMap[capabilityId] = suffixedName; + return; + } + + if (AreOptionsCompatible(suffixedParams, optionalParams)) + { + _capabilityOptionsInterfaceMap[capabilityId] = suffixedName; + return; + } + } + } + /// /// Checks whether two sets of optional parameters are compatible for sharing an options interface. /// Parameters with the same name must have the same type (including callback parameter types). @@ -1547,6 +1573,23 @@ private static bool AreOptionsCompatible(List existing, List existing, List candidate) + { + if (existing.Count != candidate.Count) + { + return false; + } + for (var i = 0; i < existing.Count; i++) + { + if (!string.Equals(existing[i].Name, candidate[i].Name, StringComparison.Ordinal) + || !AreParameterTypesEqual(existing[i], candidate[i])) + { + return false; + } + } + return true; + } + /// /// Checks whether two parameter infos have the same type (including callback types). /// diff --git a/src/Aspire.Hosting.Docker/README.md b/src/Aspire.Hosting.Docker/README.md index 0d214dd008c..df4eb0db600 100644 --- a/src/Aspire.Hosting.Docker/README.md +++ b/src/Aspire.Hosting.Docker/README.md @@ -28,6 +28,26 @@ builder.AddDockerComposeEnvironment("compose"); await builder.addDockerComposeEnvironment("compose"); ``` +### Volumes + +Use an environment variable so projects and executables can use a local Aspire store directory while Docker Compose mounts the published named volume: + +**C#** + +```csharp +builder.AddProject("api") + .WithVolume("data", "/data", env: "DATA_PATH"); +``` + +**TypeScript** + +```typescript +const api = await builder.addNodeApp("api", "../api", "server.js"); +await api.withVolume("/data", "data", "DATA_PATH"); +``` + +In run mode, projects and executables receive a workload-scoped directory through `DATA_PATH`. Containers receive `/data` and use a local container volume. Named storage is preserved independently of resource lifetime: session resources stop with the AppHost and reuse their storage on the next run, while persistent resources can keep the compute instance alive. In the generated Compose service, all compute resource types receive `/data` and a named volume mounted at that path. + ```shell aspire publish -o docker-compose-artifacts ``` diff --git a/src/Aspire.Hosting.Kubernetes/Annotations/KubernetesPersistentVolumeBindingAnnotation.cs b/src/Aspire.Hosting.Kubernetes/Annotations/KubernetesPersistentVolumeBindingAnnotation.cs index 61fbea35e12..5b75f9e74c7 100644 --- a/src/Aspire.Hosting.Kubernetes/Annotations/KubernetesPersistentVolumeBindingAnnotation.cs +++ b/src/Aspire.Hosting.Kubernetes/Annotations/KubernetesPersistentVolumeBindingAnnotation.cs @@ -16,10 +16,25 @@ namespace Aspire.Hosting.Kubernetes.Annotations; /// promote the workload to a StatefulSet. /// /// The persistent volume resource the workload binds to. -internal sealed class KubernetesPersistentVolumeBindingAnnotation(KubernetesPersistentVolumeResource volume) : IResourceAnnotation +/// The environment variable that exposes the effective mount path, if configured. +/// The worktree-scoped container volume name to apply in run mode, if required. +internal sealed class KubernetesPersistentVolumeBindingAnnotation( + KubernetesPersistentVolumeResource volume, + string? environmentVariableName = null, + string? runModeContainerVolumeName = null) : IResourceAnnotation { /// /// Gets the persistent volume resource bound to the workload. /// public KubernetesPersistentVolumeResource Volume { get; } = volume ?? throw new ArgumentNullException(nameof(volume)); + + /// + /// Gets the environment variable that exposes the effective mount path, if configured. + /// + public string? EnvironmentVariableName { get; } = environmentVariableName; + + /// + /// Gets the worktree-scoped container volume name to apply in run mode, if required. + /// + public string? RunModeContainerVolumeName { get; } = runModeContainerVolumeName; } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs index 31d177232a1..1c4437e5b72 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Kubernetes; +using Aspire.Hosting.Kubernetes.Annotations; using Aspire.Hosting.Kubernetes.Extensions; using Aspire.Hosting.Pipelines; using Microsoft.Extensions.DependencyInjection; @@ -27,9 +28,8 @@ internal static IDistributedApplicationBuilder AddKubernetesInfrastructureCore(t // // The per-environment work (creating Kubernetes service resources and DeploymentTargetAnnotations) // is registered as a separate per-environment pipeline step on KubernetesEnvironmentResource. - // This global step only validates that no resource has a PublishAsKubernetesService annotation - // when there are no KubernetesEnvironmentResource instances or Kubernetes-backed - // compute environments in the model. + // This global step validates model-wide Kubernetes configuration before those steps filter + // resources to their selected compute environments. if (builder.Services.All(d => d.ServiceType != typeof(KubernetesPipelineStepMarker))) { builder.Services.AddSingleton(); @@ -38,6 +38,8 @@ internal static IDistributedApplicationBuilder AddKubernetesInfrastructureCore(t name: KubernetesPipelineStepMarker.StepName, action: ctx => { + ValidateAndFinalizePersistentVolumeBindings(ctx); + if (!ctx.ExecutionContext.IsPublishMode) { return Task.CompletedTask; @@ -60,12 +62,158 @@ internal static IDistributedApplicationBuilder AddKubernetesInfrastructureCore(t return Task.CompletedTask; }, + dependsOn: WellKnownPipelineSteps.ValidateComputeEnvironments, requiredBy: WellKnownPipelineSteps.BeforeStart); } return builder; } + private static void ValidateAndFinalizePersistentVolumeBindings(PipelineStepContext context) + { + var bindings = GetPersistentVolumeBindings(context); + + if (context.ExecutionContext.IsRunMode) + { + ValidateRunModePersistentVolumeBindings(bindings); + ApplyRunModeContainerVolumeNames(bindings); + return; + } + + ValidatePublishModePersistentVolumeBindings(bindings); + } + + private static PersistentVolumeBinding[] GetPersistentVolumeBindings(PipelineStepContext context) + { + // GetComputeResources intentionally represents publishable workloads and excludes plain + // executables. Run mode must inspect every compute resource because those executables can + // consume the local IAspireStore-backed volume path. + var computeResources = context.ExecutionContext.IsRunMode + ? context.Model.Resources.Where(resource => resource is IComputeResource) + : context.Model.GetComputeResources(); + + return computeResources + .SelectMany(resource => + resource.Annotations + .OfType() + .Select(binding => new PersistentVolumeBinding(resource, binding))) + .ToArray(); + } + + private static void ValidateRunModePersistentVolumeBindings(PersistentVolumeBinding[] bindings) + { + foreach (var (resource, annotation) in bindings) + { + if (annotation.EnvironmentVariableName is not null && + resource is not ProjectResource and not ExecutableResource and not ContainerResource) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' cannot resolve the '{annotation.EnvironmentVariableName}' persistent-volume path in run mode. " + + $"Only project, executable, and container resources are supported."); + } + } + + ValidateRunModeBackingStoreCompatibility(bindings); + } + + private static void ValidateRunModeBackingStoreCompatibility(PersistentVolumeBinding[] bindings) + { + // Host processes use an IAspireStore directory while containers use a named runtime + // volume. Treating those as one logical volume would silently split the data in run mode. + foreach (var environmentGroup in bindings.GroupBy( + item => item.Annotation.Volume.Parent.Name, + StringComparer.OrdinalIgnoreCase)) + { + foreach (var volumeGroup in environmentGroup.GroupBy( + item => item.Annotation.Volume.Name, + StringComparer.OrdinalIgnoreCase)) + { + var containers = volumeGroup.Where(item => item.Resource is ContainerResource).ToArray(); + + // Only host processes that asked for the environment path materialize an IAspireStore + // directory. A project or executable bound to a publish-only volume consumes no local + // backing store in run mode, so it cannot conflict with a container's named volume. + // Rejecting it would break AppHosts that predate the environment-path feature. + var hostProcesses = volumeGroup.Where(item => + item.Resource is ProjectResource or ExecutableResource && + item.Annotation.EnvironmentVariableName is not null).ToArray(); + + if (containers.Length > 0 && hostProcesses.Length > 0) + { + var volume = volumeGroup.First().Annotation.Volume; + var resourceNames = string.Join(", ", containers.Concat(hostProcesses).Select(item => $"'{item.Resource.Name}'")); + throw new DistributedApplicationException( + $"Kubernetes persistent volume '{volume.Name}' is used by both local container and host-process resources ({resourceNames}). " + + $"Run mode cannot provide one shared backing store across those execution types. Use only containers or only projects/executables for this volume."); + } + } + } + } + + private static void ApplyRunModeContainerVolumeNames(PersistentVolumeBinding[] bindings) + { + foreach (var (resource, annotation) in bindings) + { + ApplyRunModeContainerVolumeName(resource, annotation); + } + } + + private static void ValidatePublishModePersistentVolumeBindings(PersistentVolumeBinding[] bindings) + { + foreach (var (resource, annotation) in bindings) + { + var targetEnvironment = resource.GetComputeEnvironment(); + var volumeEnvironment = annotation.Volume.Parent; + + // AKS owns an inner Kubernetes environment, so a binding is valid when the workload + // targets either the Kubernetes environment directly or its owning compute environment. + if (targetEnvironment != volumeEnvironment && + targetEnvironment != volumeEnvironment.OwningComputeEnvironment) + { + var targetName = targetEnvironment?.Name ?? ""; + var supportedTargetName = (volumeEnvironment.OwningComputeEnvironment ?? volumeEnvironment).Name; + throw new DistributedApplicationException( + $"Resource '{resource.Name}' is assigned to compute environment '{targetName}' but binds " + + $"Kubernetes persistent volume '{annotation.Volume.Name}' which belongs to environment " + + $"'{volumeEnvironment.Name}'. A workload can only bind persistent volumes declared on its " + + $"Kubernetes compute environment. Declare the volume on the workload's Kubernetes environment, " + + $"or assign the workload to '{supportedTargetName}' with WithComputeEnvironment."); + } + } + } + + private static void ApplyRunModeContainerVolumeName( + IResource resource, + KubernetesPersistentVolumeBindingAnnotation binding) + { + if (resource is not ContainerResource || binding.RunModeContainerVolumeName is not { } localVolumeName) + { + return; + } + + // Resolve after the model is complete so WithPersistentVolume and WithVolume remain + // order-independent while publish mode can continue matching the original claim name. + // + // Replace each mount in place rather than Remove-then-Add. ContainerMountAnnotation is a + // record, so Remove matches by value and a Remove/Add pair would both relocate the mount to + // the end of the annotation collection and risk removing a value-identical sibling. + // Assigning through the indexer preserves position and swaps atomically. + var annotations = resource.Annotations; + + for (var i = 0; i < annotations.Count; i++) + { + if (annotations[i] is ContainerMountAnnotation { Type: ContainerMountType.Volume } mount && + string.Equals(mount.Source, binding.Volume.Name, StringComparison.Ordinal)) + { + annotations[i] = new ContainerMountAnnotation(localVolumeName, mount.Target, mount.Type, mount.IsReadOnly); + } + } + } + + private readonly record struct PersistentVolumeBinding( + IResource Resource, + KubernetesPersistentVolumeBindingAnnotation Annotation); + private sealed class KubernetesPipelineStepMarker { public const string StepName = "validate-kubernetes"; diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs index 6ccc245d316..5694116cacf 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs @@ -9,7 +9,6 @@ using System.Security.Cryptography.X509Certificates; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Process; -using Aspire.Hosting.Kubernetes.Annotations; using Aspire.Hosting.Kubernetes.Extensions; using Aspire.Hosting.Kubernetes.Resources; using Aspire.Hosting.Pipelines; @@ -473,30 +472,6 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) ConfigureOtlp(r, otlpGrpcEndpoint); } - // Fail the publish if this workload binds a persistent volume owned by a - // different Kubernetes environment. The two charts would render into - // separate namespaces/clusters, so the workload's claimName reference would - // resolve to a PVC that does not exist alongside it — Kubernetes would fail - // to schedule the pod with "persistentvolumeclaim not found". Detect and - // surface it here where we have both the resolved workload compute - // environment and the annotated PV. - if (r.TryGetAnnotationsOfType(out var pvBindings)) - { - foreach (var binding in pvBindings) - { - if (binding.Volume.Parent != this) - { - throw new InvalidOperationException( - $"Resource '{r.Name}' is assigned to Kubernetes environment '{Name}' but binds " + - $"persistent volume '{binding.Volume.Name}' which belongs to environment " + - $"'{binding.Volume.Parent.Name}'. A workload can only bind persistent volumes " + - $"declared on its own environment. Move the AddPersistentVolume call to " + - $"'{Name}', or assign the workload to '{binding.Volume.Parent.Name}' with " + - $"WithComputeEnvironment."); - } - } - } - // Create a Kubernetes compute resource for the resource var serviceResource = await environmentContext.CreateKubernetesResourceAsync(r, executionContext, cancellationToken).ConfigureAwait(false); serviceResource.AddPrintSummaryStep(); diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeExtensions.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeExtensions.cs index 41d1e878641..9c632eca943 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeExtensions.cs @@ -2,9 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Kubernetes; using Aspire.Hosting.Kubernetes.Annotations; +using Aspire.Hosting.Kubernetes.Extensions; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting; @@ -221,7 +224,7 @@ public static IResourceBuilder WithVolumeAnn /// /// Binds a workload to a Kubernetes - /// using name matching. The workload must already declare a volume with + /// using name matching. The workload must declare a volume with /// a matching source name (typically via WithVolume("name", "/path") /// or an integration helper such as Postgres' /// WithDataVolume()). The publisher rewrites that volume's pod-spec entry @@ -234,7 +237,7 @@ public static IResourceBuilder WithVolumeAnn /// The persistent volume resource to bind to. /// The same builder for chaining. /// - /// To bind a workload that does not already have a matching named mount (for + /// To bind a workload that does not have a matching named mount (for /// example a ProjectResource), use the overload that accepts a /// mountPath instead. The generated pod uses an Aspire-managed /// fsGroup of 2000 with an OnRootMismatch change policy so @@ -263,7 +266,19 @@ public static IResourceBuilder WithPersistentVolume( ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(volume); - builder.WithAnnotation(new KubernetesPersistentVolumeBindingAnnotation(volume.Resource)); + VolumeResourceBuilderExtensions.AddRunModePathResolver( + builder, + volume.Resource.Name, + context => + { + var store = context.ExecutionContext.Services.GetRequiredService(); + return KubernetesPersistentVolumeLocalStorage.GetOrCreatePath(store, volume.Resource); + }); + + var runModeContainerVolumeName = GetRunModeContainerVolumeName(builder, volume); + builder.WithAnnotation(new KubernetesPersistentVolumeBindingAnnotation( + volume.Resource, + runModeContainerVolumeName: runModeContainerVolumeName)); return builder; } @@ -301,23 +316,132 @@ public static IResourceBuilder WithPersistentVolume( /// .WithPersistentVolume(media, "/srv/media"); /// /// - [AspireExport("withKubernetesPersistentVolumeMount")] + [OverloadResolutionPriority(1)] + [AspireExportIgnore(Reason = "Polyglot AppHosts use the withKubernetesPersistentVolumeMount adapter.")] + public static IResourceBuilder WithPersistentVolume( + this IResourceBuilder builder, + IResourceBuilder volume, + string mountPath, + bool isReadOnly = false) + where T : IComputeResource + { + return WithPersistentVolumeCore(builder, volume, mountPath, isReadOnly, env: null); + } + + /// + /// Binds a workload to a Kubernetes , + /// mounts it at the specified path when deployed, and exposes the effective storage + /// path through an environment variable. + /// + /// A compute resource that supports environment variables. + /// The workload resource builder. + /// The persistent volume resource to bind to. + /// The path inside the deployed container where the volume is mounted. + /// The environment variable that receives the effective storage path. + /// When , mounts the deployed volume read-only. + /// The same builder for chaining. + /// + /// In run mode, projects and executables receive a deterministic host directory under + /// the AppHost's . Containers receive the in-container + /// . In publish and deploy modes, every workload receives + /// . + /// + /// + /// + /// var data = k8s.AddPersistentVolume("data") + /// .WithCapacity("20Gi"); + /// + /// builder.AddProject<Projects.Api>("api") + /// .WithPersistentVolume(data, "/srv/data", env: "DATA_PATH"); + /// + /// + [AspireExportIgnore(Reason = "Polyglot AppHosts use the withKubernetesPersistentVolumeMount adapter.")] public static IResourceBuilder WithPersistentVolume( this IResourceBuilder builder, IResourceBuilder volume, string mountPath, + string env, bool isReadOnly = false) + where T : IComputeResource, IResourceWithEnvironment + { + ArgumentException.ThrowIfNullOrEmpty(env); + + return WithPersistentVolumeCore(builder, volume, mountPath, isReadOnly, env); + } + + /// + /// Binds a workload to a Kubernetes persistent volume and optionally exposes the + /// effective storage path through an environment variable. + /// + /// Binds a workload to a Kubernetes persistent volume and mounts it at a path + /// A compute resource. + /// The workload resource builder. + /// The persistent volume resource to bind to. + /// The path inside the deployed container where the volume is mounted. + /// When true, mounts the deployed volume read-only. + /// An optional environment variable that receives the effective storage path. + /// The same builder for chaining. + /// The resource builder. + [AspireExport("withKubernetesPersistentVolumeMount")] + internal static IResourceBuilder WithPersistentVolumeMountForExport( + this IResourceBuilder builder, + IResourceBuilder volume, + string mountPath, + bool isReadOnly = false, + string? env = null) + where T : IComputeResource + { + return WithPersistentVolumeCore(builder, volume, mountPath, isReadOnly, env); + } + + private static IResourceBuilder WithPersistentVolumeCore( + IResourceBuilder builder, + IResourceBuilder volume, + string mountPath, + bool isReadOnly, + string? env) where T : IComputeResource { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(volume); ArgumentException.ThrowIfNullOrEmpty(mountPath); - builder.WithAnnotation(new ContainerMountAnnotation(volume.Resource.Name, mountPath, ContainerMountType.Volume, isReadOnly)); - builder.WithAnnotation(new KubernetesPersistentVolumeBindingAnnotation(volume.Resource)); + var runModeContainerVolumeName = GetRunModeContainerVolumeName(builder, volume); + + VolumeResourceBuilderExtensions.WithVolumeCore( + builder, + volume.Resource.Name, + mountPath, + isReadOnly, + env, + context => + { + var store = context.ExecutionContext.Services.GetRequiredService(); + return KubernetesPersistentVolumeLocalStorage.GetOrCreatePath(store, volume.Resource); + }); + + builder.WithAnnotation(new KubernetesPersistentVolumeBindingAnnotation( + volume.Resource, + env, + runModeContainerVolumeName)); + return builder; } + private static string? GetRunModeContainerVolumeName( + IResourceBuilder builder, + IResourceBuilder volume) + where T : IComputeResource + { + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode || builder.Resource is not ContainerResource) + { + return null; + } + + var environmentName = volume.Resource.Parent.Name.ToKubernetesResourceName(); + return VolumeNameGenerator.Generate(volume, $"kubernetes-{environmentName}"); + } + /// /// Converts a enum value to the /// Kubernetes API string representation. diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeLocalStorage.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeLocalStorage.cs new file mode 100644 index 00000000000..c5be9d7bf79 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeLocalStorage.cs @@ -0,0 +1,36 @@ +// 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 ASPIRECOMPUTE002 + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Kubernetes; + +/// +/// Resolves the run-mode host directory for a Kubernetes persistent volume. +/// +internal static class KubernetesPersistentVolumeLocalStorage +{ + internal static string GetOrCreatePath(IAspireStore store, KubernetesPersistentVolumeResource volume) + { + var path = GetPath(store, volume); + Directory.CreateDirectory(path); + return path; + } + + internal static string GetPath(IAspireStore store, KubernetesPersistentVolumeResource volume) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(volume); + + // IAspireStore is scoped to the AppHost's intermediate output directory, which keeps + // local persistent data isolated between repositories and worktrees without another hash. + return VolumeMountPathResolver.GetPathUnderStore( + store, + "kubernetes", + VolumeMountPathResolver.GetStablePathSegment(volume.Parent.Name), + "volumes", + VolumeMountPathResolver.GetStablePathSegment(volume.GetClaimName())); + } +} diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeResource.cs index 643b8cdc5bf..a9a48124b6c 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPersistentVolumeResource.cs @@ -25,7 +25,7 @@ namespace Aspire.Hosting.Kubernetes; /// /// /// Adding a matching WithVolume("name", "/mount/path") on a container resource -/// and then calling WithPersistentVolume(volume). The publisher matches by +/// and calling WithPersistentVolume(volume). The publisher matches by /// volume name and routes the pod's volumes[] entry through this resource's /// generated PVC. /// @@ -35,6 +35,12 @@ namespace Aspire.Hosting.Kubernetes; /// overload that takes a mount path. Works for both ContainerResource and /// ProjectResource. /// +/// +/// Calling the +/// +/// overload that also sets an environment variable. Projects and executables receive +/// a local persistent path in run mode and the deployed mount path in publish mode. +/// /// /// /// Any workload bound to a persistent volume is automatically rendered as a @@ -42,7 +48,9 @@ namespace Aspire.Hosting.Kubernetes; /// stable identity and ordered rollout for pods that share named PVCs. /// /// -/// This resource is publish-only. It has no run-mode behavior or dashboard surface. +/// This resource is publish-only and has no dashboard surface. The environment-variable +/// binding overload provides run-mode storage without adding the volume resource to the +/// local application model. /// /// /// diff --git a/src/Aspire.Hosting.Kubernetes/README.md b/src/Aspire.Hosting.Kubernetes/README.md index cc5854adb30..2b705badf7b 100644 --- a/src/Aspire.Hosting.Kubernetes/README.md +++ b/src/Aspire.Hosting.Kubernetes/README.md @@ -1,6 +1,6 @@ # Kubernetes hosting integration -Provides publishing extensions to Aspire for Kubernetes. +Use this integration to model, configure, and deploy Aspire compute resources to Kubernetes. ## Getting started @@ -34,6 +34,56 @@ builder.AddKubernetesEnvironment("k8s"); await builder.addKubernetesEnvironment("k8s"); ``` +### Volumes + +Use a target-neutral volume when the Kubernetes environment's default storage policy is sufficient: + +**C#** + +```csharp +builder.AddProject("api") + .WithVolume("data", "/data", env: "DATA_PATH"); +``` + +**TypeScript** + +```typescript +const api = await builder.addNodeApp("api", "../api", "server.js"); +await api.withVolume("/data", "data", "DATA_PATH"); +``` + +Projects and executables receive a workload-scoped Aspire store directory in run mode. The directory is reused across AppHost runs regardless of whether the process has a session or persistent lifetime. When published, the volume uses the Kubernetes environment's `DefaultStorageType` and `DATA_PATH` contains `/data`. + +### Persistent volumes + +Add a persistent volume and expose its effective path through an environment variable: + +**C#** + +```csharp +var k8s = builder.AddKubernetesEnvironment("k8s"); +var data = k8s.AddPersistentVolume("data") + .WithCapacity("20Gi"); + +builder.AddProject("api") + .WithPersistentVolume(data, "/data", env: "DATA_PATH"); +``` + +**TypeScript** + +```typescript +const k8s = await builder.addKubernetesEnvironment("k8s"); +const data = await k8s.addPersistentVolume("data"); +await data.withCapacity("20Gi"); + +const api = await builder.addNodeApp("api", "../api", "server.js"); +await api.withKubernetesPersistentVolumeMount(data, "/data", { env: "DATA_PATH" }); +``` + +When a project or executable runs locally, `DATA_PATH` points to a persistent directory in the AppHost's Aspire store. That store is normally under the AppHost intermediate-output directory, so cleaning build outputs can remove the local data. Local containers use a worktree-scoped container volume instead. A single persistent-volume resource cannot be shared between local containers and local projects or executables because those execution types cannot use one backing store reliably. + +When published or deployed, `DATA_PATH` contains `/data`. Applications can therefore use the same environment variable in both environments. The `isReadOnly` mount option is enforced after deployment, but Aspire cannot make a directory read-only for a process running directly on the host. + ```shell aspire publish -o k8s-artifacts ``` diff --git a/src/Aspire.Hosting/ApplicationModel/VolumeMountPathResolverAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/VolumeMountPathResolverAnnotation.cs new file mode 100644 index 00000000000..da1130ea923 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/VolumeMountPathResolverAnnotation.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Overrides the run-mode host path for a named volume mount. +/// +internal sealed class VolumeMountPathResolverAnnotation( + string volumeName, + Func resolver) : IResourceAnnotation +{ + internal string VolumeName { get; } = volumeName; + + internal Func Resolver { get; } = resolver; +} diff --git a/src/Aspire.Hosting/Ats/CoreExports.cs b/src/Aspire.Hosting/Ats/CoreExports.cs index c16cd15a2b2..92ed8fd301f 100644 --- a/src/Aspire.Hosting/Ats/CoreExports.cs +++ b/src/Aspire.Hosting/Ats/CoreExports.cs @@ -54,7 +54,7 @@ internal static class CoreExports #endregion - #region Container Configuration + #region Compute Configuration /// /// Adds a volume to a container resource. @@ -76,6 +76,18 @@ internal static class CoreExports /// The volume name. If null, an anonymous volume is created. /// Whether the volume is read-only. /// The same resource builder handle for chaining. + /// + /// + /// This capability deliberately does not expose the C# env parameter. A container always + /// receives as its effective volume path in every mode, so the C# + /// convenience overload is exactly equivalent to withVolume(target, name).withEnvironment(env, target) + /// in a polyglot AppHost. Keeping the exported parameter list frozen matters because the Rust + /// generator emits optional capability parameters positionally and has no overloading, so appending + /// a parameter here would be a source-breaking change for existing Rust AppHosts. Projects and + /// executables genuinely need the parameter because their run-mode path is computed by the host, + /// and they get it through the separate withProjectVolume/withExecutableVolume capabilities. + /// + /// [AspireExport] public static IResourceBuilder WithVolume( this IResourceBuilder resource, @@ -83,7 +95,58 @@ public static IResourceBuilder WithVolume( string? name = null, bool isReadOnly = false) { - return ContainerResourceBuilderExtensions.WithVolume(resource, name, target, isReadOnly); + return VolumeResourceBuilderExtensions.WithVolumeCore(resource, name, target, isReadOnly, env: null); + } + + /// + /// Adds a volume to a project resource. + /// + /// The project resource builder handle. + /// The mount path inside the published container. + /// The volume name. + /// The environment variable that receives the effective volume path. + /// Whether the published volume is read-only. + /// The same project resource builder handle for chaining. + [AspireExport("withProjectVolume", MethodName = "withVolume")] + public static IResourceBuilder WithProjectVolumeForPolyglot( + this IResourceBuilder resource, + string target, + string name, + string env, + bool isReadOnly = false) + { + return WithProcessVolume(resource, target, name, isReadOnly, env); + } + + /// + /// Adds a volume to an executable resource. + /// + /// The executable resource builder handle. + /// The mount path inside the published container. + /// The volume name. + /// The environment variable that receives the effective volume path. + /// Whether the published volume is read-only. + /// The same executable resource builder handle for chaining. + [AspireExport("withExecutableVolume", MethodName = "withVolume")] + public static IResourceBuilder WithExecutableVolumeForPolyglot( + this IResourceBuilder resource, + string target, + string name, + string env, + bool isReadOnly = false) + { + return WithProcessVolume(resource, target, name, isReadOnly, env); + } + + private static IResourceBuilder WithProcessVolume( + IResourceBuilder resource, + string target, + string name, + bool isReadOnly, + string env) + where T : IComputeResource, IResourceWithEnvironment + { + return VolumeResourceBuilderExtensions.WithVolumeCore(resource, name, target, isReadOnly, env); } #endregion diff --git a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs index 8f5304dffa1..248e0bb063f 100644 --- a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs @@ -7,6 +7,7 @@ #pragma warning disable ASPIREPERSISTENCE001 // Persistence annotation APIs are experimental. using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Text; using Aspire.Hosting.Ats; using Aspire.Hosting.ApplicationModel; @@ -197,6 +198,11 @@ private static IResourceBuilder AddContainer( /// They are not shared with the host's file-system. To mount files from the host inside the container, call . /// /// + /// Named volumes are preserved independently of the container lifetime. A session-lifetime + /// container is removed when the AppHost stops and reuses the named volume on its next run. + /// A persistent-lifetime container can remain running and keeps the same attached volume. + /// + /// /// If a value for the of the volume is not provided, the volume is created as an "anonymous volume" and will be given a random name by the container /// runtime. To share a volume between multiple containers, specify the same . /// @@ -217,14 +223,11 @@ private static IResourceBuilder AddContainer( /// // Note: [AspireExport] is on CoreExports.WithVolume which reorders parameters // so the required 'target' comes before the optional 'name' - better for polyglot APIs. + [OverloadResolutionPriority(1)] [AspireExportIgnore(Reason = "Polyglot export is via CoreExports.WithVolume which reorders parameters.")] public static IResourceBuilder WithVolume(this IResourceBuilder builder, string? name, string target, bool isReadOnly = false) where T : ContainerResource { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(target); - - var annotation = new ContainerMountAnnotation(name, target, ContainerMountType.Volume, isReadOnly); - return builder.WithAnnotation(annotation); + return VolumeResourceBuilderExtensions.WithVolumeCore(builder, name, target, isReadOnly, env: null); } /// @@ -261,11 +264,7 @@ public static IResourceBuilder WithVolume(this IResourceBuilder builder [AspireExportIgnore(Reason = "Polyglot export is via CoreExports.WithVolume which accepts optional name parameter.")] public static IResourceBuilder WithVolume(this IResourceBuilder builder, string target) where T : ContainerResource { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(target); - - var annotation = new ContainerMountAnnotation(null, target, ContainerMountType.Volume, false); - return builder.WithAnnotation(annotation); + return VolumeResourceBuilderExtensions.WithVolumeCore(builder, name: null, target, isReadOnly: false, env: null); } /// diff --git a/src/Aspire.Hosting/VolumeMountPathResolver.cs b/src/Aspire.Hosting/VolumeMountPathResolver.cs new file mode 100644 index 00000000000..7ff3ad40205 --- /dev/null +++ b/src/Aspire.Hosting/VolumeMountPathResolver.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Hashing; +using System.Text; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Resolves workload-scoped host directories for volume mounts used by local processes. +/// +internal static class VolumeMountPathResolver +{ + internal static string GetOrCreateLocalPath(IAspireStore store, IResource resource, string volumeName) + { + var path = GetLocalPath(store, resource, volumeName); + Directory.CreateDirectory(path); + return path; + } + + internal static string GetLocalPath(IAspireStore store, IResource resource, string volumeName) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(resource); + ArgumentException.ThrowIfNullOrEmpty(volumeName); + + // Generic target publishers do not share volume identity consistently, so local process + // storage is scoped to the workload. First-class target volume resources can supply a + // target-specific resolver when they intentionally model shared storage. + return GetPathUnderStore( + store, + "volumes", + GetStablePathSegment(resource.Name), + GetStablePathSegment(volumeName)); + } + + internal static string GetStablePathSegment(string value) + { + ArgumentException.ThrowIfNullOrEmpty(value); + + // Hash the complete identity instead of sanitizing it. Sanitization can collapse distinct + // names and can still produce platform-reserved file names such as CON on Windows. + return Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + } + + internal static string GetPathUnderStore(IAspireStore store, params string[] pathSegments) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(pathSegments); + + // Every caller passes either a literal segment or GetStablePathSegment output, so the + // combined path is always contained within the store and needs no traversal guard. + return Path.GetFullPath(Path.Combine([Path.GetFullPath(store.BasePath), .. pathSegments])); + } +} diff --git a/src/Aspire.Hosting/VolumeResourceBuilderExtensions.cs b/src/Aspire.Hosting/VolumeResourceBuilderExtensions.cs new file mode 100644 index 00000000000..1f175c0fa97 --- /dev/null +++ b/src/Aspire.Hosting/VolumeResourceBuilderExtensions.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding volume-backed storage to compute resources. +/// +public static class VolumeResourceBuilderExtensions +{ + /// + /// Adds a volume to a compute resource and exposes its effective path through an environment variable. + /// + /// The resource type. + /// The resource builder. + /// The name of the volume. + /// The target path where the volume is mounted after publishing. + /// The environment variable that receives the effective volume path. + /// A flag that indicates if the published volume should be mounted as read-only. + /// The . + /// + /// Containers receive in run and publish modes. Projects and + /// executables receive a workload-scoped directory in run mode + /// and in publish mode. + /// Named storage is independent of the resource lifetime. Session resources stop with the + /// AppHost and reuse their named storage on the next run; persistent resources can keep the + /// compute instance alive and continue using the same storage. Cleaning the AppHost store can + /// remove local project and executable data. + /// + /// + /// + /// builder.AddProject<Projects.Api>("api") + /// .WithVolume("data", "/usr/data", env: "DATA_PATH"); + /// + /// + [AspireExportIgnore(Reason = "Polyglot export is via CoreExports.WithVolume which reorders parameters.")] + public static IResourceBuilder WithVolume( + this IResourceBuilder builder, + string name, + string target, + string env, + bool isReadOnly = false) + where T : IComputeResource, IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(target); + ArgumentException.ThrowIfNullOrEmpty(env); + + return WithVolumeCore(builder, name, target, isReadOnly, env); + } + + internal static IResourceBuilder WithVolumeCore( + IResourceBuilder builder, + string? name, + string target, + bool isReadOnly, + string? env, + Func? getRunModeHostPath = null) + where T : IComputeResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(target); + + if (env is not null) + { + ArgumentException.ThrowIfNullOrEmpty(env); + + if (builder.Resource is ProjectResource or ExecutableResource) + { + ArgumentException.ThrowIfNullOrEmpty(name); + } + + if (builder.Resource is not IResourceWithEnvironment) + { + throw new InvalidOperationException( + $"Resource '{builder.Resource.Name}' does not support environment variables and cannot use the '{env}' volume path variable."); + } + } + + builder.WithAnnotation(new ContainerMountAnnotation(name, target, ContainerMountType.Volume, isReadOnly)); + + if (name is not null && getRunModeHostPath is not null) + { + AddRunModePathResolver(builder, name, getRunModeHostPath); + } + + if (env is not null) + { + builder.WithAnnotation(new EnvironmentCallbackAnnotation(context => + { + context.EnvironmentVariables[env] = GetEffectiveVolumePath(context, name, target, env); + })); + } + + return builder; + } + + internal static IResourceBuilder AddRunModePathResolver( + IResourceBuilder builder, + string volumeName, + Func resolver) + where T : IComputeResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(volumeName); + ArgumentNullException.ThrowIfNull(resolver); + + return builder.WithAnnotation(new VolumeMountPathResolverAnnotation(volumeName, resolver)); + } + + private static string GetEffectiveVolumePath( + EnvironmentCallbackContext context, + string? name, + string target, + string env) + { + if (context.ExecutionContext.IsPublishMode || context.Resource is ContainerResource) + { + return target; + } + + if (name is null) + { + throw new InvalidOperationException( + $"Resource '{context.Resource.Name}' cannot resolve the '{env}' volume path in run mode because the volume is anonymous."); + } + + var resolver = context.Resource.Annotations + .OfType() + .LastOrDefault(annotation => string.Equals(annotation.VolumeName, name, StringComparison.Ordinal)) + ?.Resolver; + + if (resolver is not null) + { + return resolver(context); + } + + // Containers already returned above, so everything remaining runs as a host process and needs + // a local directory. Projects and executables are the in-box cases, but the public overload + // accepts any IComputeResource, so custom compute resources resolve here too. Throwing instead + // would let a call that compiles cleanly fail much later during environment evaluation. + var store = context.ExecutionContext.Services.GetRequiredService(); + return VolumeMountPathResolver.GetOrCreateLocalPath(store, context.Resource, name); + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerComposeDeployWithVolumeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerComposeDeployWithVolumeTests.cs new file mode 100644 index 00000000000..9ed769b4880 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerComposeDeployWithVolumeTests.cs @@ -0,0 +1,148 @@ +// 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; + +/// +/// E2E test for aspire deploy to Docker Compose that proves the +/// WithVolume(name, target, env) overload projects correctly for project resources. +/// +/// Scenario: a project mounts a named volume and only ever learns the mount path from +/// DATA_PATH. The test asserts the generated compose file and then the *running* +/// container, because generation alone cannot show that the environment variable and the +/// volume actually reach the deployed workload. +/// +/// The obvious next assertion — write a file, force-recreate the container, read it back — +/// is deliberately absent. It cannot pass today: .NET images run as a non-root user while a +/// fresh Docker named volume is created root-owned, so the app gets "Permission denied" on +/// its own volume. Kubernetes avoids this by setting fsGroup (see KubernetesResource); Compose +/// has no equivalent. Tracked by https://github.com/microsoft/aspire/issues/19422 — add the +/// durability round-trip here once that is fixed. +/// +/// This is the Compose counterpart to . +/// Both run on every PR because neither needs a cloud subscription. +/// +public sealed class DockerComposeDeployWithVolumeTests(ITestOutputHelper output) +{ + private const string ProjectName = "ComposeDeployVolumeTest"; + private const string VolumeName = "serverdata"; + private const string MountPath = "/data"; + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task DeployComposeWithProjectVolumeMountsNamedVolumeAtEnvPath() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + using var workspace = TemporaryWorkspace.Create(output); + + 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); + + // The project never names the mount path itself — it only knows DATA_PATH. That is the + // whole point of the overload: the same source works in run mode (where the path is an + // Aspire store directory on the host) and after publish (where it is MountPath). + var appHostCode = $$""" + using Aspire.Hosting; + + var builder = DistributedApplication.CreateBuilder(args); + + builder.AddDockerComposeEnvironment("compose"); + + builder.AddProject("server") + .WithVolume("{{VolumeName}}", "{{MountPath}}", env: "DATA_PATH") + .WithExternalHttpEndpoints(); + + builder.Build().Run(); + """; + + // Throwing at startup turns a missing projection into a container that never reaches a + // running state, so the container lookup below fails loudly instead of the test quietly + // asserting against a workload that ignored DATA_PATH. + var apiProgramCode = """ + var builder = WebApplication.CreateBuilder(args); + builder.AddServiceDefaults(); + + var app = builder.Build(); + app.MapDefaultEndpoints(); + + var dataPath = Environment.GetEnvironmentVariable("DATA_PATH") + ?? throw new InvalidOperationException("DATA_PATH is not configured."); + + app.MapGet("/data-path", () => dataPath); + + app.Run(); + """; + + await auto.ScaffoldK8sDeployProjectAsync( + counter, + ProjectName, + Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName), + appHostHostingPackages: ["Aspire.Hosting.Docker"], + apiClientPackages: [], + appHostCode: appHostCode, + apiProgramCode: apiProgramCode, + output: output); + + await auto.TypeAsync("mkdir -p deploy-output"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // ASPIRE_PLAYGROUND=true takes precedence over --non-interactive in CliHostEnvironment, + // which causes Spectre.Console to try to show interactive spinners and prompts concurrently, + // resulting in "Operations with dynamic displays cannot run at the same time" errors. + await auto.TypeAsync("unset ASPIRE_PLAYGROUND"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire deploy -o deploy-output --non-interactive"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(10)); + + // === Verify the published projection === + // DATA_PATH must carry the published target (not a host path), and the mount must be + // backed by a named volume rather than a bind mount, which is what makes the storage + // outlive the container. + output.WriteLine("Verify: compose file projects DATA_PATH and a named volume"); + await auto.TypeAsync( + $"grep -q 'DATA_PATH: \"{MountPath}\"' deploy-output/docker-compose.yaml && " + + $"grep -q 'source: \"{VolumeName}\"' deploy-output/docker-compose.yaml && " + + $"grep -q 'type: \"volume\"' deploy-output/docker-compose.yaml && " + + "echo COMPOSE_SHAPE_OK || echo COMPOSE_SHAPE_BAD"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("COMPOSE_SHAPE_OK", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + // === Verify the running container === + // Compose expands the volume name with a project prefix (aspire-compose-_serverdata), + // so match on a substring rather than the literal declared name. Retried because the + // service can still be starting immediately after deploy reports success. + output.WriteLine("Verify: running container has DATA_PATH and the named volume mounted there"); + await auto.TypeAsync( + "for i in $(seq 1 20); do " + + "id=$(docker ps --filter 'name=server' --format '{{.ID}}' | head -1); " + + "if [ -n \"$id\" ]; then " + + "envval=$(docker exec $id printenv DATA_PATH 2>/dev/null); " + + "mounts=$(docker inspect -f '{{range .Mounts}}{{.Type}}|{{.Name}}|{{.Destination}} {{end}}' $id 2>/dev/null); " + + "echo \"DATA_PATH=[$envval] MOUNTS=[$mounts]\"; " + + $"if [ \"$envval\" = \"{MountPath}\" ] && echo \"$mounts\" | grep -q 'volume|.*{VolumeName}|{MountPath}'; " + + "then echo RUNTIME_OK; break; fi; " + + "fi; " + + "echo \"Attempt $i: waiting for server container...\"; sleep 3; done"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("RUNTIME_OK", timeout: TimeSpan.FromMinutes(3)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3)); + + await auto.AspireDestroyAsync(counter); + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs index 24e14113dfd..7d0291849e3 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.TestUtilities; using Hex1b.Automation; using Xunit; @@ -18,8 +17,6 @@ public sealed class DockerDeploymentTests(ITestOutputHelper output) private const string ProjectName = "AspireDockerDeployTest"; [Fact] - [ActiveIssue("https://github.com/microsoft/aspire/issues/15930")] - [QuarantinedTest("https://github.com/microsoft/aspire/issues/15882")] public async Task CreateAndDeployToDockerCompose() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); @@ -118,8 +115,6 @@ public async Task CreateAndDeployToDockerCompose() } [Fact] - [ActiveIssue("https://github.com/microsoft/aspire/issues/15930")] - [QuarantinedTest("https://github.com/microsoft/aspire/issues/15871")] public async Task CreateAndDeployToDockerComposeInteractive() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptWithPersistentVolumeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptWithPersistentVolumeTests.cs index bc0dc887d08..f74af4f110d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptWithPersistentVolumeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptWithPersistentVolumeTests.cs @@ -13,8 +13,9 @@ namespace Aspire.Cli.EndToEnd.Tests; /// addPersistentVolume/withStorageClass/withCapacity/withKubernetesPersistentVolumeMount /// produce the same first-class persistent volume wiring as the C# API: the /// node-app workload is auto-promoted to a StatefulSet, 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. +/// binds to the rancher local-path-provisioner that ships with KinD, the application +/// resolves its path through an environment variable, 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 @@ -127,10 +128,9 @@ await k8sEnv.withHelm({ 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"); +// The environment variable resolves to a local host path in run mode and the +// deployed mount path in Kubernetes. +await app.withKubernetesPersistentVolumeMount(scratch, "/srv/data", { env: "DATA_PATH" }); await builder.build().run(); """); @@ -150,13 +150,17 @@ await k8sEnv.withHelm({ .Replace( "app.get(\"/health\", (_req, res) => {", """ -const MARKER_PATH = "/srv/data/marker.txt"; +const DATA_PATH = process.env.DATA_PATH; +if (!DATA_PATH) { + throw new Error("DATA_PATH is not configured."); +} +const MARKER_PATH = `${DATA_PATH}/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 }); + mkdirSync(DATA_PATH, { recursive: true }); writeFileSync(MARKER_PATH, MARKER_TOKEN); res.send(`PASSED: wrote ${MARKER_TOKEN}`); return; diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithProjectPersistentVolumeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithProjectPersistentVolumeTests.cs index b68315ceb76..5122bed25ab 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithProjectPersistentVolumeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithProjectPersistentVolumeTests.cs @@ -9,7 +9,7 @@ namespace Aspire.Cli.EndToEnd.Tests; /// /// E2E test for aspire deploy to Kubernetes that proves the -/// WithPersistentVolume(volume, mountPath) overload works for project +/// WithPersistentVolume(volume, mountPath, env) overload works for project /// resources — closes the scenario tracked by aspire/issues/9430. /// /// Scenario: a project mounts a first-class persistent volume at /srv/data, @@ -49,10 +49,10 @@ public async Task DeployK8sWithProjectPersistentVolumeSurvivesPodRestart() await auto.InstallKindAndHelmAsync(counter); await auto.CreateKindClusterWithRegistryAsync(counter, clusterName); - // Mount-path overload of WithPersistentVolume — works for ProjectResource + // Environment-variable overload of WithPersistentVolume — works for ProjectResource // (no ContainerMountAnnotation needs to pre-exist; the overload adds one - // itself) and triggers StatefulSet auto-promotion just like the name-match - // overload. + // itself), maps local and deployed paths through DATA_PATH, and triggers + // StatefulSet auto-promotion just like the name-match overload. var appHostCode = $$""" #pragma warning disable ASPIRECOMPUTE002, ASPIRECOMPUTE003 using Aspire.Hosting; @@ -76,7 +76,7 @@ public async Task DeployK8sWithProjectPersistentVolumeSurvivesPodRestart() .WithAccessMode(PersistentVolumeAccessMode.ReadWriteOnce); builder.AddProject("server") - .WithPersistentVolume(scratch, "/srv/data") + .WithPersistentVolume(scratch, "/srv/data", env: "DATA_PATH") .WithExternalHttpEndpoints(); builder.Build().Run(); @@ -91,25 +91,27 @@ public async Task DeployK8sWithProjectPersistentVolumeSurvivesPodRestart() var app = builder.Build(); app.MapDefaultEndpoints(); - const string MarkerPath = "/srv/data/marker.txt"; + var dataPath = Environment.GetEnvironmentVariable("DATA_PATH") + ?? throw new InvalidOperationException("DATA_PATH is not configured."); + var markerPath = Path.Combine(dataPath, "marker.txt"); const string MarkerToken = "wrote-42"; app.MapGet("/test-deployment", (string? action) => { if (action == "write") { - Directory.CreateDirectory(Path.GetDirectoryName(MarkerPath)!); - File.WriteAllText(MarkerPath, MarkerToken); + Directory.CreateDirectory(Path.GetDirectoryName(markerPath)!); + File.WriteAllText(markerPath, MarkerToken); return Results.Ok("PASSED: wrote " + MarkerToken); } if (action == "read") { - if (!File.Exists(MarkerPath)) + if (!File.Exists(markerPath)) { - return Results.Problem("FAILED: marker file missing at " + MarkerPath); + return Results.Problem("FAILED: marker file missing at " + markerPath); } - var content = File.ReadAllText(MarkerPath); + var content = File.ReadAllText(markerPath); if (content == MarkerToken) { return Results.Ok("PASSED: read " + content); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs index a7e3f500a93..fa375b4c17f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksPersistentVolumeDeploymentTests.cs @@ -237,7 +237,7 @@ private static void ConfigureAppHost(string appHostPath) """builder.AddProject("apiservice")""", """ builder.AddProject("apiservice") - .WithPersistentVolume(data, "/srv/data") + .WithPersistentVolume(data, "/srv/data", env: "DATA_PATH") .WithEnvironment("DEPLOYMENT_REVISION", "first") """, appHostPath); @@ -276,8 +276,10 @@ private static void ConfigureApi(string apiProgramPath) var app = builder.Build(); - const string markerPath = "/srv/data/marker.txt"; - const string newMarkerPath = "/srv/data/new-marker.txt"; + var dataPath = app.Configuration["DATA_PATH"] + ?? throw new InvalidOperationException("DATA_PATH is not configured."); + var markerPath = Path.Combine(dataPath, "marker.txt"); + var newMarkerPath = Path.Combine(dataPath, "new-marker.txt"); const string markerToken = "aks-pv-marker-42"; var deploymentRevision = app.Configuration["DEPLOYMENT_REVISION"] ?? throw new InvalidOperationException("DEPLOYMENT_REVISION is not configured."); diff --git a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesPersistentVolumeTests.cs b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesPersistentVolumeTests.cs index f61f01e97b7..6f09083b79e 100644 --- a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesPersistentVolumeTests.cs +++ b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesPersistentVolumeTests.cs @@ -4,6 +4,10 @@ #pragma warning disable ASPIREAZURE003, ASPIRECOMPUTE002 using Aspire.Hosting.Utils; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Kubernetes; +using Aspire.Hosting.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting.Azure.Tests; @@ -39,4 +43,56 @@ public async Task AksAddPersistentVolume_GeneratesClaimUsingClusterDefaults() var content = await File.ReadAllTextAsync(claimPath); await Verify(content, "yaml"); } + + [Fact] + public async Task AksPersistentVolumeEnvironmentUsesAspireStoreInRunMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var aks = builder.AddAzureKubernetesEnvironment("aks"); + var volume = aks.AddPersistentVolume("data"); + var executable = builder.AddExecutable("executable", "test-command", ".") + .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH"); + + using var app = builder.Build(); + var store = app.Services.GetRequiredService(); + var expectedPath = KubernetesPersistentVolumeLocalStorage.GetPath(store, volume.Resource); + var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + executable.Resource, + serviceProvider: app.Services); + + Assert.Equal(expectedPath, environment["DATA_PATH"]); + Assert.True(Directory.Exists(expectedPath)); + } + + [Fact] + public async Task AksPersistentVolume_PublishesWhenWorkloadImplicitlyTargetsSoleEnvironment() + { + // AKS is the only compute environment here, so the workload is never explicitly bound with + // WithComputeEnvironment. EnsureComputeEnvironmentAnnotationsApplied implements the + // "single compute environment is the default" convention before the before-start pipeline + // runs, so by the time the publish-mode binding validation executes the workload resolves to + // the AKS resource rather than null. This mirrors the AKS deployment E2E AppHost, which + // binds a persistent volume without calling WithComputeEnvironment. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + var aks = builder.AddAzureKubernetesEnvironment("aks"); + var volume = aks.AddPersistentVolume("data").WithCapacity("5Gi"); + + builder.AddContainer("service", "nginx") + .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH"); + + 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}."); + + // A workload bound to a persistent volume renders as a StatefulSet rather than a Deployment. + var statefulSetPath = Path.Combine(workspace.Path, "templates", "service", "statefulset.yaml"); + Assert.True(File.Exists(statefulSetPath), $"Expected workload YAML at {statefulSetPath}."); + + var statefulSetContent = await File.ReadAllTextAsync(statefulSetPath); + Assert.Contains("/srv/data", statefulSetContent, StringComparison.Ordinal); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 256572b87f2..2341b912c25 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -700,7 +700,7 @@ public async Task VolumesAndBindMountsAreTranslation() builder.AddAzureContainerAppEnvironment("env"); builder.AddContainer("api", "myimage") - .WithVolume("vol1", "/path1") + .WithVolume("vol1", "/path1", env: "DATA_PATH") .WithVolume("vol2", "/path2") .WithBindMount("bind1", "/path3"); @@ -724,6 +724,42 @@ await Verify(manifest.ToString(), "json") .AppendContentAsFile(bicep, "bicep"); } + [Fact] + public async Task ProjectAndExecutableVolumesIncludeEnvironmentPaths() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppEnvironment("env"); + + builder.AddProject("project", launchProfileName: null) + .WithVolume("project-data", "/srv/project", env: "DATA_PATH"); + builder.AddExecutable("executable", "node", ".") + .PublishAsDockerFile() + .WithVolume("executable-data", "/srv/executable", env: "DATA_PATH"); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + SettingsTask settingsTask = default!; + + foreach (var resource in model.Resources + .Where(resource => resource.Name is "project" or "executable") + .OrderBy(resource => resource.Name)) + { + var target = resource.GetDeploymentTargetAnnotation(); + var deploymentResource = target?.DeploymentTarget as AzureProvisioningResource; + Assert.NotNull(deploymentResource); + + var (manifest, bicep) = await GetManifestWithBicep(deploymentResource); + settingsTask = settingsTask is null + ? Verify(manifest.ToString(), "json").AppendContentAsFile(bicep, "bicep") + : settingsTask.AppendContentAsFile(manifest.ToString(), "json").AppendContentAsFile(bicep, "bicep"); + } + + await settingsTask; + } + [Fact] public async Task MultipleVolumesHaveUniqueNamesInBicep() { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#00.verified.bicep new file mode 100644 index 00000000000..ae985b47e47 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#00.verified.bicep @@ -0,0 +1,67 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +param executable_containerimage string + +param env_outputs_volumes_executable_0 string + +resource executable 'Microsoft.App/containerApps@2025-07-01' = { + name: 'executable' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: executable_containerimage + name: 'executable' + env: [ + { + name: 'DATA_PATH' + value: '/srv/executable' + } + ] + volumeMounts: [ + { + volumeName: 'v0' + mountPath: '/srv/executable' + } + ] + } + ] + scale: { + minReplicas: 1 + } + volumes: [ + { + name: 'v0' + storageType: 'AzureFile' + storageName: env_outputs_volumes_executable_0 + } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#00.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#00.verified.json new file mode 100644 index 00000000000..ffb87ea34bf --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#00.verified.json @@ -0,0 +1,12 @@ +{ + "type": "azure.bicep.v0", + "path": "executable-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "executable_containerimage": "{executable.containerImage}", + "env_outputs_volumes_executable_0": "{env.outputs.volumes_executable_0}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#01.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#01.verified.bicep new file mode 100644 index 00000000000..0ced94e3f96 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#01.verified.bicep @@ -0,0 +1,76 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +param project_containerimage string + +param env_outputs_volumes_project_0 string + +resource project 'Microsoft.App/containerApps@2025-10-02-preview' = { + name: 'project' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: project_containerimage + name: 'project' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'DATA_PATH' + value: '/srv/project' + } + ] + volumeMounts: [ + { + volumeName: 'v0' + mountPath: '/srv/project' + } + ] + } + ] + scale: { + minReplicas: 1 + } + volumes: [ + { + name: 'v0' + storageType: 'AzureFile' + storageName: env_outputs_volumes_project_0 + } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#01.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#01.verified.json new file mode 100644 index 00000000000..724970d0c22 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectAndExecutableVolumesIncludeEnvironmentPaths#01.verified.json @@ -0,0 +1,12 @@ +{ + "type": "azure.bicep.v0", + "path": "project-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "project_containerimage": "{project.containerImage}", + "env_outputs_volumes_project_0": "{env.outputs.volumes_project_0}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.VolumesAndBindMountsAreTranslation.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.VolumesAndBindMountsAreTranslation.verified.bicep index 8748fd4ac18..26f365e179e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.VolumesAndBindMountsAreTranslation.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.VolumesAndBindMountsAreTranslation.verified.bicep @@ -24,6 +24,12 @@ resource api 'Microsoft.App/containerApps@2025-07-01' = { { image: 'myimage:latest' name: 'api' + env: [ + { + name: 'DATA_PATH' + value: '/path1' + } + ] volumeMounts: [ { volumeName: 'v0' diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 85542715773..f09d5cfe477 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -3740,6 +3740,7 @@ type CSharpAppResource interface { WithUrlForEndpoint(endpointName string, callback func(obj *ResourceUrlAnnotation)) CSharpAppResource WithUrls(callback func(obj ResourceUrlsCallbackContext)) CSharpAppResource WithValidator(validator func(arg TestResourceContext) bool) CSharpAppResource + WithVolume(target string, name string, env string, options ...*ProjectResourceWithVolumeOptions) CSharpAppResource WithoutHttpsCertificate() CSharpAppResource Err() error } @@ -5375,6 +5376,27 @@ func (s *cSharpAppResource) WithValidator(validator func(arg TestResourceContext return s } +// WithVolume adds a volume to a project resource. +func (s *cSharpAppResource) WithVolume(target string, name string, env string, options ...*ProjectResourceWithVolumeOptions) CSharpAppResource { + if s.err != nil { return s } + ctx := context.Background() + reqArgs := map[string]any{ + "resource": s.handle.ToJSON(), + } + reqArgs["target"] = serializeValue(target) + reqArgs["name"] = serializeValue(name) + reqArgs["env"] = serializeValue(env) + if len(options) > 0 { + merged := &ProjectResourceWithVolumeOptions{} + for _, opt := range options { + if opt != nil { merged = deepUpdate(merged, opt) } + } + for k, v := range merged.ToMap() { reqArgs[k] = v } + } + if _, err := s.client.invokeCapability(ctx, "Aspire.Hosting/withProjectVolume", reqArgs); err != nil { s.setErr(err) } + return s +} + // WithoutHttpsCertificate disable HTTPS/TLS server certificate configuration for the resource. No HTTPS/TLS termination configuration will be applied. func (s *cSharpAppResource) WithoutHttpsCertificate() CSharpAppResource { if s.err != nil { return s } @@ -11290,6 +11312,7 @@ type DotnetToolResource interface { WithUrlForEndpoint(endpointName string, callback func(obj *ResourceUrlAnnotation)) DotnetToolResource WithUrls(callback func(obj ResourceUrlsCallbackContext)) DotnetToolResource WithValidator(validator func(arg TestResourceContext) bool) DotnetToolResource + WithVolume(target string, name string, env string, options ...*ExecutableResourceWithVolumeOptions) DotnetToolResource WithWorkingDirectory(workingDirectory string) DotnetToolResource WithoutHttpsCertificate() DotnetToolResource Err() error @@ -12951,6 +12974,27 @@ func (s *dotnetToolResource) WithValidator(validator func(arg TestResourceContex return s } +// WithVolume adds a volume to an executable resource. +func (s *dotnetToolResource) WithVolume(target string, name string, env string, options ...*ExecutableResourceWithVolumeOptions) DotnetToolResource { + if s.err != nil { return s } + ctx := context.Background() + reqArgs := map[string]any{ + "resource": s.handle.ToJSON(), + } + reqArgs["target"] = serializeValue(target) + reqArgs["name"] = serializeValue(name) + reqArgs["env"] = serializeValue(env) + if len(options) > 0 { + merged := &ExecutableResourceWithVolumeOptions{} + for _, opt := range options { + if opt != nil { merged = deepUpdate(merged, opt) } + } + for k, v := range merged.ToMap() { reqArgs[k] = v } + } + if _, err := s.client.invokeCapability(ctx, "Aspire.Hosting/withExecutableVolume", reqArgs); err != nil { s.setErr(err) } + return s +} + // WithWorkingDirectory sets the working directory for the executable resource. func (s *dotnetToolResource) WithWorkingDirectory(workingDirectory string) DotnetToolResource { if s.err != nil { return s } @@ -14086,6 +14130,7 @@ type ExecutableResource interface { WithUrlForEndpoint(endpointName string, callback func(obj *ResourceUrlAnnotation)) ExecutableResource WithUrls(callback func(obj ResourceUrlsCallbackContext)) ExecutableResource WithValidator(validator func(arg TestResourceContext) bool) ExecutableResource + WithVolume(target string, name string, env string, options ...*ExecutableResourceWithVolumeOptions) ExecutableResource WithWorkingDirectory(workingDirectory string) ExecutableResource WithoutHttpsCertificate() ExecutableResource Err() error @@ -15678,6 +15723,27 @@ func (s *executableResource) WithValidator(validator func(arg TestResourceContex return s } +// WithVolume adds a volume to an executable resource. +func (s *executableResource) WithVolume(target string, name string, env string, options ...*ExecutableResourceWithVolumeOptions) ExecutableResource { + if s.err != nil { return s } + ctx := context.Background() + reqArgs := map[string]any{ + "resource": s.handle.ToJSON(), + } + reqArgs["target"] = serializeValue(target) + reqArgs["name"] = serializeValue(name) + reqArgs["env"] = serializeValue(env) + if len(options) > 0 { + merged := &ExecutableResourceWithVolumeOptions{} + for _, opt := range options { + if opt != nil { merged = deepUpdate(merged, opt) } + } + for k, v := range merged.ToMap() { reqArgs[k] = v } + } + if _, err := s.client.invokeCapability(ctx, "Aspire.Hosting/withExecutableVolume", reqArgs); err != nil { s.setErr(err) } + return s +} + // WithWorkingDirectory sets the working directory for the executable resource. func (s *executableResource) WithWorkingDirectory(workingDirectory string) ExecutableResource { if s.err != nil { return s } @@ -20476,6 +20542,7 @@ type ProjectResource interface { WithUrlForEndpoint(endpointName string, callback func(obj *ResourceUrlAnnotation)) ProjectResource WithUrls(callback func(obj ResourceUrlsCallbackContext)) ProjectResource WithValidator(validator func(arg TestResourceContext) bool) ProjectResource + WithVolume(target string, name string, env string, options ...*ProjectResourceWithVolumeOptions) ProjectResource WithoutHttpsCertificate() ProjectResource Err() error } @@ -22111,6 +22178,27 @@ func (s *projectResource) WithValidator(validator func(arg TestResourceContext) return s } +// WithVolume adds a volume to a project resource. +func (s *projectResource) WithVolume(target string, name string, env string, options ...*ProjectResourceWithVolumeOptions) ProjectResource { + if s.err != nil { return s } + ctx := context.Background() + reqArgs := map[string]any{ + "resource": s.handle.ToJSON(), + } + reqArgs["target"] = serializeValue(target) + reqArgs["name"] = serializeValue(name) + reqArgs["env"] = serializeValue(env) + if len(options) > 0 { + merged := &ProjectResourceWithVolumeOptions{} + for _, opt := range options { + if opt != nil { merged = deepUpdate(merged, opt) } + } + for k, v := range merged.ToMap() { reqArgs[k] = v } + } + if _, err := s.client.invokeCapability(ctx, "Aspire.Hosting/withProjectVolume", reqArgs); err != nil { s.setErr(err) } + return s +} + // WithoutHttpsCertificate disable HTTPS/TLS server certificate configuration for the resource. No HTTPS/TLS termination configuration will be applied. func (s *projectResource) WithoutHttpsCertificate() ProjectResource { if s.err != nil { return s } @@ -29195,6 +29283,30 @@ func (o *WithVolumeOptions) ToMap() map[string]any { return m } +// ProjectResourceWithVolumeOptions carries optional parameters for WithVolume. +type ProjectResourceWithVolumeOptions struct { + IsReadOnly *bool `json:"isReadOnly,omitempty"` +} + +func (o *ProjectResourceWithVolumeOptions) ToMap() map[string]any { + m := map[string]any{} + if o == nil { return m } + if o.IsReadOnly != nil { m["isReadOnly"] = serializeValue(o.IsReadOnly) } + return m +} + +// ExecutableResourceWithVolumeOptions carries optional parameters for WithVolume. +type ExecutableResourceWithVolumeOptions struct { + IsReadOnly *bool `json:"isReadOnly,omitempty"` +} + +func (o *ExecutableResourceWithVolumeOptions) ToMap() map[string]any { + m := map[string]any{} + if o == nil { return m } + if o.IsReadOnly != nil { m["isReadOnly"] = serializeValue(o.IsReadOnly) } + return m +} + // ArgOptions carries optional parameters for Arg. type ArgOptions struct { DefaultValue *string `json:"defaultValue,omitempty"` diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 6738d5a5c1a..2fed82cae31 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -2881,6 +2881,24 @@ public CSharpAppResource withPipelineConfiguration(AspireAction1 reqArgs = new HashMap<>(); + reqArgs.put("resource", AspireClient.serializeValue(getHandle())); + reqArgs.put("target", AspireClient.serializeValue(target)); + reqArgs.put("name", AspireClient.serializeValue(name)); + reqArgs.put("env", AspireClient.serializeValue(env)); + if (isReadOnly != null) { + reqArgs.put("isReadOnly", AspireClient.serializeValue(isReadOnly)); + } + getClient().invokeCapability("Aspire.Hosting/withProjectVolume", reqArgs); + return this; + } + /** Gets the name of the resource from a builder. */ public String getResourceName() { Map reqArgs = new HashMap<>(); @@ -9669,6 +9687,24 @@ public DotnetToolResource withPipelineConfiguration(AspireAction1 reqArgs = new HashMap<>(); + reqArgs.put("resource", AspireClient.serializeValue(getHandle())); + reqArgs.put("target", AspireClient.serializeValue(target)); + reqArgs.put("name", AspireClient.serializeValue(name)); + reqArgs.put("env", AspireClient.serializeValue(env)); + if (isReadOnly != null) { + reqArgs.put("isReadOnly", AspireClient.serializeValue(isReadOnly)); + } + getClient().invokeCapability("Aspire.Hosting/withExecutableVolume", reqArgs); + return this; + } + /** Gets the name of the resource from a builder. */ public String getResourceName() { Map reqArgs = new HashMap<>(); @@ -11980,6 +12016,24 @@ public ExecutableResource withPipelineConfiguration(AspireAction1 reqArgs = new HashMap<>(); + reqArgs.put("resource", AspireClient.serializeValue(getHandle())); + reqArgs.put("target", AspireClient.serializeValue(target)); + reqArgs.put("name", AspireClient.serializeValue(name)); + reqArgs.put("env", AspireClient.serializeValue(env)); + if (isReadOnly != null) { + reqArgs.put("isReadOnly", AspireClient.serializeValue(isReadOnly)); + } + getClient().invokeCapability("Aspire.Hosting/withExecutableVolume", reqArgs); + return this; + } + /** Gets the name of the resource from a builder. */ public String getResourceName() { Map reqArgs = new HashMap<>(); @@ -19960,6 +20014,24 @@ public ProjectResource withPipelineConfiguration(AspireAction1 reqArgs = new HashMap<>(); + reqArgs.put("resource", AspireClient.serializeValue(getHandle())); + reqArgs.put("target", AspireClient.serializeValue(target)); + reqArgs.put("name", AspireClient.serializeValue(name)); + reqArgs.put("env", AspireClient.serializeValue(env)); + if (isReadOnly != null) { + reqArgs.put("isReadOnly", AspireClient.serializeValue(isReadOnly)); + } + getClient().invokeCapability("Aspire.Hosting/withProjectVolume", reqArgs); + return this; + } + /** Gets the name of the resource from a builder. */ public String getResourceName() { Map reqArgs = new HashMap<>(); diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 1a69ee9c55a..264638c936b 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1751,6 +1751,20 @@ class VolumeParameters(typing.TypedDict, total=False): is_read_only: bool +class ProjectVolumeParameters(typing.TypedDict, total=False): + target: typing.Required[str] + name: typing.Required[str] + env: typing.Required[str] + is_read_only: bool + + +class ExecutableVolumeParameters(typing.TypedDict, total=False): + target: typing.Required[str] + name: typing.Required[str] + env: typing.Required[str] + is_read_only: bool + + class DataVolumeParameters(typing.TypedDict, total=False): name: str is_read_only: bool @@ -10344,6 +10358,7 @@ class ProjectResourceKwargs(_BaseResourceKwargs, total=False): image_push_options: typing.Callable[[ContainerImagePushOptionsCallbackContext], None] remote_image_name: str remote_image_tag: str + volume: tuple[str, str, str] | ProjectVolumeParameters endpoints_in_env: typing.Iterable[str] on_resource_endpoints_allocated: typing.Callable[[ResourceEndpointsAllocatedEvent], None] test_with_env_callback: typing.Callable[[TestEnvironmentContext], None] @@ -10843,6 +10858,21 @@ def with_remote_image_tag(self, remote_image_tag: str) -> typing.Self: self._handle = self._wrap_builder(result) return self + def with_volume(self, target: str, name: str, env: str, *, is_read_only: bool = False) -> typing.Self: + """Adds a volume to a project resource.""" + rpc_args: dict[str, typing.Any] = {'resource': self._handle} + rpc_args['target'] = target + rpc_args['name'] = name + rpc_args['env'] = env + if is_read_only is not None: + rpc_args['isReadOnly'] = is_read_only + result = self._client.invoke_capability( + 'Aspire.Hosting/withProjectVolume', + rpc_args, + ) + self._handle = self._wrap_builder(result) + return self + def with_endpoints_in_env(self, endpoint_names: typing.Iterable[str]) -> typing.Self: """Includes only the specified project endpoint names in environment-variable injection.""" rpc_args: dict[str, typing.Any] = {'resource': self._handle} @@ -11239,6 +11269,22 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withRemoteImageTag', rpc_args)) else: raise TypeError("Invalid type for option 'remote_image_tag'. Expected: str") + if _volume := kwargs.pop("volume", None): + if _validate_tuple_types(_volume, (str, str, str)): + rpc_args: dict[str, typing.Any] = {"resource": handle} + rpc_args["target"] = typing.cast(tuple[str, str, str], _volume)[0] + rpc_args["name"] = typing.cast(tuple[str, str, str], _volume)[1] + rpc_args["env"] = typing.cast(tuple[str, str, str], _volume)[2] + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withProjectVolume', rpc_args)) + elif _validate_dict_types(_volume, ProjectVolumeParameters): + rpc_args: dict[str, typing.Any] = {"resource": handle} + rpc_args["target"] = typing.cast(ProjectVolumeParameters, _volume)["target"] + rpc_args["name"] = typing.cast(ProjectVolumeParameters, _volume)["name"] + rpc_args["env"] = typing.cast(ProjectVolumeParameters, _volume)["env"] + rpc_args["isReadOnly"] = typing.cast(ProjectVolumeParameters, _volume).get("is_read_only") + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withProjectVolume', rpc_args)) + else: + raise TypeError("Invalid type for option 'volume'. Expected: (str, str, str) or ProjectVolumeParameters") if _endpoints_in_env := kwargs.pop("endpoints_in_env", None): if _validate_type(_endpoints_in_env, typing.Iterable[str]): rpc_args: dict[str, typing.Any] = {"resource": handle} @@ -11322,6 +11368,7 @@ class ExecutableResourceKwargs(_BaseResourceKwargs, total=False): image_push_options: typing.Callable[[ContainerImagePushOptionsCallbackContext], None] remote_image_name: str remote_image_tag: str + volume: tuple[str, str, str] | ExecutableVolumeParameters on_resource_endpoints_allocated: typing.Callable[[ResourceEndpointsAllocatedEvent], None] test_with_env_callback: typing.Callable[[TestEnvironmentContext], None] env_vars: typing.Mapping[str, str] @@ -11808,6 +11855,21 @@ def with_remote_image_tag(self, remote_image_tag: str) -> typing.Self: self._handle = self._wrap_builder(result) return self + def with_volume(self, target: str, name: str, env: str, *, is_read_only: bool = False) -> typing.Self: + """Adds a volume to an executable resource.""" + rpc_args: dict[str, typing.Any] = {'resource': self._handle} + rpc_args['target'] = target + rpc_args['name'] = name + rpc_args['env'] = env + if is_read_only is not None: + rpc_args['isReadOnly'] = is_read_only + result = self._client.invoke_capability( + 'Aspire.Hosting/withExecutableVolume', + rpc_args, + ) + self._handle = self._wrap_builder(result) + return self + def on_resource_endpoints_allocated(self, callback: typing.Callable[[ResourceEndpointsAllocatedEvent], None]) -> typing.Self: """Subscribes to the ResourceEndpointsAllocated event.""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} @@ -12183,6 +12245,22 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withRemoteImageTag', rpc_args)) else: raise TypeError("Invalid type for option 'remote_image_tag'. Expected: str") + if _volume := kwargs.pop("volume", None): + if _validate_tuple_types(_volume, (str, str, str)): + rpc_args: dict[str, typing.Any] = {"resource": handle} + rpc_args["target"] = typing.cast(tuple[str, str, str], _volume)[0] + rpc_args["name"] = typing.cast(tuple[str, str, str], _volume)[1] + rpc_args["env"] = typing.cast(tuple[str, str, str], _volume)[2] + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withExecutableVolume', rpc_args)) + elif _validate_dict_types(_volume, ExecutableVolumeParameters): + rpc_args: dict[str, typing.Any] = {"resource": handle} + rpc_args["target"] = typing.cast(ExecutableVolumeParameters, _volume)["target"] + rpc_args["name"] = typing.cast(ExecutableVolumeParameters, _volume)["name"] + rpc_args["env"] = typing.cast(ExecutableVolumeParameters, _volume)["env"] + rpc_args["isReadOnly"] = typing.cast(ExecutableVolumeParameters, _volume).get("is_read_only") + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withExecutableVolume', rpc_args)) + else: + raise TypeError("Invalid type for option 'volume'. Expected: (str, str, str) or ExecutableVolumeParameters") if _on_resource_endpoints_allocated := kwargs.pop("on_resource_endpoints_allocated", None): if _validate_type(_on_resource_endpoints_allocated, typing.Callable[[ResourceEndpointsAllocatedEvent], None]): rpc_args: dict[str, typing.Any] = {"builder": handle} diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 9b5e35db48c..5f4b3df9a73 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -3130,6 +3130,21 @@ impl CSharpAppResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds a volume to a project resource. + pub fn with_volume(&self, target: &str, name: &str, env: &str, is_read_only: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("resource".to_string(), self.handle.to_json()); + args.insert("target".to_string(), serde_json::to_value(&target).unwrap_or(Value::Null)); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + args.insert("env".to_string(), serde_json::to_value(&env).unwrap_or(Value::Null)); + if let Some(ref v) = is_read_only { + args.insert("isReadOnly".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withProjectVolume", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ProjectResource::new(handle, self.client.clone())) + } + /// Gets the name of the resource from a builder. pub fn get_resource_name(&self) -> Result> { let mut args: HashMap = HashMap::new(); @@ -7851,6 +7866,21 @@ impl DotnetToolResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds a volume to an executable resource. + pub fn with_volume(&self, target: &str, name: &str, env: &str, is_read_only: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("resource".to_string(), self.handle.to_json()); + args.insert("target".to_string(), serde_json::to_value(&target).unwrap_or(Value::Null)); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + args.insert("env".to_string(), serde_json::to_value(&env).unwrap_or(Value::Null)); + if let Some(ref v) = is_read_only { + args.insert("isReadOnly".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withExecutableVolume", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ExecutableResource::new(handle, self.client.clone())) + } + /// Gets the name of the resource from a builder. pub fn get_resource_name(&self) -> Result> { let mut args: HashMap = HashMap::new(); @@ -9687,6 +9717,21 @@ impl ExecutableResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds a volume to an executable resource. + pub fn with_volume(&self, target: &str, name: &str, env: &str, is_read_only: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("resource".to_string(), self.handle.to_json()); + args.insert("target".to_string(), serde_json::to_value(&target).unwrap_or(Value::Null)); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + args.insert("env".to_string(), serde_json::to_value(&env).unwrap_or(Value::Null)); + if let Some(ref v) = is_read_only { + args.insert("isReadOnly".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withExecutableVolume", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ExecutableResource::new(handle, self.client.clone())) + } + /// Gets the name of the resource from a builder. pub fn get_resource_name(&self) -> Result> { let mut args: HashMap = HashMap::new(); @@ -15557,6 +15602,21 @@ impl ProjectResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds a volume to a project resource. + pub fn with_volume(&self, target: &str, name: &str, env: &str, is_read_only: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("resource".to_string(), self.handle.to_json()); + args.insert("target".to_string(), serde_json::to_value(&target).unwrap_or(Value::Null)); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + args.insert("env".to_string(), serde_json::to_value(&env).unwrap_or(Value::Null)); + if let Some(ref v) = is_read_only { + args.insert("isReadOnly".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withProjectVolume", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ProjectResource::new(handle, self.client.clone())) + } + /// Gets the name of the resource from a builder. pub fn get_resource_name(&self) -> Result> { let mut args: HashMap = HashMap::new(); diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj index 6dfc6bf2fd3..3aad7b94753 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs index 780e8abd4ea..0de651b41f4 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREBROWSERLOGS001 // Type is for evaluation purposes only +#pragma warning disable ASPIRECOMPUTE002 using System.Reflection; using Aspire.Hosting.Azure; @@ -931,10 +932,43 @@ public void BugFix_TargetParameterName_WithVolumeUsesResource() Assert.NotNull(withVolume); Assert.Equal("resource", withVolume.TargetParameterName); - - // Verify correct parameter order: target comes first (required), then name (optional) - Assert.Equal("target", withVolume.Parameters[0].Name); - Assert.Equal("name", withVolume.Parameters[1].Name); + Assert.Equal("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource", withVolume.TargetTypeId); + Assert.False(withVolume.TargetType?.IsInterface); + + // Preserve the exported parameter list exactly. The Rust generator emits optional capability + // parameters positionally and Rust has no overloading, so appending a parameter here would be + // a source-breaking change for existing Rust AppHosts. A container always receives `target` as + // its effective volume path, so the C# `env` convenience parameter is intentionally not + // exported: polyglot callers use withEnvironment(env, target) for the same result. + Assert.Equal( + ["target", "name", "isReadOnly"], + withVolume.Parameters.Select(parameter => parameter.Name)); + + var withProjectVolume = Assert.Single( + capabilities, + capability => capability.CapabilityId == "Aspire.Hosting/withProjectVolume"); + Assert.Equal("withVolume", withProjectVolume.MethodName); + Assert.Equal("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource", withProjectVolume.TargetTypeId); + Assert.False(withProjectVolume.TargetType?.IsInterface); + + // Projects and executables compute their run-mode path in the host, so `name` and `env` are + // required rather than optional. Modelling them as optional would generate polyglot APIs that + // type-check but always fail at runtime. + Assert.Equal( + ["target", "name", "env", "isReadOnly"], + withProjectVolume.Parameters.Select(parameter => parameter.Name)); + Assert.False(withProjectVolume.Parameters[1].IsOptional); + Assert.False(withProjectVolume.Parameters[2].IsOptional); + + var withExecutableVolume = Assert.Single( + capabilities, + capability => capability.CapabilityId == "Aspire.Hosting/withExecutableVolume"); + Assert.Equal("withVolume", withExecutableVolume.MethodName); + Assert.Equal("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource", withExecutableVolume.TargetTypeId); + Assert.False(withExecutableVolume.TargetType?.IsInterface); + Assert.Equal( + ["target", "name", "env", "isReadOnly"], + withExecutableVolume.Parameters.Select(parameter => parameter.Name)); // Note: withBindMount still uses "builder" - it hasn't been moved to CoreExports yet var withBindMount = capabilities @@ -951,6 +985,30 @@ public void BugFix_TargetParameterName_WithVolumeUsesResource() Assert.Equal("builder", withCommand.TargetParameterName); } + [Fact] + public void Generate_KubernetesPersistentVolumeMount_UsesOptionsObject() + { + var scanResult = AtsCapabilityScanner.ScanAssemblies( + [ + typeof(DistributedApplication).Assembly, + typeof(global::Aspire.Hosting.Kubernetes.KubernetesPersistentVolumeResource).Assembly + ]); + var files = _generator.GenerateDistributedApplication(scanResult.ToAtsContext()); + var generatedCode = files["aspire.mts"]; + + Assert.Contains("export interface WithKubernetesPersistentVolumeMountOptions", generatedCode); + Assert.Contains("isReadOnly?: boolean;", generatedCode); + Assert.Contains("env?: string;", generatedCode); + Assert.Contains( + generatedCode.Split('\n'), + line => line.Contains( + "withKubernetesPersistentVolumeMount(", + StringComparison.Ordinal) && + line.Contains( + "options?: WithKubernetesPersistentVolumeMountOptions", + StringComparison.Ordinal)); + } + // ===== 2-Pass Scanning / Cross-Assembly Expansion Tests ===== [Fact] diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index d4a6ce5bcc3..df4eb553cae 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -1775,6 +1775,11 @@ export interface WithEndpointOptions { protocol?: ProtocolType; } +export interface WithExecutableVolumeOptions { + /** Whether the published volume is read-only. */ + isReadOnly?: boolean; +} + export interface WithHiddenOnCompletionOptions { /** The completion exit code to treat as successful. Defaults to `0`. */ exitCode?: number; @@ -1897,6 +1902,11 @@ export interface WithPipelineStepFactoryOptions { description?: string; } +export interface WithProjectVolumeOptions { + /** Whether the published volume is read-only. */ + isReadOnly?: boolean; +} + export interface WithReferenceOptions { connectionName?: string; optional?: boolean; @@ -21919,6 +21929,15 @@ export interface CSharpAppResource { * @returns The resource builder for chaining. */ withPipelineConfiguration(callback: (obj: PipelineConfigurationContext) => Promise): CSharpAppResourcePromise; + /** + * Adds a volume to a project resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same project resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithProjectVolumeOptions): CSharpAppResourcePromise; /** * Gets the name of the resource from a builder. * @@ -22543,6 +22562,15 @@ export interface CSharpAppResourcePromise extends PromiseLike * @returns The resource builder for chaining. */ withPipelineConfiguration(callback: (obj: PipelineConfigurationContext) => Promise): CSharpAppResourcePromise; + /** + * Adds a volume to a project resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same project resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithProjectVolumeOptions): CSharpAppResourcePromise; /** * Gets the name of the resource from a builder. * @@ -24350,6 +24378,30 @@ class CSharpAppResourceImpl extends ResourceBuilderBase return new CSharpAppResourcePromiseImpl(this._withPipelineConfigurationInternal(callback), this._client); } + /** @internal */ + private async _withVolumeInternal(target: string, name: string, env: string, isReadOnly?: boolean): Promise { + const rpcArgs: Record = { resource: this._handle, target, name, env }; + if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withProjectVolume', + rpcArgs + ); + return new CSharpAppResourceImpl(result, this._client); + } + + /** + * Adds a volume to a project resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same project resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithProjectVolumeOptions): CSharpAppResourcePromise { + const isReadOnly = options?.isReadOnly; + return new CSharpAppResourcePromiseImpl(this._withVolumeInternal(target, name, env, isReadOnly), this._client); + } + /** * Gets the name of the resource from a builder. * @@ -25232,6 +25284,10 @@ class CSharpAppResourcePromiseImpl implements CSharpAppResourcePromise { return new CSharpAppResourcePromiseImpl(this._promise.then(obj => obj.withPipelineConfiguration(callback)), this._client); } + withVolume(target: string, name: string, env: string, options?: WithProjectVolumeOptions): CSharpAppResourcePromise { + return new CSharpAppResourcePromiseImpl(this._promise.then(obj => obj.withVolume(target, name, env, options)), this._client); + } + getResourceName(): Promise { return this._promise.then(obj => obj.getResourceName()); } @@ -25905,6 +25961,15 @@ export interface DotnetToolResource { * @returns The resource builder for chaining. */ withPipelineConfiguration(callback: (obj: PipelineConfigurationContext) => Promise): DotnetToolResourcePromise; + /** + * Adds a volume to an executable resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same executable resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithExecutableVolumeOptions): DotnetToolResourcePromise; /** * Gets the name of the resource from a builder. * @@ -26551,6 +26616,15 @@ export interface DotnetToolResourcePromise extends PromiseLike Promise): DotnetToolResourcePromise; + /** + * Adds a volume to an executable resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same executable resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithExecutableVolumeOptions): DotnetToolResourcePromise; /** * Gets the name of the resource from a builder. * @@ -28442,6 +28516,30 @@ class DotnetToolResourceImpl extends ResourceBuilderBase { + const rpcArgs: Record = { resource: this._handle, target, name, env }; + if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExecutableVolume', + rpcArgs + ); + return new DotnetToolResourceImpl(result, this._client); + } + + /** + * Adds a volume to an executable resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same executable resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithExecutableVolumeOptions): DotnetToolResourcePromise { + const isReadOnly = options?.isReadOnly; + return new DotnetToolResourcePromiseImpl(this._withVolumeInternal(target, name, env, isReadOnly), this._client); + } + /** * Gets the name of the resource from a builder. * @@ -29325,6 +29423,10 @@ class DotnetToolResourcePromiseImpl implements DotnetToolResourcePromise { return new DotnetToolResourcePromiseImpl(this._promise.then(obj => obj.withPipelineConfiguration(callback)), this._client); } + withVolume(target: string, name: string, env: string, options?: WithExecutableVolumeOptions): DotnetToolResourcePromise { + return new DotnetToolResourcePromiseImpl(this._promise.then(obj => obj.withVolume(target, name, env, options)), this._client); + } + getResourceName(): Promise { return this._promise.then(obj => obj.getResourceName()); } @@ -29968,6 +30070,15 @@ export interface ExecutableResource { * @returns The resource builder for chaining. */ withPipelineConfiguration(callback: (obj: PipelineConfigurationContext) => Promise): ExecutableResourcePromise; + /** + * Adds a volume to an executable resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same executable resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithExecutableVolumeOptions): ExecutableResourcePromise; /** * Gets the name of the resource from a builder. * @@ -30581,6 +30692,15 @@ export interface ExecutableResourcePromise extends PromiseLike Promise): ExecutableResourcePromise; + /** + * Adds a volume to an executable resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same executable resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithExecutableVolumeOptions): ExecutableResourcePromise; /** * Gets the name of the resource from a builder. * @@ -32368,6 +32488,30 @@ class ExecutableResourceImpl extends ResourceBuilderBase { + const rpcArgs: Record = { resource: this._handle, target, name, env }; + if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExecutableVolume', + rpcArgs + ); + return new ExecutableResourceImpl(result, this._client); + } + + /** + * Adds a volume to an executable resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same executable resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithExecutableVolumeOptions): ExecutableResourcePromise { + const isReadOnly = options?.isReadOnly; + return new ExecutableResourcePromiseImpl(this._withVolumeInternal(target, name, env, isReadOnly), this._client); + } + /** * Gets the name of the resource from a builder. * @@ -33227,6 +33371,10 @@ class ExecutableResourcePromiseImpl implements ExecutableResourcePromise { return new ExecutableResourcePromiseImpl(this._promise.then(obj => obj.withPipelineConfiguration(callback)), this._client); } + withVolume(target: string, name: string, env: string, options?: WithExecutableVolumeOptions): ExecutableResourcePromise { + return new ExecutableResourcePromiseImpl(this._promise.then(obj => obj.withVolume(target, name, env, options)), this._client); + } + getResourceName(): Promise { return this._promise.then(obj => obj.getResourceName()); } @@ -38300,6 +38448,15 @@ export interface ProjectResource { * @returns The resource builder for chaining. */ withPipelineConfiguration(callback: (obj: PipelineConfigurationContext) => Promise): ProjectResourcePromise; + /** + * Adds a volume to a project resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same project resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithProjectVolumeOptions): ProjectResourcePromise; /** * Gets the name of the resource from a builder. * @@ -38924,6 +39081,15 @@ export interface ProjectResourcePromise extends PromiseLike { * @returns The resource builder for chaining. */ withPipelineConfiguration(callback: (obj: PipelineConfigurationContext) => Promise): ProjectResourcePromise; + /** + * Adds a volume to a project resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same project resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithProjectVolumeOptions): ProjectResourcePromise; /** * Gets the name of the resource from a builder. * @@ -40732,6 +40898,30 @@ class ProjectResourceImpl extends ResourceBuilderBase imp return new ProjectResourcePromiseImpl(this._withPipelineConfigurationInternal(callback), this._client); } + /** @internal */ + private async _withVolumeInternal(target: string, name: string, env: string, isReadOnly?: boolean): Promise { + const rpcArgs: Record = { resource: this._handle, target, name, env }; + if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withProjectVolume', + rpcArgs + ); + return new ProjectResourceImpl(result, this._client); + } + + /** + * Adds a volume to a project resource. + * @param target The mount path inside the published container. + * @param name The volume name. + * @param env The environment variable that receives the effective volume path. + * @param options Additional options. + * @returns The same project resource builder handle for chaining. + */ + withVolume(target: string, name: string, env: string, options?: WithProjectVolumeOptions): ProjectResourcePromise { + const isReadOnly = options?.isReadOnly; + return new ProjectResourcePromiseImpl(this._withVolumeInternal(target, name, env, isReadOnly), this._client); + } + /** * Gets the name of the resource from a builder. * @@ -41614,6 +41804,10 @@ class ProjectResourcePromiseImpl implements ProjectResourcePromise { return new ProjectResourcePromiseImpl(this._promise.then(obj => obj.withPipelineConfiguration(callback)), this._client); } + withVolume(target: string, name: string, env: string, options?: WithProjectVolumeOptions): ProjectResourcePromise { + return new ProjectResourcePromiseImpl(this._promise.then(obj => obj.withVolume(target, name, env, options)), this._client); + } + getResourceName(): Promise { return this._promise.then(obj => obj.getResourceName()); } diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 9f846ba2051..b54c549b4a5 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -907,7 +907,7 @@ public async Task PublishAsync_MixedBindMountsAndVolumes() // Add a container with both bind mounts and volumes builder.AddContainer("my-service", "my-image") - .WithVolume("my-volume", "/container/volume-data") + .WithVolume("my-volume", "/container/volume-data", env: "DATA_PATH") .WithBindMount("/host/path/data", "/container/bind-data") .WithBindMount("/var/run/docker.sock", "/var/run/docker.sock"); @@ -923,6 +923,31 @@ await Verify(File.ReadAllText(composePath), "yaml") .AppendContentAsFile(File.ReadAllText(envPath), "env"); } + [Fact] + public async Task PublishAsync_ProjectAndExecutableVolumesIncludeEnvironmentPaths() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + builder.Services.AddSingleton(); + + builder.AddDockerComposeEnvironment("docker-compose") + .WithDashboard(false); + + builder.AddProject("project", launchProfileName: null) + .WithVolume("project-data", "/srv/project", env: "DATA_PATH"); + builder.AddExecutable("executable", "node", ".") + .PublishAsDockerFile() + .WithVolume("executable-data", "/srv/executable", env: "DATA_PATH"); + + var app = builder.Build(); + app.Run(); + + var composePath = Path.Combine(workspace.Path, "docker-compose.yaml"); + Assert.True(File.Exists(composePath)); + + await Verify(File.ReadAllText(composePath), "yaml"); + } + [Fact] public async Task PublishAsync_ConfigureEnvFile_AllowsMutatingCapturedEnvVars() { diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_MixedBindMountsAndVolumes.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_MixedBindMountsAndVolumes.verified.yaml index c7d6cd1e998..b18596b7337 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_MixedBindMountsAndVolumes.verified.yaml +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_MixedBindMountsAndVolumes.verified.yaml @@ -1,6 +1,8 @@ services: my-service: image: "my-image:latest" + environment: + DATA_PATH: "/container/volume-data" volumes: - type: "volume" target: "/container/volume-data" diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_ProjectAndExecutableVolumesIncludeEnvironmentPaths.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_ProjectAndExecutableVolumesIncludeEnvironmentPaths.verified.yaml new file mode 100644 index 00000000000..7b7c63a0640 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_ProjectAndExecutableVolumesIncludeEnvironmentPaths.verified.yaml @@ -0,0 +1,32 @@ +services: + project: + image: "${PROJECT_IMAGE}" + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + DATA_PATH: "/srv/project" + volumes: + - type: "volume" + target: "/srv/project" + source: "project-data" + read_only: false + networks: + - "aspire" + executable: + image: "${EXECUTABLE_IMAGE}" + environment: + DATA_PATH: "/srv/executable" + volumes: + - type: "volume" + target: "/srv/executable" + source: "executable-data" + read_only: false + networks: + - "aspire" +networks: + aspire: + driver: "bridge" +volumes: + project-data: + driver: "local" + executable-data: + driver: "local" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs index fc3f58afd6a..4b487fff363 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs @@ -196,6 +196,28 @@ public async Task MultipleComputeEnvironmentsOnlyProcessTargetedResources() Assert.Null(projectDockerResource.GetDeploymentTargetAnnotation(kubernetes.Resource)); } + [Fact] + public async Task KubernetesPersistentVolumeCannotTargetDockerCompose() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var kubernetes = builder.AddKubernetesEnvironment("kubernetes"); + var dockerCompose = builder.AddDockerComposeEnvironment("docker-compose"); + var volume = kubernetes.AddPersistentVolume("data"); + + builder.AddProject("project", launchProfileName: null) + .WithComputeEnvironment(dockerCompose) + .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH"); + + using var app = builder.Build(); + var exception = await Assert.ThrowsAsync( + () => ExecuteBeforeStartHooksAsync(app, CancellationToken.None)); + + Assert.Contains("project", exception.Message); + Assert.Contains("docker-compose", exception.Message); + Assert.Contains("data", exception.Message); + Assert.Contains("kubernetes", exception.Message); + } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPersistentVolumeRunModeTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPersistentVolumeRunModeTests.cs new file mode 100644 index 00000000000..cfdc41282f5 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPersistentVolumeRunModeTests.cs @@ -0,0 +1,276 @@ +// 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 ASPIRECOMPUTE002 + +using System.Runtime.CompilerServices; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Kubernetes.Tests; + +public class KubernetesPersistentVolumeRunModeTests +{ + [Fact] + public async Task ProjectAndExecutableUseSharedAspireStoreDirectory() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var kubernetes = builder.AddKubernetesEnvironment("env"); + var volume = kubernetes.AddPersistentVolume("data"); + + var project = builder.AddProject("project", launchProfileName: null) + .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH"); + var executable = builder.AddExecutable("executable", "test-command", ".") + .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH"); + + using var app = builder.Build(); + var store = app.Services.GetRequiredService(); + var expectedPath = KubernetesPersistentVolumeLocalStorage.GetPath(store, volume.Resource); + + Assert.False(Directory.Exists(expectedPath)); + + var projectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + project.Resource, + serviceProvider: app.Services); + + Assert.Equal(expectedPath, projectEnvironment["DATA_PATH"]); + Assert.True(Directory.Exists(expectedPath)); + + var executableEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + executable.Resource, + serviceProvider: app.Services); + + Assert.Equal(expectedPath, executableEnvironment["DATA_PATH"]); + } + + [Fact] + public async Task DifferentVolumesUseDifferentAspireStoreDirectories() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var kubernetes = builder.AddKubernetesEnvironment("env"); + var firstVolume = kubernetes.AddPersistentVolume("first"); + var secondVolume = kubernetes.AddPersistentVolume("second"); + + var firstProject = builder.AddProject("first-project", launchProfileName: null) + .WithPersistentVolume(firstVolume, "/srv/data", env: "DATA_PATH"); + var secondProject = builder.AddProject("second-project", launchProfileName: null) + .WithPersistentVolume(secondVolume, "/srv/data", env: "DATA_PATH"); + + using var app = builder.Build(); + var firstEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + firstProject.Resource, + serviceProvider: app.Services); + var secondEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + secondProject.Resource, + serviceProvider: app.Services); + + Assert.NotEqual(firstEnvironment["DATA_PATH"], secondEnvironment["DATA_PATH"]); + } + + [Fact] + public async Task SameNamedVolumesInDifferentEnvironmentsUseDifferentAspireStoreDirectories() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var firstEnvironment = builder.AddKubernetesEnvironment("first-env"); + var secondEnvironment = builder.AddKubernetesEnvironment("second-env"); + var firstVolume = firstEnvironment.AddPersistentVolume("data"); + var secondVolume = secondEnvironment.AddPersistentVolume("data"); + + var firstProject = builder.AddProject("first-project", launchProfileName: null) + .WithPersistentVolume(firstVolume, "/srv/data", env: "DATA_PATH"); + var secondProject = builder.AddProject("second-project", launchProfileName: null) + .WithPersistentVolume(secondVolume, "/srv/data", env: "DATA_PATH"); + + using var app = builder.Build(); + var firstProjectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + firstProject.Resource, + serviceProvider: app.Services); + var secondProjectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + secondProject.Resource, + serviceProvider: app.Services); + + Assert.NotEqual(firstProjectEnvironment["DATA_PATH"], secondProjectEnvironment["DATA_PATH"]); + } + + [Fact] + public async Task ContainerUsesScopedVolumeAndContainerMountPath() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var kubernetes = builder.AddKubernetesEnvironment("env"); + var volume = kubernetes.AddPersistentVolume("data"); + + var firstContainer = builder.AddContainer("first", "image") + .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH"); + var secondContainer = builder.AddContainer("second", "image") + .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH"); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + + var expectedVolumeName = VolumeNameGenerator.Generate(volume, "kubernetes-env"); + var firstMount = Assert.Single(firstContainer.Resource.Annotations.OfType()); + var secondMount = Assert.Single(secondContainer.Resource.Annotations.OfType()); + + Assert.Equal(expectedVolumeName, firstMount.Source); + Assert.Equal(expectedVolumeName, secondMount.Source); + + var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + firstContainer.Resource, + serviceProvider: app.Services); + + Assert.Equal("/srv/data", environment["DATA_PATH"]); + } + + [Fact] + public async Task NameMatchBindingScopesExistingContainerVolume() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var kubernetes = builder.AddKubernetesEnvironment("env"); + var volume = kubernetes.AddPersistentVolume("data"); + + var container = builder.AddContainer("container", "image") + .WithVolume("data", "/srv/data") + .WithPersistentVolume(volume); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + + var mount = Assert.Single(container.Resource.Annotations.OfType()); + Assert.Equal(VolumeNameGenerator.Generate(volume, "kubernetes-env"), mount.Source); + } + + [Fact] + public async Task NameMatchBindingIsIndependentOfBuilderOrder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var kubernetes = builder.AddKubernetesEnvironment("env"); + var volume = kubernetes.AddPersistentVolume("data"); + + var container = builder.AddContainer("container", "image") + .WithPersistentVolume(volume) + .WithVolume("data", "/srv/data"); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + + var mount = Assert.Single(container.Resource.Annotations.OfType()); + Assert.Equal(VolumeNameGenerator.Generate(volume, "kubernetes-env"), mount.Source); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NameMatchBindingUsesSharedPersistentVolumePathForHostProcesses(bool bindBeforeVolume) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var kubernetes = builder.AddKubernetesEnvironment("env"); + var volume = kubernetes.AddPersistentVolume("data"); + var project = builder.AddProject("project", launchProfileName: null); + + if (bindBeforeVolume) + { + project.WithPersistentVolume(volume) + .WithVolume("data", "/srv/data", env: "DATA_PATH"); + } + else + { + project.WithVolume("data", "/srv/data", env: "DATA_PATH") + .WithPersistentVolume(volume); + } + + using var app = builder.Build(); + var store = app.Services.GetRequiredService(); + var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + project.Resource, + serviceProvider: app.Services); + + Assert.Equal( + KubernetesPersistentVolumeLocalStorage.GetPath(store, volume.Resource), + environment["DATA_PATH"]); + } + + [Fact] + public async Task SameNamedVolumesInDifferentEnvironmentsUseDifferentContainerVolumes() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var firstEnvironment = builder.AddKubernetesEnvironment("first-env"); + var secondEnvironment = builder.AddKubernetesEnvironment("second-env"); + var firstVolume = firstEnvironment.AddPersistentVolume("data"); + var secondVolume = secondEnvironment.AddPersistentVolume("data"); + + var firstContainer = builder.AddContainer("first", "image") + .WithPersistentVolume(firstVolume, "/srv/data"); + var secondContainer = builder.AddContainer("second", "image") + .WithPersistentVolume(secondVolume, "/srv/data"); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + + var firstMount = Assert.Single(firstContainer.Resource.Annotations.OfType()); + var secondMount = Assert.Single(secondContainer.Resource.Annotations.OfType()); + + Assert.NotEqual(firstMount.Source, secondMount.Source); + } + + [Fact] + public void ExistingPersistentVolumeOverloadStillAcceptsPositionalDefault() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var kubernetes = builder.AddKubernetesEnvironment("env"); + var volume = kubernetes.AddPersistentVolume("data"); + + var container = builder.AddContainer("container", "image") + .WithPersistentVolume(volume, "/srv/data", default); + + var mount = Assert.Single(container.Resource.Annotations.OfType()); + Assert.False(mount.IsReadOnly); + } + + [Fact] + public async Task MixedContainerAndExecutableConsumersAreRejected() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var kubernetes = builder.AddKubernetesEnvironment("env"); + var volume = kubernetes.AddPersistentVolume("data"); + + builder.AddExecutable("executable", "test-command", ".") + .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH"); + builder.AddContainer("container", "image") + .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH"); + + using var app = builder.Build(); + var exception = await Assert.ThrowsAsync( + () => ExecuteBeforeStartHooksAsync(app, CancellationToken.None)); + + Assert.Contains("both local container and host-process resources", exception.Message); + Assert.Contains("data", exception.Message); + Assert.Contains("executable", exception.Message); + Assert.Contains("container", exception.Message); + } + + [Fact] + public async Task PublishOnlyBindingsAllowMixedContainerAndProjectConsumers() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var kubernetes = builder.AddKubernetesEnvironment("env"); + var volume = kubernetes.AddPersistentVolume("data"); + + // Neither binding asks for the run-mode environment path, so the project materializes no + // local backing store and cannot conflict with the container's named volume. This shape + // predates the env overload and must keep working. + builder.AddProject("project", launchProfileName: null) + .WithPersistentVolume(volume, "/srv/data"); + builder.AddContainer("container", "image") + .WithPersistentVolume(volume, "/srv/data"); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + } + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] + private static extern Task ExecuteBeforeStartHooksAsync( + DistributedApplication app, + CancellationToken cancellationToken); +} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 70276c6c08e..c1b1bff2b9d 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -6,6 +6,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Kubernetes.Resources; using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; using YamlDotNet.RepresentationModel; using YamlDotNet.Serialization; @@ -696,6 +697,50 @@ public async Task KubernetesMapsPortsForBaitAndSwitchResources() await settingsTask; } + [Fact] + public async Task PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + builder.AddKubernetesEnvironment("env"); + + builder.AddProject("project", launchProfileName: null) + .WithVolume("project-data", "/srv/project", env: "DATA_PATH"); + builder.AddExecutable("executable", "node", ".") + .PublishAsDockerFile() + .WithVolume("executable-data", "/srv/executable", env: "DATA_PATH"); + + var app = builder.Build(); + app.Run(); + + var expectedFiles = new[] + { + "templates/project/config.yaml", + "templates/project/deployment.yaml", + "templates/executable/config.yaml", + "templates/executable/deployment.yaml", + "values.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(workspace.Path, expectedFile); + Assert.True(File.Exists(filePath), $"Expected publisher to emit {expectedFile}."); + + var content = await File.ReadAllTextAsync(filePath); + AssertNoBuggyEmptyMappings(content); + + settingsTask = settingsTask is null + ? Verify(content, "yaml") + : settingsTask.AppendContentAsFile(content, "yaml"); + } + + await settingsTask; + } + [Fact] public async Task KubernetesTreatsZeroPublicPortAsUnspecified() { @@ -1354,6 +1399,105 @@ public async Task PublishAsync_WithFirstClassPersistentVolume_KubernetesCustomiz await Verify(content, "yaml"); } + [Fact] + public async Task PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + var kubernetes = builder.AddKubernetesEnvironment("env"); + var volume = kubernetes.AddPersistentVolume("media") + .WithStorageClass("azurefile-csi") + .WithCapacity("100Gi") + .WithAccessMode(PersistentVolumeAccessMode.ReadWriteMany); + + builder.AddProject("api", launchProfileName: null) + .WithPersistentVolume(volume, "/srv/media", env: "MEDIA_PATH"); + + var app = builder.Build(); + var store = app.Services.GetRequiredService(); + var localPath = KubernetesPersistentVolumeLocalStorage.GetPath(store, volume.Resource); + + app.Run(); + + Assert.False(Directory.Exists(localPath)); + + var expectedFiles = new[] + { + "templates/api/config.yaml", + "templates/api/statefulset.yaml", + "templates/media/media.yaml", + "values.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(workspace.Path, expectedFile); + Assert.True(File.Exists(filePath), $"Expected publisher to emit {expectedFile}."); + + var content = await File.ReadAllTextAsync(filePath); + AssertNoBuggyEmptyMappings(content); + + settingsTask = settingsTask is null + ? Verify(content, "yaml") + : settingsTask.AppendContentAsFile(content, "yaml"); + } + + await settingsTask; + } + + [Fact] + public async Task PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + var kubernetes = builder.AddKubernetesEnvironment("env"); + var containerVolume = kubernetes.AddPersistentVolume("container-data") + .WithCapacity("1Gi"); + var executableVolume = kubernetes.AddPersistentVolume("executable-data") + .WithCapacity("1Gi"); + + builder.AddContainer("container", "nginx") + .WithPersistentVolume(containerVolume, "/srv/container", env: "DATA_PATH"); + builder.AddExecutable("executable", "node", ".") + .PublishAsDockerFile() + .WithPersistentVolume(executableVolume, "/srv/executable", env: "DATA_PATH"); + + var app = builder.Build(); + app.Run(); + + var expectedFiles = new[] + { + "templates/container/config.yaml", + "templates/container/statefulset.yaml", + "templates/container-data/container-data.yaml", + "templates/executable/config.yaml", + "templates/executable/statefulset.yaml", + "templates/executable-data/executable-data.yaml", + "values.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(workspace.Path, expectedFile); + Assert.True(File.Exists(filePath), $"Expected publisher to emit {expectedFile}."); + + var content = await File.ReadAllTextAsync(filePath); + AssertNoBuggyEmptyMappings(content); + + settingsTask = settingsTask is null + ? Verify(content, "yaml") + : settingsTask.AppendContentAsFile(content, "yaml"); + } + + await settingsTask; + } + [Fact] public async Task PublishAsync_WithFirstClassPersistentVolume_FallsThroughForUnboundVolumes() { @@ -1415,7 +1559,7 @@ public async Task PublishAsync_WithFirstClassPersistentVolume_ThrowsWhenBoundAcr .WithPersistentVolume(data); var app = builder.Build(); - var ex = await Assert.ThrowsAsync(() => app.RunAsync()); + var ex = await Assert.ThrowsAsync(() => app.RunAsync()); Assert.Contains("service", ex.Message); Assert.Contains("envA", ex.Message); Assert.Contains("envB", ex.Message); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#00.verified.yaml new file mode 100644 index 00000000000..036df2a16f7 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#00.verified.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "project-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "project" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "{{ .Values.config.project.OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY }}" + DATA_PATH: "{{ .Values.config.project.DATA_PATH }}" + OTEL_EXPORTER_OTLP_ENDPOINT: "{{ .Values.config.project.OTEL_EXPORTER_OTLP_ENDPOINT }}" + OTEL_EXPORTER_OTLP_PROTOCOL: "{{ .Values.config.project.OTEL_EXPORTER_OTLP_PROTOCOL }}" + OTEL_SERVICE_NAME: "{{ .Values.config.project.OTEL_SERVICE_NAME }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#01.verified.yaml new file mode 100644 index 00000000000..4282597ae55 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#01.verified.yaml @@ -0,0 +1,42 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "project-deployment" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "project" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "project" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "{{ .Values.parameters.project.project_image }}" + name: "project" + envFrom: + - configMapRef: + name: "project-config" + volumeMounts: + - name: "project-data" + mountPath: "/srv/project" + imagePullPolicy: "IfNotPresent" + volumes: + - name: "project-data" + emptyDir: {} + selector: + matchLabels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "project" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#02.verified.yaml new file mode 100644 index 00000000000..0a37ab4916e --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#02.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "executable-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "executable" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + DATA_PATH: "{{ .Values.config.executable.DATA_PATH }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#03.verified.yaml new file mode 100644 index 00000000000..e74b035dc96 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#03.verified.yaml @@ -0,0 +1,42 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "executable-deployment" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "executable" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "executable" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "{{ .Values.parameters.executable.executable_image }}" + name: "executable" + envFrom: + - configMapRef: + name: "executable-config" + volumeMounts: + - name: "executable-data" + mountPath: "/srv/executable" + imagePullPolicy: "IfNotPresent" + volumes: + - name: "executable-data" + emptyDir: {} + selector: + matchLabels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "executable" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#04.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#04.verified.yaml new file mode 100644 index 00000000000..e8afd251a2c --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ProjectAndExecutableVolumesUseDefaultStorageAndEnvironmentPaths#04.verified.yaml @@ -0,0 +1,15 @@ +parameters: + project: + project_image: "project:latest" + executable: + executable_image: "executable:latest" +secrets: {} +config: + project: + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + DATA_PATH: "/srv/project" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard-service:18889" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_SERVICE_NAME: "project" + executable: + DATA_PATH: "/srv/executable" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#00.verified.yaml new file mode 100644 index 00000000000..bfee4686e83 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#00.verified.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "api-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "{{ .Values.config.api.OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY }}" + MEDIA_PATH: "{{ .Values.config.api.MEDIA_PATH }}" + OTEL_EXPORTER_OTLP_ENDPOINT: "{{ .Values.config.api.OTEL_EXPORTER_OTLP_ENDPOINT }}" + OTEL_EXPORTER_OTLP_PROTOCOL: "{{ .Values.config.api.OTEL_EXPORTER_OTLP_PROTOCOL }}" + OTEL_SERVICE_NAME: "{{ .Values.config.api.OTEL_SERVICE_NAME }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#01.verified.yaml new file mode 100644 index 00000000000..f50530f73d7 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#01.verified.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: "apps/v1" +kind: "StatefulSet" +metadata: + name: "api-statefulset" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "{{ .Values.parameters.api.api_image }}" + name: "api" + envFrom: + - configMapRef: + name: "api-config" + volumeMounts: + - name: "media" + mountPath: "/srv/media" + imagePullPolicy: "IfNotPresent" + volumes: + - name: "media" + persistentVolumeClaim: + claimName: "media" + securityContext: + fsGroup: 2000 + fsGroupChangePolicy: "OnRootMismatch" + selector: + matchLabels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#02.verified.yaml new file mode 100644 index 00000000000..26dc5b4013a --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#02.verified.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: "v1" +kind: "PersistentVolumeClaim" +metadata: + name: "media" +spec: + storageClassName: "azurefile-csi" + accessModes: + - "ReadWriteMany" + resources: + requests: + storage: "100Gi" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#03.verified.yaml new file mode 100644 index 00000000000..a049b6d49d4 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithFirstClassPersistentVolume_EnvironmentUsesDeploymentMountPath#03.verified.yaml @@ -0,0 +1,11 @@ +parameters: + api: + api_image: "api:latest" +secrets: {} +config: + api: + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + MEDIA_PATH: "/srv/media" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard-service:18889" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_SERVICE_NAME: "api" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#00.verified.yaml new file mode 100644 index 00000000000..06db6fe5d99 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#00.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "container-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "container" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + DATA_PATH: "{{ .Values.config.container.DATA_PATH }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#01.verified.yaml new file mode 100644 index 00000000000..2980ff5760a --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#01.verified.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: "apps/v1" +kind: "StatefulSet" +metadata: + name: "container-statefulset" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "container" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "container" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "nginx:latest" + name: "container" + envFrom: + - configMapRef: + name: "container-config" + volumeMounts: + - name: "container-data" + mountPath: "/srv/container" + imagePullPolicy: "IfNotPresent" + volumes: + - name: "container-data" + persistentVolumeClaim: + claimName: "container-data" + securityContext: + fsGroup: 2000 + fsGroupChangePolicy: "OnRootMismatch" + selector: + matchLabels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "container" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#02.verified.yaml new file mode 100644 index 00000000000..24ead1724fb --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#02.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "PersistentVolumeClaim" +metadata: + name: "container-data" +spec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: "1Gi" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#03.verified.yaml new file mode 100644 index 00000000000..0a37ab4916e --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#03.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "executable-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "executable" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + DATA_PATH: "{{ .Values.config.executable.DATA_PATH }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#04.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#04.verified.yaml new file mode 100644 index 00000000000..04c56d72f73 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#04.verified.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: "apps/v1" +kind: "StatefulSet" +metadata: + name: "executable-statefulset" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "executable" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "executable" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "{{ .Values.parameters.executable.executable_image }}" + name: "executable" + envFrom: + - configMapRef: + name: "executable-config" + volumeMounts: + - name: "executable-data" + mountPath: "/srv/executable" + imagePullPolicy: "IfNotPresent" + volumes: + - name: "executable-data" + persistentVolumeClaim: + claimName: "executable-data" + securityContext: + fsGroup: 2000 + fsGroupChangePolicy: "OnRootMismatch" + selector: + matchLabels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "executable" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#05.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#05.verified.yaml new file mode 100644 index 00000000000..ef3bbdd5034 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#05.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "PersistentVolumeClaim" +metadata: + name: "executable-data" +spec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: "1Gi" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#06.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#06.verified.yaml new file mode 100644 index 00000000000..0a1247477e2 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_WithPersistentVolumeEnvironment_OnContainerAndExecutable#06.verified.yaml @@ -0,0 +1,9 @@ +parameters: + executable: + executable_image: "executable:latest" +secrets: {} +config: + container: + DATA_PATH: "/srv/container" + executable: + DATA_PATH: "/srv/executable" diff --git a/tests/Aspire.Hosting.Tests/WithVolumeTests.cs b/tests/Aspire.Hosting.Tests/WithVolumeTests.cs new file mode 100644 index 00000000000..840dae1cc4f --- /dev/null +++ b/tests/Aspire.Hosting.Tests/WithVolumeTests.cs @@ -0,0 +1,234 @@ +// 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 ASPIREPERSISTENCE001 + +using Aspire.Hosting.Dcp.Model; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Tests; + +public class WithVolumeTests(ITestOutputHelper outputHelper) +{ + [Theory] + [InlineData(DistributedApplicationOperation.Run)] + [InlineData(DistributedApplicationOperation.Publish)] + public async Task WithVolumeEnvironmentUsesContainerMountPath(DistributedApplicationOperation operation) + { + using var builder = TestDistributedApplicationBuilder.Create(operation); + var container = builder.AddContainer("container", "image") + .WithVolume("data", "/srv/data", env: "DATA_PATH", isReadOnly: true); + + using var app = builder.Build(); + var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + container.Resource, + operation, + app.Services); + + Assert.Equal("/srv/data", environment["DATA_PATH"]); + + var mount = Assert.Single(container.Resource.Annotations.OfType()); + Assert.Equal("data", mount.Source); + Assert.Equal("/srv/data", mount.Target); + Assert.True(mount.IsReadOnly); + } + + [Fact] + public async Task WithVolumeEnvironmentUsesWorkloadScopedPathsForProjectAndExecutable() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var project = builder.AddProject("project", launchProfileName: null) + .WithVolume("data", "/srv/data", env: "DATA_PATH"); + var executable = builder.AddExecutable("executable", "test-command", ".") + .WithVolume("data", "/srv/data", env: "DATA_PATH"); + + using var app = builder.Build(); + var store = app.Services.GetRequiredService(); + var expectedProjectPath = VolumeMountPathResolver.GetLocalPath(store, project.Resource, "data"); + var expectedExecutablePath = VolumeMountPathResolver.GetLocalPath(store, executable.Resource, "data"); + + Assert.False(Directory.Exists(expectedProjectPath)); + Assert.False(Directory.Exists(expectedExecutablePath)); + + var projectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + project.Resource, + serviceProvider: app.Services); + var executableEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + executable.Resource, + serviceProvider: app.Services); + + Assert.Equal(expectedProjectPath, projectEnvironment["DATA_PATH"]); + Assert.Equal(expectedExecutablePath, executableEnvironment["DATA_PATH"]); + Assert.True(Directory.Exists(expectedProjectPath)); + Assert.True(Directory.Exists(expectedExecutablePath)); + } + + [Fact] + public async Task WithVolumeEnvironmentUsesMountPathForPublishedProjectAndExecutable() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var project = builder.AddProject("project", launchProfileName: null) + .WithVolume("data", "/srv/project", env: "DATA_PATH"); + var executable = builder.AddExecutable("executable", "test-command", ".") + .WithVolume("data", "/srv/executable", env: "DATA_PATH"); + + using var app = builder.Build(); + var projectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + project.Resource, + DistributedApplicationOperation.Publish, + app.Services); + var executableEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + executable.Resource, + DistributedApplicationOperation.Publish, + app.Services); + + Assert.Equal("/srv/project", projectEnvironment["DATA_PATH"]); + Assert.Equal("/srv/executable", executableEnvironment["DATA_PATH"]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void WithVolumeEnvironmentValidatesName(string? env) + { + using var builder = TestDistributedApplicationBuilder.Create(); + var container = builder.AddContainer("container", "image"); + + var exception = Assert.ThrowsAny(() => + container.WithVolume("data", "/srv/data", env!)); + + Assert.Equal(nameof(env), exception.ParamName); + } + + [Fact] + public void WithVolumeEnvironmentRequiresNameForProject() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var project = builder.AddProject("project", launchProfileName: null); + string name = null!; + + var exception = Assert.ThrowsAny(() => + VolumeResourceBuilderExtensions.WithVolume(project, name, "/srv/data", env: "DATA_PATH")); + + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void ExistingContainerOverloadStillAcceptsPositionalDefault() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var container = builder.AddContainer("container", "image") + .WithVolume("data", "/srv/data", default); + + var mount = Assert.Single(container.Resource.Annotations.OfType()); + Assert.False(mount.IsReadOnly); + } + + [Fact] + public async Task WithVolumeEnvironmentKeepsDistinctFilesystemSafeIdentities() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var executable = builder.AddExecutable("executable", "test-command", ".") + .WithVolume("Data", "/srv/upper", env: "UPPER_PATH") + .WithVolume("data", "/srv/lower", env: "LOWER_PATH") + .WithVolume("../escape", "/srv/escape", env: "ESCAPE_PATH"); + + using var app = builder.Build(); + var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + executable.Resource, + serviceProvider: app.Services); + + Assert.NotEqual(environment["UPPER_PATH"], environment["LOWER_PATH"]); + + var storePrefix = Path.GetFullPath(app.Services.GetRequiredService().BasePath) + Path.DirectorySeparatorChar; + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + Assert.All( + ["UPPER_PATH", "LOWER_PATH", "ESCAPE_PATH"], + name => Assert.StartsWith(storePrefix, environment[name], comparison)); + } + + [Fact] + public async Task WithVolumeEnvironmentReusesLocalPathAcrossAppHostRunsAndLifetimes() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var sessionPath = await GetVolumePathAsync(usePersistentLifetime: false); + var markerPath = Path.Combine(sessionPath, "marker.txt"); + await File.WriteAllTextAsync(markerPath, "persisted"); + + var persistentPath = await GetVolumePathAsync(usePersistentLifetime: true); + + Assert.Equal(sessionPath, persistentPath); + Assert.Equal("persisted", await File.ReadAllTextAsync(Path.Combine(persistentPath, "marker.txt"))); + + async Task GetVolumePathAsync(bool usePersistentLifetime) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + builder.Configuration[AspireStore.AspireStorePathKeyName] = workspace.Path; + + var executable = builder.AddExecutable("worker", "test-command", ".") + .WithVolume("data", "/srv/data", env: "DATA_PATH"); + if (usePersistentLifetime) + { + executable.WithPersistentLifetime(); + } + else + { + executable.WithSessionLifetime(); + } + + using var app = builder.Build(); + var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + executable.Resource, + serviceProvider: app.Services); + return environment["DATA_PATH"]; + } + } + + [Fact] + public void NamedContainerVolumeIdentityAndPersistenceAreIndependentOfContainerLifetime() + { + using var sessionBuilder = TestDistributedApplicationBuilder.Create(); + var sessionContainer = sessionBuilder.AddContainer("session", "image") + .WithSessionLifetime() + .WithVolume("shared-data", "/srv/data"); + + using var persistentBuilder = TestDistributedApplicationBuilder.Create(); + var persistentContainer = persistentBuilder.AddContainer("persistent", "image") + .WithPersistentLifetime() + .WithVolume("shared-data", "/srv/data"); + + var sessionMount = Assert.Single(sessionContainer.Resource.Annotations.OfType()); + var persistentMount = Assert.Single(persistentContainer.Resource.Annotations.OfType()); + Assert.Equal("shared-data", sessionMount.Source); + Assert.Equal(sessionMount.Source, persistentMount.Source); + + var dcpVolume = ContainerVolume.Create("shared-data-resource", sessionMount.Source!); + Assert.True(dcpVolume.Spec.Persistent); + } + + [Fact] + public async Task WithVolumeEnvironmentUsesStorePathForCustomComputeResources() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var custom = builder.AddResource(new TestComputeResource("custom")) + .WithVolume("data", "/srv/data", env: "DATA_PATH"); + + using var app = builder.Build(); + var store = app.Services.GetRequiredService(); + var expectedPath = VolumeMountPathResolver.GetLocalPath(store, custom.Resource, "data"); + + var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + custom.Resource, + serviceProvider: app.Services); + + Assert.Equal(expectedPath, environment["DATA_PATH"]); + } + + private sealed class TestComputeResource(string name) : Resource(name), IComputeResource, IResourceWithEnvironment + { + } +}