Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 Down Expand Up @@ -131,6 +133,17 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
{
Value = identity.ClientId
});

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

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

if (!builder.ExecutionContext.IsPublishMode)
Expand All @@ -140,4 +153,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 @@ -26,12 +26,25 @@ public class AzureAppServiceEnvironmentResource(string name, Action<AzureResourc
internal BicepOutputReference ContainerRegistryName => new("AZURE_CONTAINER_REGISTRY_NAME", this);
internal BicepOutputReference ContainerRegistryManagedIdentityId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", this);
internal BicepOutputReference ContainerRegistryClientId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID", this);
internal BicepOutputReference WebsiteContributorManagedIdentityId => new("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID", this);
internal BicepOutputReference WebsiteContributorManagedIdentityPrincipalId => new("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_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("AZURE_APP_SERVICE_DASHBOARD_URI", this);

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 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
{
internal const string ResourceName = "aspiredashboard";

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

public static WebSite AddDashboard(AzureResourceInfrastructure infra,
UserAssignedIdentity otelIdentity,
BicepValue<ResourceIdentifier> appServicePlanId)
{
// This ACR identity is used by the dashboard to authorize the telemetry data
// coming from the dotnet web apps. This identity is being assigned to every web app
// in the aspire project and can be safely reused for authorization in the dashboard.
var otelClientId = otelIdentity.ClientId;
var prefix = infra.AspireResource.Name;
var contributorIdentity = new UserAssignedIdentity(Infrastructure.NormalizeBicepIdentifier($"{prefix}-contributor-mi"));

infra.Add(contributorIdentity);

// Add Reader role assignment
var rgRaId = BicepFunction.GetSubscriptionResourceId(
"Microsoft.Authorization/roleDefinitions",
"acdd72a7-3385-48ef-bd42-f606fba81ae7");
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);

var dashboard = new WebSite("dashboard")
{
// Use the host name as the name of the web app
Name = GetDashboardHostName(infra.AspireResource.Name),
AppServicePlanId = appServicePlanId,
// Aspire dashboards are created with a new kind aspiredashboard
Kind = "app,linux,aspiredashboard",
SiteConfig = new SiteConfigProperties()
{
LinuxFxVersion = "ASPIREDASHBOARD|1.0",
AcrUserManagedIdentityId = otelClientId,
UseManagedIdentityCreds = true,
IsHttp20Enabled = true,
Http20ProxyFlag = 1,
// Setting NumberOfWorkers to 1 to ensure dashboard runs on 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($"{contributorIdentity.Id}").Compile().ToString();
dashboard.Identity.UserAssignedIdentities[contributorMid] = new UserAssignedIdentityDetails();

// Security is handled by app service platform
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__Frontend__AuthMode", Value = "Unsecured" });
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__Otlp__AuthMode", Value = "Unsecured" });
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__Otlp__SuppressUnsecuredTelemetryMessage", Value = "true" });
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__ResourceServiceClient__AuthMode", Value = "Unsecured" });
// Dashboard ports
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "WEBSITES_PORT", Value = "5000" });
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "HTTP20_ONLY_PORT", Value = "4317" });
// Enable SCM preloading to ensure dashboard is always available
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "WEBSITE_START_SCM_WITH_PRELOAD", Value = "true" });
// Appsettings related to managed identity for auth
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "AZURE_CLIENT_ID", Value = contributorIdentity.ClientId });
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ALLOWED_MANAGED_IDENTITIES", Value = otelClientId });
// Added appsetting to identify the resources in a specific aspire environment
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ASPIRE_ENVIRONMENT_NAME", Value = infra.AspireResource.Name });

infra.Add(dashboard);

// Outputs needed by the app service environment
// This identity needs website contributor access on the websites for resource server to work
infra.Add(new ProvisioningOutput("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID", typeof(string))
{
Value = contributorIdentity.Id
});

infra.Add(new ProvisioningOutput("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID", typeof(string))
{
Value = contributorIdentity.PrincipalId
});

return dashboard;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Aspire.Hosting.ApplicationModel;
using Azure.Provisioning;
using Azure.Provisioning.AppService;
using Azure.Provisioning.Authorization;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Resources;

Expand Down Expand Up @@ -212,7 +213,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 webSite = new WebSite("webapp")
{
// Use the host name as the name of the web app
Expand All @@ -224,6 +225,12 @@ 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
// This is required due to use of feature PerSiteScaling for the App Service plan
// We want the web apps to scale normally as defined for the app service plan
// so setting the maximum number of workers to the maximum allowed for Premium V2 SKU.
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 @@ -306,6 +313,9 @@ static FunctionCallExpression Join(BicepExpression args, string delimeter) =>
});
}

// Added appsetting to identify the resource in a specific aspire environment
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ASPIRE_ENVIRONMENT_NAME", Value = environmentContext.Environment.Name });

// Probes
#pragma warning disable ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (resource.TryGetAnnotationsOfType<ProbeAnnotation>(out var probeAnnotations))
Expand All @@ -323,7 +333,17 @@ 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.

RoleAssignment? webSiteRa = null;
if (environmentContext.Environment.EnableDashboard)
{
webSiteRa = AddDashboardPermissionAndSettings(webSite, acrClientIdParameter);
}

infra.Add(webSite);
if (webSiteRa is not null)
{
infra.Add(webSiteRa);
}

// Allow users to customize the web app here
if (resource.TryGetAnnotationsOfType<AzureAppServiceWebsiteCustomizationAnnotation>(out var customizeWebSiteAnnotations))
Expand Down Expand Up @@ -363,6 +383,36 @@ private ProvisioningParameter AllocateParameter(IManifestExpressionProvider para
return parameter.AsProvisioningParameter(Infra, isSecure: secretType == SecretType.Normal);
}

private RoleAssignment AddDashboardPermissionAndSettings(WebSite webSite, ProvisioningParameter acrClientIdParameter)
{
var dashboardUri = environmentContext.Environment.DashboardUriReference.AsProvisioningParameter(Infra);
var contributorId = environmentContext.Environment.WebsiteContributorManagedIdentityId.AsProvisioningParameter(Infra);
var contributorPrincipalId = environmentContext.Environment.WebsiteContributorManagedIdentityPrincipalId.AsProvisioningParameter(Infra);

// Add the appsettings specific to sending telemetry data to dashboard
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 });

// Add Website Contributor role assignment to dashboard's managed identity for this webapp
var websiteRaId = BicepFunction.GetSubscriptionResourceId(
"Microsoft.Authorization/roleDefinitions",
"de139f84-1756-47ae-9be6-808fbbe84772");
var websiteRaName = BicepFunction.CreateGuid(webSite.Id, contributorId, websiteRaId);

return new RoleAssignment(Infrastructure.NormalizeBicepIdentifier($"{Infra.AspireResource.Name}_ra"))
{
Name = websiteRaName,
Scope = new IdentifierExpression(webSite.BicepIdentifier),
PrincipalType = RoleManagementPrincipalType.ServicePrincipal,
PrincipalId = contributorPrincipalId,
RoleDefinitionId = websiteRaId,
};
}

enum SecretType
{
None,
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Hosting.Azure/AzureDeployingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,12 @@ private static string TryGetComputeResourceEndpoint(IResource computeResource, I
{
return $"https://aspire-dashboard.ext.{domainValue}";
}
// If the resource is a compute environment (app service), we can use its properties
// to get the dashboard URL.
if (environmentBicepResource.Outputs.TryGetValue($"AZURE_APP_SERVICE_DASHBOARD_URI", out var dashboardUri))
{
return (string?)dashboardUri;
}
}
}

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 @@ -397,6 +397,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
Loading