Skip to content

Hook up Azure Deployer to BicepProvisioner #10845

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Aspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@
<Project Path="playground/DatabaseMigration/DatabaseMigration.AppHost/DatabaseMigration.AppHost.csproj" />
<Project Path="playground/DatabaseMigration/DatabaseMigration.MigrationService/DatabaseMigration.MigrationService.csproj" />
</Folder>
<Folder Name="/playground/deployers/">
<Project Path="playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj" />
</Folder>
<Folder Name="/playground/dockerfile/">
<Project Path="playground/withdockerfile/WithDockerfile.AppHost/WithDockerfile.AppHost.csproj" />
</Folder>
Expand Down
22 changes: 22 additions & 0 deletions playground/deployers/Deployers.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
var builder = DistributedApplication.CreateBuilder(args);

builder.AddAzureContainerAppEnvironment("env");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its kind of strange that we add AddAzureContainerAppEnvironment(...) when we aren't using anything other than Azure resources. I realize this is probably just the case for this playground but I wonder if we will see cases of people using apphosts to deploy things without any compute (containers/projects) because the resources they deploy have some built-in compute functionality that they are leveraging.

In which case - does that API name make sense?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see a world where you only only use Aspire deploy to deploy Azure resources and not any actual compute resources. For example, I saw a scenario recently on a static front-end deployed on an Azure Storage instance with Azure Front Door for routing.

We've set up the APIs for AddAcaEnv such that you don't really "discover" you need them until you try to publish or deploy a project with compute resources (or role assignments).

We've really overloaded the term "environment" in our APIs and I'm not sure how we dig ourselves out of it. Although it won't really be exposed in public API to the user until the add compute.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today we need a compute environment or we'll generate invalid bicep. If you provision an azure resource that depends on managed identity (which we do by default) but don't provide a compute environment, we'll generate invalid bicep (with a missing user assigned managed identity).


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<Projects.Aspire_Dashboard>(KnownResourceNames.AspireDashboard);
#endif

builder.Build().Run();
21 changes: 21 additions & 0 deletions playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>

<ItemGroup>
<Compile Include="..\..\KnownResourceNames.cs" Link="KnownResourceNames.cs" />
</ItemGroup>

<ItemGroup>
<AspireProjectOrPackageReference Include="Aspire.Hosting.Azure.AppContainers" />
<AspireProjectOrPackageReference Include="Aspire.Hosting.Azure" />
<AspireProjectOrPackageReference Include="Aspire.Hosting.Azure.Storage" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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",
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions playground/deployers/Deployers.AppHost/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
3 changes: 2 additions & 1 deletion src/Aspire.Hosting.Azure/AzureBicepResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
Expand Down
63 changes: 60 additions & 3 deletions src/Aspire.Hosting.Azure/AzureDeployingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this happen?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently -- never. I left this as an explicit check for when we do #10620.

{
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);
Comment on lines +49 to +50
Copy link
Preview

Copilot AI Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO comment indicates incomplete functionality for handling unresolvable parameters. This could leave the deployment in an inconsistent state without proper parameter resolution.

Suggested change
// TODO: Prompt here.
await deployingStep.FailAsync("Deployment contains unresolvable parameters.", cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(
$"Deployment contains unresolvable parameter: '{provisioningParameter.BicepIdentifier}'. " +
"Please ensure all required parameters are provided or handled before deployment.");

Copilot uses AI. Check for mistakes.

}
}

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;
}
}
}
}
21 changes: 15 additions & 6 deletions src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -17,7 +19,7 @@ namespace Aspire.Hosting.Azure;
/// Emits a <c>main.bicep</c> that aggregates all provisionable resources.
/// </summary>
[Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")]
public sealed class AzureEnvironmentResource : Resource
public sealed class AzureEnvironmentResource : AzureBicepResource
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AzureEnvironmentResource needs to implement AzureBicepResource so we can set parameters and store outputs from the ARM deployment, like the ACR instance associated with a container apps environment for future image pushes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this represents main.bicep now? Should this be an AzureProvisoningResource instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this represents main.bicep now?

Yep.

Should this be an AzureProvisoningResource instead?

AzureProvisioningResource encodes some concepts that I don't think make sense for main.bicep (role assignments, existing resources). Also, main.bicep isn't a provisionable resource itself but more so a set of provisioned resources so I feel like it makes more sense to just model it as a Bicep resource.

Copy link
Member

@davidfowl davidfowl Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not make it one? The underlying logic in AzureProvisioningContext creates an infrastructure object. I don’t think it need to be in this PR

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why it make it one?

We need to be able to set outputs from the deployment onto the AzureEnvironmentResource so that we can propogate them to future steps (e.g. resolving the registry info for the ACR instance that we automatically provision) and we need a shape that matches the API of the AzureBicepProvisioner.

If AzureEnvironmentResource doesn't implement AzureBicepResource, how do you initiate the provisioning of main.bicep without modifying BicepProvisioner?

{
/// <summary>
/// Gets or sets the Azure location that the resources will be deployed to.
Expand All @@ -34,6 +36,8 @@ public sealed class AzureEnvironmentResource : Resource
/// </summary>
public ParameterResource PrincipalId { get; set; }

internal AzurePublishingContext? PublishingContext { get; set; }

/// <summary>
/// Initializes a new instance of the <see cref="AzureEnvironmentResource"/> class.
/// </summary>
Expand All @@ -43,10 +47,11 @@ public sealed class AzureEnvironmentResource : Resource
/// <param name="principalId">The Azure principal ID that will be used to deploy the resources.</param>
/// <exception cref="ArgumentNullException">Thrown when the name is null or empty.</exception>
/// <exception cref="ArgumentException">Thrown when the name is invalid.</exception>
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;
Expand All @@ -57,24 +62,28 @@ private Task PublishAsync(PublishingContext context)
{
var azureProvisioningOptions = context.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>();

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<IProvisioningContextProvider>();
var userSecretsManager = context.Services.GetRequiredService<IUserSecretsManager>();
var bicepProvisioner = context.Services.GetRequiredService<IBicepProvisioner>();
var activityPublisher = context.Services.GetRequiredService<IPublishingActivityReporter>();

var azureCtx = new AzureDeployingContext(
provisioningContextProvider,
userSecretsManager);
userSecretsManager,
bicepProvisioner,
activityPublisher);

return azureCtx.DeployModelAsync(context.CancellationToken);
return azureCtx.DeployModelAsync(this, context.CancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ public static IDistributedApplicationBuilder AddAzureProvisioning(this IDistribu

builder.Services.AddSingleton<ITokenCredentialProvider, DefaultTokenCredentialProvider>();

// Register BicepProvisioner directly
builder.Services.AddSingleton<BicepProvisioner>();
// Register BicepProvisioner via interface
builder.Services.AddSingleton<IBicepProvisioner, BicepProvisioner>();

// Register the new internal services for testability
builder.Services.AddSingleton<IArmClientProvider, DefaultArmClientProvider>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ArmOperation<ArmDeploymentResource>> CreateOrUpdateAsync(
WaitUntil waitUntil,
string deploymentName,
ArmDeploymentContent content,
CancellationToken cancellationToken = default)
{
return armDeploymentCollection.CreateOrUpdateAsync(waitUntil, deploymentName, content, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,9 +29,11 @@ internal sealed partial class DefaultProvisioningContextProvider(
IArmClientProvider armClientProvider,
IUserPrincipalProvider userPrincipalProvider,
ITokenCredentialProvider tokenCredentialProvider,
DistributedApplicationExecutionContext distributedApplicationExecutionContext) : IProvisioningContextProvider
DistributedApplicationExecutionContext distributedApplicationExecutionContext,
IOptions<PublishingOptions> publishingOptions) : IProvisioningContextProvider
{
private readonly AzureProvisionerOptions _options = options.Value;
private readonly PublishingOptions _publishingOptions = publishingOptions.Value;

private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously);

Expand Down Expand Up @@ -259,6 +262,7 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
}

var principal = await userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false);
var outputPath = _publishingOptions.OutputPath is { } outputPathValue ? Path.GetFullPath(outputPathValue) : null;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We past the output path to the ProvisioningContext during deployment so that we can reuse the bicep templates that were generated by the publish step during deployment.


return new ProvisioningContext(
credential,
Expand All @@ -268,7 +272,9 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
tenantResource,
location,
principal,
userSecrets);
userSecrets,
distributedApplicationExecutionContext,
outputPath);
}

private string GetDefaultResourceGroupName()
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -21,16 +18,4 @@ public IArmDeploymentCollection GetArmDeployments()
{
return new DefaultArmDeploymentCollection(resourceGroupResource.GetArmDeployments());
}

private sealed class DefaultArmDeploymentCollection(ArmDeploymentCollection armDeploymentCollection) : IArmDeploymentCollection
{
public Task<ArmOperation<ArmDeploymentResource>> CreateOrUpdateAsync(
WaitUntil waitUntil,
string deploymentName,
ArmDeploymentContent content,
CancellationToken cancellationToken = default)
{
return armDeploymentCollection.CreateOrUpdateAsync(waitUntil, deploymentName, content, cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ internal interface ISubscriptionResource
/// Gets resource groups collection.
/// </summary>
IResourceGroupCollection GetResourceGroups();

/// <summary>
/// Gets ARM deployments collection.
/// </summary>
IArmDeploymentCollection GetArmDeployments();
}

/// <summary>
Expand Down
Loading
Loading