Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3aa796d
Added support for Aspire dashboard in App Service
ShilpiR Sep 26, 2025
4626741
Fixed failing tests
ShilpiR Sep 26, 2025
2baefd2
Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironment…
ShilpiRach Sep 26, 2025
f21c564
Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironment…
ShilpiRach Sep 26, 2025
622ba2d
Added option to exclude dashboard and moved contributor identity to d…
ShilpiR Sep 30, 2025
c0cca3a
Added appsetting to suppress unsecured warning
ShilpiR Sep 30, 2025
45968a4
Merge branch 'main' into shilpirach/appsvc_dashboard
ShilpiRach Sep 30, 2025
a669727
Fixed failing unit tests
ShilpiR Sep 30, 2025
77aa4a1
Merge branch 'shilpirach/appsvc_dashboard' of https://github.com/Shil…
ShilpiR Sep 30, 2025
a554e77
Made parameter dashboardUri conditional in web app bicep templates
ShilpiR Oct 3, 2025
d1eac7c
Publishing DashboardUri paramater only when dashboard is enabled
ShilpiR Oct 3, 2025
df38b1c
Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironment…
ShilpiRach Oct 3, 2025
26793ea
Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironment…
ShilpiRach Oct 3, 2025
ae6fec2
Handled PR feedback and added support to log dashboard Uri
ShilpiR Oct 3, 2025
884857c
Updated a comment
ShilpiR Oct 3, 2025
da816ef
Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironment…
davidfowl Oct 4, 2025
ab80970
Merge branch 'dotnet:main' into shilpirach/appsvc_dashboard
ShilpiRach Oct 6, 2025
e43750e
Handled scenario where there are multiple Aspire environments in a si…
ShilpiR Oct 6, 2025
5f553ef
Merge from aspire repo (main)
ShilpiR Oct 6, 2025
9d3200b
Added Reader role assignment to managed identity for dashboard
ShilpiR Oct 6, 2025
378a9d7
Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteCont…
ShilpiRach Oct 6, 2025
c39fac6
Taking a nit change
ShilpiR Oct 6, 2025
96f5683
Merge branch 'shilpirach/appsvc_dashboard' of https://github.com/Shil…
ShilpiR Oct 7, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
var resource = new AzureAppServiceEnvironmentResource(name, static infra =>
{
var prefix = infra.AspireResource.Name;
var resource = infra.AspireResource;
var resource = (AzureAppServiceEnvironmentResource)infra.AspireResource;

// This tells azd to avoid creating infrastructure
var userPrincipalId = new ProvisioningParameter(AzureBicepResource.KnownParameters.UserPrincipalId, typeof(string)) { Value = new BicepValue<string>(string.Empty) };
Expand Down Expand Up @@ -96,7 +96,9 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
Tier = "Premium"
},
Kind = "Linux",
IsReserved = true
IsReserved = true,
// Enable per-site scaling so each app service can scale independently
IsPerSiteScaling = true
};

infra.Add(plan);
Expand All @@ -111,6 +113,12 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
Value = plan.Id
});

if (resource.EnableDashboard)
{
// Add aspire dashboard website
var website = AzureAppServiceEnvironmentUtility.AddDashboard(infra, identity, plan.Id);
}

infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_NAME", typeof(string))
{
Value = containerRegistry.Name
Expand All @@ -131,6 +139,16 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
{
Value = identity.ClientId
});

infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_NAME", typeof(string))
{
Value = identity.Name
});

infra.Add(new ProvisioningOutput("DASHBOARD_URI", typeof(string))
{
Value = BicepFunction.Interpolate($"https://{AzureAppServiceEnvironmentUtility.DashboardHostName}.azurewebsites.net")
});
});

if (!builder.ExecutionContext.IsPublishMode)
Expand All @@ -140,4 +158,16 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe

return builder.AddResource(resource);
}

/// <summary>
/// Configures whether the Aspire dashboard should be included in the Azure App Service environment.
/// </summary>
/// <param name="builder">The AzureAppServiceEnvironmentResource to configure.</param>
/// <param name="enable">Whether to include the Aspire dashboard. Default is true.</param>
/// <returns><see cref="IResourceBuilder{T}"/></returns>
public static IResourceBuilder<AzureAppServiceEnvironmentResource> WithDashboard(this IResourceBuilder<AzureAppServiceEnvironmentResource> builder, bool enable = true)
{
builder.Resource.EnableDashboard = enable;
return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,22 @@ public class AzureAppServiceEnvironmentResource(string name, Action<AzureResourc
internal BicepOutputReference ContainerRegistryManagedIdentityId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", this);
internal BicepOutputReference ContainerRegistryClientId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID", this);

/// <summary>
/// Gets or sets a value indicating whether the Aspire dashboard should be included in the container app environment.
/// Default is true.
/// </summary>
internal bool EnableDashboard { get; set; } = true;

/// <summary>
/// Gets the name of the App Service Plan.
/// </summary>
public BicepOutputReference NameOutputReference => new("name", this);

/// <summary>
/// Gets the URI of the App Service Environment dashboard.
/// </summary>
public BicepOutputReference DashboardUriReference => new("DASHBOARD_URI", this);

ReferenceExpression IAzureContainerRegistry.ManagedIdentityId =>
ReferenceExpression.Create($"{ContainerRegistryManagedIdentityId}");

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Azure.Core;
using Azure.Provisioning;
using Azure.Provisioning.AppService;
using Azure.Provisioning.Authorization;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Resources;
using Azure.Provisioning.Roles;

namespace Aspire.Hosting.Azure.AppService;

internal static class AzureAppServiceEnvironmentUtility
{
const string ResourceName = "dashboard";

public static BicepValue<string> DashboardHostName => BicepFunction.Take(
BicepFunction.Interpolate($"{BicepFunction.ToLower(ResourceName)}-{BicepFunction.GetUniqueString(BicepFunction.GetResourceGroup().Id)}"), 60);

public static WebSite AddDashboard(AzureResourceInfrastructure infra,
UserAssignedIdentity otelIdentity,
BicepValue<ResourceIdentifier> appServicePlanId)
{
var acrClientIdParameter = otelIdentity.ClientId;
var prefix = infra.AspireResource.Name;
var contributorIdentity = new UserAssignedIdentity(Infrastructure.NormalizeBicepIdentifier($"{prefix}-contributor-mi"));

var contributorMidParameter = contributorIdentity.Id;
var contributorClientIdParameter = contributorIdentity.ClientId;

infra.Add(contributorIdentity);

// Add Website Contributor role assignment
var rgRaId = BicepFunction.GetSubscriptionResourceId(
"Microsoft.Authorization/roleDefinitions",
"de139f84-1756-47ae-9be6-808fbbe84772");
var rgRaName = BicepFunction.CreateGuid(BicepFunction.GetResourceGroup().Id, contributorIdentity.Id, rgRaId);
var rgRa = new RoleAssignment(Infrastructure.NormalizeBicepIdentifier($"{prefix}_ra"))
{
Name = rgRaName,
PrincipalType = RoleManagementPrincipalType.ServicePrincipal,
PrincipalId = contributorIdentity.PrincipalId,
RoleDefinitionId = rgRaId,
};

infra.Add(rgRa);

// Add Reader role assignment
var rgRaId2 = BicepFunction.GetSubscriptionResourceId(
"Microsoft.Authorization/roleDefinitions",
"acdd72a7-3385-48ef-bd42-f606fba81ae7");
var rgRaName2 = BicepFunction.CreateGuid(BicepFunction.GetResourceGroup().Id, contributorIdentity.Id, rgRaId2);

var rgRa2 = new RoleAssignment(Infrastructure.NormalizeBicepIdentifier($"{prefix}_ra2"))
{
Name = rgRaName2,
PrincipalType = RoleManagementPrincipalType.ServicePrincipal,
PrincipalId = contributorIdentity.PrincipalId,
RoleDefinitionId = rgRaId2
};

infra.Add(rgRa2);

var webSite = new WebSite("webapp")
{
// Use the host name as the name of the web app
Name = DashboardHostName,
AppServicePlanId = appServicePlanId,
// Aspire dashboards are created with a new kind aspiredashboard
Kind = "app,linux,aspiredashboard",
SiteConfig = new SiteConfigProperties()
{
LinuxFxVersion = "ASPIREDASHBOARD|1.0",
AcrUserManagedIdentityId = acrClientIdParameter,
UseManagedIdentityCreds = true,
IsHttp20Enabled = true,
Http20ProxyFlag = 1,
// Setting NumberOfWorkers to 1 to ensure dashboard runs of 1 instance
NumberOfWorkers = 1,
// IsAlwaysOn set to true ensures the app is always running
IsAlwaysOn = true,
AppSettings = []
},
Identity = new ManagedServiceIdentity()
{
ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned,
UserAssignedIdentities = []
}
};

var contributorMid = BicepFunction.Interpolate($"{contributorMidParameter}").Compile().ToString();
webSite.Identity.UserAssignedIdentities[contributorMid] = new UserAssignedIdentityDetails();

// Security is handled by app service platform
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__Frontend__AuthMode", Value = "Unsecured" });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__Otlp__AuthMode", Value = "Unsecured" });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__Otlp__SuppressUnsecuredTelemetryMessage", Value = "true" });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__ResourceServiceClient__AuthMode", Value = "Unsecured" });
// Dashboard ports
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "WEBSITES_PORT", Value = "5000" });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "HTTP20_ONLY_PORT", Value = "4317" });
// Enable SCM preloading to ensure dashboard is always available
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "WEBSITE_START_SCM_WITH_PRELOAD", Value = "true" });
// Appsettings related to managed identity for auth
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "AZURE_CLIENT_ID", Value = contributorClientIdParameter });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ALLOWED_MANAGED_IDENTITIES", Value = acrClientIdParameter });
infra.Add(webSite);

return webSite;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ public void BuildWebSite(AzureResourceInfrastructure infra)
var acrMidParameter = environmentContext.Environment.ContainerRegistryManagedIdentityId.AsProvisioningParameter(infra);
var acrClientIdParameter = environmentContext.Environment.ContainerRegistryClientId.AsProvisioningParameter(infra);
var containerImage = AllocateParameter(new ContainerImageReference(Resource));
var dashboardUri = environmentContext.Environment.DashboardUriReference.AsProvisioningParameter(infra);

var webSite = new WebSite("webapp")
{
Expand All @@ -224,6 +225,9 @@ public void BuildWebSite(AzureResourceInfrastructure infra)
LinuxFxVersion = "SITECONTAINERS",
AcrUserManagedIdentityId = acrClientIdParameter,
UseManagedIdentityCreds = true,
// Setting NumberOfWorkers to maximum allowed value for Premium SKU
// https://learn.microsoft.com/en-us/azure/app-service/manage-scale-up
NumberOfWorkers = 30,
Comment on lines 228 to 233
Copy link

Copilot AI Oct 3, 2025

Choose a reason for hiding this comment

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

The hardcoded value 30 appears to be a magic number. Consider defining this as a named constant to improve maintainability and make the intent clearer.

Copilot uses AI. Check for mistakes.
AppSettings = []
},
Identity = new ManagedServiceIdentity()
Expand Down Expand Up @@ -323,6 +327,11 @@ static FunctionCallExpression Join(BicepExpression args, string delimeter) =>
}
#pragma warning restore ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

if (environmentContext.Environment.EnableDashboard)
{
AddDashboardSettings(webSite, acrClientIdParameter, dashboardUri);
}

infra.Add(webSite);

// Allow users to customize the web app here
Expand Down Expand Up @@ -362,6 +371,15 @@ private ProvisioningParameter AllocateParameter(IManifestExpressionProvider para
{
return parameter.AsProvisioningParameter(Infra, isSecure: secretType == SecretType.Normal);
}
private void AddDashboardSettings(WebSite webSite, ProvisioningParameter acrClientIdParameter, ProvisioningParameter dashboardUri)
{
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_SERVICE_NAME", Value = resource.Name });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_EXPORTER_OTLP_PROTOCOL", Value = "grpc" });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_EXPORTER_OTLP_ENDPOINT", Value = "http://localhost:6001" });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "WEBSITE_ENABLE_ASPIRE_OTEL_SIDECAR", Value = "true" });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_COLLECTOR_URL", Value = dashboardUri });
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_CLIENT_ID", Value = acrClientIdParameter });
}

enum SecretType
{
Expand Down
56 changes: 56 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,62 @@ public async Task ResourceWithProbes()
await Verify(projectBicep, "bicep");
}

[Fact]
public async Task AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

builder.AddAzureAppServiceEnvironment("env").WithDashboard(false);

using var app = builder.Build();

await ExecuteBeforeStartHooksAsync(app, default);

var model = app.Services.GetRequiredService<DistributedApplicationModel>();

var environment = Assert.Single(model.Resources.OfType<AzureAppServiceEnvironmentResource>());

var (manifest, bicep) = await GetManifestWithBicep(environment);

await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep");
}

[Fact]
public async Task AddAppServiceToEnvironmentWithoutDashboard()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

builder.AddAzureAppServiceEnvironment("env").WithDashboard(false);

// Add 2 projects with endpoints
var project1 = builder.AddProject<Project>("project1", launchProfileName: null)
.WithHttpEndpoint()
.WithExternalHttpEndpoints();

var project2 = builder.AddProject<Project>("project2", launchProfileName: null)
.WithHttpEndpoint()
.WithExternalHttpEndpoints()
.WithReference(project1);

using var app = builder.Build();

await ExecuteBeforeStartHooksAsync(app, default);

var model = app.Services.GetRequiredService<DistributedApplicationModel>();

project2.Resource.TryGetLastAnnotation<DeploymentTargetAnnotation>(out var target);

var resource = target?.DeploymentTarget as AzureProvisioningResource;

Assert.NotNull(resource);

var (manifest, bicep) = await GetManifestWithBicep(resource);

await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep");
}

private static Task<(JsonNode ManifestNode, string BicepText)> GetManifestWithBicep(IResource resource) =>
AzureManifestUtils.GetManifestWithBicep(resource, skipPreparer: true);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@description('The location for the resource(s) to be deployed.')
param location string = resourceGroup().location

param userPrincipalId string = ''

param tags object = { }

resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: take('env_mi-${uniqueString(resourceGroup().id)}', 128)
location: location
tags: tags
}

resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = {
name: take('envacr${uniqueString(resourceGroup().id)}', 50)
location: location
sku: {
name: 'Basic'
}
tags: tags
}

resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d'))
properties: {
principalId: env_mi.properties.principalId
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')
principalType: 'ServicePrincipal'
}
scope: env_acr
}

resource env_asplan 'Microsoft.Web/serverfarms@2024-11-01' = {
name: take('envasplan-${uniqueString(resourceGroup().id)}', 60)
location: location
properties: {
perSiteScaling: true
reserved: true
}
kind: 'Linux'
sku: {
name: 'P0V3'
tier: 'Premium'
}
}

output name string = env_asplan.name

output planId string = env_asplan.id

output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name

output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer

output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id

output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId

output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_NAME string = env_mi.name

output DASHBOARD_URI string = 'https://${take('${toLower('dashboard')}-${uniqueString(resourceGroup().id)}', 60)}.azurewebsites.net'
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "azure.bicep.v0",
"path": "env.module.bicep",
"params": {
"userPrincipalId": ""
}
}
Loading