diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs index c48bf20cd12..519b1daeebf 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs @@ -105,8 +105,11 @@ public static IResourceBuilder AddAzureAppSe }, Kind = "Linux", IsReserved = true, - // Enable perSiteScaling so each app service can scale independently - IsPerSiteScaling = true + // Enable perSiteScaling or automatic scaling so each app service can scale independently + IsPerSiteScaling = !resource.EnableAutomaticScaling, + IsElasticScaleEnabled = resource.EnableAutomaticScaling, + // Capping the automatic scaling limit to 10 as per best practices + MaximumElasticWorkerCount = 10 }; infra.Add(plan); @@ -318,6 +321,18 @@ public static IResourceBuilder WithDeploymen return builder; } + /// + /// Configures whether automatic scaling should be enabled for the app services in Azure App Service environment. + /// + /// The to configure. + /// A reference to the for chaining additional configuration. + [AspireExport] + public static IResourceBuilder WithAutomaticScaling(this IResourceBuilder builder) + { + builder.Resource.EnableAutomaticScaling = true; + return builder; + } + private static AzureContainerRegistryResource CreateDefaultAzureContainerRegistry(IDistributedApplicationBuilder builder, string name) { var configureInfrastructure = (AzureResourceInfrastructure infrastructure) => diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs index df69ee2cbb9..d985856d40f 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs @@ -310,6 +310,11 @@ await context.ReportingStep.CompleteAsync( /// internal string? DeploymentSlot { get; set; } + /// + /// Enables or disables automatic scaling for the App Service Plan. + /// + internal bool EnableAutomaticScaling { get; set; } + /// /// Gets the name of the App Service Plan. /// diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentUtility.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentUtility.cs index 1bed7f6c03e..8333a23c0bc 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentUtility.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentUtility.cs @@ -66,6 +66,8 @@ public static WebSite AddDashboard(AzureResourceInfrastructure infra, Http20ProxyFlag = 1, // Setting instance count to 1 to ensure dashboard runs on 1 instance NumberOfWorkers = 1, + FunctionAppScaleLimit = 1, + ElasticWebAppScaleLimit = 1, // IsAlwaysOn set to true ensures the app is always running IsAlwaysOn = true, AppSettings = [] diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs index 67041919ce8..78dcc450b07 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs @@ -495,6 +495,27 @@ await Verify(manifest.ToString(), "json") .AppendContentAsFile(bicep, "bicep"); } + [Fact] + public async Task AddAppServiceEnvironmentWithAutomaticScalingAddsEnvironmentResource() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureAppServiceEnvironment("env").WithAutomaticScaling(); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var environment = Assert.Single(model.Resources.OfType()); + + var (manifest, bicep) = await GetManifestWithBicep(environment); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } + [Fact] public async Task AddAppServiceToEnvironmentWithoutDashboard() { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithAutomaticScalingAddsEnvironmentResource.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithAutomaticScalingAddsEnvironmentResource.verified.bicep new file mode 100644 index 00000000000..48f15fd81a6 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithAutomaticScalingAddsEnvironmentResource.verified.bicep @@ -0,0 +1,150 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +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' existing = { + name: env_acr_outputs_name +} + +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@2025-03-01' = { + name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + elasticScaleEnabled: true + perSiteScaling: false + reserved: true + maximumElasticWorkerCount: 10 + } + kind: 'Linux' + sku: { + name: 'P0V3' + tier: 'Premium' + } +} + +resource env_contributor_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_contributor_mi-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +resource env_ra 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, env_contributor_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')) + properties: { + principalId: env_contributor_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7') + principalType: 'ServicePrincipal' + } +} + +resource dashboard 'Microsoft.Web/sites@2025-03-01' = { + name: take('${toLower('env')}-${toLower('aspiredashboard')}-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + serverFarmId: env_asplan.id + siteConfig: { + numberOfWorkers: 1 + linuxFxVersion: 'ASPIREDASHBOARD|1.0' + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: env_mi.properties.clientId + appSettings: [ + { + name: 'DASHBOARD__FRONTEND__AUTHMODE' + value: 'Unsecured' + } + { + name: 'DASHBOARD__OTLP__AUTHMODE' + value: 'Unsecured' + } + { + name: 'DASHBOARD__OTLP__SUPPRESSUNSECUREDTELEMETRYMESSAGE' + value: 'true' + } + { + name: 'DASHBOARD__RESOURCESERVICECLIENT__AUTHMODE' + value: 'Unsecured' + } + { + name: 'DASHBOARD__UI__DISABLEIMPORT' + value: 'true' + } + { + name: 'WEBSITES_PORT' + value: '5000' + } + { + name: 'HTTP20_ONLY_PORT' + value: '4317' + } + { + name: 'WEBSITE_START_SCM_WITH_PRELOAD' + value: 'true' + } + { + name: 'AZURE_CLIENT_ID' + value: env_contributor_mi.properties.clientId + } + { + name: 'ALLOWED_MANAGED_IDENTITIES' + value: env_mi.properties.clientId + } + { + name: 'ASPIRE_ENVIRONMENT_NAME' + value: 'env' + } + ] + alwaysOn: true + http20Enabled: true + http20ProxyFlag: 1 + functionAppScaleLimit: 1 + elasticWebAppScaleLimit: 1 + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_contributor_mi.id}': { } + } + } + kind: 'app,linux,aspiredashboard' +} + +output name string = env_asplan.name + +output planId string = env_asplan.id + +output webSiteSuffix string = uniqueString(resourceGroup().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_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID string = env_contributor_mi.id + +output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID string = env_contributor_mi.properties.principalId + +output AZURE_APP_SERVICE_DASHBOARD_URI string = 'https://${take('${toLower('env')}-${toLower('aspiredashboard')}-${uniqueString(resourceGroup().id)}', 60)}.azurewebsites.net' \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithAutomaticScalingAddsEnvironmentResource.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithAutomaticScalingAddsEnvironmentResource.verified.json new file mode 100644 index 00000000000..aff9b2f4d4f --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithAutomaticScalingAddsEnvironmentResource.verified.json @@ -0,0 +1,8 @@ +{ + "type": "azure.bicep.v0", + "path": "env.module.bicep", + "params": { + "env_acr_outputs_name": "{env-acr.outputs.name}", + "userPrincipalId": "" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.bicep index d1203024fbc..2e4377de42c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.bicep @@ -1,4 +1,4 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' @@ -31,8 +31,10 @@ resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location properties: { + elasticScaleEnabled: false perSiteScaling: true reserved: true + maximumElasticWorkerCount: 10 } kind: 'Linux' sku: { @@ -53,4 +55,4 @@ 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_CLIENT_ID string = env_mi.properties.clientId \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.bicep index 643fb1fb539..62ceeba7329 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.bicep @@ -31,8 +31,10 @@ resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location properties: { + elasticScaleEnabled: false perSiteScaling: true reserved: true + maximumElasticWorkerCount: 10 } kind: 'Linux' sku: { @@ -114,6 +116,8 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { alwaysOn: true http20Enabled: true http20ProxyFlag: 1 + functionAppScaleLimit: 1 + elasticWebAppScaleLimit: 1 } } identity: { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.bicep index e5d783a7680..c554f19df2f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.bicep @@ -31,8 +31,10 @@ resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location properties: { + elasticScaleEnabled: false perSiteScaling: true reserved: true + maximumElasticWorkerCount: 10 } kind: 'Linux' sku: { @@ -114,6 +116,8 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { alwaysOn: true http20Enabled: true http20ProxyFlag: 1 + functionAppScaleLimit: 1 + elasticWebAppScaleLimit: 1 } } identity: { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.bicep index 67c97b129a5..dc8021f8c9f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.bicep @@ -33,8 +33,10 @@ resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location properties: { + elasticScaleEnabled: false perSiteScaling: true reserved: true + maximumElasticWorkerCount: 10 } kind: 'Linux' sku: { @@ -116,6 +118,8 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { alwaysOn: true http20Enabled: true http20ProxyFlag: 1 + functionAppScaleLimit: 1 + elasticWebAppScaleLimit: 1 } } identity: { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.bicep index d702111eb40..38d6e5f8468 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.bicep @@ -33,8 +33,10 @@ resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location properties: { + elasticScaleEnabled: false perSiteScaling: true reserved: true + maximumElasticWorkerCount: 10 } kind: 'Linux' sku: { @@ -116,6 +118,8 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { alwaysOn: true http20Enabled: true http20ProxyFlag: 1 + functionAppScaleLimit: 1 + elasticWebAppScaleLimit: 1 } } identity: { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.bicep index 38fae319461..262cf001f17 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.bicep @@ -31,8 +31,10 @@ resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location properties: { + elasticScaleEnabled: false perSiteScaling: true reserved: true + maximumElasticWorkerCount: 10 } kind: 'Linux' sku: { @@ -114,6 +116,8 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { alwaysOn: true http20Enabled: true http20ProxyFlag: 1 + functionAppScaleLimit: 1 + elasticWebAppScaleLimit: 1 } } identity: { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.bicep index 38fae319461..262cf001f17 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.bicep @@ -31,8 +31,10 @@ resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location properties: { + elasticScaleEnabled: false perSiteScaling: true reserved: true + maximumElasticWorkerCount: 10 } kind: 'Linux' sku: { @@ -114,6 +116,8 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { alwaysOn: true http20Enabled: true http20ProxyFlag: 1 + functionAppScaleLimit: 1 + elasticWebAppScaleLimit: 1 } } identity: {