Skip to content

Commit b4695fa

Browse files
CopilotcaptainsafiadavidfowlCopilot
authored
Support storing state in filesystem for local deploys (#11877)
* Support storing state in file system for local deployments * Move IDeploymentStateManager initialization to DistributedApplicationBuilder Co-authored-by: captainsafia <[email protected]> * Implement PR feedback: config ordering, save all params, remove clearCache check, add logging Co-authored-by: davidfowl <[email protected]> * Clean up build errors * Fix setting of execution mode * Fix order for cached deployment test * Update src/Aspire.Hosting/Publishing/Internal/FileDeploymentStateManager.cs Co-authored-by: Copilot <[email protected]> * Remove Console.WriteLine from FileDeploymentStateManager, use logger only Co-authored-by: captainsafia <[email protected]> * Address PR feedback: rename userSecrets to deploymentState, fix parameter names, remove pragma, change visibility Co-authored-by: captainsafia <[email protected]> * Save deployment state after initializing provisioning context, persist AllowResourceGroupCreation, ensure it's true in publish mode Co-authored-by: captainsafia <[email protected]> * Add back ClearCache check to LoadDeploymentState to skip loading when cache should be cleared Co-authored-by: captainsafia <[email protected]> * Add File.Exists check before emitting deployment state step and only emit save steps when ClearCache is false Co-authored-by: captainsafia <[email protected]> * Fix parameter saving to handle newly added parameters without throwing exceptions Co-authored-by: davidfowl <[email protected]> * Simplify SaveParametersToDeploymentStateAsync to use GetValueAsync and remove inner exception handling Co-authored-by: davidfowl <[email protected]> * Remove step reporting from deployment state saves, just save the state directly Co-authored-by: davidfowl <[email protected]> --------- Co-authored-by: Safia Abdalla <[email protected]> Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: captainsafia <[email protected]> Co-authored-by: davidfowl <[email protected]> Co-authored-by: Safia Abdalla <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 87cda37 commit b4695fa

28 files changed

+873
-232
lines changed

src/Aspire.Cli/Commands/DeployCommand.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.CommandLine;
45
using Aspire.Cli.Configuration;
56
using Aspire.Cli.DotNet;
67
using Aspire.Cli.Interaction;
@@ -13,16 +14,23 @@ namespace Aspire.Cli.Commands;
1314

1415
internal sealed class DeployCommand : PublishCommandBase
1516
{
17+
private readonly Option<bool> _clearCacheOption;
18+
1619
public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext)
1720
: base("deploy", DeployCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext)
1821
{
22+
_clearCacheOption = new Option<bool>("--clear-cache")
23+
{
24+
Description = "Clear the deployment cache associated with the current environment and do not save deployment state"
25+
};
26+
Options.Add(_clearCacheOption);
1927
}
2028

2129
protected override string OperationCompletedPrefix => DeployCommandStrings.OperationCompletedPrefix;
2230
protected override string OperationFailedPrefix => DeployCommandStrings.OperationFailedPrefix;
2331
protected override string GetOutputPathDescription() => DeployCommandStrings.OutputPathArgumentDescription;
2432

25-
protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens)
33+
protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult)
2634
{
2735
var baseArgs = new List<string> { "--operation", "publish", "--publisher", "default" };
2836

@@ -32,6 +40,13 @@ protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, st
3240
}
3341

3442
baseArgs.AddRange(["--deploy", "true"]);
43+
44+
var clearCache = parseResult.GetValue(_clearCacheOption);
45+
if (clearCache)
46+
{
47+
baseArgs.AddRange(["--clear-cache", "true"]);
48+
}
49+
3550
baseArgs.AddRange(unmatchedTokens);
3651

3752
return [.. baseArgs];

src/Aspire.Cli/Commands/PublishCommand.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.CommandLine;
45
using Aspire.Cli.Configuration;
56
using Aspire.Cli.DotNet;
67
using Aspire.Cli.Interaction;
@@ -44,7 +45,7 @@ public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionSe
4445
protected override string OperationFailedPrefix => PublishCommandStrings.OperationFailedPrefix;
4546
protected override string GetOutputPathDescription() => PublishCommandStrings.OutputPathArgumentDescription;
4647

47-
protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens)
48+
protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult)
4849
{
4950
var baseArgs = new List<string> { "--operation", "publish", "--publisher", "default" };
5051

src/Aspire.Cli/Commands/PublishCommandBase.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ protected PublishCommandBase(string name, string description, IDotNetCliRunner r
7575
}
7676

7777
protected abstract string GetOutputPathDescription();
78-
protected abstract string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens);
78+
protected abstract string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult);
7979
protected abstract string GetCanceledMessage();
8080
protected abstract string GetProgressMessage();
8181

@@ -173,7 +173,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
173173
effectiveAppHostFile,
174174
false,
175175
true,
176-
GetRunArguments(fullyQualifiedOutputPath, unmatchedTokens),
176+
GetRunArguments(fullyQualifiedOutputPath, unmatchedTokens, parseResult),
177177
env,
178178
backchannelCompletionSource,
179179
operationRunOptions,

src/Aspire.Hosting.Azure/AzureDeployingContext.cs

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ namespace Aspire.Hosting.Azure;
2323

2424
internal sealed class AzureDeployingContext(
2525
IProvisioningContextProvider provisioningContextProvider,
26-
IUserSecretsManager userSecretsManager,
26+
IDeploymentStateManager deploymentStateManager,
2727
IBicepProvisioner bicepProvisioner,
2828
IPublishingActivityReporter activityReporter,
2929
IResourceContainerImageBuilder containerImageBuilder,
@@ -40,9 +40,16 @@ public async Task DeployModelAsync(DistributedApplicationModel model, Cancellati
4040
return;
4141
}
4242

43-
var userSecrets = await userSecretsManager.LoadUserSecretsAsync(cancellationToken).ConfigureAwait(false);
43+
var userSecrets = await deploymentStateManager.LoadStateAsync(cancellationToken).ConfigureAwait(false);
4444
var provisioningContext = await provisioningContextProvider.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false);
4545

46+
// Save deployment state after initializing provisioning context (only if ClearCache is false)
47+
var clearCache = configuration.GetValue<bool>("Publishing:ClearCache");
48+
if (!clearCache)
49+
{
50+
await deploymentStateManager.SaveStateAsync(provisioningContext.DeploymentState, cancellationToken).ConfigureAwait(false);
51+
}
52+
4653
// Step 1: Provision Azure Bicep resources from the distributed application model
4754
var bicepResources = model.Resources.OfType<AzureBicepResource>()
4855
.Where(r => !r.IsExcludedFromPublish())
@@ -65,6 +72,12 @@ public async Task DeployModelAsync(DistributedApplicationModel model, Cancellati
6572
return;
6673
}
6774

75+
// Step 4: Save deployment state after successful deployment (only if ClearCache is false)
76+
if (!clearCache)
77+
{
78+
await deploymentStateManager.SaveStateAsync(provisioningContext.DeploymentState, cancellationToken).ConfigureAwait(false);
79+
}
80+
6881
// Display dashboard URL after successful deployment
6982
var dashboardUrl = TryGetDashboardUrl(model);
7083
if (!string.IsNullOrEmpty(dashboardUrl))
@@ -104,7 +117,16 @@ private async Task<bool> TryValidateAzureCliLoginAsync(CancellationToken cancell
104117

105118
private async Task<bool> TryProvisionAzureBicepResources(List<AzureBicepResource> bicepResources, ProvisioningContext provisioningContext, CancellationToken cancellationToken)
106119
{
107-
var deployingStep = await activityReporter.CreateStepAsync("Deploying Azure resources", cancellationToken).ConfigureAwait(false);
120+
bicepResources = bicepResources
121+
.Where(r => r.ProvisioningTaskCompletionSource == null || !r.ProvisioningTaskCompletionSource.Task.IsCompleted)
122+
.ToList();
123+
124+
if (bicepResources.Count == 0)
125+
{
126+
return true;
127+
}
128+
129+
var deployingStep = await activityReporter.CreateStepAsync($"Deploying {bicepResources.Count} Azure resource(s)", cancellationToken).ConfigureAwait(false);
108130
await using (deployingStep.ConfigureAwait(false))
109131
{
110132
try
@@ -125,11 +147,17 @@ private async Task<bool> TryProvisionAzureBicepResources(List<AzureBicepResource
125147
{
126148
bicepResource.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
127149

128-
await bicepProvisioner.GetOrCreateResourceAsync(bicepResource, provisioningContext, cancellationToken).ConfigureAwait(false);
129-
130-
bicepResource.ProvisioningTaskCompletionSource?.TrySetResult();
131-
132-
await resourceTask.CompleteAsync($"Successfully provisioned {bicepResource.Name}", CompletionState.Completed, cancellationToken).ConfigureAwait(false);
150+
if (await bicepProvisioner.ConfigureResourceAsync(configuration, bicepResource, cancellationToken).ConfigureAwait(false))
151+
{
152+
bicepResource.ProvisioningTaskCompletionSource?.TrySetResult();
153+
await resourceTask.CompleteAsync($"Using existing deployment for {bicepResource.Name}", CompletionState.Completed, cancellationToken).ConfigureAwait(false);
154+
}
155+
else
156+
{
157+
await bicepProvisioner.GetOrCreateResourceAsync(bicepResource, provisioningContext, cancellationToken).ConfigureAwait(false);
158+
bicepResource.ProvisioningTaskCompletionSource?.TrySetResult();
159+
await resourceTask.CompleteAsync($"Successfully provisioned {bicepResource.Name}", CompletionState.Completed, cancellationToken).ConfigureAwait(false);
160+
}
133161
}
134162
catch (Exception ex)
135163
{

src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ private Task PublishAsync(PublishingContext context)
7373
private Task DeployAsync(DeployingContext context)
7474
{
7575
var provisioningContextProvider = context.Services.GetRequiredService<IProvisioningContextProvider>();
76-
var userSecretsManager = context.Services.GetRequiredService<IUserSecretsManager>();
76+
var userSecretsManager = context.Services.GetRequiredService<IDeploymentStateManager>();
7777
var bicepProvisioner = context.Services.GetRequiredService<IBicepProvisioner>();
7878
var activityPublisher = context.Services.GetRequiredService<IPublishingActivityReporter>();
7979
var containerImageBuilder = context.Services.GetRequiredService<IResourceContainerImageBuilder>();

src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ public static IDistributedApplicationBuilder AddAzureProvisioning(this IDistribu
4444
builder.Services.TryAddSingleton<IArmClientProvider, DefaultArmClientProvider>();
4545
builder.Services.TryAddSingleton<ISecretClientProvider, DefaultSecretClientProvider>();
4646
builder.Services.TryAddSingleton<IBicepCompiler, BicepCliCompiler>();
47-
builder.Services.TryAddSingleton<IUserSecretsManager, DefaultUserSecretsManager>();
4847
builder.Services.TryAddSingleton<IUserPrincipalProvider, DefaultUserPrincipalProvider>();
48+
4949
if (builder.ExecutionContext.IsPublishMode)
5050
{
5151
builder.Services.AddSingleton<IProvisioningContextProvider, PublishModeProvisioningContextProvider>();

src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ protected static bool IsValidResourceGroupName(string? name)
7272
return !name.Contains("..");
7373
}
7474

75-
public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default)
75+
public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject deploymentState, CancellationToken cancellationToken = default)
7676
{
7777
var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value.");
7878

@@ -103,19 +103,26 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
103103
if (string.IsNullOrEmpty(_options.ResourceGroup))
104104
{
105105
// Generate an resource group name since none was provided
106-
// Create a unique resource group name and save it in user secrets
106+
// Create a unique resource group name and save it in deployment state
107107
resourceGroupName = GetDefaultResourceGroupName();
108108

109109
createIfAbsent = true;
110110

111-
userSecrets.Prop("Azure")["ResourceGroup"] = resourceGroupName;
111+
deploymentState.Prop("Azure")["ResourceGroup"] = resourceGroupName;
112112
}
113113
else
114114
{
115115
resourceGroupName = _options.ResourceGroup;
116116
createIfAbsent = _options.AllowResourceGroupCreation ?? false;
117117
}
118118

119+
// In publish mode, always allow resource group creation
120+
if (_distributedApplicationExecutionContext.IsPublishMode)
121+
{
122+
createIfAbsent = true;
123+
_options.AllowResourceGroupCreation = true;
124+
}
125+
119126
var resourceGroups = subscriptionResource.GetResourceGroups();
120127

121128
IResourceGroupResource? resourceGroup;
@@ -149,6 +156,16 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
149156

150157
var principal = await _userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false);
151158

159+
// Persist the provisioning options to deployment state so they can be reused in the future
160+
var azureSection = deploymentState.Prop("Azure");
161+
azureSection["Location"] = _options.Location;
162+
azureSection["SubscriptionId"] = _options.SubscriptionId;
163+
azureSection["ResourceGroup"] = resourceGroupName;
164+
if (_options.AllowResourceGroupCreation.HasValue)
165+
{
166+
azureSection["AllowResourceGroupCreation"] = _options.AllowResourceGroupCreation.Value;
167+
}
168+
152169
return new ProvisioningContext(
153170
credential,
154171
armClient,
@@ -157,7 +174,7 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
157174
tenantResource,
158175
location,
159176
principal,
160-
userSecrets,
177+
deploymentState,
161178
_distributedApplicationExecutionContext);
162179
}
163180

src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,6 @@ internal interface IBicepCompiler
4949
Task<string> CompileBicepToArmAsync(string bicepFilePath, CancellationToken cancellationToken = default);
5050
}
5151

52-
/// <summary>
53-
/// Provides user secrets management functionality.
54-
/// </summary>
55-
internal interface IUserSecretsManager
56-
{
57-
/// <summary>
58-
/// Loads user secrets from the current application.
59-
/// </summary>
60-
Task<JsonObject> LoadUserSecretsAsync(CancellationToken cancellationToken = default);
61-
62-
/// <summary>
63-
/// Saves user secrets to the current application.
64-
/// </summary>
65-
Task SaveUserSecretsAsync(JsonObject userSecrets, CancellationToken cancellationToken = default);
66-
}
67-
6852
/// <summary>
6953
/// Provides provisioning context creation functionality.
7054
/// </summary>

src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ protected override string GetDefaultResourceGroupName()
6161
return $"{prefix}-{normalizedApplicationName}-{suffix}";
6262
}
6363

64-
private void EnsureProvisioningOptions(JsonObject userSecrets)
64+
private void EnsureProvisioningOptions()
6565
{
6666
if (!_interactionService.IsAvailable ||
6767
(!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId)))
@@ -77,7 +77,7 @@ private void EnsureProvisioningOptions(JsonObject userSecrets)
7777
{
7878
try
7979
{
80-
await RetrieveAzureProvisioningOptions(userSecrets).ConfigureAwait(false);
80+
await RetrieveAzureProvisioningOptions().ConfigureAwait(false);
8181

8282
_logger.LogDebug("Azure provisioning options have been handled successfully.");
8383
}
@@ -91,14 +91,14 @@ private void EnsureProvisioningOptions(JsonObject userSecrets)
9191

9292
public override async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default)
9393
{
94-
EnsureProvisioningOptions(userSecrets);
94+
EnsureProvisioningOptions();
9595

9696
await _provisioningOptionsAvailable.Task.ConfigureAwait(false);
9797

9898
return await base.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false);
9999
}
100100

101-
private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, CancellationToken cancellationToken = default)
101+
private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellationToken = default)
102102
{
103103
var locations = typeof(AzureLocation).GetProperties(BindingFlags.Public | BindingFlags.Static)
104104
.Where(p => p.PropertyType == typeof(AzureLocation))
@@ -166,13 +166,6 @@ private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, Canc
166166
_options.ResourceGroup = result.Data[ResourceGroupName].Value;
167167
_options.AllowResourceGroupCreation = true; // Allow the creation of the resource group if it does not exist.
168168

169-
var azureSection = userSecrets.Prop("Azure");
170-
171-
// Persist the parameter value to user secrets so they can be reused in the future
172-
azureSection["Location"] = _options.Location;
173-
azureSection["SubscriptionId"] = _options.SubscriptionId;
174-
azureSection["ResourceGroup"] = _options.ResourceGroup;
175-
176169
_provisioningOptionsAvailable.SetResult();
177170
}
178171
}

src/Aspire.Hosting.Azure/Provisioning/JsonExtensions.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,27 @@ internal static class JsonExtensions
99
{
1010
public static JsonNode Prop(this JsonNode obj, string key)
1111
{
12-
var node = obj[key];
12+
var jsonObj = obj.AsObject();
13+
14+
// Try to get the existing node
15+
var node = jsonObj[key];
1316
if (node is not null)
1417
{
1518
return node;
1619
}
1720

21+
// Create a new node and try to add it
1822
node = new JsonObject();
19-
obj.AsObject().Add(key, node);
23+
24+
if (!jsonObj.TryAdd(key, node))
25+
{
26+
node = jsonObj[key];
27+
if (node is null)
28+
{
29+
throw new InvalidOperationException($"Failed to get or create property '{key}'");
30+
}
31+
}
32+
2033
return node;
2134
}
2235
}

0 commit comments

Comments
 (0)