diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index 7b1beeb22f1..5961c6ca5fe 100644 --- a/docs/CHANGELOG-v1.md +++ b/docs/CHANGELOG-v1.md @@ -39,6 +39,9 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers [#2846](https://github.com/Azure/PSRule.Rules.Azure/issues/2846) - Check that database accounts have public network access disabled by @BenjaminEngeset. [#2702](https://github.com/Azure/PSRule.Rules.Azure/issues/2702) + - API Management: + - Check that scale units are distributed evenly across the configured availability zones by @BenjaminEngeset. + [#2788](https://github.com/Azure/PSRule.Rules.Azure/issues/2788) ## v1.37.0-B0009 (pre-release) diff --git a/docs/en/rules/Azure.APIM.AvailabilityZone.Units.md b/docs/en/rules/Azure.APIM.AvailabilityZone.Units.md new file mode 100644 index 00000000000..537453f1760 --- /dev/null +++ b/docs/en/rules/Azure.APIM.AvailabilityZone.Units.md @@ -0,0 +1,147 @@ +--- +severity: Important +pillar: Reliability +category: RE:05 Regions and availability zones +resource: API Management +online version: https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.APIM.AvailabilityZone.Units/ +--- + +# API management services should use the same number of units as the number of availability zones or greater in a region. + +## SYNOPSIS + +Configure the same number of units as the number of availability zones or greater in a region. + +## DESCRIPTION + +Enabling zone redundancy for an API Management instance in a supported region provides redundancy for all service components: gateway, management plane, and developer portal. Azure automatically replicates all service components across the zones that you select. Zone redundancy is only available in the Premium service tier. + +When you enable zone redundancy in a region, both in primary and additional regions, consider the number of API Management scale units that need to be distributed. Minimally, configure the same number of units as the number of availability zones, or a multiple so that the units are distributed evenly across the zones. For example, if you select 3 availability zones in a region, you could have 3 units so that each zone hosts one unit. + +## RECOMMENDATION + +Consider configuring the same number of units as the number of availability zones or greater in a region. + +## EXAMPLES + +### Configure with Azure template + +To deploy API management instances that pass this rule: + +- Set `zones` to a minimum of two zones from `["1", "2", "3"]`, ensuring the number of zones match `sku.capacity` and/or `properties.additionalLocations[*].zones` to a minimum of two zones from `["1", "2", "3"]`, ensuring the number of zones match `properties.additionalLocations[*].sku.capacity`. +- Set `sku.name` and/or `properties.additionalLocations[*].sku.name` to `Premium`. + +For example: + +```json +{ + "type": "Microsoft.ApiManagement/service", + "apiVersion": "2023-05-01-preview", + "name": "[parameters('service_api_mgmt_name')]", + "location": "Australia East", + "sku": { + "name": "Premium", + "capacity": 3 + }, + "zones": [ + "1", + "2", + "3" + ], + "properties": { + "publisherEmail": "john.doe@contoso.com", + "publisherName": "contoso", + "notificationSenderEmail": "apimgmt-noreply@mail.windowsazure.com", + "hostnameConfigurations": [ + { + "type": "Proxy", + "hostName": "[concat(parameters('service_api_mgmt_name'), '.azure-api.net')]", + "negotiateClientCertificate": false, + "defaultSslBinding": true, + "certificateSource": "BuiltIn" + } + ], + "additionalLocations": [ + { + "location": "East US", + "sku": { + "name": "Premium", + "capacity": 3 + }, + "zones": [ + "1", + "2", + "3" + ], + "disableGateway": false + } + ], + "virtualNetworkType": "None", + "disableGateway": false, + "apiVersionConstraint": {} + } +} +``` + +### Configure with Bicep + +To set availability zones for a API management service + +- Set `zones` to a minimum of two zones from `["1", "2", "3"]`, ensuring the number of zones match `sku.capacity` and/or `properties.additionalLocations[*].zones` to a minimum of two zones from `["1", "2", "3"]`, ensuring the number of zones match `properties.additionalLocations[*].sku.capacity`. +- Set `sku.name` and/or `properties.additionalLocations[*].sku.name` to `Premium`. + +For example: + +```bicep +resource apim 'Microsoft.ApiManagement/service@2023-05-01-preview' = { + name: service_api_mgmt_name + location: 'Australia East' + sku: { + name: 'Premium' + capacity: 3 + } + zones: [ + '1', + '2', + '3' + ] + properties: { + publisherEmail: 'john.doe@contoso.com' + publisherName: 'contoso' + notificationSenderEmail: 'apimgmt-noreply@mail.windowsazure.com' + hostnameConfigurations: [ + { + type: 'Proxy' + hostName: '${service_api_mgmt_test2_name}.azure-api.net' + negotiateClientCertificate: false + defaultSslBinding: true + certificateSource: 'BuiltIn' + } + ] + additionalLocations: [ + { + location: 'East US' + sku: { + name: 'Premium' + capacity: 3 + } + zones: [ + '1' + '2' + '3' + ] + disableGateway: false + } + ] + virtualNetworkType: 'None' + disableGateway: false + apiVersionConstraint: {} + } +} +``` + +## LINKS + +- [RE:05 Regions and availability zones](https://learn.microsoft.com/azure/well-architected/reliability/regions-availability-zones) +- [Units distribution evenly across the zones](https://learn.microsoft.com/azure/api-management/high-availability#availability-zones) +- [Azure deployment reference](https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service) diff --git a/src/PSRule.Rules.Azure/rules/Azure.APIM.Rule.ps1 b/src/PSRule.Rules.Azure/rules/Azure.APIM.Rule.ps1 index aeef3bfca41..4a44563876a 100644 --- a/src/PSRule.Rules.Azure/rules/Azure.APIM.Rule.ps1 +++ b/src/PSRule.Rules.Azure/rules/Azure.APIM.Rule.ps1 @@ -334,6 +334,36 @@ Rule 'Azure.APIM.DefenderCloud' -Ref 'AZR-000387' -Type 'Microsoft.ApiManagement } } +# Synopsis: Configure the same number of units as the number of availability zones or greater in a region. +Rule 'Azure.APIM.AvailabilityZone.Units' -Ref 'AZR-000422' -Type 'Microsoft.ApiManagement/service' -If { (IsPremiumAPIM) -and (Test-IsZoneRedundant) } -Tag @{ release = 'GA'; ruleSet = '2024_06'; 'Azure.WAF/pillar' = 'Reliability'; } { + # Configure the same number of units as the number of availability zones or greater in the primary region. If you select 3 availability zones in the region > 3 units so that each zone hosts one unit. + $zones = @($TargetObject.zones) + if ($zones.Count -eq '2' -and -not (Compare-Object -DifferenceObject $zones -ReferenceObject '1', '2')) { + $Assert.GreaterOrEqual($TargetObject, 'sku.capacity', 2) + } + + if ($zones.Count -eq '3' -and -not (Compare-Object -DifferenceObject $zones -ReferenceObject '1', '2', '3')) { + $Assert.GreaterOrEqual($TargetObject, 'sku.capacity', 3) + } + + # Configure the same number of units as the number of availability zones or greater in additional regions. If you select 3 availability zones in a region > 3 units so that each zone hosts one unit. + $additionalLocations = @($TargetObject.properties.additionalLocations) + if ($additionalLocations.Count -gt 0) { + foreach ($location in $additionalLocations) { + $sku = $location.sku.name + $zones = @($location.zones) + } + if ($sku -eq 'Premium' -and $zones.Count -eq '2' -and -not (Compare-Object -DifferenceObject $zones -ReferenceObject '1', '2')) { + $Assert.GreaterOrEqual($location, 'sku.capacity', 2) + } + + if ($sku -eq 'Premium' -and $zones.Count -eq '3' -and -not (Compare-Object -DifferenceObject $zones -ReferenceObject '1', '2', '3')) { + $Assert.GreaterOrEqual($location, 'sku.capacity', 3) + + } + } +} + #endregion Rules #region Helper functions @@ -389,4 +419,28 @@ function global:HasRestApi { } } +function global:Test-IsZoneRedundant { + [CmdletBinding()] + param ( ) + # Check if more than 1 zone is selected in the primary region. + if ($TargetObject.zones.Count -gt 1) { + return $true + } + + # Check if more than 1 zone is selected in additional regions. + if ($additionalLocations.Count -gt 0) { + foreach ($location in $additionalLocations) { + $sku = $location.sku.name + $zones = @($location.zones) + } + if ($sku -eq 'Premium' -and $zones.Count -eq '2' -and -not (Compare-Object -DifferenceObject $zones -ReferenceObject '1', '2')) { + return $true + } + + if ($sku -eq 'Premium' -and $zones.Count -eq '3' -and -not (Compare-Object -DifferenceObject $zones -ReferenceObject '1', '2', '3')) { + return $true + } + } +} + #endregion Helper functions diff --git a/tests/PSRule.Rules.Azure.Tests/Azure.APIM.Tests.ps1 b/tests/PSRule.Rules.Azure.Tests/Azure.APIM.Tests.ps1 index 6318849df25..8f3157673c0 100644 --- a/tests/PSRule.Rules.Azure.Tests/Azure.APIM.Tests.ps1 +++ b/tests/PSRule.Rules.Azure.Tests/Azure.APIM.Tests.ps1 @@ -465,6 +465,26 @@ Describe 'Azure.APIM' -Tag 'APIM' { $ruleResult.Length | Should -Be 1; $ruleResult.TargetName | Should -BeIn 'apim-D'; } + + It 'Azure.APIM.AvailabilityZone.Units' { + $filteredResult = $result | Where-Object { $_.RuleName -eq 'Azure.APIM.AvailabilityZone.Units' }; + + # Fail + $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); + $ruleResult.Length | Should -Be 4; + $ruleResult.TargetName | Should -BeIn 'apim-E', 'apim-F', 'apim-G', 'apim-I'; + + $ruleResult[0].Reason | Should -BeIn "Path sku.capacity: The value '1' was not >= '2'."; + $ruleResult[1].Reason | Should -BeIn "Path sku.capacity: The value '2' was not >= '3'."; + $ruleResult[2].Reason | Should -BeIn "Path sku.capacity: The value '1' was not >= '3'."; + $ruleResult[3].Reason | Should -BeIn "Path sku.capacity: The value '2' was not >= '3'."; + + + # Pass + $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); + $ruleResult.Length | Should -Be 5; + $ruleResult.TargetName | Should -BeIn 'apim-J', 'apim-M', 'apim-N', 'apim-O', 'apim-P'; + } } Context 'With Template' { diff --git a/tests/PSRule.Rules.Azure.Tests/Resources.APIM.json b/tests/PSRule.Rules.Azure.Tests/Resources.APIM.json index 2b1dc3c575e..53e6e04aa3c 100644 --- a/tests/PSRule.Rules.Azure.Tests/Resources.APIM.json +++ b/tests/PSRule.Rules.Azure.Tests/Resources.APIM.json @@ -1742,7 +1742,10 @@ "SubscriptionId": "00000000-0000-0000-0000-000000000000" } ], - "Zones": null + "Zones": [ + "1", + "2" + ] }, { "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.ApiManagement/service/apim-F", @@ -1820,10 +1823,12 @@ "location": "East US", "sku": { "name": "Premium", - "capacity": 1 + "capacity": 2 }, "zones": [ - "1" + "1", + "2", + "3" ], "publicIPAddresses": [ "xx.xx.xx.xx" @@ -1864,13 +1869,14 @@ "Size": null, "Family": null, "Model": null, - "Capacity": 1 + "Capacity": 2 }, "Tags": {}, "SubscriptionId": "00000000-0000-0000-0000-000000000000", "resources": [], "Zones": [ - "1" + "1", + "2" ] }, { @@ -1973,9 +1979,11 @@ "location": "Antarctica South", "sku": { "name": "Premium", - "capacity": 1 + "capacity": 2 }, - "zones": null, + "zones": [ + "1" + ], "publicIPAddresses": [ "xx.xx.xx.xx" ], @@ -2021,7 +2029,9 @@ "SubscriptionId": "00000000-0000-0000-0000-000000000000", "resources": [], "Zones": [ - "1" + "1", + "2", + "3" ] }, { @@ -2230,7 +2240,46 @@ "0.0.0.0" ], "privateIPAddresses": null, - "additionalLocations": null, + "additionalLocations": [ + { + "location": "Antarctica South", + "sku": { + "name": "Premium", + "capacity": 3 + }, + "zones": [ + "1", + "2", + "3" + ], + "publicIPAddresses": [ + "xx.xx.xx.xx" + ], + "privateIPAddresses": null, + "virtualNetworkConfiguration": null, + "gatewayRegionalUrl": "https://apim-I-eastus-01.regional.azure-api.ne", + "disableGateway": true + }, + { + "location": "Norway East", + "sku": { + "name": "Premium", + "capacity": 2 + }, + "zones": [ + "1", + "2", + "3" + ], + "publicIPAddresses": [ + "xx.xx.xx.xx" + ], + "privateIPAddresses": null, + "virtualNetworkConfiguration": null, + "gatewayRegionalUrl": "https://apim-I-eastus-01.regional.azure-api.ne", + "disableGateway": true + } + ], "virtualNetworkConfiguration": null, "customProperties": { "Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Tls10": "False", @@ -2261,12 +2310,16 @@ "Size": null, "Family": null, "Model": null, - "Capacity": 1 + "Capacity": 3 }, "Tags": {}, "SubscriptionId": "00000000-0000-0000-0000-000000000000", "resources": [], - "Zones": null + "Zones": [ + "1", + "2", + "3" + ] }, { "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.ApiManagement/service/apim-J", @@ -2343,7 +2396,46 @@ "0.0.0.0" ], "privateIPAddresses": null, - "additionalLocations": null, + "additionalLocations": [ + { + "location": "Antarctica South", + "sku": { + "name": "Premium", + "capacity": 3 + }, + "zones": [ + "1", + "2", + "3" + ], + "publicIPAddresses": [ + "xx.xx.xx.xx" + ], + "privateIPAddresses": null, + "virtualNetworkConfiguration": null, + "gatewayRegionalUrl": "https://apim-J-eastus-01.regional.azure-api.ne", + "disableGateway": true + }, + { + "location": "Norway East", + "sku": { + "name": "Premium", + "capacity": 3 + }, + "zones": [ + "1", + "2", + "3" + ], + "publicIPAddresses": [ + "xx.xx.xx.xx" + ], + "privateIPAddresses": null, + "virtualNetworkConfiguration": null, + "gatewayRegionalUrl": "https://apim-J-eastus-01.regional.azure-api.ne", + "disableGateway": true + } + ], "virtualNetworkConfiguration": null, "customProperties": { "Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Tls10": "False", @@ -2374,12 +2466,16 @@ "Size": null, "Family": null, "Model": null, - "Capacity": 1 + "Capacity": 3 }, "Tags": {}, "SubscriptionId": "00000000-0000-0000-0000-000000000000", "resources": [], - "Zones": [] + "Zones": [ + "1", + "2", + "3" + ] }, { "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.ApiManagement/service/apim-K",