diff --git a/src/Aspire.Hosting/Publishing/Internal/FileDeploymentStateManager.cs b/src/Aspire.Hosting/Publishing/Internal/FileDeploymentStateManager.cs
index 9bdde9a3430..1912c385020 100644
--- a/src/Aspire.Hosting/Publishing/Internal/FileDeploymentStateManager.cs
+++ b/src/Aspire.Hosting/Publishing/Internal/FileDeploymentStateManager.cs
@@ -89,7 +89,13 @@ public async Task SaveStateAsync(JsonObject state, CancellationToken cancellatio
return;
}
- var flattenedSecrets = FlattenJsonObject(state);
+ // Load current state from disk to merge with incoming state
+ var currentState = await LoadStateAsync(cancellationToken).ConfigureAwait(false);
+
+ // Merge incoming state into current state (preserves concurrent writes)
+ MergeJsonObjects(currentState, state);
+
+ var flattenedSecrets = FlattenJsonObject(currentState);
Directory.CreateDirectory(Path.GetDirectoryName(deploymentStatePath)!);
await File.WriteAllTextAsync(
deploymentStatePath,
@@ -104,6 +110,17 @@ await File.WriteAllTextAsync(
}
}
+ ///
+ /// Merges properties from source JsonObject into target JsonObject, overwriting existing keys.
+ ///
+ private static void MergeJsonObjects(JsonObject target, JsonObject source)
+ {
+ foreach (var kvp in source)
+ {
+ target[kvp.Key] = kvp.Value?.DeepClone();
+ }
+ }
+
private static JsonObject FlattenJsonObject(JsonObject input)
{
var result = new JsonObject();
diff --git a/src/Aspire.Hosting/Publishing/Internal/UserSecretsDeploymentStateManager.cs b/src/Aspire.Hosting/Publishing/Internal/UserSecretsDeploymentStateManager.cs
index a4ca850a7c1..c9a3e6d29cb 100644
--- a/src/Aspire.Hosting/Publishing/Internal/UserSecretsDeploymentStateManager.cs
+++ b/src/Aspire.Hosting/Publishing/Internal/UserSecretsDeploymentStateManager.cs
@@ -61,7 +61,13 @@ public async Task SaveStateAsync(JsonObject state, CancellationToken cancellatio
throw new InvalidOperationException("User secrets path could not be determined.");
}
- var flattenedUserSecrets = FlattenJsonObject(state);
+ // Load current state from disk to merge with incoming state
+ var currentState = await LoadStateAsync(cancellationToken).ConfigureAwait(false);
+
+ // Merge incoming state into current state (preserves concurrent writes)
+ MergeJsonObjects(currentState, state);
+
+ var flattenedUserSecrets = FlattenJsonObject(currentState);
Directory.CreateDirectory(Path.GetDirectoryName(userSecretsPath)!);
await File.WriteAllTextAsync(userSecretsPath, flattenedUserSecrets.ToJsonString(s_jsonSerializerOptions), cancellationToken).ConfigureAwait(false);
@@ -77,6 +83,17 @@ public async Task SaveStateAsync(JsonObject state, CancellationToken cancellatio
}
}
+ ///
+ /// Merges properties from source JsonObject into target JsonObject, overwriting existing keys.
+ ///
+ private static void MergeJsonObjects(JsonObject target, JsonObject source)
+ {
+ foreach (var kvp in source)
+ {
+ target[kvp.Key] = kvp.Value?.DeepClone();
+ }
+ }
+
///
/// Flattens a JsonObject to use colon-separated keys for configuration compatibility.
/// This ensures all secrets are stored in the flat format expected by .NET configuration.
diff --git a/tests/Aspire.Hosting.Azure.Tests/DefaultUserSecretsManagerTests.cs b/tests/Aspire.Hosting.Azure.Tests/DefaultUserSecretsManagerTests.cs
index 61e9ed1f924..8a2153c42e7 100644
--- a/tests/Aspire.Hosting.Azure.Tests/DefaultUserSecretsManagerTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/DefaultUserSecretsManagerTests.cs
@@ -213,4 +213,167 @@ public void FlattenJsonObject_HandlesEmptyArrays()
Assert.Equal("test", result["OtherValue"]!.ToString());
Assert.False(result.ContainsKey("EmptyArray"));
}
+
+ [Fact]
+ public async Task SaveStateAsync_MergesWithExistingState()
+ {
+ // Arrange
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var secretsFile = Path.Combine(tempDir, "secrets.json");
+
+ try
+ {
+ // Create initial state with Parameters
+ var initialState = new JsonObject
+ {
+ ["Parameters"] = new JsonObject
+ {
+ ["param1"] = "value1"
+ }
+ };
+ await File.WriteAllTextAsync(secretsFile, initialState.ToJsonString());
+
+ // Simulate what would happen without merge: new state with only Azure data
+ var newState = new JsonObject
+ {
+ ["Azure"] = new JsonObject
+ {
+ ["SubscriptionId"] = "sub-123"
+ }
+ };
+
+ // Simulate the merge logic that SaveStateAsync should perform
+ var loadedState = JsonNode.Parse(await File.ReadAllTextAsync(secretsFile))!.AsObject();
+ foreach (var kvp in newState)
+ {
+ loadedState[kvp.Key] = kvp.Value?.DeepClone();
+ }
+
+ // Act - Save the merged state
+ await File.WriteAllTextAsync(secretsFile, loadedState.ToJsonString());
+
+ // Assert - Both Parameters and Azure should be present
+ var finalState = JsonNode.Parse(await File.ReadAllTextAsync(secretsFile))!.AsObject();
+ Assert.NotNull(finalState["Parameters"]);
+ Assert.NotNull(finalState["Azure"]);
+ Assert.Equal("value1", finalState["Parameters"]!["param1"]!.ToString());
+ Assert.Equal("sub-123", finalState["Azure"]!["SubscriptionId"]!.ToString());
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task SaveStateAsync_PreservesExistingKeysNotInNewState()
+ {
+ // Arrange
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var secretsFile = Path.Combine(tempDir, "secrets.json");
+
+ try
+ {
+ // Create initial state with multiple keys
+ var initialState = new JsonObject
+ {
+ ["Key1"] = "value1",
+ ["Key2"] = "value2",
+ ["Key3"] = "value3"
+ };
+ await File.WriteAllTextAsync(secretsFile, initialState.ToJsonString());
+
+ // New state only updates Key2 and adds Key4
+ var newState = new JsonObject
+ {
+ ["Key2"] = "updated-value2",
+ ["Key4"] = "value4"
+ };
+
+ // Simulate merge
+ var loadedState = JsonNode.Parse(await File.ReadAllTextAsync(secretsFile))!.AsObject();
+ foreach (var kvp in newState)
+ {
+ loadedState[kvp.Key] = kvp.Value?.DeepClone();
+ }
+
+ // Act
+ await File.WriteAllTextAsync(secretsFile, loadedState.ToJsonString());
+
+ // Assert - All keys should be present with correct values
+ var finalState = JsonNode.Parse(await File.ReadAllTextAsync(secretsFile))!.AsObject();
+ Assert.Equal(4, finalState.Count);
+ Assert.Equal("value1", finalState["Key1"]!.ToString());
+ Assert.Equal("updated-value2", finalState["Key2"]!.ToString());
+ Assert.Equal("value3", finalState["Key3"]!.ToString());
+ Assert.Equal("value4", finalState["Key4"]!.ToString());
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task SaveStateAsync_OverwritesExistingKeysWithNewValues()
+ {
+ // Arrange
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var secretsFile = Path.Combine(tempDir, "secrets.json");
+
+ try
+ {
+ // Create initial state
+ var initialState = new JsonObject
+ {
+ ["Azure"] = new JsonObject
+ {
+ ["SubscriptionId"] = "old-sub-id",
+ ["Tenant"] = "old-tenant"
+ }
+ };
+ await File.WriteAllTextAsync(secretsFile, initialState.ToJsonString());
+
+ // New state with updated Azure data
+ var newState = new JsonObject
+ {
+ ["Azure"] = new JsonObject
+ {
+ ["SubscriptionId"] = "new-sub-id",
+ ["Tenant"] = "new-tenant"
+ }
+ };
+
+ // Simulate merge
+ var loadedState = JsonNode.Parse(await File.ReadAllTextAsync(secretsFile))!.AsObject();
+ foreach (var kvp in newState)
+ {
+ loadedState[kvp.Key] = kvp.Value?.DeepClone();
+ }
+
+ // Act
+ await File.WriteAllTextAsync(secretsFile, loadedState.ToJsonString());
+
+ // Assert - Azure values should be updated
+ var finalState = JsonNode.Parse(await File.ReadAllTextAsync(secretsFile))!.AsObject();
+ Assert.Equal("new-sub-id", finalState["Azure"]!["SubscriptionId"]!.ToString());
+ Assert.Equal("new-tenant", finalState["Azure"]!["Tenant"]!.ToString());
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
}
\ No newline at end of file