Skip to content
Merged
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
14 changes: 11 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,23 @@ 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
{
public UserSecretsParameterDefault(Assembly appHostAssembly, string applicationName, string parameterName, ParameterDefault parameterDefault)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserSecretsParameterDefault

Who uses the ctor that takes a factory?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot write tests that use this overload.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor taking a factory is used only by tests to enable test isolation. Tests create their own UserSecretsManagerFactory instances to avoid cross-contamination between test runs. Production code uses the parameterless constructor which delegates to the factory-based constructor with UserSecretsManagerFactory.Instance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 3 tests in commit 50647b4 that specifically use the constructor overload with factory parameter:

  • UserSecretsParameterDefault_WithCustomFactory_UsesProvidedFactory - Verifies the custom factory is used
  • UserSecretsParameterDefault_WithCustomFactory_IsolatesFromGlobalInstance - Tests isolation between custom factory and singleton instance
  • UserSecretsParameterDefault_WithCustomFactory_ConcurrentAccess - Validates thread safety with custom factory under concurrent access

: 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" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the file be deleted now? This appears to be the only project that uses it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot delete this file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit 81d3417. The file has been deleted as it's no longer used after the refactoring.

<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 @@ -26,6 +26,7 @@
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Pipelines.Internal;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.UserSecrets;
using Aspire.Hosting.VersionChecking;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -34,7 +35,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 @@ -64,6 +64,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 @@ -288,6 +289,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 @@ -356,13 +362,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 @@ -63,7 +63,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/Pipelines/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.Pipelines.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,42 @@

#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.Pipelines.Internal;

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

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
42 changes: 42 additions & 0 deletions src/Aspire.Hosting/UserSecrets/IUserSecretsManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;

namespace Aspire.Hosting.UserSecrets;

/// <summary>
/// Defines an interface for managing user secrets with support for read and write operations.
/// </summary>
internal interface IUserSecretsManager
{
/// <summary>
/// Gets the path to the user secrets file.
/// </summary>
string FilePath { get; }

/// <summary>
/// Attempts to set a user secret value synchronously.
/// </summary>
/// <param name="name">The name of the secret.</param>
/// <param name="value">The value of the secret.</param>
/// <returns>True if the secret was set successfully; otherwise, false.</returns>
bool TrySetSecret(string name, string value);

/// <summary>
/// Gets a secret value if it exists in configuration, or sets it using the value generator if it doesn't.
/// </summary>
/// <param name="configuration">The configuration manager to check and update.</param>
/// <param name="name">The name of the secret.</param>
/// <param name="valueGenerator">Function to generate the value if it doesn't exist.</param>
void GetOrSetSecret(IConfigurationManager configuration, string name, Func<string> valueGenerator);

/// <summary>
/// Saves state to user secrets asynchronously (for deployment state manager).
/// If multiple callers save state concurrently, the last write wins.
/// </summary>
/// <param name="state">The state to save as a JSON object.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SaveStateAsync(JsonObject state, CancellationToken cancellationToken = default);
}
Loading
Loading