Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e9ef2d5
Initial plan
Copilot Oct 1, 2025
8c9c3b0
Add parameter name normalization support for dashes to underscores
Copilot Oct 1, 2025
37eff3c
Refine parameter normalization to only apply to default configuration…
Copilot Oct 1, 2025
372ab25
Simplify normalization logic to apply to all configuration keys
Copilot Oct 2, 2025
104c90a
Update src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs
davidfowl Oct 2, 2025
f032e64
Make GetParameterValue public and update OpenAI/GitHub Models to use …
Copilot Oct 2, 2025
bfa3c7c
Revert public API and duplicate normalization logic in extensions
Copilot Oct 2, 2025
87f81f5
Fix ParameterProcessor to skip GenerateParameterDefault exception for…
Copilot Oct 2, 2025
b6203f8
Add tests for GenerateParameterDefault behavior in publish mode
Copilot Oct 2, 2025
11c5361
Make GetParameterValue internal and use it in ParameterProcessor to d…
Copilot Oct 2, 2025
0d00e16
Apply suggestions from code review
davidfowl Oct 2, 2025
9bee1ff
Replace obsolete parameterResource.Value with GetValueAsync in tests
Copilot Oct 2, 2025
0b585a8
Fix GenerateParameterDefault tests to provide ServiceProvider with IC…
Copilot Oct 2, 2025
ee0c675
Update src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs
davidfowl Oct 2, 2025
308c7ea
Update src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs
davidfowl Oct 2, 2025
3d144f1
Move normalization logic to shared helper in IConfigurationExtensions
Copilot Oct 2, 2025
4d55aff
Use extension method syntax for GetValueWithNormalizedKey across all …
Copilot Oct 2, 2025
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 @@ -13,6 +13,10 @@
<Compile Remove="tools\**\*.cs" />
</ItemGroup>

<ItemGroup>
<Compile Include="$(SharedDir)IConfigurationExtensions.cs" Link="IConfigurationExtensions.cs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
</ItemGroup>
Expand Down
11 changes: 8 additions & 3 deletions src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ public static IResourceBuilder<GitHubModelResource> AddGitHubModel(this IDistrib
ArgumentException.ThrowIfNullOrEmpty(model);

var defaultApiKeyParameter = builder.AddParameter($"{name}-gh-apikey", () =>
builder.Configuration[$"Parameters:{name}-gh-apikey"] ??
Environment.GetEnvironmentVariable("GITHUB_TOKEN") ??
throw new MissingParameterValueException($"GitHub API key parameter '{name}-gh-apikey' is missing and GITHUB_TOKEN environment variable is not set."),
{
var configKey = $"Parameters:{name}-gh-apikey";
var value = builder.Configuration.GetValueWithNormalizedKey(configKey);

return value ??
Environment.GetEnvironmentVariable("GITHUB_TOKEN") ??
throw new MissingParameterValueException($"GitHub API key parameter '{name}-gh-apikey' is missing and GITHUB_TOKEN environment variable is not set.");
},
secret: true);
defaultApiKeyParameter.Resource.Description = """
The API key used to authenticate requests to the GitHub Models API.
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Hosting.OpenAI/Aspire.Hosting.OpenAI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
<SuppressFinalPackageVersion>true</SuppressFinalPackageVersion>
</PropertyGroup>

<ItemGroup>
<Compile Include="$(SharedDir)IConfigurationExtensions.cs" Link="IConfigurationExtensions.cs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
</ItemGroup>
Expand Down
11 changes: 8 additions & 3 deletions src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ public static IResourceBuilder<OpenAIResource> AddOpenAI(this IDistributedApplic
ArgumentException.ThrowIfNullOrEmpty(name);

var defaultApiKeyParameter = builder.AddParameter($"{name}-openai-apikey", () =>
builder.Configuration[$"Parameters:{name}-openai-apikey"] ??
Environment.GetEnvironmentVariable("OPENAI_API_KEY") ??
throw new MissingParameterValueException($"OpenAI API key parameter '{name}-openai-apikey' is missing and OPENAI_API_KEY environment variable is not set."),
{
var configKey = $"Parameters:{name}-openai-apikey";
var value = builder.Configuration.GetValueWithNormalizedKey(configKey);

return value ??
Environment.GetEnvironmentVariable("OPENAI_API_KEY") ??
throw new MissingParameterValueException($"OpenAI API key parameter '{name}-openai-apikey' is missing and OPENAI_API_KEY environment variable is not set.");
},
secret: true);
defaultApiKeyParameter.Resource.Description = """
The API key used to authenticate requests to the OpenAI API.
Expand Down
9 changes: 8 additions & 1 deletion src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
using Aspire.Dashboard.Model;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Resources;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.SecretManager.Tools.Internal;

Expand Down Expand Up @@ -163,9 +165,14 @@ private async Task ProcessParameterAsync(ParameterResource parameterResource)
{
var value = parameterResource.ValueInternal ?? "";

// Check if we need to validate GenerateParameterDefault in publish mode
// We use GetParameterValue to distinguish between configured values and generated values
// because ValueInternal might contain a generated value even if no configuration was provided.
if (parameterResource.Default is GenerateParameterDefault generateDefault && executionContext.IsPublishMode)
{
throw new MissingParameterValueException("GenerateParameterDefault is not supported in this context. Falling back to prompting.");
// Try to get a configured value (without using the default) to see if the parameter was actually specified. This will throw if the value is missing.
var configuration = executionContext.ServiceProvider.GetRequiredService<IConfiguration>();
value = ParameterResourceBuilderExtensions.GetParameterValue(configuration, parameterResource.Name, parameterDefault: null, parameterResource.ConfigurationKey);
}

await notificationService.PublishUpdateAsync(parameterResource, s =>
Expand Down
10 changes: 8 additions & 2 deletions src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,16 @@ public static IResourceBuilder<ParameterResource> WithCustomInput(this IResource
return builder;
}

private static string GetParameterValue(ConfigurationManager configuration, string name, ParameterDefault? parameterDefault, string? configurationKey = null)
// Internal to allow ParameterProcessor to check for configured values
// without triggering default value generation
internal static string GetParameterValue(IConfiguration configuration, string name, ParameterDefault? parameterDefault, string? configurationKey = null)
{
configurationKey ??= $"Parameters:{name}";
return configuration[configurationKey]

// Use the shared helper to get the value with normalization support
var value = configuration.GetValueWithNormalizedKey(configurationKey);

return value
?? parameterDefault?.GetDefaultValue()
?? throw new MissingParameterValueException($"Parameter resource could not be used because configuration key '{configurationKey}' is missing and the Parameter has no default value.");
}
Expand Down
26 changes: 26 additions & 0 deletions src/Shared/IConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,30 @@ public static T GetEnum<T>(this IConfiguration configuration, string key)

return value.Value;
}

/// <summary>
/// Gets a configuration value with support for dash-to-underscore normalization.
/// First tries the exact configuration key, then tries with dashes replaced by underscores.
/// </summary>
/// <remarks>
/// This supports command-line arguments and environment variables where dashes are replaced with underscores.
/// For example, a parameter named "my-param" can be resolved from configuration key "my_param".
/// </remarks>
/// <param name="configuration">The <see cref="IConfiguration"/> this method extends.</param>
/// <param name="configKey">The configuration key to look up.</param>
/// <returns>The configuration value, or <see langword="null"/> if not found.</returns>
public static string? GetValueWithNormalizedKey(this IConfiguration configuration, string configKey)
{
// First try to get the value with the exact configuration key
var value = configuration[configKey];

// If not found, try with underscores as a fallback
if (string.IsNullOrEmpty(value))
{
var normalizedKey = configKey.Replace("-", "_", StringComparison.Ordinal);
value = configuration[normalizedKey];
}

return value;
}
}
96 changes: 96 additions & 0 deletions tests/Aspire.Hosting.Tests/AddParameterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -500,4 +500,100 @@ public void ParameterWithCustomInput_AddsInputGeneratorAnnotation()
Assert.False(input.EnableDescriptionMarkdown);
}
#pragma warning restore ASPIREINTERACTION001

[Fact]
public async Task ParameterWithDashInName_CanBeResolvedWithUnderscoreInConfiguration()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();

// Configuration using underscore instead of dash (as would come from environment variables or command line)
appBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Parameters:storage_account_name"] = "test-storage-account"
});

// Act - parameter defined with dash
appBuilder.AddParameter("storage-account-name");

using var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var parameterResource = Assert.Single(appModel.Resources.OfType<ParameterResource>());

// Assert - should resolve to the value set with underscore
Assert.Equal("test-storage-account", await parameterResource.GetValueAsync(default));
}

[Fact]
public async Task ParameterWithDashInName_PrefersDashConfigurationOverUnderscore()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();

// Set both versions, dash version should take precedence
appBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Parameters:storage-account-name"] = "dash-value",
["Parameters:storage_account_name"] = "underscore-value"
});

// Act
appBuilder.AddParameter("storage-account-name");

using var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var parameterResource = Assert.Single(appModel.Resources.OfType<ParameterResource>());

// Assert - should prefer the exact match (with dash)
Assert.Equal("dash-value", await parameterResource.GetValueAsync(default));
}

[Fact]
public async Task ParameterWithoutDash_DoesNotFallbackToUnderscore()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();

// Set only underscore version for a parameter without dash
appBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Parameters:storage_name"] = "underscore-value"
});

// Act
appBuilder.AddParameter("storagename");

using var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var parameterResource = Assert.Single(appModel.Resources.OfType<ParameterResource>());

// Assert - should not find the value because names don't match
await Assert.ThrowsAsync<MissingParameterValueException>(async () =>
{
_ = await parameterResource.GetValueAsync(default);
});
}

[Fact]
public async Task ParameterWithCustomConfigurationKey_UsesFallback()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();

// Set configuration with custom key that has underscore
appBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["CustomSection:my_key"] = "custom-value"
});

// Act - use custom configuration key with dash
appBuilder.AddParameterFromConfiguration("my-param", "CustomSection:my-key");

using var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var parameterResource = Assert.Single(appModel.Resources.OfType<ParameterResource>());

// Assert - should find the value using the normalized key (dash -> underscore)
Assert.Equal("custom-value", await parameterResource.GetValueAsync(default));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -798,4 +798,77 @@ private static ParameterResource CreateParameterWithGenericError(string name)
{
return new ParameterResource(name, _ => throw new InvalidOperationException($"Generic error for parameter '{name}'"), secret: false);
}

[Fact]
public async Task InitializeParametersAsync_WithGenerateParameterDefaultInPublishMode_ThrowsWhenValueIsEmpty()
{
// Arrange
var configuration = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
var serviceProvider = services.BuildServiceProvider();

var executionContext = new DistributedApplicationExecutionContext(
new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish, "manifest")
{
ServiceProvider = serviceProvider
});

var interactionService = CreateInteractionService();
var parameterProcessor = CreateParameterProcessor(
interactionService: interactionService,
executionContext: executionContext);

var parameterWithGenerateDefault = new ParameterResource(
"generatedParam",
parameterDefault => parameterDefault?.GetDefaultValue() ?? throw new MissingParameterValueException("Parameter 'generatedParam' is missing"),
secret: false)
{
Default = new GenerateParameterDefault()
};

// Act
await parameterProcessor.InitializeParametersAsync([parameterWithGenerateDefault]);

// Assert - Should be added to unresolved parameters because GenerateParameterDefault is not supported in publish mode
Assert.NotNull(parameterWithGenerateDefault.WaitForValueTcs);
Assert.False(parameterWithGenerateDefault.WaitForValueTcs.Task.IsCompleted);
}

[Fact]
public async Task InitializeParametersAsync_WithGenerateParameterDefaultInPublishMode_DoesNotThrowWhenValueExists()
{
// Arrange
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Parameters:generatedParam"] = "existingValue" })
.Build();

var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
var serviceProvider = services.BuildServiceProvider();

var executionContext = new DistributedApplicationExecutionContext(
new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish, "manifest")
{
ServiceProvider = serviceProvider
});

var parameterProcessor = CreateParameterProcessor(executionContext: executionContext);

var parameterWithGenerateDefault = new ParameterResource(
"generatedParam",
parameterDefault => configuration["Parameters:generatedParam"] ?? parameterDefault?.GetDefaultValue() ?? throw new MissingParameterValueException("Parameter 'generatedParam' is missing"),
secret: false)
{
Default = new GenerateParameterDefault()
};

// Act
await parameterProcessor.InitializeParametersAsync([parameterWithGenerateDefault]);

// Assert - Should succeed because value exists in configuration
Assert.NotNull(parameterWithGenerateDefault.WaitForValueTcs);
Assert.True(parameterWithGenerateDefault.WaitForValueTcs.Task.IsCompletedSuccessfully);
Assert.Equal("existingValue", await parameterWithGenerateDefault.WaitForValueTcs.Task);
}
}
Loading