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