diff --git a/Aspire.slnx b/Aspire.slnx
index 9e145cd7c14..e8fd10007eb 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -161,6 +161,9 @@
+
+
+
diff --git a/playground/deployers/Deployers.AppHost/AppHost.cs b/playground/deployers/Deployers.AppHost/AppHost.cs
new file mode 100644
index 00000000000..911dfe3c44c
--- /dev/null
+++ b/playground/deployers/Deployers.AppHost/AppHost.cs
@@ -0,0 +1,22 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+builder.AddAzureContainerAppEnvironment("env");
+
+var storage = builder.AddAzureStorage("storage");
+
+storage.AddBlobs("blobs");
+storage.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1");
+storage.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2");
+storage.AddQueue("myqueue", queueName: "my-queue");
+
+#if !SKIP_DASHBOARD_REFERENCE
+// This project is only added in playground projects to support development/debugging
+// of the dashboard. It is not required in end developer code. Comment out this code
+// or build with `/p:SkipDashboardReference=true`, to test end developer
+// dashboard launch experience, Refer to Directory.Build.props for the path to
+// the dashboard binary (defaults to the Aspire.Dashboard bin output in the
+// artifacts dir).
+builder.AddProject(KnownResourceNames.AspireDashboard);
+#endif
+
+builder.Build().Run();
diff --git a/playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj b/playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj
new file mode 100644
index 00000000000..75841a8532f
--- /dev/null
+++ b/playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ $(DefaultTargetFramework)
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/deployers/Deployers.AppHost/Properties/launchSettings.json b/playground/deployers/Deployers.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000000..79731a8c75e
--- /dev/null
+++ b/playground/deployers/Deployers.AppHost/Properties/launchSettings.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17222;http://localhost:15020",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21267",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22116"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15020",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19093",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20094"
+ }
+ },
+ "deploy": {
+ "commandName": "Project",
+ "commandLineArgs": "--publisher default --deploy true --output-path deploy",
+ }
+ }
+}
diff --git a/playground/deployers/Deployers.AppHost/appsettings.Development.json b/playground/deployers/Deployers.AppHost/appsettings.Development.json
new file mode 100644
index 00000000000..0c208ae9181
--- /dev/null
+++ b/playground/deployers/Deployers.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/playground/deployers/Deployers.AppHost/appsettings.json b/playground/deployers/Deployers.AppHost/appsettings.json
new file mode 100644
index 00000000000..31c092aa450
--- /dev/null
+++ b/playground/deployers/Deployers.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs
index af15f13767c..028e256d53f 100644
--- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs
+++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs
@@ -120,7 +120,8 @@ public virtual BicepTemplateFile GetBicepTemplateFile(string? directory = null,
}
}
- return new(path, isTempFile && deleteTemporaryFileOnDispose);
+ var targetPath = directory is not null ? Path.Combine(directory, path) : path;
+ return new(targetPath, isTempFile && deleteTemporaryFileOnDispose);
}
///
diff --git a/src/Aspire.Hosting.Azure/AzureDeployingContext.cs b/src/Aspire.Hosting.Azure/AzureDeployingContext.cs
index 056447eff37..03efbc4b623 100644
--- a/src/Aspire.Hosting.Azure/AzureDeployingContext.cs
+++ b/src/Aspire.Hosting.Azure/AzureDeployingContext.cs
@@ -4,17 +4,74 @@
#pragma warning disable ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+using Aspire.Hosting.Azure.Provisioning;
using Aspire.Hosting.Azure.Provisioning.Internal;
+using Aspire.Hosting.Publishing;
namespace Aspire.Hosting.Azure;
internal sealed class AzureDeployingContext(
IProvisioningContextProvider provisioningContextProvider,
- IUserSecretsManager userSecretsManager)
+ IUserSecretsManager userSecretsManager,
+ IBicepProvisioner bicepProvisioner,
+ IPublishingActivityReporter activityReporter)
{
- public async Task DeployModelAsync(CancellationToken cancellationToken = default)
+ public async Task DeployModelAsync(AzureEnvironmentResource resource, CancellationToken cancellationToken = default)
{
var userSecrets = await userSecretsManager.LoadUserSecretsAsync(cancellationToken).ConfigureAwait(false);
- await provisioningContextProvider.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false);
+ var provisioningContext = await provisioningContextProvider.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false);
+
+ if (resource.PublishingContext is null)
+ {
+ throw new InvalidOperationException($"Publishing context is not initialized. Please ensure that the {nameof(AzurePublishingContext)} has been initialized before deploying.");
+ }
+
+ var deployingStep = await activityReporter.CreateStepAsync("Deploying to Azure", cancellationToken).ConfigureAwait(false);
+ await using (deployingStep.ConfigureAwait(false))
+ {
+ // Map parameters from the AzurePublishingContext
+ foreach (var (parameterResource, provisioningParameter) in resource.PublishingContext.ParameterLookup)
+ {
+ if (parameterResource == resource.Location)
+ {
+ resource.Parameters[provisioningParameter.BicepIdentifier] = provisioningContext.Location.Name;
+ }
+ else if (parameterResource == resource.ResourceGroupName)
+ {
+ resource.Parameters[provisioningParameter.BicepIdentifier] = provisioningContext.ResourceGroup.Name;
+ }
+ else if (parameterResource == resource.PrincipalId)
+ {
+ resource.Parameters[provisioningParameter.BicepIdentifier] = provisioningContext.Principal.Id.ToString();
+ }
+ else
+ {
+ // TODO: Prompt here.
+ await deployingStep.FailAsync("Deployment contains unresolvable parameters.", cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ try
+ {
+ var azureTask = await deployingStep.CreateTaskAsync("Provisioning Azure environment", cancellationToken).ConfigureAwait(false);
+ await using (azureTask.ConfigureAwait(false))
+ {
+ try
+ {
+ await bicepProvisioner.GetOrCreateResourceAsync(resource, provisioningContext, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ await azureTask.FailAsync($"Provisioning failed: {ex.Message}", cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ await deployingStep.FailAsync($"Deployment failed: {ex.Message}", cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+ }
}
}
diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
index 76359a14d6e..af2c613e40f 100644
--- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
+++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
@@ -6,7 +6,9 @@
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Azure.Provisioning;
using Aspire.Hosting.Azure.Provisioning.Internal;
+using Aspire.Hosting.Publishing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@@ -17,7 +19,7 @@ namespace Aspire.Hosting.Azure;
/// Emits a main.bicep that aggregates all provisionable resources.
///
[Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")]
-public sealed class AzureEnvironmentResource : Resource
+public sealed class AzureEnvironmentResource : AzureBicepResource
{
///
/// Gets or sets the Azure location that the resources will be deployed to.
@@ -34,6 +36,8 @@ public sealed class AzureEnvironmentResource : Resource
///
public ParameterResource PrincipalId { get; set; }
+ internal AzurePublishingContext? PublishingContext { get; set; }
+
///
/// Initializes a new instance of the class.
///
@@ -43,10 +47,11 @@ public sealed class AzureEnvironmentResource : Resource
/// The Azure principal ID that will be used to deploy the resources.
/// Thrown when the name is null or empty.
/// Thrown when the name is invalid.
- public AzureEnvironmentResource(string name, ParameterResource location, ParameterResource resourceGroupName, ParameterResource principalId) : base(name)
+ public AzureEnvironmentResource(string name, ParameterResource location, ParameterResource resourceGroupName, ParameterResource principalId) : base(name, templateFile: "main.bicep")
{
Annotations.Add(new PublishingCallbackAnnotation(PublishAsync));
Annotations.Add(new DeployingCallbackAnnotation(DeployAsync));
+ Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore);
Location = location;
ResourceGroupName = resourceGroupName;
@@ -57,24 +62,28 @@ private Task PublishAsync(PublishingContext context)
{
var azureProvisioningOptions = context.Services.GetRequiredService>();
- var azureCtx = new AzurePublishingContext(
+ PublishingContext = new AzurePublishingContext(
context.OutputPath,
azureProvisioningOptions.Value,
context.Logger,
context.ActivityReporter);
- return azureCtx.WriteModelAsync(context.Model, this);
+ return PublishingContext.WriteModelAsync(context.Model, this);
}
private Task DeployAsync(DeployingContext context)
{
var provisioningContextProvider = context.Services.GetRequiredService();
var userSecretsManager = context.Services.GetRequiredService();
+ var bicepProvisioner = context.Services.GetRequiredService();
+ var activityPublisher = context.Services.GetRequiredService();
var azureCtx = new AzureDeployingContext(
provisioningContextProvider,
- userSecretsManager);
+ userSecretsManager,
+ bicepProvisioner,
+ activityPublisher);
- return azureCtx.DeployModelAsync(context.CancellationToken);
+ return azureCtx.DeployModelAsync(this, context.CancellationToken);
}
}
diff --git a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs
index defb3781102..8183a1e7454 100644
--- a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs
+++ b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs
@@ -251,6 +251,11 @@ static BicepValue ResolveValue(object val)
module.Parameters.Add(parameter.Key, principalId);
continue;
}
+ if (parameter.Key == AzureBicepResource.KnownParameters.PrincipalId && parameter.Value is null)
+ {
+ module.Parameters.Add(parameter.Key, principalId);
+ continue;
+ }
var value = ResolveValue(Eval(parameter.Value));
diff --git a/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs b/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs
index 05d99857256..61bfd7a6d08 100644
--- a/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs
+++ b/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs
@@ -36,8 +36,8 @@ public static IDistributedApplicationBuilder AddAzureProvisioning(this IDistribu
builder.Services.AddSingleton();
- // Register BicepProvisioner directly
- builder.Services.AddSingleton();
+ // Register BicepProvisioner via interface
+ builder.Services.AddSingleton();
// Register the new internal services for testability
builder.Services.AddSingleton();
diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs
new file mode 100644
index 00000000000..c948ddf6d91
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Azure;
+using Azure.ResourceManager;
+using Azure.ResourceManager.Resources;
+using Azure.ResourceManager.Resources.Models;
+
+namespace Aspire.Hosting.Azure.Provisioning.Internal;
+
+internal sealed class DefaultArmDeploymentCollection(ArmDeploymentCollection armDeploymentCollection) : IArmDeploymentCollection
+{
+ public Task> CreateOrUpdateAsync(
+ WaitUntil waitUntil,
+ string deploymentName,
+ ArmDeploymentContent content,
+ CancellationToken cancellationToken = default)
+ {
+ return armDeploymentCollection.CreateOrUpdateAsync(waitUntil, deploymentName, content, cancellationToken);
+ }
+}
diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs
index 75b44e9cfbc..7970074d596 100644
--- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs
+++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs
@@ -8,6 +8,7 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Aspire.Hosting.Azure.Utils;
+using Aspire.Hosting.Publishing;
using Azure;
using Azure.Core;
using Azure.ResourceManager.Resources;
@@ -28,9 +29,11 @@ internal sealed partial class DefaultProvisioningContextProvider(
IArmClientProvider armClientProvider,
IUserPrincipalProvider userPrincipalProvider,
ITokenCredentialProvider tokenCredentialProvider,
- DistributedApplicationExecutionContext distributedApplicationExecutionContext) : IProvisioningContextProvider
+ DistributedApplicationExecutionContext distributedApplicationExecutionContext,
+ IOptions publishingOptions) : IProvisioningContextProvider
{
private readonly AzureProvisionerOptions _options = options.Value;
+ private readonly PublishingOptions _publishingOptions = publishingOptions.Value;
private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -259,6 +262,7 @@ public async Task CreateProvisioningContextAsync(JsonObject
}
var principal = await userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false);
+ var outputPath = _publishingOptions.OutputPath is { } outputPathValue ? Path.GetFullPath(outputPathValue) : null;
return new ProvisioningContext(
credential,
@@ -268,7 +272,9 @@ public async Task CreateProvisioningContextAsync(JsonObject
tenantResource,
location,
principal,
- userSecrets);
+ userSecrets,
+ distributedApplicationExecutionContext,
+ outputPath);
}
private string GetDefaultResourceGroupName()
diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs
index 2eecd0442b1..5ec8d1f7e1c 100644
--- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs
+++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs
@@ -1,11 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using Azure;
using Azure.Core;
-using Azure.ResourceManager;
using Azure.ResourceManager.Resources;
-using Azure.ResourceManager.Resources.Models;
namespace Aspire.Hosting.Azure.Provisioning.Internal;
@@ -21,16 +18,4 @@ public IArmDeploymentCollection GetArmDeployments()
{
return new DefaultArmDeploymentCollection(resourceGroupResource.GetArmDeployments());
}
-
- private sealed class DefaultArmDeploymentCollection(ArmDeploymentCollection armDeploymentCollection) : IArmDeploymentCollection
- {
- public Task> CreateOrUpdateAsync(
- WaitUntil waitUntil,
- string deploymentName,
- ArmDeploymentContent content,
- CancellationToken cancellationToken = default)
- {
- return armDeploymentCollection.CreateOrUpdateAsync(waitUntil, deploymentName, content, cancellationToken);
- }
- }
}
diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultSubscriptionResource.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultSubscriptionResource.cs
index 6eb02cedd2d..6ad5d97821d 100644
--- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultSubscriptionResource.cs
+++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultSubscriptionResource.cs
@@ -17,6 +17,11 @@ internal sealed class DefaultSubscriptionResource(SubscriptionResource subscript
public string? DisplayName => subscriptionResource.Data.DisplayName;
public Guid? TenantId => subscriptionResource.Data.TenantId;
+ public IArmDeploymentCollection GetArmDeployments()
+ {
+ return new DefaultArmDeploymentCollection(subscriptionResource.GetArmDeployments());
+ }
+
public IResourceGroupCollection GetResourceGroups()
{
return new DefaultResourceGroupCollection(subscriptionResource.GetResourceGroups());
diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs
index 3578a3daf31..06e72e3cde0 100644
--- a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs
+++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs
@@ -106,6 +106,11 @@ internal interface ISubscriptionResource
/// Gets resource groups collection.
///
IResourceGroupCollection GetResourceGroups();
+
+ ///
+ /// Gets ARM deployments collection.
+ ///
+ IArmDeploymentCollection GetArmDeployments();
}
///
diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs
index 99a8ea61eff..8a13f998170 100644
--- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs
+++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs
@@ -16,7 +16,7 @@ internal sealed class AzureProvisioner(
DistributedApplicationExecutionContext executionContext,
IConfiguration configuration,
IServiceProvider serviceProvider,
- BicepProvisioner bicepProvisioner,
+ IBicepProvisioner bicepProvisioner,
ResourceNotificationService notificationService,
ResourceLoggerService loggerService,
IDistributedApplicationEventing eventing,
diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs
index 5d84aa3ad43..56d1e4576be 100644
--- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs
+++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs
@@ -17,8 +17,9 @@ internal sealed class BicepProvisioner(
ResourceNotificationService notificationService,
ResourceLoggerService loggerService,
IBicepCompiler bicepCompiler,
- ISecretClientProvider secretClientProvider)
+ ISecretClientProvider secretClientProvider) : IBicepProvisioner
{
+ ///
public async Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken)
{
var section = configuration.GetSection($"Azure:Deployments:{resource.Name}");
@@ -98,6 +99,7 @@ await notificationService.PublishUpdateAsync(resource, state =>
return true;
}
+ ///
public async Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken)
{
var resourceGroup = context.ResourceGroup;
@@ -124,7 +126,7 @@ await notificationService.PublishUpdateAsync(resource, state => state with
])
}).ConfigureAwait(false);
- var template = resource.GetBicepTemplateFile();
+ var template = resource.GetBicepTemplateFile(context.OutputPath);
var path = template.Path;
// GetBicepTemplateFile may have added new well-known parameters, so we need
@@ -162,15 +164,28 @@ await notificationService.PublishUpdateAsync(resource, state =>
resourceLogger.LogInformation("Deploying {Name} to {ResourceGroup}", resource.Name, resourceGroup.Name);
- var deployments = resourceGroup.GetArmDeployments();
+ // Deploy-time provisioning should target the subscription scope while run-time
+ // provisioning should target the resource group scope.
+ var deployments = context.ExecutionContext.IsPublishMode
+ ? context.Subscription.GetArmDeployments()
+ : resourceGroup.GetArmDeployments();
+ var deploymentName = resource.Name;
- var operation = await deployments.CreateOrUpdateAsync(WaitUntil.Started, resource.Name, new ArmDeploymentContent(new(ArmDeploymentMode.Incremental)
+ var deploymentContent = new ArmDeploymentContent(new(ArmDeploymentMode.Incremental)
{
Template = BinaryData.FromString(armTemplateContents),
Parameters = BinaryData.FromObjectAsJson(parameters),
DebugSettingDetailLevel = "ResponseContent"
- }),
- cancellationToken).ConfigureAwait(false);
+ });
+ // Only set the location for publish mode deployments
+ // and set the deployment name to include the resource group name
+ // hashed with the current Unix timestamp
+ if (context.ExecutionContext.IsPublishMode)
+ {
+ deploymentContent.Location = context.Location;
+ deploymentName = $"{resourceGroup.Name}-{DateTimeOffset.Now.ToUnixTimeSeconds()}";
+ }
+ var operation = await deployments.CreateOrUpdateAsync(WaitUntil.Started, deploymentName, deploymentContent, cancellationToken).ConfigureAwait(false);
// Resolve the deployment URL before waiting for the operation to complete
var url = GetDeploymentUrl(context, resourceGroup, resource.Name);
@@ -198,7 +213,10 @@ await notificationService.PublishUpdateAsync(resource, state =>
if (deployment.Data.Properties.ProvisioningState == ResourcesProvisioningState.Succeeded)
{
- template.Dispose();
+ if (context.ExecutionContext.IsRunMode)
+ {
+ template.Dispose();
+ }
}
else
{
@@ -206,40 +224,43 @@ await notificationService.PublishUpdateAsync(resource, state =>
}
// e.g. { "sqlServerName": { "type": "String", "value": "" }}
-
var outputObj = outputs?.ToObjectFromJson();
- var az = context.UserSecrets.Prop("Azure");
- az["Tenant"] = context.Tenant.DefaultDomain;
+ // Populate values into user-secrets during run mode
+ if (context.ExecutionContext.IsRunMode)
+ {
+ var az = context.UserSecrets.Prop("Azure");
+ az["Tenant"] = context.Tenant.DefaultDomain;
- var resourceConfig = context.UserSecrets
- .Prop("Azure")
- .Prop("Deployments")
- .Prop(resource.Name);
+ var resourceConfig = context.UserSecrets
+ .Prop("Azure")
+ .Prop("Deployments")
+ .Prop(resource.Name);
- // Clear the entire section
- resourceConfig.AsObject().Clear();
+ // Clear the entire section
+ resourceConfig.AsObject().Clear();
- // Save the deployment id to the configuration
- resourceConfig["Id"] = deployment.Id.ToString();
+ // Save the deployment id to the configuration
+ resourceConfig["Id"] = deployment.Id.ToString();
- // Stash all parameters as a single JSON string
- resourceConfig["Parameters"] = parameters.ToJsonString();
+ // Stash all parameters as a single JSON string
+ resourceConfig["Parameters"] = parameters.ToJsonString();
- if (outputObj is not null)
- {
- // Same for outputs
- resourceConfig["Outputs"] = outputObj.ToJsonString();
- }
+ if (outputObj is not null)
+ {
+ // Same for outputs
+ resourceConfig["Outputs"] = outputObj.ToJsonString();
+ }
- // Write resource scope to config for consistent checksums
- if (scope is not null)
- {
- resourceConfig["Scope"] = scope.ToJsonString();
- }
+ // Write resource scope to config for consistent checksums
+ if (scope is not null)
+ {
+ resourceConfig["Scope"] = scope.ToJsonString();
+ }
- // Save the checksum to the configuration
- resourceConfig["CheckSum"] = BicepUtilities.GetChecksum(resource, parameters, scope);
+ // Save the checksum to the configuration
+ resourceConfig["CheckSum"] = BicepUtilities.GetChecksum(resource, parameters, scope);
+ }
if (outputObj is not null)
{
diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs
new file mode 100644
index 00000000000..567fe6e3c1a
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Configuration;
+
+namespace Aspire.Hosting.Azure.Provisioning;
+
+///
+/// Provides functionality for provisioning Azure Bicep resources.
+///
+internal interface IBicepProvisioner
+{
+ ///
+ /// Configures an Azure Bicep resource from configuration settings.
+ ///
+ /// The configuration containing Azure deployment settings.
+ /// The Azure Bicep resource to configure.
+ /// A cancellation token to cancel the operation.
+ /// A task that represents the asynchronous operation. The task result contains a value indicating whether the resource was successfully configured.
+ Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken);
+
+ ///
+ /// Gets an existing resource or creates a new Azure Bicep resource.
+ ///
+ /// The Azure Bicep resource to get or create.
+ /// The provisioning context containing Azure subscription, resource group, and other deployment details.
+ /// A cancellation token to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken);
+}
diff --git a/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs b/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs
index 8cee595a72e..f995fdd29c7 100644
--- a/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs
+++ b/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs
@@ -17,7 +17,9 @@ internal sealed class ProvisioningContext(
ITenantResource tenant,
AzureLocation location,
UserPrincipal principal,
- JsonObject userSecrets)
+ JsonObject userSecrets,
+ DistributedApplicationExecutionContext executionContext,
+ string? outputPath)
{
public TokenCredential Credential => credential;
public IArmClient ArmClient => armClient;
@@ -27,4 +29,11 @@ internal sealed class ProvisioningContext(
public AzureLocation Location => location;
public UserPrincipal Principal => principal;
public JsonObject UserSecrets => userSecrets;
-}
\ No newline at end of file
+ public DistributedApplicationExecutionContext ExecutionContext => executionContext;
+ ///
+ /// The directory containing the Bicep template files to
+ /// deploy. If not specified, a temporary directory will be used
+ /// for run-mode provisioning. Typically set for deploy-time provisioning.
+ ///
+ public string? OutputPath => outputPath;
+}
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs
index 59128dfceab..fea2e9b6278 100644
--- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs
@@ -12,6 +12,8 @@
using Aspire.Hosting.Azure.Provisioning.Internal;
using Aspire.Hosting.Testing;
using System.Text.Json.Nodes;
+using Aspire.Hosting.Azure.Provisioning;
+using Microsoft.Extensions.Configuration;
namespace Aspire.Hosting.Azure.Tests;
@@ -126,6 +128,7 @@ private static void ConfigureTestServices(IDistributedApplicationTestingBuilder
builder.Services.AddSingleton(interactionService);
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
}
private sealed class NoOpUserSecretsManager : IUserSecretsManager
@@ -134,4 +137,17 @@ private sealed class NoOpUserSecretsManager : IUserSecretsManager
public Task SaveUserSecretsAsync(JsonObject userSecrets, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
+
+ private sealed class NoOpBicepProvisioner : IBicepProvisioner
+ {
+ public Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(true);
+ }
+
+ public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+ }
}
diff --git a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs
index 97eede45522..9f68614e9c8 100644
--- a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs
@@ -20,6 +20,7 @@ public async Task CreateProvisioningContextAsync_ReturnsValidContext()
{
// Arrange
var options = ProvisioningTestHelpers.CreateOptions();
+ var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions();
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
@@ -35,7 +36,8 @@ public async Task CreateProvisioningContextAsync_ReturnsValidContext()
armClientProvider,
userPrincipalProvider,
tokenCredentialProvider,
- new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run));
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ publishingOptions);
// Act
var context = await provider.CreateProvisioningContextAsync(userSecrets);
@@ -58,6 +60,7 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenSubscriptionIdMissing
{
// Arrange
var options = ProvisioningTestHelpers.CreateOptions(subscriptionId: null);
+ var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions();
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
@@ -73,7 +76,8 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenSubscriptionIdMissing
armClientProvider,
userPrincipalProvider,
tokenCredentialProvider,
- new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run));
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ publishingOptions);
// Act & Assert
var exception = await Assert.ThrowsAsync(
@@ -86,6 +90,7 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenLocationMissing()
{
// Arrange
var options = ProvisioningTestHelpers.CreateOptions(location: null);
+ var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions();
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
@@ -101,7 +106,8 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenLocationMissing()
armClientProvider,
userPrincipalProvider,
tokenCredentialProvider,
- new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run));
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ publishingOptions);
// Act & Assert
var exception = await Assert.ThrowsAsync(
@@ -114,6 +120,7 @@ public async Task CreateProvisioningContextAsync_GeneratesResourceGroupNameWhenN
{
// Arrange
var options = ProvisioningTestHelpers.CreateOptions(resourceGroup: null);
+ var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions();
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
@@ -129,7 +136,8 @@ public async Task CreateProvisioningContextAsync_GeneratesResourceGroupNameWhenN
armClientProvider,
userPrincipalProvider,
tokenCredentialProvider,
- new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run));
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ publishingOptions);
// Act
var context = await provider.CreateProvisioningContextAsync(userSecrets);
@@ -150,6 +158,7 @@ public async Task CreateProvisioningContextAsync_UsesProvidedResourceGroupName()
// Arrange
var resourceGroupName = "my-custom-rg";
var options = ProvisioningTestHelpers.CreateOptions(resourceGroup: resourceGroupName);
+ var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions();
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
@@ -165,7 +174,8 @@ public async Task CreateProvisioningContextAsync_UsesProvidedResourceGroupName()
armClientProvider,
userPrincipalProvider,
tokenCredentialProvider,
- new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run));
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ publishingOptions);
// Act
var context = await provider.CreateProvisioningContextAsync(userSecrets);
@@ -180,6 +190,7 @@ public async Task CreateProvisioningContextAsync_RetrievesUserPrincipal()
{
// Arrange
var options = ProvisioningTestHelpers.CreateOptions();
+ var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions();
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
@@ -195,7 +206,8 @@ public async Task CreateProvisioningContextAsync_RetrievesUserPrincipal()
armClientProvider,
userPrincipalProvider,
tokenCredentialProvider,
- new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run));
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ publishingOptions);
// Act
var context = await provider.CreateProvisioningContextAsync(userSecrets);
@@ -211,6 +223,7 @@ public async Task CreateProvisioningContextAsync_SetsCorrectTenant()
{
// Arrange
var options = ProvisioningTestHelpers.CreateOptions();
+ var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions();
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
@@ -226,7 +239,8 @@ public async Task CreateProvisioningContextAsync_SetsCorrectTenant()
armClientProvider,
userPrincipalProvider,
tokenCredentialProvider,
- new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run));
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ publishingOptions);
// Act
var context = await provider.CreateProvisioningContextAsync(userSecrets);
@@ -243,6 +257,7 @@ public async Task CreateProvisioningContextAsync_PromptsIfNoOptions()
// Arrange
var testInteractionService = new TestInteractionService();
var options = ProvisioningTestHelpers.CreateOptions(null, null, null);
+ var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions();
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
@@ -258,7 +273,8 @@ public async Task CreateProvisioningContextAsync_PromptsIfNoOptions()
armClientProvider,
userPrincipalProvider,
tokenCredentialProvider,
- new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run));
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ publishingOptions);
// Act
var createTask = provider.CreateProvisioningContextAsync(userSecrets);
@@ -317,6 +333,7 @@ public async Task CreateProvisioningContextAsync_Prompt_ValidatesSubAndResourceG
{
var testInteractionService = new TestInteractionService();
var options = ProvisioningTestHelpers.CreateOptions(null, null, null);
+ var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions();
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
@@ -332,7 +349,8 @@ public async Task CreateProvisioningContextAsync_Prompt_ValidatesSubAndResourceG
armClientProvider,
userPrincipalProvider,
tokenCredentialProvider,
- new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run));
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ publishingOptions);
var createTask = provider.CreateProvisioningContextAsync(userSecrets);
diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs
index b49c6020172..5d56cf27b13 100644
--- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs
@@ -7,6 +7,7 @@
using System.Text.Json.Nodes;
using Aspire.Hosting.Azure.Provisioning;
using Aspire.Hosting.Azure.Provisioning.Internal;
+using Aspire.Hosting.Publishing;
using Azure;
using Azure.Core;
using Azure.ResourceManager;
@@ -37,7 +38,9 @@ public static ProvisioningContext CreateTestProvisioningContext(
ITenantResource? tenant = null,
AzureLocation? location = null,
UserPrincipal? principal = null,
- JsonObject? userSecrets = null)
+ JsonObject? userSecrets = null,
+ DistributedApplicationExecutionContext? executionContext = null,
+ string? outputPath = null)
{
return new ProvisioningContext(
credential ?? new TestTokenCredential(),
@@ -47,7 +50,9 @@ public static ProvisioningContext CreateTestProvisioningContext(
tenant ?? new TestTenantResource(),
location ?? AzureLocation.WestUS2,
principal ?? new UserPrincipal(Guid.NewGuid(), "test@example.com"),
- userSecrets ?? new JsonObject());
+ userSecrets ?? new JsonObject(),
+ executionContext ?? new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ outputPath);
}
// Factory methods for test implementations of provisioning services interfaces
@@ -76,6 +81,16 @@ public static IOptions CreateOptions(
return Options.Create(options);
}
+ public static IOptions CreatePublishingOptions(
+ string? outputPath = null)
+ {
+ var options = new PublishingOptions
+ {
+ OutputPath = outputPath,
+ };
+ return Options.Create(options);
+ }
+
///
/// Creates a test host environment.
///
@@ -169,6 +184,11 @@ internal sealed class TestSubscriptionResource : ISubscriptionResource
public string? DisplayName { get; } = "Test Subscription";
public Guid? TenantId { get; } = Guid.Parse("87654321-4321-4321-4321-210987654321");
+ public IArmDeploymentCollection GetArmDeployments()
+ {
+ return new TestArmDeploymentCollection();
+ }
+
public IResourceGroupCollection GetResourceGroups()
{
return new TestResourceGroupCollection();
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_IgnoresAzureBicepResourcesWithIgnoreAnnotation.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_IgnoresAzureBicepResourcesWithIgnoreAnnotation.verified.bicep
index 29020972b6d..9abb39e9ff8 100644
--- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_IgnoresAzureBicepResourcesWithIgnoreAnnotation.verified.bicep
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_IgnoresAzureBicepResourcesWithIgnoreAnnotation.verified.bicep
@@ -26,6 +26,6 @@ module included_storage_roles 'included-storage-roles/included-storage-roles.bic
location: location
included_storage_outputs_name: included_storage.outputs.name
principalType: ''
- principalId: ''
+ principalId: principalId
}
}
\ No newline at end of file