diff --git a/docs/wiki/ResourceDeletion.md b/docs/wiki/ResourceDeletion.md index a6c2a028..022167e7 100644 --- a/docs/wiki/ResourceDeletion.md +++ b/docs/wiki/ResourceDeletion.md @@ -1,19 +1,47 @@ # AzOps Resource Deletion - [Introduction](#introduction) -- [Deletion dependency validation](#deletion-dependency-validation) +- [Deletion of AzOps generated File](#deletion-of-azops-generated-file) + - [Deletion dependency validation](#deletion-dependency-validation) - [Deletion dependency validation scenario](#deletion-dependency-validation-scenario) +- [Deletion of Custom Template](#deletion-of-custom-template) + - [Enable Deletion of Custom Template](#enable-deletion-of-custom-template) - [Integration with AzOps Accelerator](#integration-with-azops-accelerator) - [How to Add AzOps Resource Deletion to existing AzOps Push](#how-to-add-azops-resource-deletion-to-existing-azops-push-and-validate-pipelines) ## Introduction -**AzOps Resource Deletion** performs deletion of locks, policyAssignments, policyDefinitions, policyExemptions, policySetDefinitions and roleAssignments in Azure, based on `AzOps - Pull` generated templates at all Azure scope levels `(Management Group/Subscription/Resource Group)`. +**AzOps Resource Deletion** at a high level enables two scenarios. +1. [Deletion of AzOps generated File](#deletion-of-azops-generated-file) of supported resource type, resulting in AzOps removes the corresponding resource in Azure. +2. [Deletion of Custom Template](#deletion-of-custom-template), resulting in AzOps removes the corresponding resource in Azure. + +```mermaid +flowchart TD + A[(Main Branch)] --> B[(1.Delete Branch)] + B -- Remove Template Files --> C([2. filename.json]) + C --> D([3. Commit]) + D --> B + B -- Pull Request to Main ----> E(((4. AzOps - Validate + '/tmp/diff.txt' + '/tmp/diffdeletedfiles.txt'))) + E -- git diff --- A + E ---> F[5. Invoke-AzOpsPush -WhatIf:$true] + E -- Merge ---> A + A -- Automated trigger----> G(((6. AzOps - Push + '/tmp/diff.txt' + '/tmp/diffdeletedfiles.txt'))) + G -- git diff --- A + G --> H[7. Invoke-AzOpsPush -WhatIf:$false] +``` -- For any other resource type **deletion** is **not** supported by AzOps at this time. +## Deletion of AzOps generated File By removing a AzOps generated file of a supported resource type AzOps removes the corresponding resource in Azure. +_Supported resource types include: locks, policyAssignments, policyDefinitions, policyExemptions, policySetDefinitions, roleAssignments and resourceGroups in Azure._ + +- For any other `AzOps - Pull` generated resource **deletion** is **not** supported by AzOps at this time. + **_Please Note_** - SPN used for deletion/change action, requires below actions in its role definition. Choose which combination best suites your implementation. @@ -68,7 +96,15 @@ By removing a AzOps generated file of a supported resource type AzOps removes th OR Microsoft.Authorization/roleAssignments/* ``` -## Deletion dependency validation + +- For Azure Resource group removal + +```bash + Microsoft.Resources/subscriptions/resourceGroups/delete + OR + Microsoft.Resources/subscriptions/resourceGroups/* +``` +### Deletion dependency validation When deletion of a supported object is sent to AzOps it evaluates to ensure resource dependencies are included in the deletion job. If a dependency is missing the module will throw (exit with error) and post the result of missing dependencies to the pull request conversation asking you to add it and try again. **_Please Note: For the validation pipeline to fail in the manner intended (applicable to implementations created prior to AzOps release v1.9.0)_** @@ -91,6 +127,41 @@ Scenario: Deletion of a policy definition and policy assignment where the assign - a) In the branch delete the dependent file corresponding to the resulting error. - b) Delete the dependency in Azure and re-run validation. +## Deletion of Custom Template +Deletion of custom templates is a opt-in feature that you need to enable [see](#enable-deletion-of-custom-template). + +Once enabled, deletion of `yourCustomTemplate.bicep`, `yourCustomTemplate.bicepparam`, `yourCustomTemplate.json` or `yourCustomTemplate.parameters.json` results in AzOps attempting deletion of the resolved Azure resources. + +How does AzOps attempt deletion of custom template? + +```mermaid +flowchart TD + A(((Invoke-AzOpsPush))) --> B[Validate Template + filename.parameters.json] + B -- Failed --> K[Skip] + B -- Success --> C[Resolve template files] + C -- No template found --> D + C -- Found template + filename.json --> D[Sort Templates + Attempt locks before other resources] + D --> E[(Process templates for deletion in series)] + E -- Success ---> A + E --> F([Identify resources within template by attempting a WhatIf deployment and gather returned resource ids]) + F --> G([Attempt resource deletion for each identified resource id]) + G -- Success ---> E + G -- Fail --> H([If resource fails deletion, recursively retry deletion in different order]) + H -- Success ---> E + H -- Fail --> I([For resources still failing deletion, collect them for a last deletion attempt, once all other templates are processed]) + I -- Success ---> A + I --Fail --> J([If resource deletion still fails, module will log error and throw]) + J --Fail ---> A +``` + +### Enable Deletion of Custom Template +Set the `Core.CustomTemplateResourceDeletion` value in `settings.json` to `true`. + +`AzOps - Push` will now evaluate and attempt deletion of corresponding resource (_from template_) in Azure when a custom template is deleted. + ## Integration with AzOps Accelerator The [AzOps Accelerator pipelines](https://github.com/azure/azops-accelerator) (including `Git Hub Actions` & `Azure Pipelines`) incorporates the execution of resource deletion. diff --git a/docs/wiki/Settings.md b/docs/wiki/Settings.md index ed5716ef..17ab7e8b 100644 --- a/docs/wiki/Settings.md +++ b/docs/wiki/Settings.md @@ -13,35 +13,36 @@ The following configuration values can be modified within the `settings.json` fi | 01 | ApplicationInsights | Turn on or off logging to Application Insight | `"Core.ApplicationInsights": true` | | 02 | AutoGeneratedTemplateFolderPath | Generate sub folder for composite resources (/.az) | `"Core.AutoGeneratedTemplateFolderPath": ".az"`
root
└── tenant root group (e42bc18f)
        ├── .az
        │      └── microsoft.management_managementgroups.json
        └── mymanagementgroup (mymanagementgroup)
                └── .az
                        ├── microsoft.authorization_policyassignments.json
                        ├── microsoft.authorization_policydefinitions.json
                        ├── microsoft.authorization_roleassignments.json
                        └── microsoft.management_managementgroups.json | | 03 | AutoInitialize | Run Initialize-AzOpsEnvironment when module is loaded. *Not recommended to change* | `"Core.AutoInitialize": true` | -| 04 | CustomJqTemplatePath | Folder where custom Jq templates are located. | `"Core.CustomJqTemplatePath": ".customtemplates"` | -| 05 | SkipCustomJqTemplate | Do not use custom Jq templates, controls if AzOps looks for custom templates at `CustomJqTemplatePath`. [Read more](https://github.com/azure/azops/wiki/custom-jq-templates) | `"Core.SkipCustomJqTemplate": true` | -| 06 | DefaultDeploymentRegion | Default region for deployments | `"Core.DefaultDeploymentRegion": "northeurope"` | -| 07 | EnrollmentAccountPrincipalName | Default enrollment account for Subscription creation | `"Core.EnrollmentAccountPrincipalName": ""` | -| 08 | ExcludedSubOffer | Exclude specific Subscription offer types from pull | `"Core.ExcludedSubOffer": ["AzurePass_2014-09-01","FreeTrial_2014-09-01","AAD_2015-09-01"]` | -| 09 | ExcludedSubState | Exclude specific states of Subscription from pull | `"Core.ExcludedSubState": ["Disabled","Deleted","Warned","Expired"]` | -| 10 | IgnoreContextCheck | Skip Azure PowerShell context validation. *Not recommended to change* | `"Core.IgnoreContextCheck": false` | -| 11 | IncludeResourcesInResourceGroup | Discover only resources in these resource groups | `"Core.IncludeResourcesInResourceGroup": ["rg1","rg2"]` | -| 12 | IncludeResourceType | Discover only specific resource types [Resource Types](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types) (only targets Resource Group scoped resources) | `"Core.IncludeResourceType": ["Microsoft.Network/privateDnsZones","Microsoft.Network/firewallPolicies"]` | -| 13 | InvalidateCache | Invalidate cached Subscriptions and Management Groups and do a full discovery. *Not recommended to change* | `"Core.InvalidateCache": false` | -| 14 | OfferType | Default offer type for Subscription creation | `"Core.OfferType": "MS-AZR-0017P"` | -| 15 | PartialMgDiscoveryRoot | Generate folder hierachy for specific Management Groups IDs | `"Core.PartialMgDiscoveryRoot": []` | -| 16 | SkipPim | Do not include Privileged Identity Management resources in pull | `"Core.SkipPim": true` | -| 17 | SkipLock | Do not include ResourceLock resources in pull | `"Core.SkipLock": true` | -| 18 | SkipPolicy | Do not include Azure Policy state in pull | `"Core.SkipPolicy": false` | -| 19 | SkipResource | Do not include Resources within Resource Groups | `"Core.SkipResource": false` | -| 20 | SkipChildResource | Do not include Azure child resources | `"Core.SkipChildResource": false` | -| 21 | SkipResourceGroup | Do not include Resource Groups in pull | `"Core.SkipResourceGroup": false` | -| 22 | SkipResourceType | Skip specific [Resource Types](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types) (only targets Resource Group scoped resources) | `"Core.SkipResourceType": ["Microsoft.VSOnline/plans"]` | -| 23 | SkipRole | Do not include Role types in pull | `"Core.SkipRole": false` | -| 24 | State | Folder to store AzOpsState artefact, defaults to `root` | `"Core.State: "/root"` | -| 25 | SubscriptionsToIncludeResourceGroups | Filter which Subscription IDs should include Resource Groups in pull [Logic Updated in v2.0.0](https://github.com/Azure/AzOps/releases/tag/2.0.0) | `"Core.SubscriptionsToIncludeResourceGroups": ["*"]` | -| 26 | TemplateParameterFileSuffix | Default template file suffix. *Not recommended to change* | `"Core.TemplateParameterFileSuffix": ".json"` | -| 27 | AllowMultipleTemplateParameterFiles | Control multiple parameter file behaviour. *Not recommended to change* | `"Core.AllowMultipleTemplateParameterFiles": false` | -| 28 | DeployAllMultipleTemplateParameterFiles | Control base template deployment behaviour with changes and un-changed multiple corresponding parameter files. | `"Core.DeployAllMultipleTemplateParameterFiles": false` | -| 29 | MultipleTemplateParameterFileSuffix | Multiple parameter file suffix identifier. *Example mytemplate.x1.bicepparam* | `"Core.MultipleTemplateParameterFileSuffix": ".x"` | -| 30 | ParallelDeployMultipleTemplateParameterFiles | Control parallel deployment of MultipleTemplateParameterFiles behaviour | `"Core.ParallelDeployMultipleTemplateParameterFiles": false` | -| 31 | ThrottleLimit | Value declaring number of parallel threads. [Read more](https://github.com/azure/azops/wiki/performance-considerations) | `"Core.ThrottleLimit": 5` | -| 32 | WhatifExcludedChangeTypes | Exclude specific change types from WhatIf operations | `"Core.WhatifExcludedChangeTypes": ["NoChange","Ignore"]` | +| 04 | CustomTemplateResourceDeletion | Turn on or off deletion of custom ARM and Bicep template resources | `"Core.CustomTemplateResourceDeletion": true` | +| 05 | CustomJqTemplatePath | Folder where custom Jq templates are located. | `"Core.CustomJqTemplatePath": ".customtemplates"` | +| 06 | SkipCustomJqTemplate | Do not use custom Jq templates, controls if AzOps looks for custom templates at `CustomJqTemplatePath`. [Read more](https://github.com/azure/azops/wiki/custom-jq-templates) | `"Core.SkipCustomJqTemplate": true` | +| 07 | DefaultDeploymentRegion | Default region for deployments | `"Core.DefaultDeploymentRegion": "northeurope"` | +| 08 | EnrollmentAccountPrincipalName | Default enrollment account for Subscription creation | `"Core.EnrollmentAccountPrincipalName": ""` | +| 09 | ExcludedSubOffer | Exclude specific Subscription offer types from pull | `"Core.ExcludedSubOffer": ["AzurePass_2014-09-01","FreeTrial_2014-09-01","AAD_2015-09-01"]` | +| 10 | ExcludedSubState | Exclude specific states of Subscription from pull | `"Core.ExcludedSubState": ["Disabled","Deleted","Warned","Expired"]` | +| 11 | IgnoreContextCheck | Skip Azure PowerShell context validation. *Not recommended to change* | `"Core.IgnoreContextCheck": false` | +| 12 | IncludeResourcesInResourceGroup | Discover only resources in these resource groups | `"Core.IncludeResourcesInResourceGroup": ["rg1","rg2"]` | +| 13 | IncludeResourceType | Discover only specific resource types [Resource Types](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types) (only targets Resource Group scoped resources) | `"Core.IncludeResourceType": ["Microsoft.Network/privateDnsZones","Microsoft.Network/firewallPolicies"]` | +| 14 | InvalidateCache | Invalidate cached Subscriptions and Management Groups and do a full discovery. *Not recommended to change* | `"Core.InvalidateCache": false` | +| 15 | OfferType | Default offer type for Subscription creation | `"Core.OfferType": "MS-AZR-0017P"` | +| 16 | PartialMgDiscoveryRoot | Generate folder hierachy for specific Management Groups IDs | `"Core.PartialMgDiscoveryRoot": []` | +| 17 | SkipPim | Do not include Privileged Identity Management resources in pull | `"Core.SkipPim": true` | +| 18 | SkipLock | Do not include ResourceLock resources in pull | `"Core.SkipLock": true` | +| 19 | SkipPolicy | Do not include Azure Policy state in pull | `"Core.SkipPolicy": false` | +| 20 | SkipResource | Do not include Resources within Resource Groups | `"Core.SkipResource": false` | +| 21 | SkipChildResource | Do not include Azure child resources | `"Core.SkipChildResource": false` | +| 22 | SkipResourceGroup | Do not include Resource Groups in pull | `"Core.SkipResourceGroup": false` | +| 23 | SkipResourceType | Skip specific [Resource Types](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types) (only targets Resource Group scoped resources) | `"Core.SkipResourceType": ["Microsoft.VSOnline/plans"]` | +| 24 | SkipRole | Do not include Role types in pull | `"Core.SkipRole": false` | +| 25 | State | Folder to store AzOpsState artefact, defaults to `root` | `"Core.State: "/root"` | +| 26 | SubscriptionsToIncludeResourceGroups | Filter which Subscription IDs should include Resource Groups in pull [Logic Updated in v2.0.0](https://github.com/Azure/AzOps/releases/tag/2.0.0) | `"Core.SubscriptionsToIncludeResourceGroups": ["*"]` | +| 27 | TemplateParameterFileSuffix | Default template file suffix. *Not recommended to change* | `"Core.TemplateParameterFileSuffix": ".json"` | +| 28 | AllowMultipleTemplateParameterFiles | Control multiple parameter file behaviour. *Not recommended to change* | `"Core.AllowMultipleTemplateParameterFiles": false` | +| 29 | DeployAllMultipleTemplateParameterFiles | Control base template deployment behaviour with changes and un-changed multiple corresponding parameter files. | `"Core.DeployAllMultipleTemplateParameterFiles": false` | +| 30 | MultipleTemplateParameterFileSuffix | Multiple parameter file suffix identifier. *Example mytemplate.x1.bicepparam* | `"Core.MultipleTemplateParameterFileSuffix": ".x"` | +| 31 | ParallelDeployMultipleTemplateParameterFiles | Control parallel deployment of MultipleTemplateParameterFiles behaviour | `"Core.ParallelDeployMultipleTemplateParameterFiles": false` | +| 32 | ThrottleLimit | Value declaring number of parallel threads. [Read more](https://github.com/azure/azops/wiki/performance-considerations) | `"Core.ThrottleLimit": 5` | +| 33 | WhatifExcludedChangeTypes | Exclude specific change types from WhatIf operations | `"Core.WhatifExcludedChangeTypes": ["NoChange","Ignore"]` | ## Workflow / Pipeline Settings diff --git a/scripts/Remove-AzOpsTestsDeployment.ps1 b/scripts/Remove-AzOpsTestsDeployment.ps1 index 6e103e9b..b19f365b 100644 --- a/scripts/Remove-AzOpsTestsDeployment.ps1 +++ b/scripts/Remove-AzOpsTestsDeployment.ps1 @@ -62,46 +62,52 @@ foreach ($script:mgclean in $script:managementGroups) { Remove-ManagementGroup -DisplayName $script:mgclean.DisplayName -Name $script:mgclean.Name -RootName (Get-AzTenant).TenantId } + $cleanupSub = @() + $cleanupSub += [PSCustomObject]@{ Id = $env:ARM_SUBSCRIPTION_ID } + $cleanupSub += (Get-AzSubscription | Where-Object { $_.Id -ne $env:ARM_SUBSCRIPTION_ID } | Sort-Object Name -Descending | Select-Object Id -First 2) # Collect resources to cleanup - Get-AzResourceLock | Remove-AzResourceLock -Force - $script:resourceGroups = Get-AzResourceGroup | Where-Object {$_.ResourceGroupName -like "*-azopsrg"} - $script:roleAssignmentsCleanBase = Get-AzRoleAssignment | Where-Object {$_.Scope -ne "/"} - $script:roleAssignments = foreach ($roleAssignment in $script:roleAssignmentsCleanBase) { - if ($roleAssignment.Scope -ne "/subscriptions/$((Get-AzContext).Subscription)") { - $roleAssignment - } - else { - if ($roleAssignment.RoleDefinitionName -ne 'Owner') { + foreach ($subscription in $cleanupSub) { + $null = Set-AzContext -SubscriptionId $subscription.Id + $null = Get-AzResourceLock | Remove-AzResourceLock -Force + $script:resourceGroups = Get-AzResourceGroup | Where-Object {$_.ResourceGroupName -like "*-azopsrg"} + $script:roleAssignmentsCleanBase = Get-AzRoleAssignment | Where-Object {$_.Scope -ne "/"} + $script:roleAssignments = foreach ($roleAssignment in $script:roleAssignmentsCleanBase) { + if ($roleAssignment.Scope -ne "/subscriptions/$((Get-AzContext).Subscription)") { $roleAssignment } + else { + if ($roleAssignment.RoleDefinitionName -ne 'Owner') { + $roleAssignment + } + } + } + $script:policyAssignments = Get-AzPolicyAssignment + $script:policyDefinitions = Get-AzPolicyDefinition -Custom + $script:policySetDefinitions = Get-AzPolicySetDefinition -Custom + $script:policyExemptions = Get-AzPolicyExemption -ErrorAction SilentlyContinue + # Cleanup resourceGroups + $script:resourceGroups | ForEach-Object -ThrottleLimit 20 -Parallel { + Write-PSFMessage -Level Verbose -Message "Executing test resourceGroups cleanup thread of $($_.ResourceGroupName)" -FunctionName "Remove-AzOpsTestsDeployment" + $script:run = $_ | Remove-AzResourceGroup -Confirm:$false -Force + } + # Cleanup roleAssignments and policyAssignments + $script:roleAssignments | Remove-AzRoleAssignment -Confirm:$false -ErrorAction SilentlyContinue + $script:policyExemptions | Remove-AzPolicyExemption -Force -Confirm:$false -ErrorAction SilentlyContinue + $script:policyAssignments | Remove-AzPolicyAssignment -Confirm:$false -ErrorAction SilentlyContinue + $script:policyDefinitions | Remove-AzPolicyDefinition -Force -Confirm:$false -ErrorAction SilentlyContinue + $script:policySetDefinitions | Remove-AzPolicySetDefinition -Force -Confirm:$false -ErrorAction SilentlyContinue + # Collect and cleanup deployment jobs + $azTenantDeploymentJobs = Get-AzTenantDeployment -ErrorAction SilentlyContinue + $azTenantDeploymentJobs | ForEach-Object -ThrottleLimit 10 -Parallel { + Write-PSFMessage -Level Verbose -Message "Executing test AzDeployment cleanup thread of $($_.DeploymentName)" -FunctionName "Remove-AzOpsTestsDeployment" + $_ | Remove-AzTenantDeployment -Confirm:$false + } + Get-AzManagementGroupDeployment -ManagementGroupId "cd35e23c-537f-4553-a280-f5a60033a446" -ErrorAction SilentlyContinue | Remove-AzManagementGroupDeployment -Confirm:$false -ErrorAction SilentlyContinue + $azDeploymentJobs = Get-AzDeployment + $azDeploymentJobs | ForEach-Object -ThrottleLimit 10 -Parallel { + Write-PSFMessage -Level Verbose -Message "Executing test AzDeployment cleanup thread of $($_.DeploymentName)" -FunctionName "Remove-AzOpsTestsDeployment" + $_ | Remove-AzDeployment -Confirm:$false } - } - $script:policyAssignments = Get-AzPolicyAssignment - $script:policyDefinitions = Get-AzPolicyDefinition -Custom - $script:policySetDefinitions = Get-AzPolicySetDefinition -Custom - $script:policyExemptions = Get-AzPolicyExemption -ErrorAction SilentlyContinue - # Cleanup resourceGroups - $script:resourceGroups | ForEach-Object -ThrottleLimit 20 -Parallel { - Write-PSFMessage -Level Verbose -Message "Executing test resourceGroups cleanup thread of $($_.ResourceGroupName)" -FunctionName "Remove-AzOpsTestsDeployment" - $script:run = $_ | Remove-AzResourceGroup -Confirm:$false -Force - } - # Cleanup roleAssignments and policyAssignments - $script:roleAssignments | Remove-AzRoleAssignment -Confirm:$false -ErrorAction SilentlyContinue - $script:policyExemptions | Remove-AzPolicyExemption -Force -Confirm:$false -ErrorAction SilentlyContinue - $script:policyAssignments | Remove-AzPolicyAssignment -Confirm:$false -ErrorAction SilentlyContinue - $script:policyDefinitions | Remove-AzPolicyDefinition -Force -Confirm:$false -ErrorAction SilentlyContinue - $script:policySetDefinitions | Remove-AzPolicySetDefinition -Force -Confirm:$false -ErrorAction SilentlyContinue - # Collect and cleanup deployment jobs - $azTenantDeploymentJobs = Get-AzTenantDeployment -ErrorAction SilentlyContinue - $azTenantDeploymentJobs | ForEach-Object -ThrottleLimit 10 -Parallel { - Write-PSFMessage -Level Verbose -Message "Executing test AzDeployment cleanup thread of $($_.DeploymentName)" -FunctionName "Remove-AzOpsTestsDeployment" - $_ | Remove-AzTenantDeployment -Confirm:$false - } - Get-AzManagementGroupDeployment -ManagementGroupId "cd35e23c-537f-4553-a280-f5a60033a446" -ErrorAction SilentlyContinue | Remove-AzManagementGroupDeployment -Confirm:$false -ErrorAction SilentlyContinue - $azDeploymentJobs = Get-AzDeployment - $azDeploymentJobs | ForEach-Object -ThrottleLimit 10 -Parallel { - Write-PSFMessage -Level Verbose -Message "Executing test AzDeployment cleanup thread of $($_.DeploymentName)" -FunctionName "Remove-AzOpsTestsDeployment" - $_ | Remove-AzDeployment -Confirm:$false } } catch { diff --git a/src/AzOps.psd1 b/src/AzOps.psd1 index 6e3c335e..1197070d 100644 --- a/src/AzOps.psd1 +++ b/src/AzOps.psd1 @@ -3,7 +3,7 @@ # # Generated by: Customer Architecture Team (CAT) # -# Generated on: 02/07/2024 +# Generated on: 3/5/2024 # @{ @@ -52,10 +52,10 @@ PowerShellVersion = '7.2' # Modules that must be imported into the global environment prior to importing this module RequiredModules = @(@{ModuleName = 'PSFramework'; RequiredVersion = '1.10.318'; }, - @{ModuleName = 'Az.Accounts'; RequiredVersion = '2.15.1'; }, + @{ModuleName = 'Az.Accounts'; RequiredVersion = '2.16.0'; }, @{ModuleName = 'Az.Billing'; RequiredVersion = '2.0.3'; }, @{ModuleName = 'Az.ResourceGraph'; RequiredVersion = '0.13.0'; }, - @{ModuleName = 'Az.Resources'; RequiredVersion = '6.15.0'; }) + @{ModuleName = 'Az.Resources'; RequiredVersion = '6.16.0'; }) # Assemblies that must be loaded prior to importing this module # RequiredAssemblies = @() diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index 14a1087d..a75c1866 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -43,6 +43,69 @@ begin { #region Utility Functions + function New-AzOpsList { + [CmdletBinding()] + param ( + [string[]] + $FileSet, + [string] + $FilePath, + [string] + $AzOpsMainTemplate, + [string[]] + $ConvertedTemplate, + [string[]] + $ConvertedParameter + ) + + # Avoid duplicate entries in the deployment list + if ($FilePath.EndsWith(".parameters.json")) { + if ($FileSet -contains $FilePath.Replace(".parameters.json", ".json") -or $FileSet -contains $FilePath.Replace(".parameters.json", ".bicep")) { + continue + } + } + if ($FilePath.EndsWith(".bicepparam")) { + if ($FileSet -contains $FilePath.Replace(".bicepparam", ".bicep")) { + continue + } + } + + # Handle Bicep templates + if ($FilePath.EndsWith(".bicep")) { + $transpiledTemplatePaths = ConvertFrom-AzOpsBicepTemplate -BicepTemplatePath $FilePath -ConvertedTemplate $ConvertedTemplate -ConvertedParameter $ConvertedParameter + if ($true -eq $transpiledTemplatePaths.transpiledTemplateNew) { + $ConvertedTemplate += $transpiledTemplatePaths.transpiledTemplatePath + } + if ($true -eq $transpiledTemplatePaths.transpiledParametersNew) { + $ConvertedParameter += $transpiledTemplatePaths.transpiledParametersPath + } + $FilePath = $transpiledTemplatePaths.transpiledTemplatePath + } + + try { + # Create scope object from the given file path + $scopeObject = New-AzOpsScope -Path $FilePath -StatePath $StatePath -ErrorAction Stop + } + catch { + # Log a warning message if creating the scope object fails + Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPush.Scope.Failed' -LogStringValues $FilePath -Target $FilePath -ErrorRecord $_ + continue + } + + # Resolve ARM file association + $resolvedArmFileAssociation = Resolve-ArmFileAssociation -ScopeObject $scopeObject -FilePath $FilePath -AzOpsMainTemplate $AzOpsMainTemplate -ConvertedTemplate $ConvertedTemplate -ConvertedParameter $ConvertedParameter + if ($resolvedArmFileAssociation) { + foreach ($fileAssociation in $resolvedArmFileAssociation) { + if ($true -eq $transpiledTemplatePaths.transpiledTemplateNew -and $fileAssociation.TemplateFilePath -eq $transpiledTemplatePaths.transpiledTemplatePath) { + $fileAssociation.TranspiledTemplateNew = $true + } + if ($true -eq $transpiledTemplatePaths.TranspiledParametersNew -and $fileAssociation.TemplateParameterFilePath -eq $transpiledTemplatePaths.transpiledParametersPath) { + $fileAssociation.TranspiledParametersNew = $true + } + } + return $resolvedArmFileAssociation + } + } function Resolve-ArmFileAssociation { [CmdletBinding()] param ( @@ -59,12 +122,6 @@ ) #region Initialization Prep - $common = @{ - Level = 'Host' - Tag = 'pwsh' - FunctionName = 'Invoke-AzOpsPush' - Target = $ScopeObject - } $result = [PSCustomObject] @{ TemplateFilePath = $null @@ -237,11 +294,6 @@ } #endregion Utility Functions - $common = @{ - Level = 'Host' - Tag = 'git' - } - $WhatIfPreferenceState = $WhatIfPreference $WhatIfPreference = $false @@ -262,8 +314,6 @@ process { if (-not $ChangeSet) { return } Assert-AzOpsInitialization -Cmdlet $PSCmdlet -StatePath $StatePath - #Supported resource types for deletion - $DeletionSupportedResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.DeletionSupportedResourceType') #region Categorize Input Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Deployment.Required' $deleteSet = @() @@ -293,30 +343,47 @@ } if ($DeleteSetContents -and $deleteSet) { Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Change.Delete' - # Unique delimiter used to join, split and replace data in DeleteSetContents - $delimiter = (New-Guid).Guid - # Transform $DeleteSetContents for further processing - $DeleteSetContents = $DeleteSetContents -join $delimiter -split "$delimiter-- " -replace $delimiter,"" - # Process each $deleteSet $item - foreach ($item in $deleteSet) { - Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Change.Delete.File' -LogStringValues $item - # Processing each $deleteSet, compare it to each $DeleteSetContents - foreach ($content in $DeleteSetContents) { - if ($content.Contains($item)) { - # Transform original first line in content with missing delimiter - if ($content.StartsWith("-- ")) { - $jsonValue = $content.replace("-- $item", "") - } - # Transform remaining content - else { - $jsonValue = $content.replace($item, "") - } - # When processed as designed there is no file present in the running branch. To run a removal AzOps re-creates the file and content based on $DeleteSetContents momentarily for processing, it is disregarded afterwards. - if (-not(Test-Path -Path (Split-Path -Path $item))) { - New-Item -Path (Split-Path -Path $item) -ItemType Directory | Out-Null + # Count if $DeleteSetContents contains 1 or less + if ($DeleteSetContents.Count -le 1) { + # DeleteSetContents has no file content or is malformed + Write-AzOpsMessage -LogLevel Error -LogString 'Invoke-AzOpsPush.Change.Delete.DeleteSetContents' -LogStringValues $DeleteSetContents + } + else { + # Iterate through each line in $DeleteSetContents + for ($i = 0; $i -lt $DeleteSetContents.Count; $i++) { + $line = $DeleteSetContents[$i].Trim() + # Check if the line starts with '-- ' and matches any filename in $deleteSet + if ($line -match '^-- (.+)$') { + $fileName = $matches[1] + if ($deleteSet -contains $fileName) { + Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Change.Delete.File' -LogStringValues $fileName + # Collect lines until the next line starting with '--' + $objectLines = @($line) + $i++ + while ($i -lt $DeleteSetContents.Count) { + $currentLine = $DeleteSetContents[$i].Trim() + # Check if the line starts with '-- ' followed by any filename in $deleteSet + if ($currentLine -match '^-- (.+)$' -and $deleteSet -contains $matches[1]) { + $i-- + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Change.Delete.NextTempFile' -LogStringValues $currentLine + break # Exit the loop if the line starts with '-- ' and matches a filename in $deleteSet + } + $objectLines += $currentLine + $i++ + } + # When processed as designed there is no file present in the running branch. + # To run a removal AzOps re-creates the file and content based on $DeleteSetContents momentarily for processing, it is disregarded afterwards. + if (-not(Test-Path -Path (Split-Path -Path $fileName))) { + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Change.Delete.TempFile' -LogStringValues $fileName + New-Item -Path (Split-Path -Path $fileName) -ItemType Directory | Out-Null + } + # Create $fileName and set $content + $objectLines = $objectLines[1..$objectLines.Count] + $content = $objectLines.replace("-- $fileName", "") -join "`r`n" + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Change.Delete.SetTempFileContent' -LogStringValues $fileName, $content + Set-Content -Path $fileName -Value $content + $i-- # Move back one step to process the next line properly } - # Update item - Set-Content -Path $item -Value $jsonValue } } } @@ -324,7 +391,7 @@ #endregion Categorize Input #region Deploy State - $common.Tag = 'pwsh' + # Nested Pipeline allows economizing on New-AzOpsStateDeployment having to run its "begin" block once only $newStateDeploymentCmd = { New-AzOpsStateDeployment -StatePath $StatePath }.GetSteppablePipeline() $newStateDeploymentCmd.Begin($true) @@ -346,91 +413,45 @@ $newStateDeploymentCmd.End() #endregion Deploy State + #region Create DeploymentList $deploymentList = foreach ($addition in $addModifySet | Where-Object { $_ -match ((Get-Item $StatePath).Name) }) { - - # Avoid duplicate entries in the deployment list - if ($addition.EndsWith(".parameters.json")) { - if ($addModifySet -contains $addition.Replace(".parameters.json", ".json") -or $addModifySet -contains $addition.Replace(".parameters.json", ".bicep")) { - continue - } - } - if ($addition.EndsWith(".bicepparam")) { - if ($addModifySet -contains $addition.Replace(".bicepparam", ".bicep")) { - continue - } - } - - # Handle Bicep templates - if ($addition.EndsWith(".bicep")) { - $transpiledTemplatePaths = ConvertFrom-AzOpsBicepTemplate -BicepTemplatePath $addition -ConvertedTemplate $AzOpsTranspiledTemplate -ConvertedParameter $AzOpsTranspiledParameter - if ($true -eq $transpiledTemplatePaths.transpiledTemplateNew) { - $AzOpsTranspiledTemplate += $transpiledTemplatePaths.transpiledTemplatePath - } - if ($true -eq $transpiledTemplatePaths.transpiledParametersNew) { - $AzOpsTranspiledParameter += $transpiledTemplatePaths.transpiledParametersPath - } - $addition = $transpiledTemplatePaths.transpiledTemplatePath - } - - try { - $scopeObject = New-AzOpsScope -Path $addition -StatePath $StatePath -ErrorAction Stop - } - catch { - Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPush.Scope.Failed' -LogStringValues $addition -Target $addition -ErrorRecord $_ - continue - } - - $resolvedArmFileAssociation = Resolve-ArmFileAssociation -ScopeObject $scopeObject -FilePath $addition -AzOpsMainTemplate $AzOpsMainTemplate -ConvertedTemplate $AzOpsTranspiledTemplate -ConvertedParameter $AzOpsTranspiledParameter - foreach ($fileAssociation in $resolvedArmFileAssociation) { + # Create a list of deployment file associations using the New-AzOpsList function + $deployFileAssociationList = New-AzOpsList -FilePath $addition -FileSet $addModifySet -AzOpsMainTemplate $AzOpsMainTemplate -ConvertedTemplate $AzOpsTranspiledTemplate -ConvertedParameter $AzOpsTranspiledParameter + # Iterate through each file association in the list + foreach ($fileAssociation in $deployFileAssociationList) { + # Check if the transpiled template is new and add it to the collection if true if ($true -eq $fileAssociation.transpiledTemplateNew) { $AzOpsTranspiledTemplate += $fileAssociation.TemplateFilePath } + # Check if the transpiled parameters are new and add them to the collection if true if ($true -eq $fileAssociation.transpiledParametersNew) { $AzOpsTranspiledParameter += $fileAssociation.TemplateParameterFilePath } } - $resolvedArmFileAssociation + # Output the list of file associations for the current addition + $deployFileAssociationList } + #endregion Create DeploymentList + #region Create DeletionList $deletionList = foreach ($deletion in $deleteSet | Where-Object { $_ -match ((Get-Item $StatePath).Name) }) { - - if ($deletion.EndsWith(".bicep")) { - continue - } - - $templateContent = Get-Content $deletion | ConvertFrom-Json -AsHashtable - $schemavalue = '$schema' - if ($templateContent.$schemavalue -like "*deploymentParameters.json#" -and (-not($templateContent.parameters.input.value.type -in $DeletionSupportedResourceType))) { - Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.SkipUnsupportedResource' -LogStringValues $deletion -Target $scopeObject - continue - } - elseif ($templateContent.$schemavalue -like "*deploymentTemplate.json#" -and (-not($templateContent.resources[0].type -in $DeletionSupportedResourceType))) { - Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.SkipUnsupportedResource' -LogStringValues $deletion -Target $scopeObject - continue - } - - try { - $scopeObject = New-AzOpsScope -Path $deletion -StatePath $StatePath -ErrorAction Stop - } - catch { - Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPush.Scope.Failed' -LogStringValues $deletion, $StatePath -Target $deletion -ErrorRecord $_ - continue + # Create a list of deletion file associations using the New-AzOpsList function + $deletionFileAssociationList = New-AzOpsList -FilePath $deletion -FileSet $deleteSet -AzOpsMainTemplate $AzOpsMainTemplate -ConvertedTemplate $AzOpsTranspiledTemplate -ConvertedParameter $AzOpsTranspiledParameter + # Iterate through each file association in the list + foreach ($fileAssociation in $deletionFileAssociationList) { + # Check if the transpiled template is new and add it to the collection if true + if ($true -eq $fileAssociation.transpiledTemplateNew) { + $AzOpsTranspiledTemplate += $fileAssociation.TemplateFilePath + } + # Check if the transpiled parameters are new and add them to the collection if true + if ($true -eq $fileAssociation.transpiledParametersNew) { + $AzOpsTranspiledParameter += $fileAssociation.TemplateParameterFilePath + } } - - Resolve-ArmFileAssociation -ScopeObject $scopeObject -FilePath $deletion -AzOpsMainTemplate $AzOpsMainTemplate + # Output the list of file associations for the current deletion + $deletionFileAssociationList } - - #Required deletion order - $deletionListPriority = @( - "locks", - "policyExemptions", - "policyAssignments", - "policySetDefinitions", - "policyDefinitions" - ) - - #Sort 'deletionList' based on 'deletionListPriority' - $deletionList = $deletionList | Sort-Object -Property {$deletionListPriority.IndexOf($_.ScopeObject.Resource)} + #endregion Create DeletionList #If addModifySet exists and no deploymentList has been generated at the same time as the StatePath root has additional directories and AllowMultipleTemplateParameterFiles is default false, exit with terminating error if (($addModifySet -and -not $deploymentList) -and (Get-ChildItem -Path $StatePath -Directory) -and ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $false)) { @@ -444,90 +465,128 @@ $uniqueDeployment = $deploymentList | Select-Object $uniqueProperties -Unique $deploymentResult = @() - #Determine what deployment pattern to adopt serial or parallel - if ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and (Get-PSFConfigValue -FullName 'AzOps.Core.ParallelDeployMultipleTemplateParameterFiles') -eq $true) { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.ParallelCondition' - # Group deployments based on TemplateFilePath - $groups = $uniqueDeployment | Group-Object -Property TemplateFilePath | Where-Object { $_.Count -ge '2' -and $_.Name -ne $(Get-Item $AzOpsMainTemplate).FullName } - if ($groups) { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.ParallelGroup' - $processedTargets = @() - # Process each deployment and evaluate serial or parallel deployment pattern - foreach ($deployment in $uniqueDeployment) { - if ($deployment.TemplateFilePath -in $groups.Name -and $deployment -notin $processedTargets) { - # Deployment part of group association for parallel processing, process entire group as parallel deployment - $targets = $($groups | Where-Object { $_.Name -eq $deployment.TemplateFilePath }).Group - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Parallel' -LogStringValues $deployment.TemplateFilePath, $targets.Count - # Prepare Input Data for parallel processing - $runspaceData = @{ - AzOpsPath = "$($script:ModuleRoot)\AzOps.psd1" - StatePath = $StatePath - WhatIfPreference = $WhatIfPreference - runspace_AzOpsAzManagementGroup = $script:AzOpsAzManagementGroup - runspace_AzOpsSubscriptions = $script:AzOpsSubscriptions - runspace_AzOpsPartialRoot = $script:AzOpsPartialRoot - runspace_AzOpsResourceProvider = $script:AzOpsResourceProvider - } - # Pass deployment targets for parallel processing and output deployment result for later - $deploymentResult += $targets | Foreach-Object -ThrottleLimit (Get-PSFConfigValue -FullName 'AzOps.Core.ThrottleLimit') -Parallel { - $deployment = $_ - $runspaceData = $using:runspaceData - - Import-Module "$([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot)/PSFramework.psd1" - $azOps = Import-Module $runspaceData.AzOpsPath -Force -PassThru - - & $azOps { - $script:AzOpsAzManagementGroup = $runspaceData.runspace_AzOpsAzManagementGroup - $script:AzOpsSubscriptions = $runspaceData.runspace_AzOpsSubscriptions - $script:AzOpsPartialRoot = $runspaceData.runspace_AzOpsPartialRoot - $script:AzOpsResourceProvider = $runspaceData.runspace_AzOpsResourceProvider + if ($uniqueDeployment) { + #Determine what deployment pattern to adopt serial or parallel + if ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and (Get-PSFConfigValue -FullName 'AzOps.Core.ParallelDeployMultipleTemplateParameterFiles') -eq $true) { + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.ParallelCondition' + # Group deployments based on TemplateFilePath + $groups = $uniqueDeployment | Group-Object -Property TemplateFilePath | Where-Object { $_.Count -ge '2' -and $_.Name -ne $(Get-Item $AzOpsMainTemplate).FullName } + if ($groups) { + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.ParallelGroup' + $processedTargets = @() + # Process each deployment and evaluate serial or parallel deployment pattern + foreach ($deployment in $uniqueDeployment) { + if ($deployment.TemplateFilePath -in $groups.Name -and $deployment -notin $processedTargets) { + # Deployment part of group association for parallel processing, process entire group as parallel deployment + $targets = $($groups | Where-Object { $_.Name -eq $deployment.TemplateFilePath }).Group + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Parallel' -LogStringValues $deployment.TemplateFilePath, $targets.Count + # Prepare Input Data for parallel processing + $runspaceData = @{ + AzOpsPath = "$($script:ModuleRoot)\AzOps.psd1" + StatePath = $StatePath + WhatIfPreference = $WhatIfPreference + runspace_AzOpsAzManagementGroup = $script:AzOpsAzManagementGroup + runspace_AzOpsSubscriptions = $script:AzOpsSubscriptions + runspace_AzOpsPartialRoot = $script:AzOpsPartialRoot + runspace_AzOpsResourceProvider = $script:AzOpsResourceProvider } - - & $azOps { - $deployment | New-AzOpsDeployment -WhatIf:$runspaceData.WhatIfPreference - } - } -UseNewRunspace - Clear-PSFMessage - # Add targets to processed list to avoid duplicate deployment - $processedTargets += $targets - } - elseif ($deployment -notin $processedTargets) { - # Deployment not part of group association for parallel processing, process this as serial deployment - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Serial' -LogStringValues $deployment.Count - $deploymentResult += $deployment | New-AzOpsDeployment -WhatIf:$WhatIfPreference - } - else { - # Deployment already processed by group association from parallel processing, skip this duplicate deployment - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Skip' -LogStringValues $deployment.TemplateFilePath, $deployment.TemplateParameterFilePath + # Pass deployment targets for parallel processing and output deployment result for later + $deploymentResult += $targets | Foreach-Object -ThrottleLimit (Get-PSFConfigValue -FullName 'AzOps.Core.ThrottleLimit') -Parallel { + $deployment = $_ + $runspaceData = $using:runspaceData + + Import-Module "$([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot)/PSFramework.psd1" + $azOps = Import-Module $runspaceData.AzOpsPath -Force -PassThru + + & $azOps { + $script:AzOpsAzManagementGroup = $runspaceData.runspace_AzOpsAzManagementGroup + $script:AzOpsSubscriptions = $runspaceData.runspace_AzOpsSubscriptions + $script:AzOpsPartialRoot = $runspaceData.runspace_AzOpsPartialRoot + $script:AzOpsResourceProvider = $runspaceData.runspace_AzOpsResourceProvider + } + + & $azOps { + $deployment | New-AzOpsDeployment -WhatIf:$runspaceData.WhatIfPreference + } + } -UseNewRunspace + Clear-PSFMessage + # Add targets to processed list to avoid duplicate deployment + $processedTargets += $targets + } + elseif ($deployment -notin $processedTargets) { + # Deployment not part of group association for parallel processing, process this as serial deployment + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Serial' -LogStringValues $deployment.Count + $deploymentResult += $deployment | New-AzOpsDeployment -WhatIf:$WhatIfPreference + } + else { + # Deployment already processed by group association from parallel processing, skip this duplicate deployment + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Skip' -LogStringValues $deployment.TemplateFilePath, $deployment.TemplateParameterFilePath + } } } - } - else { - # No deployments with matching TemplateFilePath identified - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Serial' -LogStringValues $deployment.Count + else { + # No deployments with matching TemplateFilePath identified + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Serial' -LogStringValues $deployment.Count + $deploymentResult += $uniqueDeployment | New-AzOpsDeployment -WhatIf:$WhatIfPreference + } + } else { + # Perform serial deployment only + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Serial' -LogStringValues $uniqueDeployment.Count $deploymentResult += $uniqueDeployment | New-AzOpsDeployment -WhatIf:$WhatIfPreference } - } else { - # Perform serial deployment only - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Deployment.Serial' -LogStringValues $uniqueDeployment.Count - $deploymentResult += $uniqueDeployment | New-AzOpsDeployment -WhatIf:$WhatIfPreference - } - if ($deploymentResult) { - # Output deploymentResult outside module - $deploymentResult - #Process deploymentResult and output result - foreach ($result in $deploymentResult) { - Set-AzOpsWhatIfOutput -FilePath $result.filePath -ParameterFilePath $result.parameterFilePath -Results $result.results + if ($deploymentResult) { + # Output deploymentResult outside module + $deploymentResult + #Process deploymentResult and output result + foreach ($result in $deploymentResult) { + Set-AzOpsWhatIfOutput -FilePath $result.filePath -ParameterFilePath $result.parameterFilePath -Results $result.results + } } } - #Removal of Supported resourceTypes - $uniqueProperties = 'Scope', 'TemplateFilePath', 'TemplateParameterFilePath' - $removalJob = $deletionList | Select-Object $uniqueProperties -Unique | Remove-AzOpsDeployment -WhatIf:$WhatIfPreference - if ($removalJob.dependencyMissing -eq $true) { - Write-AzOpsMessage -LogLevel Critical -LogString 'Invoke-AzOpsPush.Dependency.Missing' - throw + if ($deletionList) { + #Removal of Supported resourceTypes and Custom Templates + $deletionList = Set-AzOpsRemoveOrder -DeletionList $deletionList -Index { $_.ScopeObject.Resource } + $removalJob = $deletionList | Select-Object $uniqueProperties -Unique | Remove-AzOpsDeployment -WhatIf:$WhatIfPreference -DeleteSet (Resolve-Path -Path $deleteSet).Path + if ($removalJob.ScopeObject.Scope.Count -gt 0) { + Clear-PSFMessage + # Identify failed removal attempts for potential retries + $retry = $removalJob | Where-Object { $_.Status -eq 'failed' } + # If there are retries, log and attempt them again + if ($retry) { + Write-AzOpsMessage -LogLevel Verbose -LogString 'Invoke-AzOpsPush.Deletion.Retry' -LogStringValues $retry.Count + Start-Sleep -Seconds 30 + # Reset the status of failed attempts and perform recursive removal + foreach ($try in $retry) { $try.Status = $null } + $removeActionRecursive = Remove-AzResourceRaw -InputObject $retry -Recursive + $removeActionFail = $removeActionRecursive | Where-Object { $_.Status -eq 'failed' } + # If removal fails, log and attempt to fetch the resource causing the failure + if ($removeActionFail) { + Start-Sleep -Seconds 90 + $throwFail = $false + # Check each failed removal and attempt to get the associated resource + foreach ($fail in $removeActionFail) { + $resource = $null + $resource = Get-AzOpsResource -ScopeObject $fail.ScopeObject -ErrorAction SilentlyContinue + # If the resource is found, log the failure + if ($resource) { + $throwFail = $true + Write-AzOpsMessage -LogLevel Critical -LogString 'Invoke-AzOpsPush.Deletion.Failed' -LogStringValues $fail.ScopeObject.Scope, $fail.TemplateFilePath, $fail.TemplateParameterFilePath + } + } + # If any failures occurred, throw an exception + if ($throwFail) { + throw + } + } + } + } + # If there are missing dependencies, log the error and throw an exception + if ($removalJob.dependencyMissing -eq $true) { + Write-AzOpsMessage -LogLevel Critical -LogString 'Invoke-AzOpsPush.Dependency.Missing' + throw + } } $stopWatch.Stop() Write-AzOpsMessage -LogLevel Important -LogString 'Invoke-AzOpsPush.Duration' -LogStringValues $stopWatch.Elapsed -Metric $stopWatch.Elapsed.TotalSeconds -MetricName 'AzOpsPush Time' diff --git a/src/internal/configurations/Core.ps1 b/src/internal/configurations/Core.ps1 index 12cb50a1..ad5a98ce 100644 --- a/src/internal/configurations/Core.ps1 +++ b/src/internal/configurations/Core.ps1 @@ -1,7 +1,8 @@ Set-PSFConfig -Module AzOps -Name Core.ApplicationInsights -Value $false -Initialize -Validation bool -Description 'Global flag to turn on or off logging to Application Insight' Set-PSFConfig -Module AzOps -Name Core.AutoGeneratedTemplateFolderPath -Value "." -Initialize -Validation string -Description 'Auto-Generated Template Folder Path i.e. ./Az' Set-PSFConfig -Module AzOps -Name Core.AutoInitialize -Value $false -Initialize -Validation bool -Description '-' -Set-PSFConfig -Module AzOps -Name Core.DeletionSupportedResourceType -Value @('Microsoft.Authorization/locks', 'locks', 'Microsoft.Authorization/policyAssignments', 'policyAssignments', 'Microsoft.Authorization/policyDefinitions', 'policyDefinitions', 'Microsoft.Authorization/policyExemptions', 'policyExemptions', 'Microsoft.Authorization/policySetDefinitions', 'policySetDefinitions', 'Microsoft.Authorization/roleAssignments', 'roleAssignments') -Initialize -Validation stringarray -Description 'Global flag declaring resource types supported for deletion by AzOps.' +Set-PSFConfig -Module AzOps -Name Core.CustomTemplateResourceDeletion -Value $false -Initialize -Validation bool -Description 'Global flag declaring on or off deletion of resources in custom template.' +Set-PSFConfig -Module AzOps -Name Core.DeletionSupportedResourceType -Value @('Microsoft.Authorization/locks', 'locks', 'Microsoft.Authorization/policyAssignments', 'policyAssignments', 'Microsoft.Authorization/policyDefinitions', 'policyDefinitions', 'Microsoft.Authorization/policyExemptions', 'policyExemptions', 'Microsoft.Authorization/policySetDefinitions', 'policySetDefinitions', 'Microsoft.Authorization/roleAssignments', 'roleAssignments', 'Microsoft.Resources/resourceGroups', 'microsoft.resources/subscriptions/resourcegroups', 'resourceGroups') -Initialize -Validation stringarray -Description 'Global flag declaring resource types supported for deletion by AzOps.' Set-PSFConfig -Module AzOps -Name Core.DefaultDeploymentRegion -Value northeurope -Initialize -Validation string -Description 'Default deployment region for state deployments (ARM region, not region where a resource is deployed)' Set-PSFConfig -Module AzOps -Name Core.EnrollmentAccountPrincipalName -Value '' -Initialize -Validation stringorempty -Description '-' Set-PSFConfig -Module AzOps -Name Core.ExcludedSubOffer -Value 'AzurePass_2014-09-01', 'FreeTrial_2014-09-01', 'AAD_2015-09-01' -Initialize -Validation stringarray -Description 'Excluded QuotaID' diff --git a/src/internal/functions/Get-AzOpsResource.ps1 b/src/internal/functions/Get-AzOpsResource.ps1 new file mode 100644 index 00000000..d1e26963 --- /dev/null +++ b/src/internal/functions/Get-AzOpsResource.ps1 @@ -0,0 +1,54 @@ +function Get-AzOpsResource { + + <# + .SYNOPSIS + Check if the Azure resource exists. + .DESCRIPTION + Check if the Azure resource exists. + .PARAMETER ScopeObject + The Resource to check. + .EXAMPLE + > Get-AzOpsResource -ScopeObject $ScopeObject + #> + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [AzOpsScope] + $ScopeObject + ) + + process { + Set-AzOpsContext -ScopeObject $ScopeObject + switch ($ScopeObject.Resource) { + # Check if the resource exist + 'locks' { + $resource = Get-AzResourceLock -Scope "/subscriptions/$($ScopeObject.Subscription)" -ErrorAction SilentlyContinue | Where-Object { $_.ResourceID -eq $ScopeObject.Scope } + } + 'policyAssignments' { + $resource = Get-AzPolicyAssignment -Id $scopeObject.Scope -ErrorAction SilentlyContinue + } + 'policyDefinitions' { + $resource = Get-AzPolicyDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue + } + 'policyExemptions' { + $resource = Get-AzPolicyExemption -Id $scopeObject.Scope -ErrorAction SilentlyContinue + } + 'policySetDefinitions' { + $resource = Get-AzPolicySetDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue + } + 'roleAssignments' { + $resource = Invoke-AzRestMethod -Path "$($scopeObject.Scope)?api-version=2022-04-01" | Where-Object { $_.StatusCode -eq 200 } + } + 'resourceGroups' { + $resource = Get-AzResourceGroup -Id $scopeObject.Scope -ErrorAction SilentlyContinue + } + default { + $resource = Get-AzResource -ResourceId $ScopeObject.Scope -ErrorAction SilentlyContinue + } + } + if ($resource) { + return $resource + } + } +} \ No newline at end of file diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index 926ccdde..2d8d94e2 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -18,6 +18,10 @@ The root folder under which to find the resource json. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. + .PARAMETER WhatifExcludedChangeTypes + Exclude specific change types from WhatIf operations. + .PARAMETER WhatIfResultFormat + Accepts ResourceIdOnly or FullResourcePayloads. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE @@ -53,7 +57,11 @@ $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'), [string[]] - $WhatifExcludedChangeTypes = (Get-PSFConfigValue -FullName 'AzOps.Core.WhatifExcludedChangeTypes') + $WhatifExcludedChangeTypes = (Get-PSFConfigValue -FullName 'AzOps.Core.WhatifExcludedChangeTypes'), + + [string] + [ValidateSet("ResourceIdOnly","FullResourcePayloads")] + $WhatIfResultFormat ) @@ -97,6 +105,9 @@ 'SkipTemplateParameterPrompt' = $true 'Location' = $defaultDeploymentRegion } + if ($WhatIfResultFormat) { + $parameters.ResultFormat = $WhatIfResultFormat + } # Resource Groups excluding Microsoft.Resources/resourceGroups that needs to be submitted at subscription scope if ($scopeObject.resourcegroup -and $templateContent.resources[0].type -ne 'Microsoft.Resources/resourceGroups') { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.ResourceGroup.Processing' -LogStringValues $scopeObject -Target $scopeObject diff --git a/src/internal/functions/New-AzOpsScope.ps1 b/src/internal/functions/New-AzOpsScope.ps1 index 13802fe6..c1a0be23 100644 --- a/src/internal/functions/New-AzOpsScope.ps1 +++ b/src/internal/functions/New-AzOpsScope.ps1 @@ -42,7 +42,6 @@ [AzOpsScope] #> - #[OutputType([AzOpsScope])] [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(ParameterSetName = "scope")] diff --git a/src/internal/functions/Remove-AzOpsDeployment.ps1 b/src/internal/functions/Remove-AzOpsDeployment.ps1 index 1c144267..b969fd6b 100644 --- a/src/internal/functions/Remove-AzOpsDeployment.ps1 +++ b/src/internal/functions/Remove-AzOpsDeployment.ps1 @@ -1,9 +1,14 @@ function Remove-AzOpsDeployment { + <# .SYNOPSIS - Deletion of supported resource types from azure according to AzOps.Core.DeletionSupportedResourceType. + Deletion of supported resource types AzOps.Core.DeletionSupportedResourceType and custom templates. .DESCRIPTION - Deletion of supported resource types from azure according to AzOps.Core.DeletionSupportedResourceType. + Deletion of supported resource types AzOps.Core.DeletionSupportedResourceType and custom templates. + .PARAMETER CustomTemplateResourceDeletion + Enable or disable, deletion of resources in custom templates. + .PARAMETER DeploymentName + Dummy name used to run Azure WhatIf deployment. .PARAMETER TemplateFilePath Path where the ARM templates can be found. .PARAMETER TemplateParameterFilePath @@ -11,7 +16,9 @@ .PARAMETER StatePath The root folder under which to find the resource json. .PARAMETER DeletionSupportedResourceType - Supported resource types for deletion. + Supported resource types for deletion of AzOps generated file. + .PARAMETER DeleteSet + String of file names to validate deletion. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE @@ -21,6 +28,13 @@ [CmdletBinding(SupportsShouldProcess = $true)] param ( + [bool] + $CustomTemplateResourceDeletion = (Get-PSFConfigValue -FullName 'AzOps.Core.CustomTemplateResourceDeletion'), + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string] + $DeploymentName = "azops-template-deployment", + [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $TemplateFilePath = (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate'), @@ -33,7 +47,10 @@ $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'), [object[]] - $DeletionSupportedResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.DeletionSupportedResourceType') + $DeletionSupportedResourceType = (Get-PSFConfigValue -FullName 'AzOps.Core.DeletionSupportedResourceType'), + + [string[]] + $DeleteSet ) process { @@ -176,9 +193,10 @@ return $results } } + $dependencyMissing = $null #Adjust TemplateParameterFilePath to compensate for policyDefinitions and policySetDefinitions usage of parameters.json - if ($TemplateParameterFilePath) { + if ($TemplateParameterFilePath -and $TemplateFilePath -eq (Resolve-Path (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate')).Path) { $TemplateFilePath = $TemplateParameterFilePath } #Deployment Name @@ -186,19 +204,27 @@ $removeJobName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' $removeJobName = "AzOps-RemoveResource-$removeJobName" Write-AzOpsMessage -LogLevel Important -LogString 'Remove-AzOpsDeployment.Processing' -LogStringValues $removeJobName, $TemplateFilePath + #region Parse Content $templateContent = Get-Content $TemplateFilePath | ConvertFrom-Json -AsHashtable - #endregion - #region Validate it is AzOpsgenerated template + #endregion Parse Content + + #region Validate template type AzOps generated or not $schemavalue = '$schema' + $customDeletion = $false if ($templateContent.metadata._generator.name -eq "AzOps" -or $templateContent.$schemavalue -like "*deploymentParameters.json#") { - Write-AzOpsMessage -LogLevel Verbose -LogString 'Remove-AzOpsDeployment.Metadata.Success' -LogStringValues $TemplateFilePath + Write-AzOpsMessage -LogLevel Verbose -LogString 'Remove-AzOpsDeployment.Metadata.AzOps' -LogStringValues $TemplateFilePath + } + elseif ($true -eq $CustomTemplateResourceDeletion) { + Write-AzOpsMessage -LogLevel Verbose -LogString 'Remove-AzOpsDeployment.Metadata.Custom' -LogStringValues $TemplateFilePath + $customDeletion = $true } else { Write-AzOpsMessage -LogLevel Error -LogString 'Remove-AzOpsDeployment.Metadata.Failed' -LogStringValues $TemplateFilePath return } - #endregion Validate it is AzOpsgenerated template + #endregion Validate template type AzOps generated or not + #region Resolve Scope try { $scopeObject = New-AzOpsScope -Path $TemplateFilePath -StatePath $StatePath -ErrorAction Stop -WhatIf:$false @@ -217,23 +243,23 @@ Set-AzOpsContext -ScopeObject $scopeObject #endregion SetContext - #region remove supported resources - if ($scopeObject.Resource -in $DeletionSupportedResourceType) { + #region remove resources + if ($customDeletion -eq $false -and $scopeObject.Resource -in $DeletionSupportedResourceType) { $dependency = @() switch ($scopeObject.Resource) { # Check resource existance through optimal path 'locks' { - $resourceToDelete = Get-AzResourceLock -Scope "/subscriptions/$($ScopeObject.Subscription)" -ErrorAction SilentlyContinue | Where-Object {$_.ResourceID -eq $ScopeObject.scope} + $resourceToDelete = Get-AzResourceLock -Scope "/subscriptions/$($ScopeObject.Subscription)" -ErrorAction SilentlyContinue | Where-Object { $_.ResourceID -eq $ScopeObject.Scope } } 'policyAssignments' { - $resourceToDelete = Get-AzPolicyAssignment -Id $scopeObject.scope -ErrorAction SilentlyContinue + $resourceToDelete = Get-AzPolicyAssignment -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'policyDefinitions' { - $resourceToDelete = Get-AzPolicyDefinition -Id $scopeObject.scope -ErrorAction SilentlyContinue + $resourceToDelete = Get-AzPolicyDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzPolicyDefinitionDeletionDependency -resourceToDelete $resourceToDelete @@ -241,21 +267,29 @@ } } 'policyExemptions' { - $resourceToDelete = Get-AzPolicyExemption -Id $scopeObject.scope -ErrorAction SilentlyContinue + $resourceToDelete = Get-AzPolicyExemption -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'policySetDefinitions' { - $resourceToDelete = Get-AzPolicySetDefinition -Id $scopeObject.scope -ErrorAction SilentlyContinue + $resourceToDelete = Get-AzPolicySetDefinition -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { $dependency += Get-AzPolicyAssignmentDeletionDependency -resourceToDelete $resourceToDelete $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } 'roleAssignments' { - $resourceToDelete = Invoke-AzRestMethod -Path "$($scopeObject.scope)?api-version=2022-01-01-preview" | Where-Object { $_.StatusCode -eq 200 } + $resourceToDelete = Invoke-AzRestMethod -Path "$($scopeObject.Scope)?api-version=2022-04-01" | Where-Object { $_.StatusCode -eq 200 } + if ($resourceToDelete) { + $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete + } + } + 'resourceGroups' { + $resourceToDelete = Get-AzResourceGroup -Id $scopeObject.Scope -ErrorAction SilentlyContinue if ($resourceToDelete) { + $resourceToDelete | Add-Member -MemberType NoteProperty -Name "ResourceType" -Value "$($scopeObject.Type)" + $resourceToDelete | Add-Member -MemberType NoteProperty -Name "SubscriptionId" -Value "$($scopeObject.Subscription)" $dependency += Get-AzLocksDeletionDependency -resourceToDelete $resourceToDelete } } @@ -295,11 +329,102 @@ Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true } if ($PSCmdlet.ShouldProcess("Remove $($scopeObject.Scope)?")) { - $null = Remove-AzResource -ResourceId $scopeObject.Scope -Force + $null = Remove-AzResourceRaw -ScopeObject $scopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath } else { Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.SkipDueToWhatIf' } } + elseif ($customDeletion -eq $false -and $scopeObject.Resource -notin $DeletionSupportedResourceType) { + Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.SkipUnsupportedResource' -LogStringValues $TemplateFilePath -Target $scopeObject + return + } + elseif ($customDeletion -eq $true) { + # Perform a New-AzOpsDeployment using WhatIf with ResourceIdOnly to extrapolate resources inside template + $removalJob = New-AzOpsDeployment -DeploymentName $DeploymentName -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath -WhatIfResultFormat 'ResourceIdOnly' -WhatIf:$true + if ($removalJob.results.Changes.Count -gt 0) { + # Initialize array to store items that need retry + $retry = @() + $removalJobChanges = Set-AzOpsRemoveOrder -DeletionList $removalJob.results.Changes -Index { (New-AzOpsScope -Scope $_.FullyQualifiedResourceId -WhatIf:$false).Resource } + $allResults = @() + foreach ($change in $removalJobChanges) { + $resource = $null + $resourceScopeObject = $null + $removeAction = $null + # Check if the resource exists + $resourceScopeObject = New-AzOpsScope -Scope $change.FullyQualifiedResourceId -WhatIf:$false + $resource = Get-AzOpsResource -ScopeObject $resourceScopeObject -ErrorAction SilentlyContinue + if ($resource) { + $results = 'What if successful:{1}Performing the operation:{1}Deletion of target resource {0}.' -f $resourceScopeObject.Scope, [environment]::NewLine + $allResults += $results + Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' + # Check if the removal should be performed + if ($PSCmdlet.ShouldProcess("Remove $($resourceScopeObject.Scope)?")) { + $removeAction = Remove-AzResourceRaw -ScopeObject $resourceScopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath + # If removal failed, add to retry + if ($removeAction.Status -eq 'failed') { + $retry += $removeAction + } + } + else { + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.SkipDueToWhatIf' + } + } + else { + # Log warning if resource not found + Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $ScopeObject.Resource, $change.FullyQualifiedResourceId + $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $change.FullyQualifiedResourceId, [environment]::NewLine + $allResults += $results + } + + } + $baseTemplateCheck = $TemplateFilePath -replace '\.bicep$', '.json' + if ($TemplateParameterFilePath) { + $baseParameterCheck = $TemplateParameterFilePath -replace '\.bicepparam$', 'parameters.json' + } + if ($DeleteSet) { + $deleteSetCheck = $DeleteSet -replace '\.bicep$', '.json' + $deleteSetCheck = $deleteSetCheck -replace '\.bicepparam$', '.parameters.json' + # Check if template and parameter file exist in $DeleteSet, example AzOps has been instructed to remove template.json but not the associated parameter.json + $resultsFileAssociation = switch ($null) { + { $baseTemplateCheck -notin $deleteSetCheck -and $baseParameterCheck -notin $deleteSetCheck } { + 'Missing template and parameter file association:{2}{0} and {1} for deletion.{2}{2}Ensure that you have reviewed and confirmed the necessity of each deletion.{2}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{2}' -f $TemplateFilePath, $TemplateParameterFilePath, [environment]::NewLine + } + { $baseTemplateCheck -notin $deleteSetCheck } { + 'Missing template file association:{1}{0} for deletion.{1}{1}Ensure that you have reviewed and confirmed the necessity of each deletion.{1}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{1}' -f $TemplateFilePath, [environment]::NewLine + } + { $baseParameterCheck -notin $deleteSetCheck } { + 'Missing parameter file association:{1}{0} for deletion.{1}{1}Ensure that you have reviewed and confirmed the necessity of each deletion.{1}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{1}' -f $TemplateParameterFilePath, [environment]::NewLine + } + } + # If there are $resultsFileAssociation, combine them with existing results and log a warning + if ($resultsFileAssociation) { + $finalResults = @() + $finalResults += $resultsFileAssociation + $finalResults += $allResults + $allResults = $finalResults + Write-AzOpsMessage -LogLevel Warning -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $allResults + } + } + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $allResults -RemoveAzOpsFlag $true + if ($retry.Count -gt 0) { + # Retry failed removals recursively + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.Resource.RetryCount' -LogStringValues $retry.Count + foreach ($try in $retry) { $try.Status = $null } + $removeActionRecursive = Remove-AzResourceRaw -InputObject $retry -Recursive + $removeActionRecursiveRemaining = $removeActionRecursive | Where-Object { $_.Status -eq 'failed' } + return $removeActionRecursiveRemaining + } + } + else { + # No resource to remove was found + Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $scopeObject.Resource, $scopeObject.Scope + $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $scopeObject.Scope, [environment]::NewLine + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $results -RemoveAzOpsFlag $true + return + } + } + #endregion remove resources } } \ No newline at end of file diff --git a/src/internal/functions/Remove-AzResourceRaw.ps1 b/src/internal/functions/Remove-AzResourceRaw.ps1 new file mode 100644 index 00000000..e47e42c9 --- /dev/null +++ b/src/internal/functions/Remove-AzResourceRaw.ps1 @@ -0,0 +1,190 @@ +function Remove-AzResourceRaw { + + <# + .SYNOPSIS + Performs resource deletion in Azure at any scope. + .DESCRIPTION + Performs resource deletion in Azure with FullyQualifiedResourceId and ScopeObject. + .PARAMETER TemplateFilePath + Path where the ARM templates can be found. + .PARAMETER TemplateParameterFilePath + Path where the parameters of the ARM templates can be found. + .PARAMETER ScopeObject + Resource to delete. + .PARAMETER InputObject + Object containing items for processing, used in combination with parameter Recursive. + .PARAMETER Recursive + If specified, performs recursive resource deletion and requires use of parameter InputObject. + .EXAMPLE + > Remove-AzResourceRaw -ScopeObject $ScopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath + Name Value + ---- ----- + TemplateFilePath /root/managementgroup/subscription/resourcegroup/template.json + TemplateParameterFilePath /root/managementgroup/subscription/resourcegroup/template.parameters.json + ScopeObject ScopeObject + Status success + + > Remove-AzResourceRaw -InputObject $retry -Recursive + Name Value + ---- ----- + TemplateFilePath /root/managementgroup/subscription/resourcegroup/template.json + TemplateParameterFilePath /root/managementgroup/subscription/resourcegroup/template.parameters.json + ScopeObject ScopeObject + Status success + #> + + [CmdletBinding()] + param ( + [string] + $TemplateFilePath, + [string] + $TemplateParameterFilePath, + [AzOpsScope] + $ScopeObject, + [array] + $InputObject, + [switch] + $Recursive + ) + + process { + function Remove-AzResourceRawRecursive { + + <# + .SYNOPSIS + Performs recursive resource deletion in Azure at any scope. + .DESCRIPTION + Takes $InputObject and performs recursive resource deletion in Azure and exhaust any permutation. + .PARAMETER InputObject + Parameter containing items for processing. + .PARAMETER CurrentOrder + Internal parameter to track recursive progress. + .PARAMETER OutputObject + Track item processing and return result. + .EXAMPLE + > $successFullItems, $failedItems = Remove-AzResourceRawRecursive -InputObject $retry + Example of a $retry array with 6 items, the number of permutations will be 6×5×4×3×2×1=720 + #> + + [CmdletBinding()] + param ( + [array] + $InputObject, + [array] + $CurrentOrder = @(), + [array] + $OutputObject = @() + ) + + process { + if ($InputObject.Count -eq 0) { + # Base case: All items have been used, perform action on the current order + foreach ($item in $CurrentOrder) { + if ($item.Status -eq 'failed' -or $null -eq $item.Status) { + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzResourceRawRecursive.Processing' -LogStringValues $item.ScopeObject.Resource, $item.ScopeObject.Scope + # Attempt to remove the resource + $result = Remove-AzResourceRaw -ScopeObject $item.ScopeObject -TemplateFilePath $item.TemplateFilePath -TemplateParameterFilePath $item.TemplateParameterFilePath + if ($result.Status -eq 'failed' -and $result.ScopeObject.Scope -notin $OutputObject.ScopeObject.Scope){ + # Add failed result to the output object + $OutputObject += $result + } + } + } + # Return the final result + return $OutputObject + } + else { + if ($InputObject -and $OutputObject) { + # Filter out items already processed successfully + $filteredOutputObject = @() + foreach ($item in $InputObject) { + if ($item.ScopeObject.Scope -in $OutputObject.ScopeObject.Scope) { + foreach ($output in $OutputObject) { + if ($output.ScopeObject.Scope -eq $item.ScopeObject.Scope -and $output.Status -eq 'failed') { + # Add previously failed item to the filtered output + $filteredOutputObject += $output + continue + } + } + } + } + if ($filteredOutputObject) { + $InputObject = $filteredOutputObject + } + } + # Recursive case: Try each item in the current position and recurse with the remaining items + foreach ($item in $InputObject) { + $remainingItems = $InputObject -ne $item + $newOrder = $CurrentOrder + $item + # Recursively call Remove-AzResourceRawRecursive + $OutputObject = Remove-AzResourceRawRecursive -InputObject $remainingItems -CurrentOrder $newOrder -OutputObject $OutputObject + } + # Return the output after all permutations + return $OutputObject + } + } + } + if ($null -ne $InputObject -and $Recursive) { + # Perform recursive resource deletion + $result = Remove-AzResourceRawRecursive -InputObject $InputObject + if ($result) { + return $result + } + else { + return + } + } + elseif ($null -eq $InputObject -and $Recursive) { + # Recursive resource deletion missing input + Write-AzOpsMessage -LogLevel Error -LogString 'Remove-AzResourceRaw.Resource.Recursive.Missing' + return + } + else { + if (-not $ScopeObject) { + # Resource deletion missing input + Write-AzOpsMessage -LogLevel Error -LogString 'Remove-AzResourceRaw.Resource.Missing' + return + } + # Construct result object + $result = [PSCustomObject]@{ + TemplateFilePath = $TemplateFilePath + TemplateParameterFilePath = $TemplateParameterFilePath + ScopeObject = $ScopeObject + Status = 'success' + } + # Check if the resource exists + $resource = Get-AzOpsResource -ScopeObject $ScopeObject -ErrorAction SilentlyContinue + # Remove the resource if it exists + if ($resource) { + try { + # Set Azure context for removal operation + Set-AzOpsContext -ScopeObject $ScopeObject + $null = Remove-AzResource -ResourceId $ScopeObject.Scope -Force -ErrorAction Stop + $maxAttempts = 4 + $attempt = 1 + $gone = $false + while ($gone -eq $false -and $attempt -le $maxAttempts) { + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzResourceRaw.Resource.CheckExistence' -LogStringValues $ScopeObject.Scope + Start-Sleep -Seconds 10 + $tryResource = Get-AzOpsResource -ScopeObject $ScopeObject -ErrorAction SilentlyContinue + if (-not $tryResource) { + $gone = $true + } + $attempt++ + } + } + catch { + # Log failure message + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzResourceRaw.Resource.Failed' -LogStringValues $ScopeObject.Resource, $ScopeObject.Scope + $result.Status = 'failed' + } + } + else { + # Log not found message + $result.Status = 'notfound' + } + # Return result object + return $result + } + } +} \ No newline at end of file diff --git a/src/internal/functions/Set-AzOpsRemoveOrder.ps1 b/src/internal/functions/Set-AzOpsRemoveOrder.ps1 new file mode 100644 index 00000000..1975c752 --- /dev/null +++ b/src/internal/functions/Set-AzOpsRemoveOrder.ps1 @@ -0,0 +1,53 @@ +function Set-AzOpsRemoveOrder { + + <# + .SYNOPSIS + Sorts a custom object list based on a specified priority order using a user-defined index. + .DESCRIPTION + Used to sort deletion priority, aka locks are removed prior to resource deletion attempts. + .PARAMETER DeletionList + Custom object list to be sorted based on the defined priority. + .PARAMETER Index + Script block that determines the index used for sorting the deletion list. + .PARAMETER Priority + Optional array of strings representing the priority order. Defaults to a predefined order if not provided. + .EXAMPLE + > $sortedList = Set-AzOpsRemoveOrder -DeletionList $myCustomObjectList -Index { $_.SomeProperty } + #> + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + $DeletionList, + [Parameter(Mandatory = $true)] + [scriptblock] + $Index, + [string[]] + $Priority = @( + "locks", + "policyExemptions", + "policyAssignments", + "policySetDefinitions", + "policyDefinitions", + "resourceGroups", + "managementGroups" + ) + ) + + process { + #Sort 'DeletionList' based on 'Priority' + $deletionListSorted = $DeletionList | Sort-Object -Property { + $resolvedIndex = & $Index + $priorityIndex = $Priority.IndexOf($resolvedIndex) + if ($priorityIndex -eq -1) { + # Set a default priority for items not found in Priority + return [int]::MaxValue + } + else { + return $priorityIndex + } + } + # Return processed list + return $deletionListSorted + } +} \ No newline at end of file diff --git a/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 b/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 index 45f481c3..171fa372 100644 --- a/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 +++ b/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 @@ -91,13 +91,13 @@ else { if ($RemoveAzOpsFlag) { if ($Results -match 'Missing resource dependency' ) { - $mdOutput = ':x: **Action Required**{0}WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $Results, $resultHeadline + $mdOutput = ':x: **Action Required**{0}WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $resultString, $resultHeadline } - elseif ($Results -match 'What if operation failed') { - $mdOutput = ':warning: WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $Results, $resultHeadline + elseif ($Results -match 'What if operation failed' -or $Results -match 'Missing template and parameter file association' -or $Results -match 'Missing template file association' -or $Results -match 'Missing parameter file association') { + $mdOutput = ':warning: WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $resultString, $resultHeadline } else { - $mdOutput = ':white_check_mark: WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $Results, $resultHeadline + $mdOutput = ':white_check_mark: WhatIf Results for Resource Deletion of {2}:{0}```{0}{1}{0}```' -f [environment]::NewLine, $resultString, $resultHeadline } } else { diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index c2c47b8c..9eac2e32 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -191,7 +191,13 @@ 'Invoke-AzOpsPush.Change.AddModify' = 'Adding or modifying:' # 'Invoke-AzOpsPush.Change.AddModify.File' = ' {0}' # $item 'Invoke-AzOpsPush.Change.Delete' = 'Deleting:' # - 'Invoke-AzOpsPush.Change.Delete.File' = ' {0}' # $item + 'Invoke-AzOpsPush.Change.Delete.DeleteSetContents' = '[DeleteSetContents] has no file content or is malformed: {0}' # $DeleteSetContents + 'Invoke-AzOpsPush.Change.Delete.File' = ' {0}' # $fileName + 'Invoke-AzOpsPush.Change.Delete.TempFile' = 'Creating temporary file dir for deletion processing: {0}' # $fileName + 'Invoke-AzOpsPush.Change.Delete.NextTempFile' = 'Exiting while loop, file detected in $DeleteSetContents for deletion processing based on this content line: [{0}]' # $currentLine + 'Invoke-AzOpsPush.Change.Delete.SetTempFileContent' = 'Set temporary file content: [{1}], in [{0}]' # $fileName, $jsonValue + 'Invoke-AzOpsPush.Deletion.Failed' = 'Deletion of resources {0}, has failed using templates: {1}, {2}, this could be due to delayed deletion acceptance from Azure, please investigate and take action.' # $fail.ScopeObject.Scope, $fail.TemplateFilePath, $fail.TemplateParameterFilePath + 'Invoke-AzOpsPush.Deletion.Retry' = 'Deletion of {0} resources unsuccessful, initiate final retry combination.' # $retry.Count 'Invoke-AzOpsPush.Deploy.ProviderFeature' = 'Invoking new state deployment - *.providerfeatures.json for a file {0}' # $addition 'Invoke-AzOpsPush.Deploy.ResourceProvider' = 'Invoking new state deployment - *.resourceproviders.json for a file {0}' # $addition 'Invoke-AzOpsPush.Deploy.Subscription' = 'Invoking new state deployment - *.subscription.json for a file {0}' # $addition @@ -213,7 +219,7 @@ 'Invoke-AzOpsPush.Resolve.NoJson' = 'The specified file is not a json or bicep file! Skipping {0}' # $fileItem.FullName 'Invoke-AzOpsPush.Resolve.NotFoundTemplate' = 'Did NOT find template {1} for parameters {0}' # $FilePath, $templatePath 'Invoke-AzOpsPush.Resolve.ParameterFound' = 'Found parameter file for template {0} : {1}' # $FilePath, $parameterPath - 'Invoke-AzOpsPush.Resolve.ParameterNotFound' = 'No parameter file found for template {0} : {1}' # $FilePath, $parameterPath + 'Invoke-AzOpsPush.Resolve.ParameterNotFound' = 'No parameter file found for template: {0}, at: {1}' # $FilePath, $parameterPath 'Invoke-AzOpsPush.Resolve.NotFoundParamFileDefaultValue' = 'Template {0} with parameter: {1} missing defaultValue and no parameter file found, skip deployment' # $FilePath, $missingString 'Invoke-AzOpsPush.Scope.Failed' = 'Failed to read {0} as part of {1}' # $addition, $StatePath @@ -271,31 +277,39 @@ 'Register-AzOpsResourceProvider.Provider.Register' = 'Registering provider {0}' # $resourceprovider.ProviderNamespace 'Remove-AzOpsDeployment.Processing' = 'Processing removal {0} for template {1}' # $removeJobName, $TemplateFilePath - 'Remove-AzOpsDeployment.Metadata.Failed' = 'Detected custom template: {0} . resource Deletion is currently only supported for AzOps Generated templates' #$TemplateFilePath - 'Remove-AzOpsDeployment.Metadata.Success' = 'Processing AzOps Generated Template File {0}' # $TemplateFilePath + 'Remove-AzOpsDeployment.Metadata.AzOps' = 'Resource deletion detected with AzOps generated template file {0}' # $TemplateFilePath + 'Remove-AzOpsDeployment.Metadata.Custom' = 'Resource deletion detected with custom template file: {0}' #$TemplateFilePath + 'Remove-AzOpsDeployment.Metadata.Failed' = 'Detected custom template: {0}, and Core.CustomTemplateResourceDeletion is not set to true' #$TemplateFilePath 'Remove-AzOpsDeployment.Scope.Failed' = 'Failed to resolve the scope for template {0}' # $TemplateFilePath 'Remove-AzOpsDeployment.Scope.Empty' = 'Unable to determine the scope of template {0}' # $TemplateFilePath 'Remove-AzOpsDeployment.SkipDueToWhatIf' = 'Skipping removal of resource due to WhatIf' # 'Remove-AzOpsDeployment.ResourceDependencyNested' = 'resource dependency {0} for complete deletion of {1} is outside of supported AzOps scope. Please remove this dependency in Azure without AzOps.'# $roleAssignmentId, $policyAssignment.ResourceId 'Remove-AzOpsDeployment.ResourceDependencyNotFound' = 'Missing resource dependency {0} for successfull deletion of {1}. Please add missing resource and retry.'# $resource.ResourceId, $scopeObject.Scope - 'Remove-AzOpsDeployment.ResourceNotFound' = 'Unable to find resource of type {0} with id {1}.'# $scopeObject.resource, $scopeObject.scope, $resultsError - 'Remove-AzOpsDeployment.SkipUnsupportedResource' = 'Deletion is currently only supported for policyAssignments, policyDefinitions, policyExemptions, policySetDefinitions and roleAssignments. Will NOT proceed with deletion of file {0}'# $templateFilePath - - 'Remove-AzOpsInvalidCharacter.Completed' = 'Valid string: {0}'# $String - 'Remove-AzOpsInvalidCharacter.Invalid' = 'Invalid character detected in string: {0}, further processing initiated'# $String - 'Remove-AzOpsInvalidCharacter.Removal' = 'Removed invalid character: {0} from string: {1}'# $character, $String - - 'Save-AzOpsManagementGroupChild.Creating.Scope' = 'Creating scope object' # - 'Save-AzOpsManagementGroupChild.Data.Directory' = 'Resolved state path directory: {0}' # $statepathDirectory - 'Save-AzOpsManagementGroupChild.Data.FileName' = 'Resolved state path filename: {0}' # $statepathFileName - 'Save-AzOpsManagementGroupChild.Data.ScopeDirectory' = 'Resolved state path scope directory: {0}' # $statepathScopeDirectory - 'Save-AzOpsManagementGroupChild.Data.ScopeDirectoryParent' = 'Resolved state path scope directory parent: {0}' # $statepathScopeDirectoryParent - 'Save-AzOpsManagementGroupChild.Data.StatePath' = 'Resolved state path: {0}' # $scopeStatepath - 'Save-AzOpsManagementGroupChild.Moving.Destination' = 'Moved existing state file to: {0}' # $statepathScopeDirectoryParent - 'Save-AzOpsManagementGroupChild.Moving.Source' = 'Found existing state file in directory: {0}' # $exisitingScopePath - 'Save-AzOpsManagementGroupChild.Processing' = 'Processing Scope: {0}' # $scopeObject.Scope - 'Save-AzOpsManagementGroupChild.Starting' = 'Starting execution' # - 'Save-AzOpsManagementGroupChild.Subscription.NotFound' = 'Unable to locate subscription: {0} within AzOpsSubscriptions object' #child.Name + 'Remove-AzOpsDeployment.Resource.RetryCount' = 'Retry deletion of {0} resources in different order'# $retry.Count + 'Remove-AzOpsDeployment.ResourceNotFound' = 'Unable to find resource of type {0} with id {1}.'# $scopeObject.Resource, $scopeObject.Scope, $resultsError + 'Remove-AzOpsDeployment.SkipUnsupportedResource' = 'Deletion of AzOps generated file resources is only supported for locks, policyAssignments, policyDefinitions, policyExemptions, policySetDefinitions and roleAssignments. Will NOT proceed with deletion of resource in file {0}'# $TemplateFilePath + + 'Remove-AzResourceRaw.Resource.Recursive.Missing' = 'Missing required parameter InputObject, when running Recursive'# + 'Remove-AzResourceRaw.Resource.Missing' = 'Missing required parameter ScopeObject'# + 'Remove-AzResourceRaw.Resource.CheckExistence' = 'Checking existence after deletion of: [{0}]'# $FullyQualifiedResourceId + 'Remove-AzResourceRaw.Resource.Failed' = 'Unable to delete resource of type {0} with id {1}'# $ScopeObject.Resource, $ScopeObject.Scope + 'Remove-AzResourceRawRecursive.Processing' = 'Recursive retry processing to delete resource of type {0} with id {1}'# $item.ScopeObject.Resource, $item.ScopeObject.Scope + + 'Remove-AzOpsInvalidCharacter.Completed' = 'Valid string: {0}'# $String + 'Remove-AzOpsInvalidCharacter.Invalid' = 'Invalid character detected in string: {0}, further processing initiated'# $String + 'Remove-AzOpsInvalidCharacter.Removal' = 'Removed invalid character: {0} from string: {1}'# $character, $String + + 'Save-AzOpsManagementGroupChild.Creating.Scope' = 'Creating scope object' # + 'Save-AzOpsManagementGroupChild.Data.Directory' = 'Resolved state path directory: {0}' # $statepathDirectory + 'Save-AzOpsManagementGroupChild.Data.FileName' = 'Resolved state path filename: {0}' # $statepathFileName + 'Save-AzOpsManagementGroupChild.Data.ScopeDirectory' = 'Resolved state path scope directory: {0}' # $statepathScopeDirectory + 'Save-AzOpsManagementGroupChild.Data.ScopeDirectoryParent' = 'Resolved state path scope directory parent: {0}' # $statepathScopeDirectoryParent + 'Save-AzOpsManagementGroupChild.Data.StatePath' = 'Resolved state path: {0}' # $scopeStatepath + 'Save-AzOpsManagementGroupChild.Moving.Destination' = 'Moved existing state file to: {0}' # $statepathScopeDirectoryParent + 'Save-AzOpsManagementGroupChild.Moving.Source' = 'Found existing state file in directory: {0}' # $exisitingScopePath + 'Save-AzOpsManagementGroupChild.Processing' = 'Processing Scope: {0}' # $scopeObject.Scope + 'Save-AzOpsManagementGroupChild.Starting' = 'Starting execution' # + 'Save-AzOpsManagementGroupChild.Subscription.NotFound' = 'Unable to locate subscription: {0} within AzOpsSubscriptions object' #child.Name 'Search-AzOpsAzGraph.Processing' = 'AzGraph processing query: [{0}]' # $Query 'Search-AzOpsAzGraph.Processing.Done' = 'AzGraph completed processing of query: [{0}]' # $Query diff --git a/src/tests/functional/Microsoft.Authorization/policyAssignments/scenario.ps1 b/src/tests/functional/Microsoft.Authorization/policyAssignments/scenario.ps1 index 5e3458c1..969dcd8b 100644 --- a/src/tests/functional/Microsoft.Authorization/policyAssignments/scenario.ps1 +++ b/src/tests/functional/Microsoft.Authorization/policyAssignments/scenario.ps1 @@ -40,7 +40,9 @@ Describe "Scenario - policyAssignments" { $changeSet = @( "D`t$script:file" ) - $deleteSetContents += (Get-Content $script:file) + [string[]]$deleteSetContents = "-- $script:file" + [string[]]$deleteSetContents += (Get-Content $script:file) + Remove-Item -Path $script:file -Force try { Write-PSFMessage -Level Debug -Message "Deletion Scenario $script:resourceType starting: $script:file" -FunctionName "Functional Tests" $script:deletion = Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents diff --git a/src/tests/functional/Microsoft.Authorization/roleAssignments/scenario.ps1 b/src/tests/functional/Microsoft.Authorization/roleAssignments/scenario.ps1 index a30d2316..dab1c6d3 100644 --- a/src/tests/functional/Microsoft.Authorization/roleAssignments/scenario.ps1 +++ b/src/tests/functional/Microsoft.Authorization/roleAssignments/scenario.ps1 @@ -40,7 +40,9 @@ Describe "Scenario - roleAssignments" { $changeSet = @( "D`t$script:file" ) - $deleteSetContents += (Get-Content $script:file) + [string[]]$deleteSetContents = "-- $script:file" + [string[]]$deleteSetContents += (Get-Content $script:file) + Remove-Item -Path $script:file -Force try { Write-PSFMessage -Level Debug -Message "Deletion Scenario $script:resourceType starting: $script:file" -FunctionName "Functional Tests" $script:deletion = Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents diff --git a/src/tests/functions/Set-AzOpsRemoveOrder.Tests.ps1 b/src/tests/functions/Set-AzOpsRemoveOrder.Tests.ps1 new file mode 100644 index 00000000..e7952b3a --- /dev/null +++ b/src/tests/functions/Set-AzOpsRemoveOrder.Tests.ps1 @@ -0,0 +1,51 @@ +Describe "Function Test - Set-AzOpsRemoveOrder" { + + BeforeAll { + + } + + Context "Test: Set-AzOpsRemoveOrder Sorts as Expected" { + It 'Sort based on priority' { + InModuleScope AzOps { + $storageAccount = 'storageAccount' + $resourceGroups = 'resourceGroups' + $locks = 'locks' + $managementGroups = 'managementGroups' + $routeTables = 'routeTables' + $testList = @( + [PSCustomObject]@{ + Name = 'Item1' + Type = $storageAccount + }, + [PSCustomObject]@{ + Name = 'Item2' + Type = $resourceGroups + }, + [PSCustomObject]@{ + Name = 'Item3' + Type = $locks + }, + [PSCustomObject]@{ + Name = 'Item4' + Type = $managementGroups + }, + [PSCustomObject]@{ + Name = 'Item5' + Type = $routeTables + } + ) + $returnList = Set-AzOpsRemoveOrder -DeletionList $testList -Index { $_.Type } + $returnList[0].Type | Should -Be $locks + $returnList[1].Type | Should -Be $resourceGroups + $returnList[2].Type | Should -Be $managementGroups + $returnList[3].Type | Should -BeIn $storageAccount,$routeTables + $returnList[4].Type | Should -BeIn $storageAccount,$routeTables + } + } + } + + AfterAll { + + } + +} \ No newline at end of file diff --git a/src/tests/integration/Repository.Tests.ps1 b/src/tests/integration/Repository.Tests.ps1 index 06bea35d..1fb24ba0 100644 --- a/src/tests/integration/Repository.Tests.ps1 +++ b/src/tests/integration/Repository.Tests.ps1 @@ -21,6 +21,7 @@ Describe "Repository" { $script:repositoryRoot = (Resolve-Path "$global:testroot/../..").Path $script:tenantId = $env:ARM_TENANT_ID $script:subscriptionId = $env:ARM_SUBSCRIPTION_ID + $otherSubscription = Get-AzSubscription | Where-Object { $_.Id -ne $script:subscriptionId } | Sort-Object Name -Descending | Select-Object Id -First 2 # Validate that the runtime variables are set as they are used to authenticate the Azure session. @@ -132,10 +133,13 @@ Describe "Repository" { $script:policySetDefinitionsDep = Get-AzPolicySetDefinition -Name 'TestPolicySetDefinitionDep' -ManagementGroupName $($script:testManagementGroup.Name) $script:subscription = (Get-AzSubscription | Where-Object Id -eq $script:subscriptionId) $script:resourceGroup = (Get-AzResourceGroup | Where-Object ResourceGroupName -eq "App1-azopsrg") + $script:resourceGroupRemovalSupport = (Get-AzResourceGroup | Where-Object ResourceGroupName -eq "RemovalSupport-azopsrg") + $script:resourceGroupCustomDeletion = (Get-AzResourceGroup | Where-Object ResourceGroupName -eq "CustomDeletion-azopsrg") $script:resourceGroupParallelDeploy = (Get-AzResourceGroup | Where-Object ResourceGroupName -eq "ParallelDeploy-azopsrg") $script:roleAssignments = (Get-AzRoleAssignment -ObjectId "023e7c1c-1fa4-4818-bb78-0a9c5e8b0217" | Where-Object { $_.Scope -eq "/subscriptions/$script:subscriptionId" -and $_.RoleDefinitionId -eq "acdd72a7-3385-48ef-bd42-f606fba81ae7" }) $script:policyExemptions = Get-AzPolicyExemption -Name "PolicyExemptionTest" -Scope "/subscriptions/$script:subscriptionId" $script:routeTable = (Get-AzResource -Name "RouteTable" -ResourceGroupName $($script:resourceGroup).ResourceGroupName) + $script:policyAssignmentsDeletion = Get-AzPolicyAssignment -Name "TestPolicyAssignmentDeletion" -Scope "/subscriptions/$script:subscriptionId/resourceGroups/$($script:resourceGroupCustomDeletion.ResourceGroupName)" $script:ruleCollectionGroups = (Get-AzResource -ExpandProperties -Name "TestPolicy" -ResourceGroupName $($script:resourceGroup).ResourceGroupName).Properties.ruleCollectionGroups.id.split("/")[-1] $script:logAnalyticsWorkspace = (Get-AzResource -Name "thisisalongloganalyticsworkspacename123456789011121314151617181" -ResourceGroupName $($script:resourceGroup).ResourceGroupName) } @@ -225,6 +229,11 @@ Describe "Repository" { $script:policyAssignmentsDeploymentName = "AzOps-{0}-{1}" -f $($script:policyAssignmentsPath.Name.Replace(".json", '')).Substring(0, 53), $deploymentLocationId Write-PSFMessage -Level Debug -Message "PolicyAssignmentsFile: $($script:policyAssignmentsFile)" -FunctionName "BeforeAll" + $script:policyAssignmentsDeletionPath = ($filePaths | Where-Object Name -eq "microsoft.authorization_policyassignments-$(($script:policyAssignmentsDeletion.Name).toLower()).json") + $script:policyAssignmentsDeletionDirectory = ($script:policyAssignmentsDeletionPath).Directory + $script:policyAssignmentsDeletionFile = ($script:policyAssignmentsDeletionPath).FullName + Write-PSFMessage -Level Debug -Message "PolicyAssignmentsDeletionFile: $($script:policyAssignmentsDeletionFile)" -FunctionName "BeforeAll" + $script:policyAssignmentsDepPath = ($filePaths | Where-Object Name -eq "microsoft.authorization_policyassignments-$(($script:policyAssignmentsDep.Name).toLower()).json") $script:policyAssignmentsDepDirectory = ($script:policyAssignmentsDepPath).Directory $script:policyAssignmentsDepFile = ($script:policyAssignmentsDepPath).FullName @@ -284,11 +293,31 @@ Describe "Repository" { $script:resourceGroupDeploymentName = "AzOps-{0}-{1}" -f $($script:resourceGroupPath.Name.Replace(".json", '')), $deploymentLocationId Write-PSFMessage -Level Debug -Message "ResourceGroupFile: $($script:resourceGroupFile)" -FunctionName "BeforeAll" + $script:resourceGroupRemovalSupportPath = ($filePaths | Where-Object Name -eq "microsoft.resources_resourcegroups-$(($script:resourceGroupRemovalSupport.ResourceGroupName).toLower()).json") + $script:resourceGroupRemovalSupportDirectory = ($script:resourceGroupRemovalSupportPath).Directory + $script:resourceGroupRemovalSupportFile = ($script:resourceGroupRemovalSupportPath).FullName + Write-PSFMessage -Level Debug -Message "ResourceGroupFile: $($script:resourceGroupRemovalSupportFile)" -FunctionName "BeforeAll" + $script:resourceGroupParallelDeployPath = ($filePaths | Where-Object Name -eq "microsoft.resources_resourcegroups-$(($script:resourceGroupParallelDeploy.ResourceGroupName).toLower()).json") $script:resourceGroupParallelDeployDirectory = ($script:resourceGroupParallelDeployPath).Directory $script:resourceGroupParallelDeployFile = ($script:resourceGroupParallelDeployPath).FullName Write-PSFMessage -Level Debug -Message "ParallelDeployResourceGroupFile: $($script:resourceGroupParallelDeployFile)" -FunctionName "BeforeAll" + $script:resourceGroupCustomDeletionPath = ($filePaths | Where-Object Name -eq "microsoft.resources_resourcegroups-$(($script:resourceGroupCustomDeletion.ResourceGroupName).toLower()).json") + $script:resourceGroupCustomDeletionDirectory = ($script:resourceGroupCustomDeletionPath).Directory + $script:resourceGroupCustomDeletionFile = ($script:resourceGroupCustomDeletionPath).FullName + Write-PSFMessage -Level Debug -Message "CustomDeletionResourceGroupFile: $($script:resourceGroupCustomDeletionFile)" -FunctionName "BeforeAll" + + $script:resourceGrouprgDualDeploy1Path = ($filePaths | Where-Object Name -eq "microsoft.subscription_subscriptions-$(($otherSubscription[0].Id).toLower()).json") + $script:resourceGrouprgDualDeploy1Directory = ($script:resourceGrouprgDualDeploy1Path).Directory + $script:resourceGrouprgDualDeploy1File = ($script:resourceGrouprgDualDeploy1Path).FullName + Write-PSFMessage -Level Debug -Message "ResourceGrouprgDualDeploy1File: $($script:resourceGrouprgDualDeploy1File)" -FunctionName "BeforeAll" + + $script:resourceGrouprgDualDeploy2Path = ($filePaths | Where-Object Name -eq "microsoft.subscription_subscriptions-$(($otherSubscription[1].Id).toLower()).json") + $script:resourceGrouprgDualDeploy2Directory = ($script:resourceGrouprgDualDeploy2Path).Directory + $script:resourceGrouprgDualDeploy2File = ($script:resourceGrouprgDualDeploy2Path).FullName + Write-PSFMessage -Level Debug -Message "ResourceGrouprgDualDeploy2File: $($script:resourceGrouprgDualDeploy2File)" -FunctionName "BeforeAll" + $script:roleAssignmentsPath = ($filePaths | Where-Object Name -eq "microsoft.authorization_roleassignments-$(($script:roleAssignments.RoleAssignmentId).toLower() -replace ".*/").json") $script:roleAssignmentsDirectory = ($script:roleAssignmentsPath).Directory $script:roleAssignmentsFile = ($script:roleAssignmentsPath).FullName @@ -356,19 +385,23 @@ Describe "Repository" { "D`t$script:policySetDefinitionsFile", "D`t$script:policyExemptionsFile", "D`t$script:roleAssignmentsFile", - "D`t$script:locksFile" + "D`t$script:locksFile", + "D`t$script:resourceGroupRemovalSupportFile" ) - $DeleteSetContents += (Get-Content $Script:policyAssignmentsFile) - $DeleteSetContents += '-- ' - $DeleteSetContents += (Get-Content $Script:policyDefinitionsFile) - $DeleteSetContents += '-- ' - $DeleteSetContents += (Get-Content $Script:policySetDefinitionsFile) - $DeleteSetContents += '-- ' - $DeleteSetContents += (Get-Content $Script:policyExemptionsFile) - $DeleteSetContents += '-- ' - $DeleteSetContents += (Get-Content $Script:roleAssignmentsFile) - $DeleteSetContents += '-- ' - $DeleteSetContents += (Get-Content $Script:locksFile) + [string[]]$deleteSetContents = "-- $script:policyAssignmentsFile" + [string[]]$deleteSetContents += (Get-Content $Script:policyAssignmentsFile) + [string[]]$deleteSetContents = "-- $Script:policyDefinitionsFile" + [string[]]$deleteSetContents += (Get-Content $Script:policyDefinitionsFile) + [string[]]$deleteSetContents = "-- $Script:policySetDefinitionsFile" + [string[]]$deleteSetContents += (Get-Content $Script:policySetDefinitionsFile) + [string[]]$deleteSetContents = "-- $Script:policyExemptionsFile" + [string[]]$deleteSetContents += (Get-Content $Script:policyExemptionsFile) + [string[]]$deleteSetContents = "-- $Script:roleAssignmentsFile" + [string[]]$deleteSetContents += (Get-Content $Script:roleAssignmentsFile) + [string[]]$deleteSetContents = "-- $Script:locksFile" + [string[]]$deleteSetContents += (Get-Content $Script:locksFile) + [string[]]$deleteSetContents = "-- $script:resourceGroupRemovalSupportFile" + [string[]]$deleteSetContents += (Get-Content $script:resourceGroupRemovalSupportFile) Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents } @@ -809,6 +842,39 @@ Describe "Repository" { } #endregion + #region Scope - Resource Group (./root/tenant root group/test/platform/management/subscription-0/RemovalSupport-azopsrg) + It "Resource Group directory should exist" { + Test-Path -Path $script:resourceGroupRemovalSupportDirectory | Should -BeTrue + } + It "Resource Group file should exist" { + Test-Path -Path $script:resourceGroupRemovalSupportFile | Should -BeTrue + } + It "Resource Group resource type should exist" { + $fileContents = Get-Content -Path $script:resourceGroupRemovalSupportFile -Raw | ConvertFrom-Json -Depth 25 + $fileContents.resources[0].type | Should -BeTrue + } + It "Resource Group resource name should exist" { + $fileContents = Get-Content -Path $script:resourceGroupRemovalSupportFile -Raw | ConvertFrom-Json -Depth 25 + $fileContents.resources[0].name | Should -BeTrue + } + It "Resource Group resource apiVersion should exist" { + $fileContents = Get-Content -Path $script:resourceGroupRemovalSupportFile -Raw | ConvertFrom-Json -Depth 25 + $fileContents.resources[0].apiVersion | Should -BeTrue + } + It "Resource Group resource properties should exist" { + $fileContents = Get-Content -Path $script:resourceGroupRemovalSupportFile -Raw | ConvertFrom-Json -Depth 25 + $fileContents.resources[0].properties | Should -BeTrue + } + It "Resource Group resource type should match" { + $fileContents = Get-Content -Path $script:resourceGroupRemovalSupportFile -Raw | ConvertFrom-Json -Depth 25 + $fileContents.resources[0].type | Should -Be "Microsoft.Resources/resourceGroups" + } + It "Resource Group deletion should be successful" { + $rgDeletion = Get-AzResourceGroup -Id $script:resourceGroupRemovalSupport.ResourceId -ErrorAction SilentlyContinue + $rgDeletion | Should -Be $Null + } + #endregion + #region Deploy Resource Group via bicep It "Bicep deployment should be successful" { $script:bicepDeployment = Get-AzSubscriptionDeployment -Name $script:bicepDeploymentName @@ -1019,35 +1085,45 @@ Describe "Repository" { $changeSet = @( "D`t$script:policyDefinitionsDepFile" ) - $DeleteSetContents = (Get-Content $Script:policyDefinitionsDepFile) + [string[]]$deleteSetContents = "-- $Script:policyDefinitionsDepFile" + [string[]]$deleteSetContents += (Get-Content $Script:policyDefinitionsDepFile) + Remove-Item -Path $Script:policyDefinitionsDepFile -Force {Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents -WhatIf:$true} | Should -Throw } It "Deletion of policySetDefinitionsFile with assignment dependency should fail" { $changeSet = @( "D`t$script:policySetDefinitionsDepFile" ) - $DeleteSetContents = (Get-Content $Script:policySetDefinitionsDepFile) + [string[]]$deleteSetContents = "-- $Script:policySetDefinitionsDepFile" + [string[]]$deleteSetContents += (Get-Content $Script:policySetDefinitionsDepFile) + Remove-Item -Path $Script:policySetDefinitionsDepFile -Force {Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents -WhatIf:$true} | Should -Throw } It "Deletion of policyDefinitionsFile with setDefinition dependency should fail" { $changeSet = @( "D`t$script:policyDefinitionsDep2File" ) - $DeleteSetContents = (Get-Content $script:policyDefinitionsDep2File) + [string[]]$deleteSetContents = "-- $script:policyDefinitionsDep2File" + [string[]]$deleteSetContents += (Get-Content $script:policyDefinitionsDep2File) + Remove-Item -Path $script:policyDefinitionsDep2File -Force {Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents -WhatIf:$true} | Should -Throw } It "Deletion of policyAssignmentFile with role assignment dependency should fail" { $changeSet = @( "D`t$script:policyAssignmentsDepFile" ) - $DeleteSetContents = (Get-Content $script:policyAssignmentsDepFile) + [string[]]$deleteSetContents = "-- $script:policyAssignmentsDepFile" + [string[]]$deleteSetContents += (Get-Content $script:policyAssignmentsDepFile) + Remove-Item -Path $script:policyAssignmentsDepFile -Force {Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents -WhatIf:$true} | Should -Throw } It "Deletion of policyAssignmentFile with lock dependency should fail" { $changeSet = @( "D`t$script:policyAssignmentsDep2File" ) - $DeleteSetContents = (Get-Content $script:policyAssignmentsDep2File) + [string[]]$deleteSetContents = "-- $script:policyAssignmentsDep2File" + [string[]]$deleteSetContents += (Get-Content $script:policyAssignmentsDep2File) + Remove-Item -Path $script:policyAssignmentsDep2File -Force {Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents -WhatIf:$true} | Should -Throw } #endregion @@ -1152,6 +1228,57 @@ Describe "Repository" { $timeTest | Should -Be 'good' } #endregion + + #region Deploy multiple resource group's to different subscriptions, test context switch + It "Deploy multiple resource group's to different subscriptions, test context switch" { + $script:deployRg1 = Get-ChildItem -Path "$($global:testRoot)/templates/rgdualdeploy*" | Copy-Item -Destination $script:resourceGrouprgDualDeploy1Directory -PassThru -Force + $script:deployRg2 = Get-ChildItem -Path "$($global:testRoot)/templates/rgdualdeploy*" | Copy-Item -Destination $script:resourceGrouprgDualDeploy2Directory -PassThru -Force + $changeSet = @( + "A`t$($script:deployRg1.FullName[0])", + "A`t$($script:deployRg2.FullName[0])" + ) + {Invoke-AzOpsPush -ChangeSet $changeSet} | Should -Not -Throw + Start-Sleep -Seconds 5 + $null = Set-AzContext -SubscriptionId $otherSubscription[0].Id + (Get-AzResourceGroup -Name Test-azopsrg).Count | Should -Be 1 + $null = Set-AzContext -SubscriptionId $otherSubscription[1].Id + (Get-AzResourceGroup -Name Test-azopsrg).Count | Should -Be 1 + Set-AzContext -SubscriptionId $script:subscriptionId + } + #endregion + + #region Deletion of custom templates and pulled resources + It "Deletion of custom templates and pulled resources" { + Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $true + $script:deployCustomRt = Get-ChildItem -Path "$($global:testRoot)/templates/rtcustomdelete*" | Copy-Item -Destination $script:resourceGroupCustomDeletionDirectory -PassThru -Force + $script:deployCustomLock = Get-ChildItem -Path "$($global:testRoot)/templates/customlockdelete*" | Copy-Item -Destination $script:subscriptionDirectory -PassThru -Force + $changeSet = @( + "A`t$($script:deployCustomRt.FullName[0])", + "A`t$($script:deployCustomLock.FullName)" + ) + {Invoke-AzOpsPush -ChangeSet $changeSet} | Should -Not -Throw + Start-Sleep -Seconds 10 + $changeSet = @( + "D`t$($script:deployCustomRt.FullName[0])", + "D`t$($script:deployCustomLock.FullName)", + "D`t$script:policyAssignmentsDeletionFile" + ) + [string[]]$deleteSetContents = "-- $($script:deployCustomRt.FullName[0])" + [string[]]$deleteSetContents += (Get-Content $script:deployCustomRt.FullName[0]) + [string[]]$deleteSetContents += "-- $($script:deployCustomLock.FullName)" + [string[]]$deleteSetContents += (Get-Content $script:deployCustomLock.FullName) + [string[]]$deleteSetContents += "-- $script:policyAssignmentsDeletionFile" + [string[]]$deleteSetContents += (Get-Content $script:policyAssignmentsDeletionFile) + Remove-Item -Path $script:deployCustomRt.FullName[0] -Force + Remove-Item -Path $script:deployCustomLock.FullName -Force + Remove-Item -Path $script:policyAssignmentsDeletionFile -Force + {Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents -WhatIf:$false} | Should -Not -Throw + Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $false + Start-Sleep -Seconds 30 + (Get-AzResource -ResourceGroupName $script:resourceGroupCustomDeletion.ResourceGroupName).Count | Should -Be 0 + Get-AzPolicyAssignment -Id $script:policyAssignmentsDeletion.ResourceId -ErrorAction SilentlyContinue | Should -Be $Null + } + #endregion } AfterAll { diff --git a/src/tests/templates/azuredeploy.jsonc b/src/tests/templates/azuredeploy.jsonc index 90d16405..5d64d707 100644 --- a/src/tests/templates/azuredeploy.jsonc +++ b/src/tests/templates/azuredeploy.jsonc @@ -506,6 +506,18 @@ "name": "App1-azopsrg", "location": "northeurope" }, + { + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2019-10-01", + "name": "RemovalSupport-azopsrg", + "location": "northeurope" + }, + { + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2019-10-01", + "name": "CustomDeletion-azopsrg", + "location": "northeurope" + }, { "type": "Microsoft.Resources/resourceGroups", "apiVersion": "2019-10-01", @@ -612,6 +624,69 @@ } } }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2019-10-01", + "name": "RemovalRGSupportDeploy", + "resourceGroup": "RemovalSupport-azopsrg", + "dependsOn": [ + "RemovalSupport-azopsrg" + ], + "properties": { + "mode": "Incremental", + "expressionEvaluationOptions": { + "scope": "inner" + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "apiVersion": "2019-02-01", + "type": "Microsoft.Network/routeTables", + "name": "ForgetMeRouteTable", + "location": "northeurope", + "properties": { + "disableBgpRoutePropagation": "false" + } + } + ], + "outputs": { + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2019-10-01", + "name": "CustomDeletion", + "resourceGroup": "CustomDeletion-azopsrg", + "dependsOn": [ + "CustomDeletion-azopsrg" + ], + "properties": { + "mode": "Incremental", + "expressionEvaluationOptions": { + "scope": "inner" + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "type": "Microsoft.Authorization/policyAssignments", + "apiVersion": "2021-06-01", + "name": "TestPolicyAssignmentDeletion", + "properties": { + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/0a914e76-4921-4c19-b460-a2d36003525a" + } + } + ], + "outputs": { + } + } + } + }, { "type": "Microsoft.Resources/deployments", "apiVersion": "2019-10-01", diff --git a/src/tests/templates/customlockdelete.bicep b/src/tests/templates/customlockdelete.bicep new file mode 100644 index 00000000..38f0fdbc --- /dev/null +++ b/src/tests/templates/customlockdelete.bicep @@ -0,0 +1,9 @@ +targetScope = 'subscription' + +resource subLock 'Microsoft.Authorization/locks@2020-05-01' = { + name: 'subscriptionLock' + properties: { + level: 'CanNotDelete' + notes: 'This subscription is locked for Delete operations.' + } +} diff --git a/src/tests/templates/rgdualdeploy.bicep b/src/tests/templates/rgdualdeploy.bicep new file mode 100644 index 00000000..9a275846 --- /dev/null +++ b/src/tests/templates/rgdualdeploy.bicep @@ -0,0 +1,11 @@ +targetScope = 'subscription' + +param resourceGroupName string +param location string + +resource myRg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location + tags: {} + properties: {} +} diff --git a/src/tests/templates/rgdualdeploy.parameters.json b/src/tests/templates/rgdualdeploy.parameters.json new file mode 100644 index 00000000..16c226c9 --- /dev/null +++ b/src/tests/templates/rgdualdeploy.parameters.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "value": "Test-azopsrg" + }, + "location": { + "value": "northeurope" + } + } +} \ No newline at end of file diff --git a/src/tests/templates/rtcustomdelete.bicep b/src/tests/templates/rtcustomdelete.bicep new file mode 100644 index 00000000..a95a768b --- /dev/null +++ b/src/tests/templates/rtcustomdelete.bicep @@ -0,0 +1,32 @@ +param name string +param staName string +param location string = resourceGroup().location + +var storageName = '${toLower(staName)}${uniqueString(resourceGroup().id)}' + +resource rt 'Microsoft.Network/routeTables@2023-04-01' = { + name: name + location: location + properties: { + disableBgpRoutePropagation: false + routes: [ + ] + } +} + +resource storage_resource 'Microsoft.Storage/storageAccounts@2021-08-01' = { + name: storageName + location: location + kind: 'StorageV2' + sku: { + name: 'Standard_GZRS' + } + properties: { + minimumTlsVersion: 'TLS1_2' + networkAcls: { + bypass: 'None' + defaultAction: 'Deny' + } + supportsHttpsTrafficOnly: true + } +} diff --git a/src/tests/templates/rtcustomdelete.parameters.json b/src/tests/templates/rtcustomdelete.parameters.json new file mode 100644 index 00000000..fc60cff9 --- /dev/null +++ b/src/tests/templates/rtcustomdelete.parameters.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "name": { + "value": "CustomRouteTable" + }, + "staName": { + "value": "deleteazops" + } + } +} \ No newline at end of file