diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index cc2ca7dad5..50e7a3830f 100644 --- a/docs/CHANGELOG-v1.md +++ b/docs/CHANGELOG-v1.md @@ -29,12 +29,31 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers ## Unreleased -What's changed since v1.41.2: +What's changed since v1.41.4: - General improvements: - Added a new quickstart guide for using Azure Pipelines with PSRule by @that-ar-guy. [#3220](https://github.com/Azure/PSRule.Rules.Azure/pull/3220) +## v1.41.4 + +What's changed since v1.41.3: + +- Bug fixes: + - Fixed Azure VM Standalone failing on data disks check by @BernieWhite. + [#3263](https://github.com/Azure/PSRule.Rules.Azure/issues/3263) + - Fixed in-flight analysis of key rotation policy fails due to missing data by @BernieWhite. + [#3261](https://github.com/Azure/PSRule.Rules.Azure/issues/3261) + - Disabled export of `keys` from management plane API because the data is incomplete for this rule. + +## v1.41.3 + +What's changed since v1.41.2: + +- Bug fixes: + - Fixed ordering of symbolic copy loop dependencies by @BernieWhite. + [#3257](https://github.com/Azure/PSRule.Rules.Azure/issues/3257) + ## v1.41.2 What's changed since v1.41.1: diff --git a/docs/en/rules/Azure.KeyVault.AutoRotationPolicy.md b/docs/en/rules/Azure.KeyVault.AutoRotationPolicy.md index be22813c9c..d475db88ef 100644 --- a/docs/en/rules/Azure.KeyVault.AutoRotationPolicy.md +++ b/docs/en/rules/Azure.KeyVault.AutoRotationPolicy.md @@ -1,5 +1,5 @@ --- -reviewed: 2024-06-17 +reviewed: 2025-02-27 severity: Important pillar: Security category: SE:09 Application secrets @@ -7,11 +7,11 @@ resource: Key Vault online version: https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.KeyVault.AutoRotationPolicy/ --- -# Enable Key Vault key auto-rotation +# Key Vault key rotation policy is not set ## SYNOPSIS -Key Vault keys should have auto-rotation enabled. +Keys that become compromised may be used to spoof, decrypt, or gain access to sensitive data. ## DESCRIPTION @@ -124,6 +124,10 @@ resource vaultName_key1 'Microsoft.KeyVault/vaults/keys@2021-06-01-preview' = { } ``` +## NOTES + +This rule only applies to pre-flight validation of Azure templates and Bicep files. + ## LINKS - [SE:09 Application secrets](https://learn.microsoft.com/azure/well-architected/security/application-secrets) diff --git a/docs/en/rules/Azure.VM.Standalone.md b/docs/en/rules/Azure.VM.Standalone.md index d04bccfbe4..4d4df78cf3 100644 --- a/docs/en/rules/Azure.VM.Standalone.md +++ b/docs/en/rules/Azure.VM.Standalone.md @@ -1,5 +1,5 @@ --- -reviewed: 2022-07-09 +reviewed: 2025-02-27 severity: Important pillar: Reliability category: RE:04 Target metrics @@ -7,11 +7,11 @@ resource: Virtual Machine online version: https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.VM.Standalone/ --- -# Standalone Virtual Machine +# Virtual Machine is not configured for improved SLA ## SYNOPSIS -Use VM features to increase reliability and improve covered SLA for VM configurations. +Single instance VMs are a single point of failure, however reliability can be improved by using premium storage. ## DESCRIPTION @@ -29,7 +29,8 @@ Taking advantage of some of the features of Azure can further increase the avail Each Availability Zone has a distinct power source, network, and cooling. - **Availability Sets** - is a logical grouping of VMs that allows Azure to understand how your application is built. By understanding the distinct tiers of the application, Azure can better organize compute and storage to improve availability. -- **Solid State Storage (SSD) Disks** - high performance block-level storage with three replicas of your data. +- **Premium Solid State Storage (SSD) Disks** - high performance block-level storage with three replicas of your data. + When you use a mix of storage for OS and data disk attached to your VMs, the SLA is based on the lowest performing disk. ## RECOMMENDATION @@ -37,65 +38,6 @@ Consider using availability zones/ sets or only premium/ ultra disks to improve ## EXAMPLES -### Configure with Azure template - -To deploy VMs that pass this rule with on of the following: - -- Deploy the VM in an Availability Set by specifying `properties.availabilitySet.id` in code. -- Deploy the VM in an Availability Zone by specifying `zones` with `1`, `2`, or `3` in code. -- Deploy the VM using only premium disks for OS and data disks by specifying `storageAccountType` as `Premium_LRS`. - -For example: - -```json -{ - "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2022-03-01", - "name": "[parameters('name')]", - "location": "[parameters('location')]", - "zones": [ - "1" - ], - "properties": { - "hardwareProfile": { - "vmSize": "Standard_D2s_v3" - }, - "osProfile": { - "computerName": "[parameters('name')]", - "adminUsername": "[parameters('adminUsername')]", - "adminPassword": "[parameters('adminPassword')]" - }, - "storageProfile": { - "imageReference": { - "publisher": "MicrosoftWindowsServer", - "offer": "WindowsServer", - "sku": "[parameters('sku')]", - "version": "latest" - }, - "osDisk": { - "name": "[format('{0}-disk0', parameters('name'))]", - "caching": "ReadWrite", - "createOption": "FromImage", - "managedDisk": { - "storageAccountType": "Premium_LRS" - } - } - }, - "licenseType": "Windows_Server", - "networkProfile": { - "networkInterfaces": [ - { - "id": "[resourceId('Microsoft.Network/networkInterfaces', format('{0}-nic0', parameters('name')))]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/networkInterfaces', format('{0}-nic0', parameters('name')))]" - ] -} -``` - ### Configure with Bicep To deploy VMs that pass this rule with on of the following: @@ -107,7 +49,7 @@ To deploy VMs that pass this rule with on of the following: For example: ```bicep -resource vm1 'Microsoft.Compute/virtualMachines@2022-03-01' = { +resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { name: name location: location zones: [ @@ -150,11 +92,70 @@ resource vm1 'Microsoft.Compute/virtualMachines@2022-03-01' = { } ``` + + +### Configure with Azure template + +To deploy VMs that pass this rule with on of the following: + +- Deploy the VM in an Availability Set by specifying `properties.availabilitySet.id` in code. +- Deploy the VM in an Availability Zone by specifying `zones` with `1`, `2`, or `3` in code. +- Deploy the VM using only premium disks for OS and data disks by specifying `storageAccountType` as `Premium_LRS`. + +For example: + +```json +{ + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2024-07-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "zones": [ + "1" + ], + "properties": { + "hardwareProfile": { + "vmSize": "Standard_D2s_v3" + }, + "osProfile": { + "computerName": "[parameters('name')]", + "adminUsername": "[parameters('adminUsername')]", + "adminPassword": "[parameters('adminPassword')]" + }, + "storageProfile": { + "imageReference": { + "publisher": "MicrosoftWindowsServer", + "offer": "WindowsServer", + "sku": "[parameters('sku')]", + "version": "latest" + }, + "osDisk": { + "name": "[format('{0}-disk0', parameters('name'))]", + "caching": "ReadWrite", + "createOption": "FromImage", + "managedDisk": { + "storageAccountType": "Premium_LRS" + } + } + }, + "licenseType": "Windows_Server", + "networkProfile": { + "networkInterfaces": [ + { + "id": "[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]" + ] +} +``` + ## LINKS - [RE:04 Target metrics](https://learn.microsoft.com/azure/well-architected/reliability/metrics) -- [Virtual Machine SLA](https://azure.microsoft.com/support/legal/sla/virtual-machines) -- [Availability options for virtual machines in Azure](https://learn.microsoft.com/azure/virtual-machines/availability) -- [Manage the availability of Windows virtual machines in Azure](https://learn.microsoft.com/azure/virtual-machines/windows/manage-availability) -- [Manage the availability of Linux virtual machines](https://learn.microsoft.com/azure/virtual-machines/linux/manage-availability) +- [Virtual Machine SLA](https://www.microsoft.com/licensing/docs/view/Service-Level-Agreements-SLA-for-Online-Services) +- [Availability options for Azure Virtual Machines](https://learn.microsoft.com/azure/virtual-machines/availability) - [Azure deployment reference](https://learn.microsoft.com/azure/templates/microsoft.compute/virtualmachines) diff --git a/docs/examples/resources/vm.bicep b/docs/examples/resources/vm.bicep index 900f9c05ef..911db657d6 100644 --- a/docs/examples/resources/vm.bicep +++ b/docs/examples/resources/vm.bicep @@ -27,7 +27,7 @@ param subnetId string param amaIdentityId string // An example virtual machine running Windows Server and one data disk attached. -resource vm 'Microsoft.Compute/virtualMachines@2024-03-01' = { +resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { name: name location: location identity: { @@ -146,7 +146,7 @@ resource config 'Microsoft.Maintenance/configurationAssignments@2023-04-01' = { } // An example virtual machine with Azure Hybrid Benefit. -resource vm_with_benefit 'Microsoft.Compute/virtualMachines@2023-09-01' = { +resource vm_with_benefit 'Microsoft.Compute/virtualMachines@2024-07-01' = { name: name location: location zones: [ diff --git a/docs/examples/resources/vm.json b/docs/examples/resources/vm.json index 2dd590c05e..8559b00d0d 100644 --- a/docs/examples/resources/vm.json +++ b/docs/examples/resources/vm.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.32.4.45862", - "templateHash": "13775676563717028722" + "version": "0.33.93.31351", + "templateHash": "2836501364278978101" } }, "parameters": { @@ -64,7 +64,7 @@ "resources": [ { "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2024-03-01", + "apiVersion": "2024-07-01", "name": "[parameters('name')]", "location": "[parameters('location')]", "identity": { @@ -193,7 +193,7 @@ }, { "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2023-09-01", + "apiVersion": "2024-07-01", "name": "[parameters('name')]", "location": "[parameters('location')]", "zones": [ diff --git a/src/PSRule.Rules.Azure/Data/Template/ResourceDependencyGraph.cs b/src/PSRule.Rules.Azure/Data/Template/ResourceDependencyGraph.cs index bba94a1f0f..7cd694e047 100644 --- a/src/PSRule.Rules.Azure/Data/Template/ResourceDependencyGraph.cs +++ b/src/PSRule.Rules.Azure/Data/Template/ResourceDependencyGraph.cs @@ -5,116 +5,111 @@ using System.Collections.Generic; using System.Diagnostics; -namespace PSRule.Rules.Azure.Data.Template +namespace PSRule.Rules.Azure.Data.Template; + +/// +/// A graph that tracks resource dependencies in scope of a deployment. +/// +internal sealed class ResourceDependencyGraph { + private readonly Dictionary _ById = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _ByName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _BySymbolicName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _ByCopyName = new(StringComparer.OrdinalIgnoreCase); + + [DebuggerDisplay("{Resource.Id}")] + private sealed class Node(IResourceValue resource, string[] dependencies) + { + internal readonly IResourceValue Resource = resource; + internal readonly string[] Dependencies = dependencies; + } + /// - /// A graph that tracks resource dependencies in scope of a deployment. + /// Sort the provided resources based on dependency graph. /// - internal sealed class ResourceDependencyGraph + /// The resources to sort. + /// An ordered set of resources. + internal IResourceValue[] Sort(IResourceValue[] resources) { - private readonly Dictionary _ById = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _ByName = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _BySymbolicName = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary> _ByCopyName = new(StringComparer.OrdinalIgnoreCase); + if (resources == null || resources.Length <= 1) + return resources; - [DebuggerDisplay("{Resource.Id}")] - private sealed class Node - { - internal readonly IResourceValue Resource; - internal readonly string[] Dependencies; + var stack = new List(resources.Length); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); - public Node(IResourceValue resource, string[] dependencies) + foreach (var resource in resources) + { + if (TryGet(resource.Id, out var item)) { - Resource = resource; - Dependencies = dependencies; + Visit(item, visited, stack); } - } - - /// - /// Sort the provided resources based on dependency graph. - /// - /// The resources to sort. - /// An ordered set of resources. - internal IResourceValue[] Sort(IResourceValue[] resources) - { - if (resources == null || resources.Length <= 1) - return resources; - - var stack = new List(resources.Length); - var visited = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var resource in resources) + else { - if (TryGet(resource.Id, out var item)) - { - Visit(item, visited, stack); - } - else - { - stack.Add(resource); - visited.Add(resource.Id); - } + stack.Add(resource); + visited.Add(resource.Id); } - return stack.ToArray(); } + return [.. stack]; + } - /// - /// Add a resource to the graph. - /// - /// The resource node to add to the graph. - /// Any dependencies for the node. - internal void Track(IResourceValue resource, string[] dependencies) - { - if (resource == null) - return; + /// + /// Add a resource to the graph. + /// + /// The resource node to add to the graph. + /// Any dependencies for the node. + internal void Track(IResourceValue resource, string[] dependencies) + { + if (resource == null) + return; - var item = new Node(resource, dependencies); - _ById[resource.Id] = item; - _ByName[resource.Name] = item; + var item = new Node(resource, dependencies); + _ById[resource.Id] = item; + _ByName[resource.Name] = item; - if (!string.IsNullOrEmpty(resource.SymbolicName)) - _BySymbolicName[resource.SymbolicName] = item; + if (!string.IsNullOrEmpty(resource.SymbolicName)) + _BySymbolicName[resource.SymbolicName] = item; - if (resource.Copy != null && !string.IsNullOrEmpty(resource.Copy.Name)) + if (resource.Copy != null && !string.IsNullOrEmpty(resource.Copy.Name)) + { + if (!_ByCopyName.TryGetValue(resource.Copy.Name, out var copyItems)) { - if (!_ByCopyName.TryGetValue(resource.Copy.Name, out var copyItems)) - { - copyItems = new List(); - _ByCopyName[resource.Copy.Name] = copyItems; - } - copyItems.Add(item); + copyItems = []; + _ByCopyName[resource.Copy.Name] = copyItems; } + copyItems.Add(item); } + } - private bool TryGet(string key, out Node item) - { - return _ById.TryGetValue(key, out item) || - _ByName.TryGetValue(key, out item) || - _BySymbolicName.TryGetValue(key, out item); - } + private bool TryGet(string key, out Node item) + { + return _ById.TryGetValue(key, out item) || + _ByName.TryGetValue(key, out item) || + _BySymbolicName.TryGetValue(key, out item); + } - /// - /// Traverse a node and dependencies. - /// - private void Visit(Node source, HashSet visited, List stack) - { - if (visited.Contains(source.Resource.Id)) - return; + /// + /// Traverse a node and dependencies. + /// + private void Visit(Node source, HashSet visited, List stack) + { + if (visited.Contains(source.Resource.Id)) + return; - visited.Add(source.Resource.Id); - for (var i = 0; source.Dependencies != null && i < source.Dependencies.Length; i++) + visited.Add(source.Resource.Id); + for (var i = 0; source.Dependencies != null && i < source.Dependencies.Length; i++) + { + if (_ByCopyName.TryGetValue(source.Dependencies[i], out var countItems)) { - if (TryGet(source.Dependencies[i], out var item)) + foreach (var countItem in countItems) { - Visit(item, visited, stack); - } - else if (_ByCopyName.TryGetValue(source.Dependencies[i], out var countItems)) - { - foreach (var countItem in countItems) - Visit(countItem, visited, stack); + Visit(countItem, visited, stack); } } - stack.Add(source.Resource); + else if (TryGet(source.Dependencies[i], out var item)) + { + Visit(item, visited, stack); + } } + stack.Add(source.Resource); } } diff --git a/src/PSRule.Rules.Azure/Pipeline/Export/ResourceExportVisitor.cs b/src/PSRule.Rules.Azure/Pipeline/Export/ResourceExportVisitor.cs index 9c31ab85ad..962572a56d 100644 --- a/src/PSRule.Rules.Azure/Pipeline/Export/ResourceExportVisitor.cs +++ b/src/PSRule.Rules.Azure/Pipeline/Export/ResourceExportVisitor.cs @@ -427,10 +427,14 @@ private static async Task VisitKeyVault(ResourceContext context, JObject r return false; await GetDiagnosticSettings(context, resource, resourceId); - if (resource.TryGetProperty(PROPERTY_PROPERTIES, out JObject properties) && - properties.TryGetProperty(PROPERTY_TENANTID, out var tenantId) && - string.Equals(tenantId, context.TenantId)) - AddSubResource(resource, await GetSubResourcesByType(context, resourceId, "keys", APIVERSION_2022_07_01)); + + // Key rotations policies are not exposed in the management API. + // Exporting keys has been disabled to resolve issue https://github.com/Azure/PSRule.Rules.Azure/issues/3261 + // Currently there is no rules applicable to in-flight that require getting key resources. + // if (resource.TryGetProperty(PROPERTY_PROPERTIES, out JObject properties) && + // properties.TryGetProperty(PROPERTY_TENANTID, out var tenantId) && + // string.Equals(tenantId, context.TenantId)) + // AddSubResource(resource, await GetSubResourcesByType(context, resourceId, "keys", APIVERSION_2022_07_01)); return true; } diff --git a/src/PSRule.Rules.Azure/rules/Azure.VM.Rule.yaml b/src/PSRule.Rules.Azure/rules/Azure.VM.Rule.yaml index e396273b7b..9c6cb503c9 100644 --- a/src/PSRule.Rules.Azure/rules/Azure.VM.Rule.yaml +++ b/src/PSRule.Rules.Azure/rules/Azure.VM.Rule.yaml @@ -15,8 +15,8 @@ metadata: name: Azure.VM.Standalone ref: AZR-000239 tags: - release: 'GA' - ruleSet: '2020_06' + release: GA + ruleSet: 2020_06 Azure.WAF/pillar: Reliability spec: type: @@ -31,8 +31,17 @@ spec: hasValue: true - allOf: - field: properties.storageProfile.osDisk.managedDisk.storageAccountType - equals: Premium_LRS - - field: properties.storageProfile.dataDisks[?@.managedDisk.storageAccountType != 'Premium_LRS' && @.managedDisk.storageAccountType != 'Premium_ZRS' && @.managedDisk.storageAccountType != 'UltraSSD_LRS'] + in: + - Premium_LRS + - Premium_ZRS + - field: properties.storageProfile.dataDisks + allOf: + - field: managedDisk.storageAccountType + notIn: + - Premium_LRS + - Premium_ZRS + - PremiumV2_LRS + - UltraSSD_LRS count: 0 --- diff --git a/tests/PSRule.Rules.Azure.Tests/Azure.VM.Tests.ps1 b/tests/PSRule.Rules.Azure.Tests/Azure.VM.Tests.ps1 index 6b84049a19..531d2d0ee8 100644 --- a/tests/PSRule.Rules.Azure.Tests/Azure.VM.Tests.ps1 +++ b/tests/PSRule.Rules.Azure.Tests/Azure.VM.Tests.ps1 @@ -52,8 +52,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 11; - $ruleResult.TargetName | Should -BeIn 'vm-A', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-C', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-C', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 13; } It 'Azure.VM.Standalone' { @@ -69,14 +69,14 @@ Describe 'Azure.VM' -Tag 'VM' { # Fail $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); $ruleResult | Should -Not -BeNullOrEmpty; + $ruleResult.TargetName | Should -Be 'vm-C', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-H'; $ruleResult.Length | Should -Be 5; - $ruleResult.TargetName | Should -Be 'vm-C', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F'; # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 5; - $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-F', 'vm-I'; + $ruleResult.Length | Should -Be 7; } It 'Azure.VM.PromoSku' { @@ -178,14 +178,14 @@ Describe 'Azure.VM' -Tag 'VM' { # Fail $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 6; - $ruleResult.TargetName | Should -Be 'vm-A', 'vm-B', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-H'; + $ruleResult.Length | Should -Be 7; # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 6; - $ruleResult.TargetName | Should -BeIn 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-C', 'vm-D', 'vm-G'; + $ruleResult.TargetName | Should -BeIn 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-C', 'vm-D', 'vm-G', 'vm-I'; + $ruleResult.Length | Should -Be 7; } It 'Azure.VM.DiskAttached' { @@ -253,13 +253,13 @@ Describe 'Azure.VM' -Tag 'VM' { $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); $ruleResult | Should -Not -BeNullOrEmpty; $ruleResult.Length | Should -Be 1; - $ruleResult.TargetName | Should -Be 'vm-B'; + $ruleResult.TargetName | Should -BeIn 'vm-B'; # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 3; - $ruleResult.TargetName | Should -Be 'vm-A', 'vm-E', 'vm-F'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-E', 'vm-F', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 5; } It 'Azure.VM.AcceleratedNetworking' { @@ -409,8 +409,8 @@ Describe 'Azure.VM' -Tag 'VM' { # None $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'None' -and $_.TargetType -eq 'Microsoft.Compute/virtualMachines' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 6; - $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'vm-D', 'vm-E', 'vm-F', 'vm-G'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'vm-D', 'vm-E', 'vm-F', 'vm-G', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 8; } It 'Azure.VM.Agent' { @@ -425,8 +425,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 10; - $ruleResult.TargetName | Should -BeIn 'vm-A', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 12; } It 'Azure.VM.Updates' { @@ -441,8 +441,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 3; - $ruleResult.TargetName | Should -Be 'vm-A', 'vm-E', 'vm-F'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-E', 'vm-F', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 5; } It 'Azure.VM.Name' { @@ -455,8 +455,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 12; - $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'vm-C', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'vm-C', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 14; } It 'Azure.VM.ComputerName' { @@ -470,8 +470,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 10; - $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'vm-C', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'vm-E', 'vm-F', 'vm-G'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'vm-C', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'vm-E', 'vm-F', 'vm-G', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 12; } It 'Azure.VM.DiskName' { @@ -517,8 +517,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Fail $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 3; - $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'vm-E'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'vm-E', 'vm-H'; + $ruleResult.Length | Should -Be 4; $ruleResult[0].Reason | Should -BeExactly "The virtual machine used for running SQL Server should use Premium disks or greater."; $ruleResult[1].Reason | Should -BeExactly "The virtual machine used for running SQL Server should use Premium disks or greater."; @@ -527,8 +527,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 1; - $ruleResult.TargetName | Should -BeIn 'vm-F'; + $ruleResult.TargetName | Should -BeIn 'vm-F', 'vm-I'; + $ruleResult.Length | Should -Be 2; } It 'Azure.VM.AMA' { @@ -539,8 +539,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Fail $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 8; - $ruleResult.TargetName | Should -BeIn 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-G'; + $ruleResult.TargetName | Should -BeIn 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-G', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 10; $ruleResult[0].Reason | Should -BeExactly "The virtual machine should have Azure Monitor Agent installed."; $ruleResult[1].Reason | Should -BeExactly "The virtual machine should have Azure Monitor Agent installed."; @@ -567,7 +567,7 @@ Describe 'Azure.VM' -Tag 'VM' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 11; + $ruleResult.Length | Should -Be 13; } It 'Azure.VM.MaintenanceConfig' { @@ -576,8 +576,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Fail $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Fail' }); $ruleResult | Should -Not -BeNullOrEmpty; - $ruleResult.Length | Should -Be 11; - $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G'; + $ruleResult.TargetName | Should -BeIn 'vm-A', 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 13; $ruleResult[0].Reason | Should -BeExactly "The virtual machine 'vm-A' should have a maintenance configuration associated."; $ruleResult[1].Reason | Should -BeExactly "The virtual machine 'vm-B' should have a maintenance configuration associated."; @@ -619,8 +619,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); - $ruleResult.Length | Should -Be 11; - $ruleResult.TargetName | Should -BeIn 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-C', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G'; + $ruleResult.TargetName | Should -BeIn 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-C', 'vm-D', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G', 'vm-H', 'vm-I'; + $ruleResult.Length | Should -Be 13; } } @@ -1290,8 +1290,8 @@ Describe 'Azure.VM' -Tag 'VM' { # Pass $ruleResult = @($filteredResult | Where-Object { $_.Outcome -eq 'Pass' }); - $ruleResult.Length | Should -Be 11; - $ruleResult.TargetName | Should -Be 'vm-A', 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-C', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G'; + $ruleResult.Length | Should -Be 13; + $ruleResult.TargetName | Should -Be 'vm-A', 'vm-B', 'aks-agentpool-00000000-1', 'aks-agentpool-00000000-2', 'aks-agentpool-00000000-3', 'vm-C', 'offerSaysLinux', 'offerInConfig', 'vm-E', 'vm-F', 'vm-G', 'vm-H', 'vm-I'; } } } diff --git a/tests/PSRule.Rules.Azure.Tests/DependencyMapTests.cs b/tests/PSRule.Rules.Azure.Tests/DependencyMapTests.cs index 9a735dc842..d6623ac6d4 100644 --- a/tests/PSRule.Rules.Azure.Tests/DependencyMapTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/DependencyMapTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Linq; using Newtonsoft.Json.Linq; using PSRule.Rules.Azure.Data.Template; using static PSRule.Rules.Azure.Data.Template.TemplateVisitor; @@ -10,7 +11,7 @@ namespace PSRule.Rules.Azure public sealed class DependencyMapTests { [Fact] - public void SortWithComparer() + public void SortDependencies_WithoutSymbolicName_ShouldReorderResources() { var context = new TemplateContext(); var resources = new IResourceValue[] @@ -36,13 +37,13 @@ public void SortWithComparer() // https://github.com/Azure/PSRule.Rules.Azure/issues/2255 context = new TemplateContext(); - resources = new IResourceValue[] - { + resources = + [ GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/virtualNetworks\", \"name\": \"vnet-001\", \"dependsOn\": [ \"/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/resourceGroups/ps-rule-test-rg/providers/Microsoft.Network/routeTables/rt-002\" ] }")), GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/routeTables\", \"name\": \"rt-001\", \"dependsOn\": [ ] }")), GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/routeTables\", \"name\": \"rt-002\" }")), GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/virtualNetworks/subnets\", \"name\": \"vnet-001/subnet-001\", \"dependsOn\": [ \"/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/resourceGroups/ps-rule-test-rg/providers/Microsoft.Network/virtualNetworks/vnet-001\" ] }")), - }; + ]; resources = context.SortDependencies(resources); actual = resources[0]; @@ -59,7 +60,7 @@ public void SortWithComparer() } [Fact] - public void SortSymbolicNameWithComparer() + public void SortDependencies_WithSymbolicName_ShouldReorderResources() { var context = new TemplateContext(); var resources = new IResourceValue[] @@ -69,30 +70,65 @@ public void SortSymbolicNameWithComparer() GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/routeTables\", \"name\": \"rt-002\" }"), symbolicName: "rt-002"), GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/virtualNetworks/subnets\", \"name\": \"vnet-001/subnet-001\", \"dependsOn\": [ \"vnet-001\" ] }"), symbolicName: "vnet-001/subnet-001"), }; - resources = context.SortDependencies(resources); - var actual = resources[0]; - Assert.Equal("rt-002", actual.Value["name"].Value()); - - actual = resources[1]; - Assert.Equal("vnet-001", actual.Value["name"].Value()); + var actual = context.SortDependencies(resources).Select(r => + { + return r.Value["name"].Value(); + }).ToArray(); + + Assert.Equal( + [ + "rt-002", + "vnet-001", + "rt-001", + "vnet-001/subnet-001" + ], actual); + } - actual = resources[2]; - Assert.Equal("rt-001", actual.Value["name"].Value()); + /// + /// If a dependency is a copy with a symbolic name all instances of the dependency should be reordered above the dependant resource. + /// + [Fact] + public void SortDependencies_WithMultipleResourceCopies_ShouldReorderAllResources() + { + var context = new TemplateContext(); + var resources = new IResourceValue[] + { + GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/virtualNetworks\", \"name\": \"vnet-001\", \"dependsOn\": [ \"routeTables\" ] }"), symbolicName: "vnet-001"), + GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/routeTables\", \"name\": \"rt-001\", \"dependsOn\": [ ] }"), symbolicName: "routeTables", copyInstance: 0), + GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/routeTables\", \"name\": \"rt-002\" }"), symbolicName: "routeTables", copyInstance: 1), + GetResourceValue(context, JObject.Parse("{ \"type\": \"Microsoft.Network/virtualNetworks/subnets\", \"name\": \"vnet-001/subnet-001\", \"dependsOn\": [ \"vnet-001\" ] }"), symbolicName: "vnet-001/subnet-001"), + }; - actual = resources[3]; - Assert.Equal("vnet-001/subnet-001", actual.Value["name"].Value()); + var actual = context.SortDependencies(resources).Select(r => + { + return r.Value["name"].Value(); + }).ToArray(); + + Assert.Equal( + [ + "rt-001", + "rt-002", + "vnet-001", + "vnet-001/subnet-001" + ], actual); } #region Helper methods - private static IResourceValue GetResourceValue(TemplateContext context, JObject resource, string symbolicName = null) + private static IResourceValue GetResourceValue(TemplateContext context, JObject resource, string symbolicName = null, int? copyInstance = null) { + var copy = copyInstance == null || symbolicName == null ? null : new TemplateContext.CopyIndexState + { + Name = symbolicName, + Index = copyInstance.Value + }; + resource.TryGetProperty("name", out var name); resource.TryGetProperty("type", out var type); resource.TryGetDependencies(out var dependencies); var resourceId = ResourceHelper.CombineResourceId(context.Subscription.SubscriptionId, context.ResourceGroup.Name, type, name); - var result = new ResourceValue(resourceId, name, type, symbolicName, resource, null); + var result = new ResourceValue(resourceId, name, type, symbolicName, resource, copy); context.TrackDependencies(result, dependencies); return result; } diff --git a/tests/PSRule.Rules.Azure.Tests/Resources.VirtualMachine.json b/tests/PSRule.Rules.Azure.Tests/Resources.VirtualMachine.json index 983d0ad7a2..791aca7699 100644 --- a/tests/PSRule.Rules.Azure.Tests/Resources.VirtualMachine.json +++ b/tests/PSRule.Rules.Azure.Tests/Resources.VirtualMachine.json @@ -2140,26 +2140,26 @@ }, "resources": [ { - "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/nic-A", - "Id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/nic-A", + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/vm-F-nic", + "Id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/vm-F-nic", "Location": "region", - "ResourceName": "nic-A", - "Name": "nic-A", + "ResourceName": "vm-F-nic", + "Name": "vm-F-nic", "Properties": { "ipConfigurations": [ { "name": "ipconfig1", - "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/nic-A/ipConfigurations/ipconfig1", + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/vm-F-nic/ipConfigurations/ipconfig1", "type": "Microsoft.Network/networkInterfaces/ipConfigurations", "properties": { "provisioningState": "Succeeded", "privateIPAddress": "10.0.0.4", "privateIPAllocationMethod": "Static", "publicIPAddress": { - "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/publicIPAddresses/nic-A-pip" + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/publicIPAddresses/vm-F-nic-pip" }, "subnet": { - "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/vnet-A/subnets/subnet-A" + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/virtualNetworks/vm-F-nic/subnets/subnet-A" }, "primary": true, "privateIPAddressVersion": "IPv4" @@ -2334,5 +2334,141 @@ "Sku": null, "Tags": null, "SubscriptionId": "00000000-0000-0000-0000-000000000000" + }, + { + "Name": "vm-H", + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/vm-H", + "ResourceName": "vm-H", + "ResourceType": "Microsoft.Compute/virtualMachines", + "ResourceGroupName": "test-rg", + "Location": "region", + "SubscriptionId": "00000000-0000-0000-0000-000000000000", + "Properties": { + "hardwareProfile": { + "vmSize": "Standard_E32s_v3" + }, + "storageProfile": { + "imageReference": { + "publisher": "MicrosoftSQLServer", + "offer": "SQL2017-WS2016", + "sku": "Enterprise", + "version": "latest" + }, + "osDisk": { + "osType": "Windows", + "name": "vm-H_OsDisk_1_0000000000000000000000000000000", + "createOption": "FromImage", + "caching": "ReadWrite", + "managedDisk": { + "storageAccountType": "Premium_LRS" + }, + "diskSizeGB": 127 + }, + "dataDisks": [ + { + "lun": 0, + "name": "vm-H_disk2_0000000000000000000000000000000", + "createOption": "Empty", + "caching": "ReadOnly", + "managedDisk": { + "storageAccountType": "Premium_ZRS" + }, + "diskSizeGB": 1023 + }, + { + "lun": 1, + "name": "vm-H_disk3_0000000000000000000000000000000", + "createOption": "Empty", + "caching": "ReadOnly", + "managedDisk": { + "storageAccountType": "Standard_LRS" + }, + "diskSizeGB": 1023 + } + ] + }, + "osProfile": { + "computerName": "vm-H", + "adminUsername": "vm-admin", + "windowsConfiguration": { + "provisionVMAgent": true, + "enableAutomaticUpdates": true + }, + "linuxConfiguration": null, + "secrets": [], + "allowExtensionOperations": true + }, + "networkProfile": { + "networkInterfaces": [ + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/vm-H-nic" + } + ] + }, + "diagnosticsProfile": { + "bootDiagnostics": { + "enabled": true, + "storageUri": "https://storage-A.blob.core.windows.net/" + } + }, + "licenseType": "Windows_Server" + } + }, + { + "Name": "vm-I", + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/vm-I", + "ResourceName": "vm-I", + "ResourceType": "Microsoft.Compute/virtualMachines", + "ResourceGroupName": "test-rg", + "Location": "region", + "SubscriptionId": "00000000-0000-0000-0000-000000000000", + "Properties": { + "hardwareProfile": { + "vmSize": "Standard_E32s_v3" + }, + "storageProfile": { + "imageReference": { + "publisher": "MicrosoftSQLServer", + "offer": "SQL2017-WS2016", + "sku": "Enterprise", + "version": "latest" + }, + "osDisk": { + "osType": "Windows", + "name": "vm-I_OsDisk_1_0000000000000000000000000000000", + "createOption": "FromImage", + "caching": "ReadWrite", + "managedDisk": { + "storageAccountType": "Premium_ZRS" + }, + "diskSizeGB": 127 + } + }, + "osProfile": { + "computerName": "vm-I", + "adminUsername": "vm-admin", + "windowsConfiguration": { + "provisionVMAgent": true, + "enableAutomaticUpdates": true + }, + "linuxConfiguration": null, + "secrets": [], + "allowExtensionOperations": true + }, + "networkProfile": { + "networkInterfaces": [ + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/vm-I-nic" + } + ] + }, + "diagnosticsProfile": { + "bootDiagnostics": { + "enabled": true, + "storageUri": "https://storage-A.blob.core.windows.net/" + } + }, + "licenseType": "Windows_Server" + } } ]