diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index f318f20bc41..cdd4d6fbfce 100644 --- a/docs/CHANGELOG-v1.md +++ b/docs/CHANGELOG-v1.md @@ -36,6 +36,9 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers - Azure SQL Managed Instance: - Verify that Azure SQL Managed Instances have a customer-controlled maintenance window configured by @BenjaminEngeset. [#2979](https://github.com/Azure/PSRule.Rules.Azure/issues/2979) + - App Service: + - Verify that app service plans have availability zones configured by @BenjaminEngeset. + [#2964](https://github.com/Azure/PSRule.Rules.Azure/issues/2964) ## v1.38.0 diff --git a/docs/en/rules/Azure.AppService.AvailabilityZone.md b/docs/en/rules/Azure.AppService.AvailabilityZone.md new file mode 100644 index 00000000000..96bcbe900dc --- /dev/null +++ b/docs/en/rules/Azure.AppService.AvailabilityZone.md @@ -0,0 +1,102 @@ +--- +severity: Important +pillar: Reliability +category: RE:05 Regions and availability zones +resource: App Service +online version: https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.AppService.AvailabilityZone/ +--- + +# Deploy app service plan instances using availability zones + +## SYNOPSIS + +Deploy app service plan instances using availability zones in supported regions to ensure high availability and resilience. + +## DESCRIPTION + +App Service plans support zone redundancy, which distributes your application running within the plan across Availablity Zones. +Each Availability Zone is a group of phyiscally separated data centers. +Deploying your application with zone redundancy: + +- Scales your plan to a minimum of 3 instances in a highly available configuration. + Additional instances can be added manually or on-demand by using autoscale. +- Improves the resiliency against service disruptions or issues affecting a single zone. + +Additionally: + +- **Even Distribution**: If the instance count is larger than 3 and divisible by 3, instances are evenly distributed across the three zones. +- **Partial Distribution**: Instance counts beyond 3*N are spread across the remaining one or two zones to ensure balanced distribution. + +**Important** Configuring zone redundancy with per-application scaling is possible but may increase costs and administrative overhead. +When `perSiteScaling` is enabled, each application can have its own scaling rules and run on dedicated instances. +To maintain zone redundancy, it is crucial that each application’s scaling rules ensure a minimum of 3 instances. +Without explicitly configuring this minimum, the application may not meet the zone redundancy requirement. + +## RECOMMENDATION + +Consider using enabling zone redundancy using availability zones to improve the resiliency of your solution. + +## EXAMPLES + +### Configure with Azure template + +To configure a zone-redundant app service plan: + +- Set the `properties.zoneRedundant` property to `true`. + +For example: + +```json +{ + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2022-09-01", + "name": "[parameters('planName')]", + "location": "[parameters('location')]", + "sku": { + "name": "P1v3", + "tier": "PremiumV3", + "size": "P1v3", + "family": "Pv3", + "capacity": 3 + }, + "properties": { + "zoneRedundant": true + } +} +``` + +### Configure with Bicep + +To configure a zone-redundant app service plan: + +- Set the `properties.zoneRedundant` property to `true`. + +For example: + +```bicep +resource plan 'Microsoft.Web/serverfarms@2022-09-01' = { + name: name + location: location + sku: { + name: 'P1v3' + tier: 'PremiumV3' + size: 'P1v3' + family: 'Pv3' + capacity: 3 + } + properties: { + zoneRedundant: true + } +} +``` + +## NOTES + +Zone-redundancy is only supported for the `PremiumV2`, `PremiumV3` and `ElasticPremium` SKU tiers. + +## LINKS + +- [RE:05 Regions and availability zones](https://learn.microsoft.com/azure/well-architected/reliability/regions-availability-zones) +- [Reliability in Azure App Service](https://learn.microsoft.com/azure/reliability/reliability-app-service) +- [Availability zone support](https://learn.microsoft.com/azure/reliability/reliability-app-service#availability-zone-support) +- [Azure resource deployment](https://learn.microsoft.com/azure/templates/microsoft.web/serverfarms) diff --git a/src/PSRule.Rules.Azure/en/PSRule-rules.psd1 b/src/PSRule.Rules.Azure/en/PSRule-rules.psd1 index 8d51a59ba9a..958490c6309 100644 --- a/src/PSRule.Rules.Azure/en/PSRule-rules.psd1 +++ b/src/PSRule.Rules.Azure/en/PSRule-rules.psd1 @@ -112,4 +112,6 @@ InsecureParameterType = "The parameter '{0}' with type '{1}' is not secure." AzureSQLMIMaintenanceWindow = "The managed instance ({0}) should have a customer-controlled maintenance window configured." AzureSQLDatabaseMaintenanceWindow = "The {0} ({1}) should have a customer-controlled maintenance window configured." + AppServiceAvailabilityZoneSKU = "The app service plan ({0}) is not deployed with a SKU that supports zone-redundancy." + AppServiceAvailabilityZone = "The app service plan ({0}) deployed to region ({1}) should use three availability zones from the following [{2}]." } diff --git a/src/PSRule.Rules.Azure/rules/Azure.AppService.Rule.ps1 b/src/PSRule.Rules.Azure/rules/Azure.AppService.Rule.ps1 index b7b3ecfbbf3..cfd2d47cb7c 100644 --- a/src/PSRule.Rules.Azure/rules/Azure.AppService.Rule.ps1 +++ b/src/PSRule.Rules.Azure/rules/Azure.AppService.Rule.ps1 @@ -189,6 +189,31 @@ Rule 'Azure.AppService.NodeJsVersion' -Ref 'AZR-000428' -Type 'Microsoft.Web/sit } } +# Synopsis: Deploy app service plan instances using availability zones in supported regions to ensure high availability and resilience. +Rule 'Azure.AppService.AvailabilityZone' -Ref 'AZR-000442' -Type 'Microsoft.Web/serverfarms' -Tag @{ release = 'GA'; ruleSet = '2024_09 '; 'Azure.WAF/pillar' = 'Reliability'; } { + # Check if the region supports availability zones. + $provider = [PSRule.Rules.Azure.Runtime.Helper]::GetResourceType('Microsoft.Compute', 'virtualMachineScaleSets') # Use VMSS provider for availability zones as the App Service provider does not provide this information. + $availabilityZones = GetAvailabilityZone -Location $TargetObject.Location -Zone $provider.ZoneMappings + + # Don't flag if the region does not support availability zones. + if (-not $availabilityZones) { + return $Assert.Pass() + } + + # Availability zones are only supported for these Premium SKUs. + $Assert.In($TargetObject, 'sku.tier', @('PremiumV2', 'PremiumV3', 'ElasticPremium')).Reason( + $LocalizedData.AppServiceAvailabilityZoneSKU, + $TargetObject.name + ) + + $Assert.HasFieldValue($TargetObject, 'properties.zoneRedundant', $true).Reason( + $LocalizedData.AppServiceAvailabilityZone, + $TargetObject.name, + $TargetObject.location, + ($availabilityZones -join ', ') + ) +} + #endregion Web Apps #region Helper functions diff --git a/tests/PSRule.Rules.Azure.Tests/Azure.AppService.Tests.ps1 b/tests/PSRule.Rules.Azure.Tests/Azure.AppService.Tests.ps1 index 7904159b382..bba5332d9fe 100644 --- a/tests/PSRule.Rules.Azure.Tests/Azure.AppService.Tests.ps1 +++ b/tests/PSRule.Rules.Azure.Tests/Azure.AppService.Tests.ps1 @@ -42,15 +42,15 @@ Describe 'Azure.AppService' -Tag 'AppService' { # Fail $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 1; - $ruleResult.TargetName | Should -Be 'plan-B'; + $ruleResult.Length | Should -Be 3; + $ruleResult.TargetName | Should -Be 'plan-B', 'plan-C', 'plan-D'; $ruleResult.Detail.Reason.Path | Should -BeIn 'sku.capacity'; # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 1; - $ruleResult.TargetName | Should -Be 'plan-A'; + $ruleResult.Length | Should -Be 2; + $ruleResult.TargetName | Should -Be 'plan-A', 'plan-E'; } It 'Azure.AppService.MinPlan' { @@ -66,8 +66,8 @@ Describe 'Azure.AppService' -Tag 'AppService' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 1; - $ruleResult.TargetName | Should -Be 'plan-A'; + $ruleResult.Length | Should -Be 4; + $ruleResult.TargetName | Should -Be 'plan-A', 'plan-C', 'plan-D', 'plan-E'; } It 'Azure.AppService.ARRAffinity' { @@ -255,6 +255,26 @@ Describe 'Azure.AppService' -Tag 'AppService' { $ruleResult.Length | Should -Be 13; $ruleResult.TargetName | Should -BeIn 'site-A', 'site-A/staging', 'site-B', 'site-B/staging', 'fn-app', 'site-c', 'site-d', 'site-f', 'site-h', 'site-j', 'site-l/web', 'site-n/web', 'site-p/appsettings'; } + + It 'Azure.AppService.AvailabilityZone' { + $filteredResult = $result | Where-Object { $_.RuleName -eq 'Azure.AppService.AvailabilityZone' }; + + # Fail + $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); + $ruleResult.Length | Should -Be 2; + $ruleResult.TargetName | Should -Be 'plan-A', 'plan-D'; + + $ruleResult[0].Reason | Should -BeExactly @( + "The app service plan (plan-A) is not deployed with a SKU that supports zone-redundancy." + "The app service plan (plan-A) deployed to region (eastus) should use three availability zones from the following [1, 2, 3]." + ); + $ruleResult[1].Reason | Should -BeExactly "The app service plan (plan-D) deployed to region (eastus) should use three availability zones from the following [1, 2, 3]."; + + # Pass + $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); + $ruleResult.Length | Should -Be 4; + $ruleResult.TargetName | Should -Be 'plan-B', 'plan-C', 'plan-E', 'plan-F'; + } } Context 'With Template' { diff --git a/tests/PSRule.Rules.Azure.Tests/Resources.AppService.json b/tests/PSRule.Rules.Azure.Tests/Resources.AppService.json index 740890febf5..488de58180d 100644 --- a/tests/PSRule.Rules.Azure.Tests/Resources.AppService.json +++ b/tests/PSRule.Rules.Azure.Tests/Resources.AppService.json @@ -6,7 +6,7 @@ "ResourceType": "Microsoft.Web/serverfarms", "Kind": "app", "ResourceGroupName": "test-rg", - "Location": "region", + "Location": "eastus", "SubscriptionId": "00000000-0000-0000-0000-000000000000", "Tags": null, "Properties": { @@ -608,6 +608,126 @@ "capacity": 1 } }, + { + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/plan-C", + "ResourceName": "plan-C", + "Name": "plan-C", + "ResourceType": "Microsoft.Web/serverfarms", + "Kind": "app", + "ResourceGroupName": "test-rg", + "Location": "notregion", + "SubscriptionId": "00000000-0000-0000-0000-000000000000", + "Tags": null, + "Properties": { + "perSiteScaling": false, + "elasticScaleEnabled": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0, + "zoneRedundant": false + }, + "Sku": { + "name": "P1v3", + "tier": "PremiumV3", + "size": "P1v3", + "family": "Pv3", + "capacity": 1 + } + }, + { + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/plan-D", + "ResourceName": "plan-D", + "Name": "plan-D", + "ResourceType": "Microsoft.Web/serverfarms", + "Kind": "app", + "ResourceGroupName": "test-rg", + "Location": "eastus", + "SubscriptionId": "00000000-0000-0000-0000-000000000000", + "Tags": null, + "Properties": { + "perSiteScaling": false, + "elasticScaleEnabled": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0, + "zoneRedundant": false + }, + "Sku": { + "name": "P1v3", + "tier": "PremiumV3", + "size": "P1v3", + "family": "Pv3", + "capacity": 1 + } + }, + { + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/plan-E", + "ResourceName": "plan-E", + "Name": "plan-E", + "ResourceType": "Microsoft.Web/serverfarms", + "Kind": "app", + "ResourceGroupName": "test-rg", + "Location": "eastus", + "SubscriptionId": "00000000-0000-0000-0000-000000000000", + "Tags": null, + "Properties": { + "perSiteScaling": false, + "elasticScaleEnabled": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0, + "zoneRedundant": true + }, + "Sku": { + "name": "P1v3", + "tier": "PremiumV3", + "size": "P1v3", + "family": "Pv3", + "capacity": 3 + } + }, + { + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/plan-F", + "ResourceName": "plan-F", + "Name": "plan-F", + "ResourceType": "Microsoft.Web/serverfarms", + "Kind": "elastic", + "ResourceGroupName": "test-rg", + "Location": "eastus", + "SubscriptionId": "00000000-0000-0000-0000-000000000000", + "Tags": null, + "Properties": { + "perSiteScaling": false, + "elasticScaleEnabled": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0, + "zoneRedundant": true + }, + "Sku": { + "name": "EP1", + "tier": "ElasticPremium", + "size": "EP1", + "family": "EP", + "capacity": 3 + } + }, { "Name": "site-B", "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Web/sites/site-B",