Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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 @@ -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 == null || !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 @@ -23,6 +23,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 @@ -31,7 +32,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 @@ -61,6 +61,7 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@copilot This should no longer be nullable.


/// <inheritdoc />
public IHostEnvironment Environment => _innerBuilder.Environment;
Expand Down Expand Up @@ -285,6 +286,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 (can be null)
_innerBuilder.Services.AddSingleton(typeof(IUserSecretsManager), sp => _userSecretsManager!);

_innerBuilder.Services.AddSingleton(sp => new DistributedApplicationModel(Resources));
_innerBuilder.Services.AddSingleton<PipelineExecutor>();
_innerBuilder.Services.AddHostedService<PipelineExecutor>(sp => sp.GetRequiredService<PipelineExecutor>());
Expand Down Expand Up @@ -351,13 +357,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 @@ -67,80 +67,15 @@ private sealed class SectionMetadata(long version)
/// </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;
}
public static JsonObject FlattenJsonObject(JsonObject source) => JsonFlattener.FlattenJsonObject(source);

/// <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();
}
}
}
public static JsonObject UnflattenJsonObject(JsonObject source) => JsonFlattener.UnflattenJsonObject(source);

/// <summary>
/// Loads the deployment state from storage, using caching to avoid repeated loads.
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>
public 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 @@ -6,6 +6,7 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.UserSecrets;
using Microsoft.Extensions.Configuration.UserSecrets;
using Microsoft.Extensions.Logging;

Expand All @@ -14,10 +15,23 @@ 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 = null)
: base(logger)
{
_userSecretsManager = userSecretsManager;
}

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

/// <inheritdoc/>
protected override string? GetStatePath()
Expand All @@ -32,13 +46,16 @@ public sealed class UserSecretsDeploymentStateManager(ILogger<UserSecretsDeploym
/// <inheritdoc/>
protected override async Task SaveStateToStorageAsync(JsonObject state, CancellationToken cancellationToken)
{
try
if (_userSecretsManager == null)
{
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);
logger.LogWarning("User secrets manager is not available. Skipping saving state to user secrets.");
return;
}

try
{
// 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
50 changes: 50 additions & 0 deletions src/Aspire.Hosting/UserSecrets/IUserSecretsManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// 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>
/// Manages user secrets for an application, providing thread-safe 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>
/// Attempts to set a user secret value asynchronously.
/// </summary>
/// <param name="name">The name of the secret.</param>
/// <param name="value">The value of the secret.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the secret was set successfully; otherwise, false.</returns>
Task<bool> TrySetSecretAsync(string name, string value, CancellationToken cancellationToken = default);

/// <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).
/// </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