diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index cdd4d6fbfc..524ae5637d 100644 --- a/docs/CHANGELOG-v1.md +++ b/docs/CHANGELOG-v1.md @@ -29,16 +29,21 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers ## Unreleased +What's changed since v1.38.0: + - New rules: + - App Service: + - Verify that app service plans have availability zones configured by @BenjaminEngeset. + [#2964](https://github.com/Azure/PSRule.Rules.Azure/issues/2964) + - App Service Environment: + - Verify that app service environments have availability zones configured by @BenjaminEngeset. + [#2964](https://github.com/Azure/PSRule.Rules.Azure/issues/2964) - Azure SQL Database: - Verify that Azure SQL databases have a customer-controlled maintenance window configured by @BenjaminEngeset. [#2956](https://github.com/Azure/PSRule.Rules.Azure/issues/2956) - 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.ASE.AvailabilityZone.md b/docs/en/rules/Azure.ASE.AvailabilityZone.md new file mode 100644 index 0000000000..b60af30223 --- /dev/null +++ b/docs/en/rules/Azure.ASE.AvailabilityZone.md @@ -0,0 +1,93 @@ +--- +severity: Important +pillar: Reliability +category: RE:05 Regions and availability zones +resource: App Service Environment +online version: https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.ASE.AvailabilityZone/ +--- + +# Deploy app service environments using availability zones + +## SYNOPSIS + +Deploy app service environments using availability zones in supported regions to ensure high availability and resilience. + +## DESCRIPTION + +App Service Environments support zone redundancy, which distributes your application running within the environment across Availablity Zones. +Each Availability Zone is a group of phyiscally separated data centers. +Deploying your application with zone redundancy: + +- Scales your plan within the environment 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 environment: + +- Set the `properties.zoneRedundant` property to `true`. + +For example: + +```json +{ + "type": "Microsoft.Web/hostingEnvironments", + "apiVersion": "2022-09-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "kind": "ASEV3", + "properties": { + "zoneRedundant": true + } +} +``` + +### Configure with Bicep + +To configure a zone-redundant app service environment: + +- Set the `properties.zoneRedundant` property to `true`. + +For example: + +```bicep +resource ase 'Microsoft.Web/hostingEnvironments@2022-09-01' = { + name: name + location: location + kind 'ASEV3' + properties: { + zoneRedundant: true + } +} +``` + +## NOTES + +Zone-redundancy is only supported for the `ASEV3` version. + +Zone-redundancy is not supported for environments deployed on a dedicated host group. + +## 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) +- [About App Service Environment](https://learn.microsoft.com/azure/app-service/environment/overview) +- [Azure resource deployment](https://learn.microsoft.com/azure/templates/microsoft.web/hostingenvironments) diff --git a/src/PSRule.Rules.Azure/en/PSRule-rules.psd1 b/src/PSRule.Rules.Azure/en/PSRule-rules.psd1 index 958490c630..a0e9975c75 100644 --- a/src/PSRule.Rules.Azure/en/PSRule-rules.psd1 +++ b/src/PSRule.Rules.Azure/en/PSRule-rules.psd1 @@ -112,6 +112,7 @@ 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." + ASEAvailabilityZoneVersion = "The app service environment ({0}) is not deployed with a version that supports zone-redundancy." 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.ASE.Rule.ps1 b/src/PSRule.Rules.Azure/rules/Azure.ASE.Rule.ps1 index 61e46014b3..1fe15c6d19 100644 --- a/src/PSRule.Rules.Azure/rules/Azure.ASE.Rule.ps1 +++ b/src/PSRule.Rules.Azure/rules/Azure.ASE.Rule.ps1 @@ -5,7 +5,36 @@ # Validation rules for Azure App Service Environment # +#region Rules + # Synopsis: Use ASEv3 as replacement for the classic app service environment versions ASEv1 and ASEv2. Rule 'Azure.ASE.MigrateV3' -Ref 'AZR-000319' -Type 'Microsoft.Web/hostingEnvironments' -Tag @{ release = 'GA'; ruleSet = '2022_12'; 'Azure.WAF/pillar' = 'Operational Excellence'; } { $Assert.HasFieldValue($TargetObject, 'kind', 'ASEV3').Reason($LocalizedData.ClassicASEDeprecated, $PSRule.TargetName, $TargetObject.kind) } + +# Synopsis: Deploy app service environments using availability zones in supported regions to ensure high availability and resilience. +Rule 'Azure.ASE.AvailabilityZone' -Ref 'AZR-000443' -Type 'Microsoft.Web/hostingEnvironments' -Tag @{ release = 'GA'; ruleSet = '2024_09 '; 'Azure.WAF/pillar' = 'Reliability'; } { + # Dedicated host group does not support zone-redundancy. + if ($TargetObject.properties.dedicatedHostCount) { + return $Assert.Pass() + } + + # Check if the region supports availability zones. + $provider = [PSRule.Rules.Azure.Runtime.Helper]::GetResourceType('Microsoft.Web', 'hostingEnvironments') + $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 the ASEv3 version (modern footprint). + $Assert.HasFieldValue($TargetObject, 'kind', 'ASEV3').Reason( + $LocalizedData.ASEAvailabilityZoneVersion, + $TargetObject.name + ) + + $Assert.HasFieldValue($TargetObject, 'properties.zoneRedundant', $true) +} + +#endregion Rules diff --git a/tests/PSRule.Rules.Azure.Tests/Azure.ASE.Tests.ps1 b/tests/PSRule.Rules.Azure.Tests/Azure.ASE.Tests.ps1 index 64c9b45695..5c5e8156a8 100644 --- a/tests/PSRule.Rules.Azure.Tests/Azure.ASE.Tests.ps1 +++ b/tests/PSRule.Rules.Azure.Tests/Azure.ASE.Tests.ps1 @@ -51,8 +51,32 @@ Describe 'Azure.ASE' -Tag 'ASE' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 1; - $ruleResult.TargetName | Should -BeIn 'environment-C'; + $ruleResult.Length | Should -Be 3; + $ruleResult.TargetName | Should -BeIn 'environment-C', 'environment-E', 'environment-F'; + } + + It 'Azure.ASE.AvailabilityZone' { + $filteredResult = $result | Where-Object { $_.RuleName -eq 'Azure.ASE.AvailabilityZone' }; + + # Fail + $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); + $ruleResult.Length | Should -Be 3; + $ruleResult.TargetName | Should -BeIn 'environment-A', 'environment-B', 'environment-D'; + + $ruleResult[0].Reason | Should -BeExactly @( + "The app service environment (environment-A) is not deployed with a version that supports zone-redundancy." + "Path properties.zoneRedundant: Does not exist." + ): + $ruleResult[1].Reason | Should -BeExactly @( + "The app service environment (environment-B) is not deployed with a version that supports zone-redundancy." + "Path properties.zoneRedundant: Is set to 'False'." + ): + $ruleResult[2].Reason | Should -BeExactly "The app service environment (environment-D) is not deployed with a version that supports zone-redundancy."; + + # Pass + $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); + $ruleResult.Length | Should -Be 3; + $ruleResult.TargetName | Should -Be 'environment-C', 'environment-E', 'environment-F'; } } } diff --git a/tests/PSRule.Rules.Azure.Tests/Resources.ASE.json b/tests/PSRule.Rules.Azure.Tests/Resources.ASE.json index 4f1609525e..02f7335cfe 100644 --- a/tests/PSRule.Rules.Azure.Tests/Resources.ASE.json +++ b/tests/PSRule.Rules.Azure.Tests/Resources.ASE.json @@ -13,8 +13,7 @@ "virtualNetwork": { "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/vnet-A", "subnet": "subnet-A" - }, - "zoneRedundant": true + } }, "ResourceGroupName": "rg-test", "Type": "microsoft.web/hostingenvironments", @@ -52,7 +51,7 @@ "Id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Web/hostingEnvironments/environment-B/serverfarms/plan-A", "Identity": null, "Kind": null, - "Location": "region", + "Location": "westeurope", "ManagedBy": null, "ResourceName": "plan-A", "Name": "plan-A", @@ -88,7 +87,7 @@ "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Web/hostingEnvironments/environment-C", "Id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Web/hostingEnvironments/environment-C", "Identity": null, - "Location": "westeurope", + "Location": "notregion", "ManagedBy": null, "ResourceName": "environment-C", "Name": "environment-C", @@ -167,5 +166,54 @@ "Sku": null, "Tags": null, "SubscriptionId": "00000000-0000-0000-0000-000000000000" + }, + { + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Web/hostingEnvironments/environment-E", + "Id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Web/hostingEnvironments/environment-E", + "Identity": null, + "Location": "westeurope", + "ManagedBy": null, + "ResourceName": "environment-E", + "Name": "environment-E", + "Kind": "ASEV3", + "Properties": { + "upgradePreference": "Early", + "virtualNetwork": { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/vnet-A", + "subnet": "subnet-A" + }, + "zoneRedundant": true + }, + "ResourceGroupName": "rg-test", + "Type": "microsoft.web/hostingenvironments", + "ResourceType": "microsoft.web/hostingenvironments", + "Sku": null, + "Tags": null, + "SubscriptionId": "00000000-0000-0000-0000-000000000000" + }, + { + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Web/hostingEnvironments/environment-F", + "Id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Web/hostingEnvironments/environment-F", + "Identity": null, + "Location": "westeurope", + "ManagedBy": null, + "ResourceName": "environment-F", + "Name": "environment-E", + "Kind": "ASEV3", + "Properties": { + "upgradePreference": "Early", + "virtualNetwork": { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/vnet-A", + "subnet": "subnet-A" + }, + "dedicatedHostCount": 2, + "zoneRedundant": false + }, + "ResourceGroupName": "rg-test", + "Type": "microsoft.web/hostingenvironments", + "ResourceType": "microsoft.web/hostingenvironments", + "Sku": null, + "Tags": null, + "SubscriptionId": "00000000-0000-0000-0000-000000000000" } ]