Skip to content

Commit 46c2529

Browse files
ShilpiRachShilpiRJamesNKCopilotdavidfowl
authored
Added support for Aspire dashboard in App Service (#11671)
* Added support for Aspire dashboard in App Service * Fixed failing tests * Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs Co-authored-by: James Newton-King <[email protected]> * Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentUtility.cs Co-authored-by: James Newton-King <[email protected]> * Added option to exclude dashboard and moved contributor identity to dashboard utility * Fixed failing unit tests * Made parameter dashboardUri conditional in web app bicep templates * Publishing DashboardUri paramater only when dashboard is enabled * Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentUtility.cs Co-authored-by: Copilot <[email protected]> * Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentUtility.cs Co-authored-by: Copilot <[email protected]> * Handled PR feedback and added support to log dashboard Uri * Updated a comment * Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentUtility.cs Co-authored-by: Eric Erhardt <[email protected]> * Handled scenario where there are multiple Aspire environments in a single resource group. - Website Contributor role is assigned for each web app in an environment instead of at resource group level - Added environment name to construct unique dashboard names - Add appsetting for ASPIRE_ENVIRONMENT_NAME to identify web apps and dashboard specific to an environment * Added Reader role assignment to managed identity for dashboard * Update src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs Co-authored-by: Eric Erhardt <[email protected]> * Taking a nit change --------- Co-authored-by: Shilpi Rachna <[email protected]> Co-authored-by: James Newton-King <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: David Fowler <[email protected]> Co-authored-by: Eric Erhardt <[email protected]>
1 parent dfcab5b commit 46c2529

File tree

28 files changed

+1018
-44
lines changed

28 files changed

+1018
-44
lines changed

src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
4444
var resource = new AzureAppServiceEnvironmentResource(name, static infra =>
4545
{
4646
var prefix = infra.AspireResource.Name;
47-
var resource = infra.AspireResource;
47+
var resource = (AzureAppServiceEnvironmentResource)infra.AspireResource;
4848

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

102104
infra.Add(plan);
@@ -131,6 +133,17 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
131133
{
132134
Value = identity.ClientId
133135
});
136+
137+
if (resource.EnableDashboard)
138+
{
139+
// Add aspire dashboard website
140+
var website = AzureAppServiceEnvironmentUtility.AddDashboard(infra, identity, plan.Id);
141+
142+
infra.Add(new ProvisioningOutput("AZURE_APP_SERVICE_DASHBOARD_URI", typeof(string))
143+
{
144+
Value = BicepFunction.Interpolate($"https://{AzureAppServiceEnvironmentUtility.GetDashboardHostName(prefix)}.azurewebsites.net")
145+
});
146+
}
134147
});
135148

136149
if (!builder.ExecutionContext.IsPublishMode)
@@ -140,4 +153,16 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
140153

141154
return builder.AddResource(resource);
142155
}
156+
157+
/// <summary>
158+
/// Configures whether the Aspire dashboard should be included in the Azure App Service environment.
159+
/// </summary>
160+
/// <param name="builder">The AzureAppServiceEnvironmentResource to configure.</param>
161+
/// <param name="enable">Whether to include the Aspire dashboard. Default is true.</param>
162+
/// <returns><see cref="IResourceBuilder{T}"/></returns>
163+
public static IResourceBuilder<AzureAppServiceEnvironmentResource> WithDashboard(this IResourceBuilder<AzureAppServiceEnvironmentResource> builder, bool enable = true)
164+
{
165+
builder.Resource.EnableDashboard = enable;
166+
return builder;
167+
}
143168
}

src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,25 @@ public class AzureAppServiceEnvironmentResource(string name, Action<AzureResourc
2626
internal BicepOutputReference ContainerRegistryName => new("AZURE_CONTAINER_REGISTRY_NAME", this);
2727
internal BicepOutputReference ContainerRegistryManagedIdentityId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", this);
2828
internal BicepOutputReference ContainerRegistryClientId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID", this);
29+
internal BicepOutputReference WebsiteContributorManagedIdentityId => new("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID", this);
30+
internal BicepOutputReference WebsiteContributorManagedIdentityPrincipalId => new("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID", this);
31+
32+
/// <summary>
33+
/// Gets or sets a value indicating whether the Aspire dashboard should be included in the container app environment.
34+
/// Default is true.
35+
/// </summary>
36+
internal bool EnableDashboard { get; set; } = true;
2937

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

43+
/// <summary>
44+
/// Gets the URI of the App Service Environment dashboard.
45+
/// </summary>
46+
public BicepOutputReference DashboardUriReference => new("AZURE_APP_SERVICE_DASHBOARD_URI", this);
47+
3548
ReferenceExpression IAzureContainerRegistry.ManagedIdentityId =>
3649
ReferenceExpression.Create($"{ContainerRegistryManagedIdentityId}");
3750

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Azure.Core;
5+
using Azure.Provisioning;
6+
using Azure.Provisioning.AppService;
7+
using Azure.Provisioning.Authorization;
8+
using Azure.Provisioning.Expressions;
9+
using Azure.Provisioning.Resources;
10+
using Azure.Provisioning.Roles;
11+
12+
namespace Aspire.Hosting.Azure.AppService;
13+
14+
internal static class AzureAppServiceEnvironmentUtility
15+
{
16+
internal const string ResourceName = "aspiredashboard";
17+
18+
public static BicepValue<string> GetDashboardHostName(string aspireResourceName)
19+
{
20+
return BicepFunction.Take(
21+
BicepFunction.Interpolate($"{BicepFunction.ToLower(aspireResourceName)}-{BicepFunction.ToLower(ResourceName)}-{BicepFunction.GetUniqueString(BicepFunction.GetResourceGroup().Id)}"), 60);
22+
}
23+
24+
public static WebSite AddDashboard(AzureResourceInfrastructure infra,
25+
UserAssignedIdentity otelIdentity,
26+
BicepValue<ResourceIdentifier> appServicePlanId)
27+
{
28+
// This ACR identity is used by the dashboard to authorize the telemetry data
29+
// coming from the dotnet web apps. This identity is being assigned to every web app
30+
// in the aspire project and can be safely reused for authorization in the dashboard.
31+
var otelClientId = otelIdentity.ClientId;
32+
var prefix = infra.AspireResource.Name;
33+
var contributorIdentity = new UserAssignedIdentity(Infrastructure.NormalizeBicepIdentifier($"{prefix}-contributor-mi"));
34+
35+
infra.Add(contributorIdentity);
36+
37+
// Add Reader role assignment
38+
var rgRaId = BicepFunction.GetSubscriptionResourceId(
39+
"Microsoft.Authorization/roleDefinitions",
40+
"acdd72a7-3385-48ef-bd42-f606fba81ae7");
41+
var rgRaName = BicepFunction.CreateGuid(BicepFunction.GetResourceGroup().Id, contributorIdentity.Id, rgRaId);
42+
43+
var rgRa = new RoleAssignment(Infrastructure.NormalizeBicepIdentifier($"{prefix}_ra"))
44+
{
45+
Name = rgRaName,
46+
PrincipalType = RoleManagementPrincipalType.ServicePrincipal,
47+
PrincipalId = contributorIdentity.PrincipalId,
48+
RoleDefinitionId = rgRaId
49+
};
50+
51+
infra.Add(rgRa);
52+
53+
var dashboard = new WebSite("dashboard")
54+
{
55+
// Use the host name as the name of the web app
56+
Name = GetDashboardHostName(infra.AspireResource.Name),
57+
AppServicePlanId = appServicePlanId,
58+
// Aspire dashboards are created with a new kind aspiredashboard
59+
Kind = "app,linux,aspiredashboard",
60+
SiteConfig = new SiteConfigProperties()
61+
{
62+
LinuxFxVersion = "ASPIREDASHBOARD|1.0",
63+
AcrUserManagedIdentityId = otelClientId,
64+
UseManagedIdentityCreds = true,
65+
IsHttp20Enabled = true,
66+
Http20ProxyFlag = 1,
67+
// Setting NumberOfWorkers to 1 to ensure dashboard runs on 1 instance
68+
NumberOfWorkers = 1,
69+
// IsAlwaysOn set to true ensures the app is always running
70+
IsAlwaysOn = true,
71+
AppSettings = []
72+
},
73+
Identity = new ManagedServiceIdentity()
74+
{
75+
ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned,
76+
UserAssignedIdentities = []
77+
}
78+
};
79+
80+
var contributorMid = BicepFunction.Interpolate($"{contributorIdentity.Id}").Compile().ToString();
81+
dashboard.Identity.UserAssignedIdentities[contributorMid] = new UserAssignedIdentityDetails();
82+
83+
// Security is handled by app service platform
84+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__Frontend__AuthMode", Value = "Unsecured" });
85+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__Otlp__AuthMode", Value = "Unsecured" });
86+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__Otlp__SuppressUnsecuredTelemetryMessage", Value = "true" });
87+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "Dashboard__ResourceServiceClient__AuthMode", Value = "Unsecured" });
88+
// Dashboard ports
89+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "WEBSITES_PORT", Value = "5000" });
90+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "HTTP20_ONLY_PORT", Value = "4317" });
91+
// Enable SCM preloading to ensure dashboard is always available
92+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "WEBSITE_START_SCM_WITH_PRELOAD", Value = "true" });
93+
// Appsettings related to managed identity for auth
94+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "AZURE_CLIENT_ID", Value = contributorIdentity.ClientId });
95+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ALLOWED_MANAGED_IDENTITIES", Value = otelClientId });
96+
// Added appsetting to identify the resources in a specific aspire environment
97+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ASPIRE_ENVIRONMENT_NAME", Value = infra.AspireResource.Name });
98+
99+
infra.Add(dashboard);
100+
101+
// Outputs needed by the app service environment
102+
// This identity needs website contributor access on the websites for resource server to work
103+
infra.Add(new ProvisioningOutput("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID", typeof(string))
104+
{
105+
Value = contributorIdentity.Id
106+
});
107+
108+
infra.Add(new ProvisioningOutput("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID", typeof(string))
109+
{
110+
Value = contributorIdentity.PrincipalId
111+
});
112+
113+
return dashboard;
114+
}
115+
}

src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Aspire.Hosting.ApplicationModel;
88
using Azure.Provisioning;
99
using Azure.Provisioning.AppService;
10+
using Azure.Provisioning.Authorization;
1011
using Azure.Provisioning.Expressions;
1112
using Azure.Provisioning.Resources;
1213

@@ -212,7 +213,7 @@ public void BuildWebSite(AzureResourceInfrastructure infra)
212213
var acrMidParameter = environmentContext.Environment.ContainerRegistryManagedIdentityId.AsProvisioningParameter(infra);
213214
var acrClientIdParameter = environmentContext.Environment.ContainerRegistryClientId.AsProvisioningParameter(infra);
214215
var containerImage = AllocateParameter(new ContainerImageReference(Resource));
215-
216+
216217
var webSite = new WebSite("webapp")
217218
{
218219
// Use the host name as the name of the web app
@@ -224,6 +225,12 @@ public void BuildWebSite(AzureResourceInfrastructure infra)
224225
LinuxFxVersion = "SITECONTAINERS",
225226
AcrUserManagedIdentityId = acrClientIdParameter,
226227
UseManagedIdentityCreds = true,
228+
// Setting NumberOfWorkers to maximum allowed value for Premium SKU
229+
// https://learn.microsoft.com/en-us/azure/app-service/manage-scale-up
230+
// This is required due to use of feature PerSiteScaling for the App Service plan
231+
// We want the web apps to scale normally as defined for the app service plan
232+
// so setting the maximum number of workers to the maximum allowed for Premium V2 SKU.
233+
NumberOfWorkers = 30,
227234
AppSettings = []
228235
},
229236
Identity = new ManagedServiceIdentity()
@@ -306,6 +313,9 @@ static FunctionCallExpression Join(BicepExpression args, string delimeter) =>
306313
});
307314
}
308315

316+
// Added appsetting to identify the resource in a specific aspire environment
317+
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ASPIRE_ENVIRONMENT_NAME", Value = environmentContext.Environment.Name });
318+
309319
// Probes
310320
#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.
311321
if (resource.TryGetAnnotationsOfType<ProbeAnnotation>(out var probeAnnotations))
@@ -323,7 +333,17 @@ static FunctionCallExpression Join(BicepExpression args, string delimeter) =>
323333
}
324334
#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.
325335

336+
RoleAssignment? webSiteRa = null;
337+
if (environmentContext.Environment.EnableDashboard)
338+
{
339+
webSiteRa = AddDashboardPermissionAndSettings(webSite, acrClientIdParameter);
340+
}
341+
326342
infra.Add(webSite);
343+
if (webSiteRa is not null)
344+
{
345+
infra.Add(webSiteRa);
346+
}
327347

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

386+
private RoleAssignment AddDashboardPermissionAndSettings(WebSite webSite, ProvisioningParameter acrClientIdParameter)
387+
{
388+
var dashboardUri = environmentContext.Environment.DashboardUriReference.AsProvisioningParameter(Infra);
389+
var contributorId = environmentContext.Environment.WebsiteContributorManagedIdentityId.AsProvisioningParameter(Infra);
390+
var contributorPrincipalId = environmentContext.Environment.WebsiteContributorManagedIdentityPrincipalId.AsProvisioningParameter(Infra);
391+
392+
// Add the appsettings specific to sending telemetry data to dashboard
393+
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_SERVICE_NAME", Value = resource.Name });
394+
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_EXPORTER_OTLP_PROTOCOL", Value = "grpc" });
395+
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_EXPORTER_OTLP_ENDPOINT", Value = "http://localhost:6001" });
396+
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "WEBSITE_ENABLE_ASPIRE_OTEL_SIDECAR", Value = "true" });
397+
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_COLLECTOR_URL", Value = dashboardUri });
398+
webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "OTEL_CLIENT_ID", Value = acrClientIdParameter });
399+
400+
// Add Website Contributor role assignment to dashboard's managed identity for this webapp
401+
var websiteRaId = BicepFunction.GetSubscriptionResourceId(
402+
"Microsoft.Authorization/roleDefinitions",
403+
"de139f84-1756-47ae-9be6-808fbbe84772");
404+
var websiteRaName = BicepFunction.CreateGuid(webSite.Id, contributorId, websiteRaId);
405+
406+
return new RoleAssignment(Infrastructure.NormalizeBicepIdentifier($"{Infra.AspireResource.Name}_ra"))
407+
{
408+
Name = websiteRaName,
409+
Scope = new IdentifierExpression(webSite.BicepIdentifier),
410+
PrincipalType = RoleManagementPrincipalType.ServicePrincipal,
411+
PrincipalId = contributorPrincipalId,
412+
RoleDefinitionId = websiteRaId,
413+
};
414+
}
415+
366416
enum SecretType
367417
{
368418
None,

src/Aspire.Hosting.Azure/AzureDeployingContext.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,12 @@ private static string TryGetComputeResourceEndpoint(IResource computeResource, I
478478
{
479479
return $"https://aspire-dashboard.ext.{domainValue}";
480480
}
481+
// If the resource is a compute environment (app service), we can use its properties
482+
// to get the dashboard URL.
483+
if (environmentBicepResource.Outputs.TryGetValue($"AZURE_APP_SERVICE_DASHBOARD_URI", out var dashboardUri))
484+
{
485+
return (string?)dashboardUri;
486+
}
481487
}
482488
}
483489

tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,62 @@ public async Task ResourceWithProbes()
397397
await Verify(projectBicep, "bicep");
398398
}
399399

400+
[Fact]
401+
public async Task AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource()
402+
{
403+
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
404+
405+
builder.AddAzureAppServiceEnvironment("env").WithDashboard(false);
406+
407+
using var app = builder.Build();
408+
409+
await ExecuteBeforeStartHooksAsync(app, default);
410+
411+
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
412+
413+
var environment = Assert.Single(model.Resources.OfType<AzureAppServiceEnvironmentResource>());
414+
415+
var (manifest, bicep) = await GetManifestWithBicep(environment);
416+
417+
await Verify(manifest.ToString(), "json")
418+
.AppendContentAsFile(bicep, "bicep");
419+
}
420+
421+
[Fact]
422+
public async Task AddAppServiceToEnvironmentWithoutDashboard()
423+
{
424+
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
425+
426+
builder.AddAzureAppServiceEnvironment("env").WithDashboard(false);
427+
428+
// Add 2 projects with endpoints
429+
var project1 = builder.AddProject<Project>("project1", launchProfileName: null)
430+
.WithHttpEndpoint()
431+
.WithExternalHttpEndpoints();
432+
433+
var project2 = builder.AddProject<Project>("project2", launchProfileName: null)
434+
.WithHttpEndpoint()
435+
.WithExternalHttpEndpoints()
436+
.WithReference(project1);
437+
438+
using var app = builder.Build();
439+
440+
await ExecuteBeforeStartHooksAsync(app, default);
441+
442+
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
443+
444+
project2.Resource.TryGetLastAnnotation<DeploymentTargetAnnotation>(out var target);
445+
446+
var resource = target?.DeploymentTarget as AzureProvisioningResource;
447+
448+
Assert.NotNull(resource);
449+
450+
var (manifest, bicep) = await GetManifestWithBicep(resource);
451+
452+
await Verify(manifest.ToString(), "json")
453+
.AppendContentAsFile(bicep, "bicep");
454+
}
455+
400456
private static Task<(JsonNode ManifestNode, string BicepText)> GetManifestWithBicep(IResource resource) =>
401457
AzureManifestUtils.GetManifestWithBicep(resource, skipPreparer: true);
402458

0 commit comments

Comments
 (0)