Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
171bae1
Initial plan
Copilot Nov 2, 2025
7d743a8
Implement user secrets refactoring with DI-based factory pattern
Copilot Nov 2, 2025
e4ed362
Update tests for user secrets refactoring
Copilot Nov 2, 2025
289c081
Address code review feedback
Copilot Nov 2, 2025
31f81ba
Move JsonFlattener tests to Aspire.Hosting.Tests
Copilot Nov 3, 2025
49f9fde
Return NoopUserSecretsManager instead of null from factory
Copilot Nov 3, 2025
20cf770
Remove FlattenJsonObject and UnflattenJsonObject from DeploymentState…
Copilot Nov 3, 2025
9542ddb
Simplify DI registration for IUserSecretsManager
Copilot Nov 3, 2025
079bed8
Remove unused TrySetSecretAsync method from IUserSecretsManager
Copilot Nov 3, 2025
0125171
Add "last-in wins" clarification to SaveStateAsync doc comments
Copilot Nov 3, 2025
0ba545c
Apply code review feedback - use ConcurrentDictionary and fix NoopUse…
Copilot Nov 3, 2025
217332f
Move semaphore dictionary into UserSecretsManager class
Copilot Nov 3, 2025
68071a4
Use single static semaphore instead of dictionary
Copilot Nov 3, 2025
691dfb4
Use instance semaphore per file path with lock-based factory
Copilot Nov 3, 2025
95cb316
Use instance semaphore with static dictionary for thread-safety
Copilot Nov 3, 2025
e23e126
Undo changes under Aspire.ProjectTemplates
Copilot Nov 3, 2025
f98196e
Fixes
davidfowl Nov 3, 2025
468fa94
Fix concurrent write tests by sharing semaphores across all instances
Copilot Nov 4, 2025
5f557de
Simplify concurrency - remove static Create methods, use isolated fac…
Copilot Nov 4, 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
17 changes: 14 additions & 3 deletions src/Aspire.Hosting/ApplicationModel/UserSecretsParameterDefault.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
using System.Diagnostics;
using System.Reflection;
using Aspire.Hosting.Publishing;
using Microsoft.Extensions.SecretManager.Tools.Internal;
using Aspire.Hosting.UserSecrets;

namespace Aspire.Hosting.ApplicationModel;

Expand All @@ -15,15 +15,26 @@ namespace Aspire.Hosting.ApplicationModel;
/// <param name="applicationName">The application name.</param>
/// <param name="parameterName">The parameter name.</param>
/// <param name="parameterDefault">The <see cref="ParameterDefault"/> that will produce the default value when it isn't found in the project's user secrets store.</param>
internal sealed class UserSecretsParameterDefault(Assembly appHostAssembly, string applicationName, string parameterName, ParameterDefault parameterDefault)
/// <param name="factory">The factory to use for creating user secrets managers.</param>
internal sealed class UserSecretsParameterDefault(Assembly appHostAssembly, string applicationName, string parameterName, ParameterDefault parameterDefault, UserSecretsManagerFactory factory)
: ParameterDefault
{
/// <summary>
/// Initializes a new instance of the <see cref="UserSecretsParameterDefault"/> class using the default factory.
/// </summary>
public UserSecretsParameterDefault(Assembly appHostAssembly, string applicationName, string parameterName, ParameterDefault parameterDefault)
: this(appHostAssembly, applicationName, parameterName, parameterDefault, UserSecretsManagerFactory.Instance)
{
}

/// <inheritdoc/>
public override string GetDefaultValue()
{
var value = parameterDefault.GetDefaultValue();
var configurationKey = $"Parameters:{parameterName}";
if (!SecretsStore.TrySetUserSecret(appHostAssembly, configurationKey, value))

var manager = factory.GetOrCreate(appHostAssembly);
if (!manager.TrySetSecret(configurationKey, value))
{
// This is a best-effort operation, so we don't throw if it fails. Common reason for failure is that the user secrets ID is not set
// in the application's assembly. Note there's no ILogger available this early in the application lifecycle.
Expand Down
1 change: 0 additions & 1 deletion src/Aspire.Hosting/Aspire.Hosting.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
<Compile Include="$(SharedDir)LoggingHelpers.cs" Link="Utils\LoggingHelpers.cs" />
<Compile Include="$(SharedDir)StringUtils.cs" Link="Utils\StringUtils.cs" />
<Compile Include="$(SharedDir)SchemaUtils.cs" Link="Utils\SchemaUtils.cs" />
<Compile Include="$(SharedDir)SecretsStore.cs" Link="Utils\SecretsStore.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\LogEntries.cs" Link="Utils\ConsoleLogs\LogEntries.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\LogEntry.cs" Link="Utils\ConsoleLogs\LogEntry.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\LogPauseViewModel.cs" Link="Utils\ConsoleLogs\LogPauseViewModel.cs" />
Expand Down
12 changes: 9 additions & 3 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
using Aspire.Hosting.Orchestrator;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.UserSecrets;
using Aspire.Hosting.VersionChecking;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -32,7 +33,6 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.SecretManager.Tools.Internal;

namespace Aspire.Hosting;

Expand Down Expand Up @@ -62,6 +62,7 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder

private readonly DistributedApplicationOptions _options;
private readonly HostApplicationBuilder _innerBuilder;
private readonly IUserSecretsManager _userSecretsManager;

/// <inheritdoc />
public IHostEnvironment Environment => _innerBuilder.Environment;
Expand Down Expand Up @@ -286,6 +287,11 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
}

// Core things
// Create and register the user secrets manager
_userSecretsManager = UserSecretsManagerFactory.Instance.GetOrCreate(AppHostAssembly);
// Always register IUserSecretsManager so dependencies can resolve
_innerBuilder.Services.AddSingleton(_userSecretsManager);

_innerBuilder.Services.AddSingleton(sp => new DistributedApplicationModel(Resources));
_innerBuilder.Services.AddSingleton<PipelineExecutor>();
_innerBuilder.Services.AddHostedService<PipelineExecutor>(sp => sp.GetRequiredService<PipelineExecutor>());
Expand Down Expand Up @@ -352,13 +358,13 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
// If a key is generated, it's stored in the user secrets store so that it will be auto-loaded
// on subsequent runs and not recreated. This is important to ensure it doesn't change the state
// of persistent containers (as a new key would be a spec change).
SecretsStore.GetOrSetUserSecret(_innerBuilder.Configuration, AppHostAssembly, "AppHost:OtlpApiKey", TokenGenerator.GenerateToken);
_userSecretsManager.GetOrSetSecret(_innerBuilder.Configuration, "AppHost:OtlpApiKey", TokenGenerator.GenerateToken);

// Set a random API key for the MCP Server if one isn't already present in configuration.
// If a key is generated, it's stored in the user secrets store so that it will be auto-loaded
// on subsequent runs and not recreated. This is important to ensure it doesn't change the state
// of MCP clients.
SecretsStore.GetOrSetUserSecret(_innerBuilder.Configuration, AppHostAssembly, "AppHost:McpApiKey", TokenGenerator.GenerateToken);
_userSecretsManager.GetOrSetSecret(_innerBuilder.Configuration, "AppHost:McpApiKey", TokenGenerator.GenerateToken);

// Determine the frontend browser token.
if (_innerBuilder.Configuration.GetString(KnownConfigNames.DashboardFrontendBrowserToken,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,87 +61,6 @@ private sealed class SectionMetadata(long version)
/// <param name="cancellationToken">Cancellation token.</param>
protected abstract Task SaveStateToStorageAsync(JsonObject state, CancellationToken cancellationToken);

/// <summary>
/// Flattens a JsonObject using colon-separated keys for configuration compatibility.
/// Handles both nested objects and arrays with indexed keys.
/// </summary>
/// <param name="source">The source JsonObject to flatten.</param>
/// <returns>A flattened JsonObject.</returns>
public static JsonObject FlattenJsonObject(JsonObject source)
{
var result = new JsonObject();
FlattenJsonObjectRecursive(source, string.Empty, result);
return result;
}

/// <summary>
/// Unflattens a JsonObject that uses colon-separated keys back into a nested structure.
/// Handles both nested objects and arrays with indexed keys.
/// </summary>
/// <param name="source">The flattened JsonObject to unflatten.</param>
/// <returns>An unflattened JsonObject with nested structure.</returns>
public static JsonObject UnflattenJsonObject(JsonObject source)
{
var result = new JsonObject();

foreach (var kvp in source)
{
var keys = kvp.Key.Split(':');
var current = result;

for (var i = 0; i < keys.Length - 1; i++)
{
var key = keys[i];
if (!current.TryGetPropertyValue(key, out var existing) || existing is not JsonObject)
{
var newObject = new JsonObject();
current[key] = newObject;
current = newObject;
}
else
{
current = existing.AsObject();
}
}

current[keys[^1]] = kvp.Value?.DeepClone();
}

return result;
}

private static void FlattenJsonObjectRecursive(JsonObject source, string prefix, JsonObject result)
{
foreach (var kvp in source)
{
var key = string.IsNullOrEmpty(prefix) ? kvp.Key : $"{prefix}:{kvp.Key}";

if (kvp.Value is JsonObject nestedObject)
{
FlattenJsonObjectRecursive(nestedObject, key, result);
}
else if (kvp.Value is JsonArray array)
{
for (var i = 0; i < array.Count; i++)
{
var arrayKey = $"{key}:{i}";
if (array[i] is JsonObject arrayObject)
{
FlattenJsonObjectRecursive(arrayObject, arrayKey, result);
}
else
{
result[arrayKey] = array[i]?.DeepClone();
}
}
}
else
{
result[key] = kvp.Value?.DeepClone();
}
}
}

/// <summary>
/// Loads the deployment state from storage, using caching to avoid repeated loads.
/// </summary>
Expand Down Expand Up @@ -169,7 +88,7 @@ protected async Task<JsonObject> LoadStateAsync(CancellationToken cancellationTo
{
var fileContent = await File.ReadAllTextAsync(statePath, cancellationToken).ConfigureAwait(false);
var flattenedState = JsonNode.Parse(fileContent, documentOptions: jsonDocumentOptions)!.AsObject();
_state = UnflattenJsonObject(flattenedState);
_state = JsonFlattener.UnflattenJsonObject(flattenedState);
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ protected override async Task SaveStateToStorageAsync(JsonObject state, Cancella
return;
}

var flattenedSecrets = FlattenJsonObject(state);
var flattenedSecrets = JsonFlattener.FlattenJsonObject(state);
Directory.CreateDirectory(Path.GetDirectoryName(deploymentStatePath)!);
await File.WriteAllTextAsync(
deploymentStatePath,
Expand Down
93 changes: 93 additions & 0 deletions src/Aspire.Hosting/Publishing/Internal/JsonFlattener.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Nodes;

namespace Aspire.Hosting.Publishing.Internal;

/// <summary>
/// Provides utility methods for flattening and unflattening JSON objects using colon-separated keys.
/// </summary>
internal static class JsonFlattener
{
/// <summary>
/// Flattens a JsonObject using colon-separated keys for configuration compatibility.
/// Handles both nested objects and arrays with indexed keys.
/// </summary>
/// <param name="source">The source JsonObject to flatten.</param>
/// <returns>A flattened JsonObject.</returns>
public static JsonObject FlattenJsonObject(JsonObject source)
{
var result = new JsonObject();
FlattenJsonObjectRecursive(source, string.Empty, result);
return result;
}

/// <summary>
/// Unflattens a JsonObject that uses colon-separated keys back into a nested structure.
/// Handles both nested objects and arrays with indexed keys.
/// </summary>
/// <param name="source">The flattened JsonObject to unflatten.</param>
/// <returns>An unflattened JsonObject with nested structure.</returns>
public static JsonObject UnflattenJsonObject(JsonObject source)
{
var result = new JsonObject();

foreach (var kvp in source)
{
var keys = kvp.Key.Split(':');
var current = result;

for (var i = 0; i < keys.Length - 1; i++)
{
var key = keys[i];
if (!current.TryGetPropertyValue(key, out var existing) || existing is not JsonObject)
{
var newObject = new JsonObject();
current[key] = newObject;
current = newObject;
}
else
{
current = existing.AsObject();
}
}

current[keys[^1]] = kvp.Value?.DeepClone();
}

return result;
}

private static void FlattenJsonObjectRecursive(JsonObject source, string prefix, JsonObject result)
{
foreach (var kvp in source)
{
var key = string.IsNullOrEmpty(prefix) ? kvp.Key : $"{prefix}:{kvp.Key}";

if (kvp.Value is JsonObject nestedObject)
{
FlattenJsonObjectRecursive(nestedObject, key, result);
}
else if (kvp.Value is JsonArray array)
{
for (var i = 0; i < array.Count; i++)
{
var arrayKey = $"{key}:{i}";
if (array[i] is JsonObject arrayObject)
{
FlattenJsonObjectRecursive(arrayObject, arrayKey, result);
}
else
{
result[arrayKey] = array[i]?.DeepClone();
}
}
}
else
{
result[key] = kvp.Value?.DeepClone();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,47 @@

#pragma warning disable ASPIREPIPELINES002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration.UserSecrets;
using Aspire.Hosting.UserSecrets;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Publishing.Internal;

/// <summary>
/// User secrets implementation of <see cref="IDeploymentStateManager"/>.
/// </summary>
public sealed class UserSecretsDeploymentStateManager(ILogger<UserSecretsDeploymentStateManager> logger) : DeploymentStateManagerBase<UserSecretsDeploymentStateManager>(logger)
internal sealed class UserSecretsDeploymentStateManager : DeploymentStateManagerBase<UserSecretsDeploymentStateManager>
{
private readonly IUserSecretsManager _userSecretsManager;

/// <summary>
/// Initializes a new instance of the <see cref="UserSecretsDeploymentStateManager"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="userSecretsManager">User secrets manager for managing secrets.</param>
public UserSecretsDeploymentStateManager(ILogger<UserSecretsDeploymentStateManager> logger, IUserSecretsManager userSecretsManager)
: base(logger)
{
_userSecretsManager = userSecretsManager;
}

/// <inheritdoc/>
public override string? StateFilePath => GetStatePath();

/// <inheritdoc/>
protected override string? GetStatePath()
{
return Assembly.GetEntryAssembly()?.GetCustomAttribute<UserSecretsIdAttribute>()?.UserSecretsId switch
{
null => Environment.GetEnvironmentVariable("DOTNET_USER_SECRETS_ID"),
string id => UserSecretsPathHelper.GetSecretsPathFromSecretsId(id)
};
return _userSecretsManager.FilePath;
}

/// <inheritdoc/>
protected override async Task SaveStateToStorageAsync(JsonObject state, CancellationToken cancellationToken)
{
try
{
var userSecretsPath = GetStatePath() ?? throw new InvalidOperationException("User secrets path could not be determined.");
var flattenedUserSecrets = DeploymentStateManagerBase<UserSecretsDeploymentStateManager>.FlattenJsonObject(state);
Directory.CreateDirectory(Path.GetDirectoryName(userSecretsPath)!);
await File.WriteAllTextAsync(userSecretsPath, flattenedUserSecrets.ToJsonString(s_jsonSerializerOptions), cancellationToken).ConfigureAwait(false);

// Use the shared manager which handles locking
await _userSecretsManager.SaveStateAsync(state, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Azure resource connection strings saved to user secrets.");
}
catch (JsonException ex)
Expand Down
Loading
Loading