Skip to content

Commit 639d1f2

Browse files
davidfowlCopilot
andcommitted
Honor existing Azure App Service plans
Honor AsExisting/PublishAsExisting for Azure App Service environments by referencing existing App Service Plans in generated Bicep instead of recreating them. Add ACR pull identity support, skip dashboard summary output when dashboards are disabled, and cover the publish/deploy shapes with App Service tests and snapshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f3a4b6c commit 639d1f2

17 files changed

Lines changed: 877 additions & 54 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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 Aspire.Hosting.ApplicationModel;
5+
6+
namespace Aspire.Hosting.Azure;
7+
8+
/// <summary>
9+
/// Indicates that an <see cref="AzureAppServiceEnvironmentResource"/> should use the supplied
10+
/// <see cref="AzureUserAssignedIdentityResource"/> as the identity that holds the <c>AcrPull</c> role on the
11+
/// configured container registry, instead of having Aspire create a new identity and a new <c>AcrPull</c>
12+
/// role assignment.
13+
/// </summary>
14+
/// <param name="identity">The user-assigned identity resource to use for the <c>AcrPull</c> role.</param>
15+
internal sealed class AzureAppServiceEnvironmentAcrPullIdentityAnnotation(AzureUserAssignedIdentityResource identity) : IResourceAnnotation
16+
{
17+
/// <summary>
18+
/// Gets the user-assigned identity resource that holds the <c>AcrPull</c> role.
19+
/// </summary>
20+
public AzureUserAssignedIdentityResource Identity { get; } = identity;
21+
}

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

Lines changed: 100 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,30 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
107107

108108
infra.Add(tags);
109109

110-
var identity = new UserAssignedIdentity($"{prefix}_mi")
110+
UserAssignedIdentity? newIdentity = null;
111+
BicepValue<string> managedIdentityIdOutputValue;
112+
BicepValue<string> managedIdentityClientIdOutputValue;
113+
114+
if (resource.TryGetLastAnnotation<AzureAppServiceEnvironmentAcrPullIdentityAnnotation>(out var identityAnnotation))
111115
{
112-
Tags = tags
113-
};
116+
// The user has supplied an existing identity (commonly via AddAzureUserAssignedIdentity +
117+
// .WithRoleAssignments(acr, AcrPull)). Skip creating env_mi + the AcrPull role assignment
118+
// here and have the env module read the identity id/client id from parameters wired to the
119+
// identity module's outputs.
120+
managedIdentityIdOutputValue = identityAnnotation.Identity.Id.AsProvisioningParameter(infra);
121+
managedIdentityClientIdOutputValue = identityAnnotation.Identity.ClientId.AsProvisioningParameter(infra);
122+
}
123+
else
124+
{
125+
newIdentity = new UserAssignedIdentity($"{prefix}_mi")
126+
{
127+
Tags = tags
128+
};
114129

115-
infra.Add(identity);
130+
infra.Add(newIdentity);
131+
managedIdentityIdOutputValue = newIdentity.Id.ToBicepExpression();
132+
managedIdentityClientIdOutputValue = newIdentity.ClientId.ToBicepExpression();
133+
}
116134

117135
AzureProvisioningResource? registry = null;
118136
if (resource.TryGetLastAnnotation<ContainerRegistryReferenceAnnotation>(out var registryReferenceAnnotation) &&
@@ -133,26 +151,40 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
133151
var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra);
134152
infra.Add(containerRegistry);
135153

136-
var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, identity);
154+
if (newIdentity is not null)
155+
{
156+
var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, newIdentity);
137157

138-
// There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265
139-
pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, identity.Id, pullRa.RoleDefinitionId);
140-
infra.Add(pullRa);
158+
// There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265
159+
pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, newIdentity.Id, pullRa.RoleDefinitionId);
160+
infra.Add(pullRa);
161+
}
141162

142-
var plan = new AppServicePlan($"{prefix}_asplan")
163+
AppServicePlan plan;
164+
if (resource.IsExisting())
165+
{
166+
// The Aspire resource models the App Service Plan. When users mark it existing,
167+
// keep provisioning the supporting app resources but reference the supplied plan
168+
// instead of declaring a new Microsoft.Web/serverfarms resource.
169+
plan = (AppServicePlan)resource.AddAsExistingResource(infra);
170+
}
171+
else
143172
{
144-
Sku = new AppServiceSkuDescription
173+
plan = new AppServicePlan($"{prefix}_asplan")
145174
{
146-
Name = "P0V3",
147-
Tier = "Premium"
148-
},
149-
Kind = "Linux",
150-
IsReserved = true,
151-
// Enable perSiteScaling so each app service can scale independently
152-
IsPerSiteScaling = true
153-
};
154-
155-
infra.Add(plan);
175+
Sku = new AppServiceSkuDescription
176+
{
177+
Name = "P0V3",
178+
Tier = "Premium"
179+
},
180+
Kind = "Linux",
181+
IsReserved = true,
182+
// Enable perSiteScaling so each app service can scale independently
183+
IsPerSiteScaling = true
184+
};
185+
186+
infra.Add(plan);
187+
}
156188

157189
infra.Add(new ProvisioningOutput("name", typeof(string))
158190
{
@@ -182,18 +214,18 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> AddAzureAppSe
182214

183215
infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", typeof(string))
184216
{
185-
Value = identity.Id.ToBicepExpression()
217+
Value = managedIdentityIdOutputValue
186218
});
187219

188220
infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID", typeof(string))
189221
{
190-
Value = identity.ClientId.ToBicepExpression()
222+
Value = managedIdentityClientIdOutputValue
191223
});
192224

193225
if (resource.EnableDashboard)
194226
{
195227
// Add aspire dashboard website
196-
var website = AzureAppServiceEnvironmentUtility.AddDashboard(infra, identity, plan.Id);
228+
var website = AzureAppServiceEnvironmentUtility.AddDashboard(infra, managedIdentityClientIdOutputValue, plan.Id);
197229

198230
infra.Add(new ProvisioningOutput("AZURE_APP_SERVICE_DASHBOARD_URI", typeof(string))
199231
{
@@ -437,6 +469,51 @@ public static IResourceBuilder<AzureAppServiceEnvironmentResource> WithDeploymen
437469
return builder;
438470
}
439471

472+
/// <summary>
473+
/// Configures the Azure App Service environment to use the supplied <see cref="AzureUserAssignedIdentityResource"/>
474+
/// as the managed identity that App Service apps use to pull images from the configured container registry
475+
/// (the <c>AcrPull</c> identity), instead of having Aspire create a new identity and a new <c>AcrPull</c>
476+
/// role assignment.
477+
/// </summary>
478+
/// <param name="builder">The Azure App Service environment to configure.</param>
479+
/// <param name="identityBuilder">
480+
/// The resource builder for the user-assigned identity that should be used for image pulls. This identity is
481+
/// only used for the <c>AcrPull</c> role; it is not assigned as the app service runtime identity.
482+
/// </param>
483+
/// <returns>The <see cref="IResourceBuilder{T}"/> for chaining.</returns>
484+
/// <remarks>
485+
/// <para>
486+
/// When this is set, Aspire will not create a new identity or an <c>AcrPull</c> role assignment for the
487+
/// container registry. The caller is responsible for ensuring the supplied identity already has the required
488+
/// <c>AcrPull</c> role assignment on the registry, for example by chaining
489+
/// <c>.WithRoleAssignments(acr, ContainerRegistryBuiltInRole.AcrPull)</c> when adding the identity.
490+
/// </para>
491+
/// <para>
492+
/// This is commonly combined with <c>AsExisting</c> on the App Service environment (App Service Plan) and on
493+
/// the container registry to deploy websites into a pre-provisioned set of Azure resources without Aspire
494+
/// emitting any new ACR-pull identity or ACR-pull role-assignment resources.
495+
/// </para>
496+
/// <para>
497+
/// If the Aspire dashboard is enabled, Aspire still provisions the dashboard website and its contributor
498+
/// identity. Use <see cref="WithDashboard(IResourceBuilder{AzureAppServiceEnvironmentResource}, bool)"/> with
499+
/// <see langword="false"/> when targeting a fully pre-provisioned App Service Plan that should not receive a
500+
/// dashboard.
501+
/// </para>
502+
/// </remarks>
503+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="identityBuilder"/> is <see langword="null"/>.</exception>
504+
[AspireExport]
505+
public static IResourceBuilder<AzureAppServiceEnvironmentResource> WithAcrPullIdentity(
506+
this IResourceBuilder<AzureAppServiceEnvironmentResource> builder,
507+
IResourceBuilder<AzureUserAssignedIdentityResource> identityBuilder)
508+
{
509+
ArgumentNullException.ThrowIfNull(builder);
510+
ArgumentNullException.ThrowIfNull(identityBuilder);
511+
512+
builder.WithAnnotation(new AzureAppServiceEnvironmentAcrPullIdentityAnnotation(identityBuilder.Resource), ResourceAnnotationMutationBehavior.Replace);
513+
514+
return builder;
515+
}
516+
440517
private static AzureContainerRegistryResource CreateDefaultAzureContainerRegistry(IDistributedApplicationBuilder builder, string name)
441518
{
442519
var resource = new AzureContainerRegistryResource(name, ContainerRegistryInfrastructure.ConfigureContainerRegistry);

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,23 @@ public AzureAppServiceEnvironmentResource(string name, Action<AzureResourceInfra
7171

7272
steps.Add(validateStep);
7373

74-
// Add print-dashboard-url step
75-
var printDashboardUrlStep = new PipelineStep
74+
if (EnableDashboard)
7675
{
77-
Name = $"print-dashboard-url-{name}",
78-
Description = $"Prints the deployment summary and dashboard URL for {name}.",
79-
Action = ctx => PrintDashboardUrlAsync(ctx),
80-
Tags = ["print-summary"],
81-
DependsOnSteps = [AzureEnvironmentResource.ProvisionInfrastructureStepName],
82-
RequiredBySteps = [WellKnownPipelineSteps.Deploy]
83-
};
84-
85-
steps.Add(printDashboardUrlStep);
76+
// The dashboard output is only emitted when the dashboard is provisioned.
77+
// Avoid registering the summary step when WithDashboard(false) is used,
78+
// otherwise deploy succeeds and then fails while trying to read a missing output.
79+
var printDashboardUrlStep = new PipelineStep
80+
{
81+
Name = $"print-dashboard-url-{name}",
82+
Description = $"Prints the deployment summary and dashboard URL for {name}.",
83+
Action = ctx => PrintDashboardUrlAsync(ctx),
84+
Tags = ["print-summary"],
85+
DependsOnSteps = [AzureEnvironmentResource.ProvisionInfrastructureStepName],
86+
RequiredBySteps = [WellKnownPipelineSteps.Deploy]
87+
};
88+
89+
steps.Add(printDashboardUrlStep);
90+
}
8691

8792
// Expand deployment target steps for all compute resources
8893
// This ensures the push/provision steps from deployment targets are included in the pipeline

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,12 @@ public static BicepValue<string> GetDashboardHostName(string aspireResourceName)
2222
}
2323

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

@@ -60,7 +59,7 @@ public static WebSite AddDashboard(AzureResourceInfrastructure infra,
6059
SiteConfig = new SiteConfigProperties()
6160
{
6261
LinuxFxVersion = "ASPIREDASHBOARD|1.0",
63-
AcrUserManagedIdentityId = otelClientId,
62+
AcrUserManagedIdentityId = acrPullIdentityClientId,
6463
UseManagedIdentityCreds = true,
6564
IsHttp20Enabled = true,
6665
Http20ProxyFlag = 1,
@@ -93,7 +92,7 @@ public static WebSite AddDashboard(AzureResourceInfrastructure infra,
9392
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "WEBSITE_START_SCM_WITH_PRELOAD", Value = "true" });
9493
// Appsettings related to managed identity for auth
9594
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "AZURE_CLIENT_ID", Value = contributorIdentity.ClientId });
96-
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ALLOWED_MANAGED_IDENTITIES", Value = otelClientId });
95+
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ALLOWED_MANAGED_IDENTITIES", Value = acrPullIdentityClientId });
9796
// Added appsetting to identify the resources in a specific aspire environment
9897
dashboard.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = "ASPIRE_ENVIRONMENT_NAME", Value = infra.AspireResource.Name });
9998

src/Aspire.Hosting.Azure.AppService/api/Aspire.Hosting.Azure.AppService.ats.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AzureAppServiceEnvironmentR
88
Aspire.Hosting.Azure.AppService/addAzureAppServiceEnvironment(name: string) -> Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AzureAppServiceEnvironmentResource
99
Aspire.Hosting.Azure.AppService/publishAsAzureAppServiceWebsite(configure?: callback, configureSlot?: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IComputeResource
1010
Aspire.Hosting.Azure.AppService/skipEnvironmentVariableNameChecks() -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IComputeResource
11+
Aspire.Hosting.Azure.AppService/withAcrPullIdentity(identityBuilder: Aspire.Hosting.Azure/Aspire.Hosting.Azure.AzureUserAssignedIdentityResource) -> Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AzureAppServiceEnvironmentResource
1112
Aspire.Hosting.Azure.AppService/withAzureApplicationInsights(applicationInsights?: string|Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource|Aspire.Hosting.Azure.ApplicationInsights/Aspire.Hosting.Azure.AzureApplicationInsightsResource) -> Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AzureAppServiceEnvironmentResource
1213
Aspire.Hosting.Azure.AppService/withDashboard(enable?: boolean) -> Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AzureAppServiceEnvironmentResource
1314
Aspire.Hosting.Azure.AppService/withDeploymentSlot(deploymentSlot: string|Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource) -> Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AzureAppServiceEnvironmentResource

0 commit comments

Comments
 (0)