Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public static class AzureKubernetesPersistentVolumeExtensions
/// .WithCapacity("20Gi");
///
/// builder.AddProject<Projects.Api>("api")
/// .WithPersistentVolume(data, "/data");
/// .WithPersistentVolume(data, "/data", env: "DATA_PATH");
/// </code>
/// </example>
[AspireExport]
Expand Down
8 changes: 5 additions & 3 deletions src/Aspire.Hosting.Azure.Kubernetes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -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

Expand Down
75 changes: 69 additions & 6 deletions src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ private sealed record OptionVariation(
List<AtsParameterInfo> OptionalParameters,
string? Experimental);

private sealed record ParameterMappingSignature(string[] RequiredParameters, string[] OptionalParameters);

/// <summary>
/// Tracks the alternate capability ID for merged capabilities.
/// Key: the merged capability ID (the "short" one without the extra param).
Expand All @@ -131,6 +133,8 @@ private sealed record MergedCapabilityDispatch(string AlternateCapabilityId, str

private PythonModuleBuilder _moduleBuilder = null!;

private readonly Dictionary<string, ParameterMappingSignature> _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<string, string> _wrapperClassNames = new(StringComparer.Ordinal);
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Generates the aspire.py SDK file with capability-based API.
/// </summary>
private string GenerateAspireSdk(AtsContext context)
{
_moduleBuilder = new PythonModuleBuilder();
_parameterMappingSignatures.Clear();

var capabilities = context.Capabilities;
var dtoTypes = context.DtoTypes;
Expand Down Expand Up @@ -2354,7 +2367,6 @@ private List<OptionVariation> 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<OptionVariation>();

Expand Down Expand Up @@ -2383,7 +2395,7 @@ private List<OptionVariation> 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));
}
Expand All @@ -2392,7 +2404,7 @@ private List<OptionVariation> 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));
}
Expand All @@ -2403,7 +2415,7 @@ private List<OptionVariation> CreateOptionVariations(
}
else
{
AddParameterMapping(parameterMappingName, requiredParameters, optionalParameters);
var parameterMappingName = AddParameterMapping(capability, requiredParameters, optionalParameters);
if (requiredParameters.Count == 0)
{
variations.Add(new OptionVariation(parameterMappingName, requiredParameters, optionalParameters, experimental));
Expand All @@ -2418,12 +2430,14 @@ private List<OptionVariation> CreateOptionVariations(
return variations;
}

private void AddParameterMapping(string methodName, List<AtsParameterInfo> requiredParameters, List<AtsParameterInfo> optionalParameters)
private string AddParameterMapping(AtsCapabilityInfo capability, List<AtsParameterInfo> requiredParameters, List<AtsParameterInfo> 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):");
Expand All @@ -2436,6 +2450,55 @@ private void AddParameterMapping(string methodName, List<AtsParameterInfo> 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<AtsParameterInfo> requiredParameters, List<AtsParameterInfo> 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<AtsParameterInfo> requiredParameters, List<AtsParameterInfo> 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)}")]);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
/// 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.
Expand Down Expand Up @@ -1456,8 +1463,7 @@ private static bool TryGetDirectOptionsParameter(List<AtsParameterInfo> optional
/// <summary>
/// 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.
/// </summary>
private void RegisterOptionsInterface(string capabilityId, string methodName, List<AtsParameterInfo> optionalParams)
{
Expand All @@ -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
Expand All @@ -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<string>(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
{
Expand All @@ -1524,6 +1514,42 @@ private void RegisterOptionsInterface(string capabilityId, string methodName, Li
}
}

private void RegisterDisambiguatedOptionsInterface(string capabilityId, string capabilityName, List<AtsParameterInfo> 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;
}
}
}

/// <summary>
/// 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).
Expand All @@ -1547,6 +1573,23 @@ private static bool AreOptionsCompatible(List<AtsParameterInfo> existing, List<A
return true;
}

private static bool AreOptionsExactMatch(List<AtsParameterInfo> existing, List<AtsParameterInfo> 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;
}

/// <summary>
/// Checks whether two parameter infos have the same type (including callback types).
/// </summary>
Expand Down
20 changes: 20 additions & 0 deletions src/Aspire.Hosting.Docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Projects.Api>("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
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,25 @@ namespace Aspire.Hosting.Kubernetes.Annotations;
/// promote the workload to a <c>StatefulSet</c>.
/// </summary>
/// <param name="volume">The persistent volume resource the workload binds to.</param>
internal sealed class KubernetesPersistentVolumeBindingAnnotation(KubernetesPersistentVolumeResource volume) : IResourceAnnotation
/// <param name="environmentVariableName">The environment variable that exposes the effective mount path, if configured.</param>
/// <param name="runModeContainerVolumeName">The worktree-scoped container volume name to apply in run mode, if required.</param>
internal sealed class KubernetesPersistentVolumeBindingAnnotation(
KubernetesPersistentVolumeResource volume,
string? environmentVariableName = null,
string? runModeContainerVolumeName = null) : IResourceAnnotation
{
/// <summary>
/// Gets the persistent volume resource bound to the workload.
/// </summary>
public KubernetesPersistentVolumeResource Volume { get; } = volume ?? throw new ArgumentNullException(nameof(volume));

/// <summary>
/// Gets the environment variable that exposes the effective mount path, if configured.
/// </summary>
public string? EnvironmentVariableName { get; } = environmentVariableName;

/// <summary>
/// Gets the worktree-scoped container volume name to apply in run mode, if required.
/// </summary>
public string? RunModeContainerVolumeName { get; } = runModeContainerVolumeName;
}
Loading
Loading