Skip to content

Commit 7d743a8

Browse files
Copilotdavidfowl
andcommitted
Implement user secrets refactoring with DI-based factory pattern
- Create IUserSecretsManager interface and UserSecretsManagerFactory - Create NoopUserSecretsManager for null object pattern - Create JsonFlattener utility class for JSON operations - Update DistributedApplicationBuilder to register user secrets manager - Update UserSecretsParameterDefault to use factory - Update UserSecretsDeploymentStateManager to use DI-based manager - Update VersionCheckService to inject and use manager - Remove SecretsStore.cs reference from Aspire.Hosting.csproj - Update DeploymentStateManagerBase to use JsonFlattener Co-authored-by: davidfowl <[email protected]>
1 parent 171bae1 commit 7d743a8

File tree

10 files changed

+554
-83
lines changed

10 files changed

+554
-83
lines changed

src/Aspire.Hosting/ApplicationModel/UserSecretsParameterDefault.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
using System.Diagnostics;
55
using System.Reflection;
66
using Aspire.Hosting.Publishing;
7-
using Microsoft.Extensions.SecretManager.Tools.Internal;
7+
using Aspire.Hosting.UserSecrets;
88

99
namespace Aspire.Hosting.ApplicationModel;
1010

@@ -15,15 +15,26 @@ namespace Aspire.Hosting.ApplicationModel;
1515
/// <param name="applicationName">The application name.</param>
1616
/// <param name="parameterName">The parameter name.</param>
1717
/// <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>
18-
internal sealed class UserSecretsParameterDefault(Assembly appHostAssembly, string applicationName, string parameterName, ParameterDefault parameterDefault)
18+
/// <param name="factory">The factory to use for creating user secrets managers.</param>
19+
internal sealed class UserSecretsParameterDefault(Assembly appHostAssembly, string applicationName, string parameterName, ParameterDefault parameterDefault, UserSecretsManagerFactory factory)
1920
: ParameterDefault
2021
{
22+
/// <summary>
23+
/// Initializes a new instance of the <see cref="UserSecretsParameterDefault"/> class using the default factory.
24+
/// </summary>
25+
public UserSecretsParameterDefault(Assembly appHostAssembly, string applicationName, string parameterName, ParameterDefault parameterDefault)
26+
: this(appHostAssembly, applicationName, parameterName, parameterDefault, UserSecretsManagerFactory.Instance)
27+
{
28+
}
29+
2130
/// <inheritdoc/>
2231
public override string GetDefaultValue()
2332
{
2433
var value = parameterDefault.GetDefaultValue();
2534
var configurationKey = $"Parameters:{parameterName}";
26-
if (!SecretsStore.TrySetUserSecret(appHostAssembly, configurationKey, value))
35+
36+
var manager = factory.GetOrCreate(appHostAssembly);
37+
if (manager == null || !manager.TrySetSecret(configurationKey, value))
2738
{
2839
// 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
2940
// in the application's assembly. Note there's no ILogger available this early in the application lifecycle.

src/Aspire.Hosting/Aspire.Hosting.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
<Compile Include="$(SharedDir)LoggingHelpers.cs" Link="Utils\LoggingHelpers.cs" />
3232
<Compile Include="$(SharedDir)StringUtils.cs" Link="Utils\StringUtils.cs" />
3333
<Compile Include="$(SharedDir)SchemaUtils.cs" Link="Utils\SchemaUtils.cs" />
34-
<Compile Include="$(SharedDir)SecretsStore.cs" Link="Utils\SecretsStore.cs" />
3534
<Compile Include="$(SharedDir)ConsoleLogs\LogEntries.cs" Link="Utils\ConsoleLogs\LogEntries.cs" />
3635
<Compile Include="$(SharedDir)ConsoleLogs\LogEntry.cs" Link="Utils\ConsoleLogs\LogEntry.cs" />
3736
<Compile Include="$(SharedDir)ConsoleLogs\LogPauseViewModel.cs" Link="Utils\ConsoleLogs\LogPauseViewModel.cs" />

src/Aspire.Hosting/DistributedApplicationBuilder.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
using Aspire.Hosting.Orchestrator;
2525
using Aspire.Hosting.Pipelines;
2626
using Aspire.Hosting.Publishing;
27+
using Aspire.Hosting.UserSecrets;
2728
using Aspire.Hosting.VersionChecking;
2829
using Microsoft.Extensions.Configuration;
2930
using Microsoft.Extensions.DependencyInjection;
@@ -32,7 +33,6 @@
3233
using Microsoft.Extensions.Hosting;
3334
using Microsoft.Extensions.Logging;
3435
using Microsoft.Extensions.Options;
35-
using Microsoft.Extensions.SecretManager.Tools.Internal;
3636

3737
namespace Aspire.Hosting;
3838

@@ -62,6 +62,7 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder
6262

6363
private readonly DistributedApplicationOptions _options;
6464
private readonly HostApplicationBuilder _innerBuilder;
65+
private readonly IUserSecretsManager? _userSecretsManager;
6566

6667
/// <inheritdoc />
6768
public IHostEnvironment Environment => _innerBuilder.Environment;
@@ -286,6 +287,11 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
286287
}
287288

288289
// Core things
290+
// Create and register the user secrets manager
291+
_userSecretsManager = UserSecretsManagerFactory.Instance.GetOrCreate(AppHostAssembly);
292+
// Always register IUserSecretsManager so dependencies can resolve (can be null)
293+
_innerBuilder.Services.AddSingleton(typeof(IUserSecretsManager), sp => _userSecretsManager!);
294+
289295
_innerBuilder.Services.AddSingleton(sp => new DistributedApplicationModel(Resources));
290296
_innerBuilder.Services.AddSingleton<PipelineExecutor>();
291297
_innerBuilder.Services.AddHostedService<PipelineExecutor>(sp => sp.GetRequiredService<PipelineExecutor>());
@@ -352,13 +358,13 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
352358
// If a key is generated, it's stored in the user secrets store so that it will be auto-loaded
353359
// on subsequent runs and not recreated. This is important to ensure it doesn't change the state
354360
// of persistent containers (as a new key would be a spec change).
355-
SecretsStore.GetOrSetUserSecret(_innerBuilder.Configuration, AppHostAssembly, "AppHost:OtlpApiKey", TokenGenerator.GenerateToken);
361+
_userSecretsManager?.GetOrSetSecret(_innerBuilder.Configuration, "AppHost:OtlpApiKey", TokenGenerator.GenerateToken);
356362

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

363369
// Determine the frontend browser token.
364370
if (_innerBuilder.Configuration.GetString(KnownConfigNames.DashboardFrontendBrowserToken,

src/Aspire.Hosting/Publishing/Internal/DeploymentStateManagerBase.cs

Lines changed: 2 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -67,77 +67,15 @@ private sealed class SectionMetadata(long version)
6767
/// </summary>
6868
/// <param name="source">The source JsonObject to flatten.</param>
6969
/// <returns>A flattened JsonObject.</returns>
70-
public static JsonObject FlattenJsonObject(JsonObject source)
71-
{
72-
var result = new JsonObject();
73-
FlattenJsonObjectRecursive(source, string.Empty, result);
74-
return result;
75-
}
70+
public static JsonObject FlattenJsonObject(JsonObject source) => JsonFlattener.FlattenJsonObject(source);
7671

7772
/// <summary>
7873
/// Unflattens a JsonObject that uses colon-separated keys back into a nested structure.
7974
/// Handles both nested objects and arrays with indexed keys.
8075
/// </summary>
8176
/// <param name="source">The flattened JsonObject to unflatten.</param>
8277
/// <returns>An unflattened JsonObject with nested structure.</returns>
83-
public static JsonObject UnflattenJsonObject(JsonObject source)
84-
{
85-
var result = new JsonObject();
86-
87-
foreach (var kvp in source)
88-
{
89-
var keys = kvp.Key.Split(':');
90-
var current = result;
91-
92-
for (var i = 0; i < keys.Length - 1; i++)
93-
{
94-
var key = keys[i];
95-
if (!current.TryGetPropertyValue(key, out var existing) || existing is not JsonObject)
96-
{
97-
var newObject = new JsonObject();
98-
current[key] = newObject;
99-
current = newObject;
100-
}
101-
else
102-
{
103-
current = existing.AsObject();
104-
}
105-
}
106-
107-
current[keys[^1]] = kvp.Value?.DeepClone();
108-
}
109-
110-
return result;
111-
}
112-
113-
private static void FlattenJsonObjectRecursive(JsonObject source, string prefix, JsonObject result)
114-
{
115-
foreach (var kvp in source)
116-
{
117-
var key = string.IsNullOrEmpty(prefix) ? kvp.Key : $"{prefix}:{kvp.Key}";
118-
119-
if (kvp.Value is JsonObject nestedObject)
120-
{
121-
FlattenJsonObjectRecursive(nestedObject, key, result);
122-
}
123-
else if (kvp.Value is JsonArray array)
124-
{
125-
for (var i = 0; i < array.Count; i++)
126-
{
127-
var arrayKey = $"{key}:{i}";
128-
if (array[i] is JsonObject arrayObject)
129-
{
130-
FlattenJsonObjectRecursive(arrayObject, arrayKey, result);
131-
}
132-
else
133-
{
134-
result[arrayKey] = array[i]?.DeepClone();
135-
}
136-
}
137-
}
138-
else
139-
{
140-
result[key] = kvp.Value?.DeepClone();
78+
public static JsonObject UnflattenJsonObject(JsonObject source) => JsonFlattener.UnflattenJsonObject(source);
14179
}
14280
}
14381
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.Json.Nodes;
5+
6+
namespace Aspire.Hosting.Publishing.Internal;
7+
8+
/// <summary>
9+
/// Provides utility methods for flattening and unflattening JSON objects using colon-separated keys.
10+
/// </summary>
11+
public static class JsonFlattener
12+
{
13+
/// <summary>
14+
/// Flattens a JsonObject using colon-separated keys for configuration compatibility.
15+
/// Handles both nested objects and arrays with indexed keys.
16+
/// </summary>
17+
/// <param name="source">The source JsonObject to flatten.</param>
18+
/// <returns>A flattened JsonObject.</returns>
19+
public static JsonObject FlattenJsonObject(JsonObject source)
20+
{
21+
var result = new JsonObject();
22+
FlattenJsonObjectRecursive(source, string.Empty, result);
23+
return result;
24+
}
25+
26+
/// <summary>
27+
/// Unflattens a JsonObject that uses colon-separated keys back into a nested structure.
28+
/// Handles both nested objects and arrays with indexed keys.
29+
/// </summary>
30+
/// <param name="source">The flattened JsonObject to unflatten.</param>
31+
/// <returns>An unflattened JsonObject with nested structure.</returns>
32+
public static JsonObject UnflattenJsonObject(JsonObject source)
33+
{
34+
var result = new JsonObject();
35+
36+
foreach (var kvp in source)
37+
{
38+
var keys = kvp.Key.Split(':');
39+
var current = result;
40+
41+
for (var i = 0; i < keys.Length - 1; i++)
42+
{
43+
var key = keys[i];
44+
if (!current.TryGetPropertyValue(key, out var existing) || existing is not JsonObject)
45+
{
46+
var newObject = new JsonObject();
47+
current[key] = newObject;
48+
current = newObject;
49+
}
50+
else
51+
{
52+
current = existing.AsObject();
53+
}
54+
}
55+
56+
current[keys[^1]] = kvp.Value?.DeepClone();
57+
}
58+
59+
return result;
60+
}
61+
62+
private static void FlattenJsonObjectRecursive(JsonObject source, string prefix, JsonObject result)
63+
{
64+
foreach (var kvp in source)
65+
{
66+
var key = string.IsNullOrEmpty(prefix) ? kvp.Key : $"{prefix}:{kvp.Key}";
67+
68+
if (kvp.Value is JsonObject nestedObject)
69+
{
70+
FlattenJsonObjectRecursive(nestedObject, key, result);
71+
}
72+
else if (kvp.Value is JsonArray array)
73+
{
74+
for (var i = 0; i < array.Count; i++)
75+
{
76+
var arrayKey = $"{key}:{i}";
77+
if (array[i] is JsonObject arrayObject)
78+
{
79+
FlattenJsonObjectRecursive(arrayObject, arrayKey, result);
80+
}
81+
else
82+
{
83+
result[arrayKey] = array[i]?.DeepClone();
84+
}
85+
}
86+
}
87+
else
88+
{
89+
result[key] = kvp.Value?.DeepClone();
90+
}
91+
}
92+
}
93+
}

src/Aspire.Hosting/Publishing/Internal/UserSecretsDeploymentStateManager.cs

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Reflection;
77
using System.Text.Json;
88
using System.Text.Json.Nodes;
9+
using Aspire.Hosting.UserSecrets;
910
using Microsoft.Extensions.Configuration.UserSecrets;
1011
using Microsoft.Extensions.Logging;
1112

@@ -14,10 +15,23 @@ namespace Aspire.Hosting.Publishing.Internal;
1415
/// <summary>
1516
/// User secrets implementation of <see cref="IDeploymentStateManager"/>.
1617
/// </summary>
17-
public sealed class UserSecretsDeploymentStateManager(ILogger<UserSecretsDeploymentStateManager> logger) : DeploymentStateManagerBase<UserSecretsDeploymentStateManager>(logger)
18+
internal sealed class UserSecretsDeploymentStateManager : DeploymentStateManagerBase<UserSecretsDeploymentStateManager>
1819
{
20+
private readonly IUserSecretsManager? _userSecretsManager;
21+
22+
/// <summary>
23+
/// Initializes a new instance of the <see cref="UserSecretsDeploymentStateManager"/> class.
24+
/// </summary>
25+
/// <param name="logger">The logger.</param>
26+
/// <param name="userSecretsManager">User secrets manager for managing secrets.</param>
27+
public UserSecretsDeploymentStateManager(ILogger<UserSecretsDeploymentStateManager> logger, IUserSecretsManager? userSecretsManager = null)
28+
: base(logger)
29+
{
30+
_userSecretsManager = userSecretsManager;
31+
}
32+
1933
/// <inheritdoc/>
20-
public override string? StateFilePath => GetStatePath();
34+
public override string? StateFilePath => _userSecretsManager?.FilePath;
2135

2236
/// <inheritdoc/>
2337
protected override string? GetStatePath()
@@ -32,13 +46,16 @@ public sealed class UserSecretsDeploymentStateManager(ILogger<UserSecretsDeploym
3246
/// <inheritdoc/>
3347
protected override async Task SaveStateToStorageAsync(JsonObject state, CancellationToken cancellationToken)
3448
{
35-
try
49+
if (_userSecretsManager == null)
3650
{
37-
var userSecretsPath = GetStatePath() ?? throw new InvalidOperationException("User secrets path could not be determined.");
38-
var flattenedUserSecrets = DeploymentStateManagerBase<UserSecretsDeploymentStateManager>.FlattenJsonObject(state);
39-
Directory.CreateDirectory(Path.GetDirectoryName(userSecretsPath)!);
40-
await File.WriteAllTextAsync(userSecretsPath, flattenedUserSecrets.ToJsonString(s_jsonSerializerOptions), cancellationToken).ConfigureAwait(false);
51+
logger.LogWarning("User secrets manager is not available. Skipping saving state to user secrets.");
52+
return;
53+
}
4154

55+
try
56+
{
57+
// Use the shared manager which handles locking
58+
await _userSecretsManager.SaveStateAsync(state, cancellationToken).ConfigureAwait(false);
4259
logger.LogInformation("Azure resource connection strings saved to user secrets.");
4360
}
4461
catch (JsonException ex)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.Json.Nodes;
5+
using Microsoft.Extensions.Configuration;
6+
7+
namespace Aspire.Hosting.UserSecrets;
8+
9+
/// <summary>
10+
/// Manages user secrets for an application, providing thread-safe read and write operations.
11+
/// </summary>
12+
internal interface IUserSecretsManager
13+
{
14+
/// <summary>
15+
/// Gets the path to the user secrets file.
16+
/// </summary>
17+
string FilePath { get; }
18+
19+
/// <summary>
20+
/// Attempts to set a user secret value synchronously.
21+
/// </summary>
22+
/// <param name="name">The name of the secret.</param>
23+
/// <param name="value">The value of the secret.</param>
24+
/// <returns>True if the secret was set successfully; otherwise, false.</returns>
25+
bool TrySetSecret(string name, string value);
26+
27+
/// <summary>
28+
/// Attempts to set a user secret value asynchronously.
29+
/// </summary>
30+
/// <param name="name">The name of the secret.</param>
31+
/// <param name="value">The value of the secret.</param>
32+
/// <param name="cancellationToken">Cancellation token.</param>
33+
/// <returns>True if the secret was set successfully; otherwise, false.</returns>
34+
Task<bool> TrySetSecretAsync(string name, string value, CancellationToken cancellationToken = default);
35+
36+
/// <summary>
37+
/// Gets a secret value if it exists in configuration, or sets it using the value generator if it doesn't.
38+
/// </summary>
39+
/// <param name="configuration">The configuration manager to check and update.</param>
40+
/// <param name="name">The name of the secret.</param>
41+
/// <param name="valueGenerator">Function to generate the value if it doesn't exist.</param>
42+
void GetOrSetSecret(IConfigurationManager configuration, string name, Func<string> valueGenerator);
43+
44+
/// <summary>
45+
/// Saves state to user secrets asynchronously (for deployment state manager).
46+
/// </summary>
47+
/// <param name="state">The state to save as a JSON object.</param>
48+
/// <param name="cancellationToken">Cancellation token.</param>
49+
Task SaveStateAsync(JsonObject state, CancellationToken cancellationToken = default);
50+
}

0 commit comments

Comments
 (0)