diff --git a/docs/wiki/DeploymentStacks.md b/docs/wiki/DeploymentStacks.md new file mode 100644 index 00000000..321e9623 --- /dev/null +++ b/docs/wiki/DeploymentStacks.md @@ -0,0 +1,188 @@ +# AzOps Managed Deployment Stacks + +- [Introduction](#introduction) +- [Settings](#deployment-stacks-settings) + - [Exclusions](#deployment-stacks-exclusions) +- [Hierarchy](#deployment-stacks-hierarchy) + - [Processing Logic](#deployment-stacks-processing) + - [DeploymentStacks Name](#deployment-stacks-name) + +## Introduction + +As a part of **AzOps Push**, the module handles creation of deployment stacks and **_[deletion](https://github.com/azure/azops/wiki/ResourceDeletion#deletion-of-custom-template)_** of deployment stacks based on your custom templates. The stacks configuration is managed by `.deploymentStacks.json` files and are supported at three scope levels: management group, subscription, resource group. + +AzOps utilizes the Az PowerShell Module Cmdlets: `New-AzResourceGroupDeploymentStack`, `New-AzSubscriptionDeploymentStack` or `New-AzManagementGroupDeploymentStack` to create/set/update the [`Microsoft.Resources/deploymentStacks`](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks) resource with `-Force`. + +_AzOps managed deployment stacks processing is only supported with custom templates, meaning AzOps Pulled templates are not intended to work with `.deploymentStacks.json` files._ + +**_Please Note_** + +- SPN used for AzOps, requires below actions in its role definition or one of the following built-in roles `Azure Deployment Stack Owner` or `Azure Deployment Stack Contributor`. Choose which combination best suits your implementation. + +```bash + Microsoft.Resources/deploymentStacks/* +``` +or +```bash + Microsoft.Resources/deploymentStacks/write + Microsoft.Resources/deploymentStacks/read +``` + +## Deployment Stacks Settings + +To control and configure the AzOps Managed Deployment Stacks, usage of settings files `.deploymentStacks.json` are key. + +They are expected to be `.json` formatted and have names that indicate their template/parameter file set relation. The presence of a `.deploymentStacks.json` matching a relation with template/parameter file set will trigger AzOps to manage that template/parameter file set as a `Microsoft.Resources/deploymentStacks` configured according to the `.deploymentStacks.json` file. + +Below is an example `.deploymentStacks.json` file: +```json +{ + "actionOnUnmanage": "deleteResources", + "bypassStackOutOfSyncError": false, + "denySettingsMode": "DenyDelete", + "excludedAzOpsFiles": [ + "dontLookAtMe.bicep" + ] +} +``` + +The following settings are honored by AzOps: +- ActionOnUnmanage +- DenySettingsMode +- DenySettingsExcludedPrincipal +- DenySettingsExcludedAction +- DenySettingsApplyToChildScopes +- BypassStackOutOfSyncError +- Location +- ExcludedAzOpsFiles + +The allowed value for each of the parameter except for `excludedAzOpsFiles` are derived by enumerating the respective Cmdlets: `New-DeploymentStack`. + +### Deployment Stacks Exclusions + +Deployment stack exclusions allow you to exclude specific templates or parameter files from being processed by AzOps as `Microsoft.Resources/deploymentStacks`. Below are examples of how to configure exclusions: + +**I want to use one "root" `.deploymentStacks.json` and have all custom templates at that scope use it** + +Can AzOps settings be configured to enable this? + +Yes, ensure you have a root `.deploymentStacks.json` file placed at that scope level. +```bash +Folder/ +├── .deploymentStacks.json +├── template1.bicep +└── template2.bicep +``` +```json +{ + "actionOnUnmanage": "deleteResources", + "bypassStackOutOfSyncError": false, + "denySettingsMode": "DenyDelete", + "excludedAzOpsFiles": [] +} +``` +**But now i need to exclude `template2.bicep` from deployment stacks processing** + +Can AzOps settings be configured to enable this? + +Yes, let's edit the root `.deploymentStacks.json` file placed at that scope level. +```json +{ + "actionOnUnmanage": "deleteResources", + "bypassStackOutOfSyncError": false, + "denySettingsMode": "DenyDelete", + "excludedAzOpsFiles": [ + "template2.bicep" + ] +} +``` + +**But now i realise i want to have `template2.bicep` managed by deployment stacks, however it needs different settings than the root `.deploymentStack.json` file offers** + +Yes, let's create a new file called `template2.deploymentStacks.json` file placed at that scope level. +```bash +Folder/ +├── .deploymentStacks.json +├── template1.bicep +├── template2.deploymentStacks.json +└── template2.bicep +``` +```json +{ + "actionOnUnmanage": "deleteResources", + "bypassStackOutOfSyncError": true, + "denySettingsMode": "DenyDelete", + "excludedAzOpsFiles": [] +} +``` +Once the `template2.deploymentStacks.json` file exists the `excludedAzOpsFiles` setting at the root `.deploymentStacks.json` file is no longer necessary. + +## Deployment Stacks Hierarchy + +The deployment stacks hierarchy defines how `.deploymentStacks.json` files are organized and applied across different Azure scopes, such as management groups, subscriptions, and resource groups. This hierarchy ensures that deployment stack configurations are applied in a structured and predictable manner, allowing for flexibility and granularity in managing custom templates. + +- **Scope Levels**: Deployment stacks can be configured at various scope levels, including management groups, subscriptions, and resource groups. The most specific configuration at a given scope takes precedence. +- **File Structure**: The hierarchy relies on the placement of `.deploymentStacks.json` files within the folder structure. These files can be scoped (e.g., folder root-level) or specifically (e.g., tied to individual templates). +- **Inheritance and Overrides**: A root `.deploymentStacks.json` file can provide default settings for all templates at a scope. However, specific `.deploymentStacks.json` files can override these settings for individual templates or parameter files. +- **Exclusions**: Templates can be excluded from deployment stack processing by specifying them in the `excludedAzOpsFiles` setting of a `.deploymentStacks.json` file. + +The following diagram illustrates the folder structure for managing deployment stacks at different levels: + +```mermaid +graph TD + A[MG root] + A --> B[Intermediate Root MG] + B --> C[.deploymentStacks.json
templateX.json
templateY.bicep] + B --> D[LZ MG] + D --> E[Subscription 1] + E --> F[templateQ.deploymentStacks.json
templateQ.bicep
templateQ.bicepparam] + E --> G[Resource Group 1] + G --> H[templateT.deploymentStacks.json
templateT.bicep
templateT.bicepparam] +``` + +### Deployment Stacks Processing + +The most specific deploymentstack configuration (at scope) will be selected by considering the following: +- **Specificity Matters**: The most specific `.deploymentStacks.json` file at a given scope (e.g., matching the parameter or template filename) is prioritized. +- **Exclusion Handling**: Files listed in the `excludedAzOpsFiles` setting of a root or base template `.deploymentStacks.json` file are skipped during processing. +- **Fallback Logic**: If no specific or general `.deploymentStacks.json` file is found, the root `.deploymentStacks.json` file is used, if available. +- **Non Stack Deployment**: If no applicable `.deploymentStacks.json` file is found or all are excluded, the template is processed as a non-stack deployment. +- **Multiple Template/Parameter Files**: The `Core.AllowMultipleTemplateParameterFiles` setting determines whether parameter or template filenames are used to locate specific stack files. +- **Override Mechanism**: Specific `.deploymentStacks.json` files override root-level settings, enabling granular control for individual templates. +```mermaid +flowchart TD + A[Start: Template and Parameter File Set] --> B[Is Core.AllowMultipleTemplateParameterFiles true?] + B -- Yes --> C[Find specific stack file
matching parameter filename] + B -- No --> D[Find specific stack file
matching template filename] + C --> E[Was a specific stack file found?] + D --> E + E -- Yes --> F[Check exclusions in the stack file
based on parameter or template filename] + F -- Excluded --> G[Evaluate next stack file at the same scope] + F -- Not Excluded --> H[Use the specific stack file] + E -- No --> I[Find general stack file
matching template filename] + I --> J[Was a general stack file found?] + J -- Yes --> K[Check exclusions in the general stack file] + K -- Excluded --> G + K -- Not Excluded --> L[Use the general stack file] + J -- No --> M[Find root .deploymentStacks.json file] + M --> N[Was a root stack file found?] + N -- Yes --> O[Check exclusions in the root stack file] + O -- Excluded --> Q[No stack file selected
process as non stack deployment] + O -- Not Excluded --> P[Use the root stack file] + N -- No --> Q +``` + +#### Deployment Stacks Name + +AzOps constructs the deployment stack name deterministically based on the associated template and parameter filename. The base name of the fileis sanitized by removing unnecessary parts (e.g., extensions like `.bicepparam`), truncated to 53 characters if necessary, and combined with a deterministic 4-character hash derived from the `DefaultDeploymentRegion`. + +For example: +- Template file: `template.bicep` +- Parameter file: `template.x1.bicepparam` +- Default region: `eastus` + +The resulting deployment stack name would be: `AzOps-template-x1-1a2b`. + +This naming convention ensures that deployment stack names are unique, predictable, and region-specific, allowing for consistent management of deployment stacks across different scopes and regions. + +**_The Deployment Stacks Name is critical to enable consistent processing and accurate lifecycle management of the stack and its resources._** \ No newline at end of file diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 05f26794..b4de027b 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -20,6 +20,7 @@ * [Steps](https://github.com/azure/azops/wiki/steps) * [Deployments](https://github.com/Azure/Enterprise-Scale/wiki/Deploying-ALZ#operating-the-azure-platform-using-azops-infrastructure-as-code-with-github-actions) * [Subscriptions](https://github.com/Azure/Enterprise-Scale/wiki/Create-Landingzones#create-landing-zones-subscription-using-azops) +* [DeploymentStacks Feature](https://github.com/azure/azops/wiki/DeploymentStacks) * [Resources Deletion Feature](https://github.com/azure/azops/wiki/ResourceDeletion) * [Feeds](https://github.com/azure/azops/wiki/feeds) * [Azure Artifacts](https://github.com/azure/azops/wiki/azure-artifacts) diff --git a/scripts/Remove-AzOpsTestsDeployment.ps1 b/scripts/Remove-AzOpsTestsDeployment.ps1 index ad289212..625176f7 100644 --- a/scripts/Remove-AzOpsTestsDeployment.ps1 +++ b/scripts/Remove-AzOpsTestsDeployment.ps1 @@ -69,6 +69,7 @@ foreach ($subscription in $cleanupSub) { $null = Set-AzContext -SubscriptionId $subscription.Id $null = Get-AzResourceLock | Remove-AzResourceLock -Force + $null = Get-AzSubscriptionDeploymentStack | Remove-AzSubscriptionDeploymentStack -ActionOnUnmanage DeleteAll -Force -BypassStackOutOfSyncError Start-Sleep -Seconds 15 $script:resourceGroups = Get-AzResourceGroup | Where-Object {$_.ResourceGroupName -like "*-azopsrg"} $script:roleAssignmentsCleanBase = Get-AzRoleAssignment | Where-Object {$_.Scope -ne "/"} diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index c2ac5bda..4437fa9d 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -92,14 +92,42 @@ $FilePath = $transpiledTemplatePaths.transpiledTemplatePath } + # Handle AzOps DeploymentStacks templates + if ($FilePath.EndsWith(".deploymentStacks.json")) { + # Retrieve reverse lookup template paths for the deployment stack + $azOpsDeploymentStackReverseLookupTemplatePaths = Get-AzOpsDeploymentStackSetting -TemplateFilePath $FilePath -ReverseLookup + # Check if reverse lookup template paths are found + if ($null -ne $azOpsDeploymentStackReverseLookupTemplatePaths.ReverseLookupTemplateFilePath) { + # Iterate through each reverse lookup template path with New-AzOpsList + $reverseLookupDeploymentStacksTemplates = foreach ($templatePath in $azOpsDeploymentStackReverseLookupTemplatePaths.ReverseLookupTemplateFilePath) { + New-AzOpsList -FilePath $templatePath -CompareDeploymentToDeletion:$CompareDeploymentToDeletion + } + if ($reverseLookupDeploymentStacksTemplates) { + # Ensure DeploymentStackTemplateFilePath is in $FileSet + $filteredReverseLookupDeploymentStacksTemplates = $reverseLookupDeploymentStacksTemplates | Where-Object { $_.DeploymentStackTemplateFilePath -in (Resolve-Path -Path $FileSet).Path } + if ($filteredReverseLookupDeploymentStacksTemplates) { + return $filteredReverseLookupDeploymentStacksTemplates + } + else { + # Skip processing if no reverse lookup template paths are found + continue + } + } + } + else { + # Skip processing if no reverse lookup template paths are found + continue + } + } + 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 + Write-AzOpsMessage -LogLevel Error -LogString 'Invoke-AzOpsPush.Scope.Failed' -LogStringValues $FilePath, $StatePath -Target $FilePath -ErrorRecord $_ + return } # Resolve ARM file association @@ -136,16 +164,18 @@ #region Initialization Prep $result = [PSCustomObject] @{ - TemplateFilePath = $null - TranspiledTemplateNew = $false - TemplateParameterFilePath = $null - TranspiledParametersNew = $false - DeploymentName = $null - ScopeObject = $ScopeObject - Scope = $ScopeObject.Scope + DeploymentStackTemplateFilePath = $null + DeploymentStackSettings = $null + TemplateFilePath = $null + TranspiledTemplateNew = $false + TemplateParameterFilePath = $null + TranspiledParametersNew = $false + DeploymentName = $null + ScopeObject = $ScopeObject + Scope = $ScopeObject.Scope } - $fileItem = Get-Item -Path $FilePath + $fileItem = Get-Item -Path $FilePath -Force if ($fileItem.Extension -notin '.json' , '.bicep', '.bicepparam') { Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPush.Resolve.NoJson' -LogStringValues $fileItem.FullName -Target $ScopeObject return @@ -187,6 +217,9 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ParameterTemplateFilePath $result.TemplateParameterFilePath -ScopeObject $result.ScopeObject + $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath + $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result } elseif (Test-Path $bicepTemplatePath) { @@ -204,6 +237,9 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ParameterTemplateFilePath $result.TemplateParameterFilePath -ScopeObject $result.ScopeObject + $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath + $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result } } @@ -232,6 +268,9 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ParameterTemplateFilePath $result.TemplateParameterFilePath -ScopeObject $result.ScopeObject + $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath + $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result } } @@ -295,7 +334,7 @@ } elseif ((Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and (Get-PSFConfigValue -FullName 'AzOps.Core.DeployAllMultipleTemplateParameterFiles') -eq $true) { # Check for multiple associated template parameter files - $paramFileList = Get-ChildItem -Path $fileItem.Directory | Where-Object { ($_.Name.Split('.')[-3] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','')) -or ($_.Name.Split('.')[-2] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','')) } + $paramFileList = Get-ChildItem -Path $fileItem.Directory -Exclude *.deploymentStacks.json | Where-Object { ($_.Name.Split('.')[-3] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','')) -or ($_.Name.Split('.')[-2] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','')) } if ($paramFileList) { $multiResult = @() foreach ($paramFile in $paramFileList) { @@ -336,17 +375,19 @@ } } } - $deploymentName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' if ($deploymentName.Length -gt 53) { $deploymentName = $deploymentName.SubString(0, 53) } $result.DeploymentName = 'AzOps-{0}-{1}' -f $deploymentName, $deploymentRegionId - + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ParameterTemplateFilePath $result.TemplateParameterFilePath -ScopeObject $result.ScopeObject + $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath + $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings $result #endregion Case: Template File } function Test-TemplateDefaultParameter { param( - [string]$FilePath + [string] + $FilePath ) # Read the template file @@ -507,9 +548,31 @@ #endregion Create DeploymentList #region Create DeletionList + # Normalize paths by removing or replacing extensions for comparison + $normalizedDeleteSet = $deleteSet | ForEach-Object { + $_ -replace '\.bicep$', '.json' -replace '\.bicepparam$', '.parameters.json' + } + $normalizedResolvedDeleteSet = ($deleteSet | Resolve-Path).Path | ForEach-Object { + $_ -replace '\.bicep$', '.json' -replace '\.bicepparam$', '.parameters.json' + } $deletionList = foreach ($deletion in $deleteSet | Where-Object { $_ -match ((Get-Item $StatePath).Name) }) { # 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 + $deletionFileAssociationList = New-AzOpsList -FilePath $deletion -FileSet $deleteSet -AzOpsMainTemplate $AzOpsMainTemplate -ConvertedTemplate $AzOpsTranspiledTemplate -ConvertedParameter $AzOpsTranspiledParameter | + Where-Object { + # Include items if TemplateFilePath or TemplateParameterFilePath matches the normalized delete set + if ($_.TemplateFilePath -in $normalizedDeleteSet -or $_.TemplateFilePath -in $normalizedResolvedDeleteSet) { + return $true + } + if ($_.TemplateParameterFilePath -in $normalizedDeleteSet -or $_.TemplateParameterFilePath -in $normalizedResolvedDeleteSet) { + return $true + } + # Exclude file associations where the condition is true + -not ( + $_ -and + ($null -ne $_.DeploymentStackTemplateFilePath -and + ($_.DeploymentStackTemplateFilePath -notin $deleteSet -or $_.DeploymentStackTemplateFilePath -notin ($deleteSet | Resolve-Path).Path)) + ) + } # 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 @@ -534,12 +597,13 @@ #Starting deployment $WhatIfPreference = $WhatIfPreferenceState - $uniqueProperties = 'Scope', 'DeploymentName', 'TemplateFilePath', 'TemplateParameterFilePath' + $uniqueProperties = 'Scope', 'DeploymentName', 'TemplateFilePath', 'TemplateParameterFilePath', 'DeploymentStackTemplateFilePath', 'DeploymentStackSettings' $uniqueDeployment = $deploymentList | Select-Object $uniqueProperties -Unique | ForEach-Object { $TemplateFileContent = [System.IO.File]::ReadAllText($_.TemplateFilePath) $TemplateObject = ConvertFrom-Json $TemplateFileContent -AsHashtable $_ | Add-Member -MemberType NoteProperty -Name 'TemplateObject' -Value $TemplateObject -PassThru } + $deploymentResult = @() if ($uniqueDeployment) { @@ -557,6 +621,29 @@ # 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 + # Process each target and evaluate if DeploymentStackSettings exist + # If they do, create a temporary file for each target to enable parallel file access, due to DeploymentStack does not support -TemplateObject + foreach ($target in $targets) { + if ($null -ne $target.DeploymentStackSettings) { + $pathHash = [System.BitConverter]::ToString((New-Object System.Security.Cryptography.SHA256Managed).ComputeHash([System.Text.Encoding]::UTF8.GetBytes($target.TemplateFilePath))).Replace("-", "").ToLower() + $tempPath = [System.IO.Path]::GetTempPath() + $pathGuid = ((New-Guid).Guid).Replace("-", "") + $tempDeploymentFilePath = Join-Path $tempPath "$pathHash-$pathGuid.json" + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Deployment.TemporaryDeploymentStackTemplateFilePath.Create' -LogStringValues $target.TemplateFilePath + # Remove lingering files from previous run + if (Test-Path -Path $tempDeploymentFilePath) { + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Deployment.TemporaryDeploymentStackTemplateFilePath.Exist' -LogStringValues $tempDeploymentFilePath, $target.TemplateFilePath + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Deployment.TemporaryDeploymentStackTemplateFilePath.Remove' -LogStringValues $tempDeploymentFilePath + Remove-Item -Path $tempDeploymentFilePath -Force -ErrorAction SilentlyContinue -WhatIf:$false + } + if (-not (Test-Path -Path ($tempDeploymentFilePath))) { + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Invoke-AzOpsPush.Deployment.TemporaryDeploymentStackTemplateFilePath.New' -LogStringValues $tempDeploymentFilePath, $target.TemplateFilePath + New-Item -Path $tempDeploymentFilePath -WhatIf:$false | Out-Null + $target.TemplateObject | ConvertTo-Json -Depth 100 | Out-File -FilePath $tempDeploymentFilePath -Force -WhatIf:$false + $target | Add-Member -MemberType NoteProperty -Name 'TemporaryTemplateFilePath' -Value $tempDeploymentFilePath + } + } + } # Prepare Input Data for parallel processing $runspaceData = @{ AzOpsPath = "$($script:ModuleRoot)\AzOps.psd1" @@ -617,9 +704,11 @@ 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 ($WhatIfPreference) { + #Process deploymentResult and output result + foreach ($result in $deploymentResult) { + Set-AzOpsWhatIfOutput -FilePath $result.filePath -ParameterFilePath $result.parameterFilePath -Results $result.results -DeploymentStackTemplateFilePath $result.deploymentStackTemplateFilePath + } } } } diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 new file mode 100644 index 00000000..7a6d548e --- /dev/null +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -0,0 +1,365 @@ +function Get-AzOpsDeploymentStackSetting { + + <# + .SYNOPSIS + Identifies and resolves the deployment stack configuration for a given template file, ensuring proper handling of excluded files and stack settings. + .DESCRIPTION + Processes a specified template file path to determine its associated deployment stack configuration. + It checks for metadata, file variants, and exclusion patterns to identify whether the file is part of a deployment stack. + If a deployment stack is found, it retrieves and returns the stack's settings and template file path. + The function also handles exclusions defined in the stack configuration and logs relevant messages for debugging and tracing purposes. + .PARAMETER TemplateFilePath + The file path of the template to be processed. This should point to a JSON or Bicep file that may be part of a deployment stack. + .PARAMETER ParameterTemplateFilePath + the file path of an optional parameter template file associated with the TemplateFilePath. + .PARAMETER ScopeObject + An optional object that specifies the deployment scope, such as ResourceGroup, Subscription, or ManagementGroup. + .PARAMETER ReverseLookup + Indicates whether the function should perform a reverse lookup to identify the associated template file(s) for a given deployment stack file. + When specified, the function attempts to resolve and return the template file paths that are part of the deployment stack configuration. + .EXAMPLE + > $result = Get-AzOpsDeploymentStackSetting -TemplateFilePath "C:\Templates\example.bicep" -ScopeObject (New-AzOpsScope -Path C:\Templates\example.bicep) + > $result + + DeploymentStackTemplateFilePath : C:\Templates\example.deploymentStacks.json + DeploymentStackSettings : @{property1=value1; property2=value2} + ReverseLookupTemplateFilePath : + + .EXAMPLE + > $result = Get-AzOpsDeploymentStackSetting -TemplateFilePath "C:\Templates\.deploymentStacks.json" -ReverseLookup + > $result + + DeploymentStackTemplateFilePath : + DeploymentStackSettings : + ReverseLookupTemplateFilePath : {C:\Templates\example1.bicep, C:\Templates\example2.json} + #> + + #region Parameters + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true, ValueFromPipeline = $true)] + [string] + $TemplateFilePath, + [Parameter(ValueFromPipeline = $true)] + [string] + $ParameterTemplateFilePath, + [Parameter(ValueFromPipeline = $true)] + [object] + $ScopeObject, + [Parameter(ValueFromPipeline = $true)] + [switch] + $ReverseLookup + ) + #endregion + + begin { + #region Helper Functions + function Get-AzOpsDeploymentStackSettingReverseLookup { + + <# + .SYNOPSIS + Performs a reverse lookup to identify associated template files for a deployment stack. + .DESCRIPTION + This function checks if the provided template file is a root stack file and retrieves all associated + Bicep and JSON files in the same folder, excluding `.deploymentStacks.json` files. + .PARAMETER TemplateFilePath + The path to the template file being processed. + .PARAMETER result + A PSCustomObject to store the resolved file paths. + .OUTPUTS + Updates the result object with the resolved file paths. + #> + + param ( + [string] + $TemplateFilePath, + [PSCustomObject] + $result + ) + + # Check if the file is a root stack file by matching its name + if ((Split-Path -Path $TemplateFilePath -Leaf) -eq '.deploymentStacks.json') { + # This is a root stack file + $folderPath = Split-Path -Path $TemplateFilePath -Parent + $folderPathLookup = Join-Path -Path $folderPath -ChildPath '*' + + # Retrieve all Bicep and JSON files in the folder + $allTemplateFiles = Get-ChildItem -Path $folderPathLookup -File -Include *.bicep, *.json -Exclude *.deploymentStacks.json | Select-Object -ExpandProperty FullName + $nonAzOpsFiles = @() + + foreach ($file in $allTemplateFiles) { + if ($file.EndsWith('.json')) { + # Check if the JSON file has AzOps metadata + $fileContent = Get-Content -Path $file | ConvertFrom-Json -AsHashtable + if ($fileContent.metadata._generator.name -eq "AzOps" -or ($fileContent.Keys -contains "`$schema" -and $fileContent.parameters.input.value)) { + Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStack.Metadata.AzOps' -LogStringValues $file + } + else { + $nonAzOpsFiles += $file + } + } + else { + $nonAzOpsFiles += $file + } + } + + # Update the result object with the resolved file paths + $result.ReverseLookupTemplateFilePath = $nonAzOpsFiles + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.ReverseLookup.TemplateFilePath' -LogStringValues $TemplateFilePath, $result.ReverseLookupTemplateFilePath + return $result + } + elseif ($TemplateFilePath.EndsWith('.deploymentStacks.json') -and (Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and $TemplateFilePath.Split('.')[-3] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','')) { + # Handle parameter template stack files if AllowMultipleTemplateParameterFiles is true + if (Test-Path -Path ($TemplateFilePath -replace '\.deploymentStacks.json$', '.bicepparam')) { + $result.ReverseLookupTemplateFilePath = $TemplateFilePath -replace '\.deploymentStacks.json$', '.bicepparam' + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.ReverseLookup.TemplateFilePath' -LogStringValues $TemplateFilePath, $result.ReverseLookupTemplateFilePath + return $result + } + elseif (Test-Path -Path ($TemplateFilePath -replace '\.deploymentStacks.json$', '.parameters.json')) { + $result.ReverseLookupTemplateFilePath = $TemplateFilePath -replace '\.deploymentStacks.json$', '.parameters.json' + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.ReverseLookup.TemplateFilePath' -LogStringValues $TemplateFilePath, $result.ReverseLookupTemplateFilePath + return $result + } + } + else { + # Handle dedicated template stack files + if (Test-Path ($TemplateFilePath -replace '\.deploymentStacks.json$', '.bicep')) { + $result.ReverseLookupTemplateFilePath = $TemplateFilePath -replace '\.deploymentStacks.json$', '.bicep' + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.ReverseLookup.TemplateFilePath' -LogStringValues $TemplateFilePath, $result.ReverseLookupTemplateFilePath + return $result + } + if (Test-Path ($TemplateFilePath -replace '\.json$', '.json')) { + $result.ReverseLookupTemplateFilePath = $TemplateFilePath -replace '\.json$', '.json' + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.ReverseLookup.TemplateFilePath' -LogStringValues $TemplateFilePath, $result.ReverseLookupTemplateFilePath + return $result + } + } + } + function Get-AzOpsDeploymentStackFile { + + <# + .SYNOPSIS + Resolves the deployment stack configuration for a given template file by identifying and processing the associated stack file. + .DESCRIPTION + The `Get-AzOpsDeploymentStackFile` function evaluates a specified template file and its associated stack file to determine the deployment stack configuration. + It checks for the existence of the stack file, parses its content, and filters valid parameters based on the deployment scope. + The function also handles exclusions defined in the stack file, ensuring that excluded files are not processed as part of the deployment stack. + If the stack file is found and valid, the function returns the stack's settings and template file path. + .PARAMETER StackPath + The file path of the deployment stack file to be evaluated. This file typically contains configuration settings for the deployment stack. + .PARAMETER TemplateFilePath + The file path of the template file being processed. This should point to a JSON or Bicep file that may be part of a deployment stack. + .PARAMETER ParameterTemplateFilePath + The file path of an optional parameter template file associated with the TemplateFilePath. This is used when multiple template parameter files are allowed. + .PARAMETER FileVariants + A switch parameter indicating whether to check for file variants (e.g., `.bicep` and `.json` versions of the template file) when evaluating exclusions. + .PARAMETER result + A PSCustomObject used to store the resolved deployment stack settings and template file path. This object is updated and returned by the function. + .PARAMETER ScopeObject + An object specifying the deployment scope, such as ResourceGroup, Subscription, or ManagementGroup. This determines the type of deployment stack command to use. + .OUTPUTS + PSCustomObject + Returns a custom object containing the following properties: + - DeploymentStackTemplateFilePath: The file path of the resolved deployment stack file. + - DeploymentStackSettings: A hashtable of filtered parameters from the stack file. + - ReverseLookupTemplateFilePath: Null (not used in this function). + #> + + param ( + [string] + $StackPath, + [Parameter(Mandatory=$true, ValueFromPipeline = $true)] + [string] + $TemplateFilePath, + [string] + $ParameterTemplateFilePath, + [switch] + $FileVariants, + [PSCustomObject] + $result, + [object] + $ScopeObject + ) + if ($StackPath) { + # Check if the stack file exists + if (Test-Path $StackPath) { + try { + # Read and parse the JSON content from the stack file + $stackContent = Get-Content -Path $StackPath -Raw | ConvertFrom-Json -AsHashtable + } + catch { + # Handle errors during JSON conversion or other operations + Write-AzOpsMessage -LogLevel Error -LogString 'Get-AzOpsDeploymentStackSetting.Setting.Error' -LogStringValues $StackPath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $StackPath + $result.DeploymentStackSettings = $null + return $result + } + if ($ScopeObject.ResourceGroup -and $ScopeObject.ResourceGroup -ne "") { + $command = "New-AzResourceGroupDeploymentStack" + } + elseif ($ScopeObject.Subscription -and $ScopeObject.Subscription -ne "") { + $command = "New-AzSubscriptionDeploymentStack" + } + elseif ($ScopeObject.ManagementGroup -and $ScopeObject.ManagementGroup -ne "") { + $command = "New-AzManagementGroupDeploymentStack" + } + else { + Write-AzOpsMessage -LogLevel Error -LogString 'Get-AzOpsDeploymentStackSetting.Scope.Error' -LogStringValues $StackPath, $TemplateFilePath + return $result + } + $allowedSettings = @( + "ActionOnUnmanage", + "DenySettingsMode", + "DenySettingsExcludedPrincipal", + "DenySettingsExcludedAction", + "DenySettingsApplyToChildScopes", + "BypassStackOutOfSyncError", + "Location" + ) + # Get the valid parameters for the command + $validParameters = (Get-Command $command).Parameters.Keys | Where-Object { $_ -in $allowedSettings } + + # Initialize an empty hashtable to store the filtered parameters + $finalParameters = @{} + + # Iterate over the keys in the stack content + foreach ($key in $stackContent.Keys) { + # Check if the key is a valid parameter + if ($validParameters -contains $key) { + # Retrieve the parameter metadata + $parameterMetadata = (Get-Command $command).Parameters[$key] + # Check if the parameter type is an enum + $allowedValues = @() + if ($parameterMetadata.ParameterType.IsEnum) { + $allowedValues = $parameterMetadata.ParameterType.GetEnumNames() + } + # Validate the value from $stackContent + if ($allowedValues.Count -gt 0 -and -not ($allowedValues -contains $stackContent[$key])) { + Write-AzOpsMessage -LogLevel Error -LogString 'Get-AzOpsDeploymentStackSetting.Parameter.Error' -LogStringValues $stackContent[$key], $key, $StackPath, $command + throw + } + # Add the key-value pair to the prepared parameters + $finalParameters[$key] = $stackContent[$key] + } + } + # Handle excluded files + if ($stackContent.excludedAzOpsFiles -and ($stackContent.excludedAzOpsFiles).Count -gt 0 -and $FileVariants) { + # Generate a list of potential file names to check + $fileName = Split-Path -Path $TemplateFilePath -Leaf + $checkFileVariants = @($fileName) + if ($fileName -like '*.json') { + $checkFileVariants += $fileName -replace '\.json$', '.bicep' + } + elseif ($fileName -like '*.bicep') { + $checkFileVariants += $fileName -replace '\.bicep$', '.json' + } + # Check if the parameter template file ends with 'parameters.json', if multiple template parameter files are allowed, and the file name matches the configured suffix. + if ($ParameterTemplateFilePath.EndsWith('parameters.json') -and (Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and $ParameterTemplateFilePath.Split('.')[-3] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','') ) { + # Extract the parameter file name and add it to the list of file variants + $parameterFileName = Split-Path -Path $ParameterTemplateFilePath -Leaf + $checkFileVariants += $parameterFileName + $checkFileVariants += $parameterFileName -replace '\.parameters.json$', '.bicepparam' + } + $matchedFile = $checkFileVariants | Where-Object { $stackContent.excludedAzOpsFiles -eq $_ } + if ($matchedFile) { + # Log the exclusion + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $StackPath, $matchedFile + } + else { + # Update the result object if the file is not excluded + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $StackPath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $StackPath + $result.DeploymentStackSettings = $finalParameters + } + } + else { + # Update the result object if there are no excluded files + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $StackPath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $StackPath + $result.DeploymentStackSettings = $finalParameters + } + return $result + } + else { + # Log if the stack file does not exist + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' -LogStringValues $StackPath + return $result + } + } + else { + if ($ParameterTemplateFilePath.EndsWith('parameters.json') -and (Get-PSFConfigValue -FullName 'AzOps.Core.AllowMultipleTemplateParameterFiles') -eq $true -and $ParameterTemplateFilePath.Split('.')[-3] -match $(Get-PSFConfigValue -FullName 'AzOps.Core.MultipleTemplateParameterFileSuffix').Replace('.','') -and (Test-Path -Path ($ParameterTemplateFilePath -replace '\.parameters.json$', '.deploymentStacks.json'))) { + $stackParameterTemplatePath = $ParameterTemplateFilePath -replace '\.parameters.json$', '.deploymentStacks.json' + $evaluateStackTemplatePath = Get-AzOpsDeploymentStackFile -StackPath $stackParameterTemplatePath -TemplateFilePath $TemplateFilePath -ParameterTemplateFilePath $ParameterTemplateFilePath -result $result -ScopeObject $ScopeObject + if ($evaluateStackTemplatePath.DeploymentStackTemplateFilePath) { + $result = $evaluateStackTemplatePath + return $result + } + } + else { + $stackTemplatePath = ($TemplateFilePath -replace '\.json$', '.deploymentStacks.json') + $evaluateStackTemplatePath = Get-AzOpsDeploymentStackFile -StackPath $stackTemplatePath -TemplateFilePath $TemplateFilePath -ParameterTemplateFilePath $ParameterTemplateFilePath -FileVariants -result $result -ScopeObject $ScopeObject + if ($evaluateStackTemplatePath.DeploymentStackTemplateFilePath) { + $result = $evaluateStackTemplatePath + return $result + } + else { + $parentStackPath = Join-Path -Path (Split-Path -Path $TemplateFilePath) -ChildPath ".deploymentStacks.json" + $evaluateParentStackPath = Get-AzOpsDeploymentStackFile -StackPath $parentStackPath -TemplateFilePath $TemplateFilePath -ParameterTemplateFilePath $ParameterTemplateFilePath -FileVariants -result $result -ScopeObject $ScopeObject + if ($evaluateParentStackPath.DeploymentStackTemplateFilePath) { + $result = $evaluateParentStackPath + return $result + } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' -LogStringValues $TemplateFilePath + return $result + } + } + } + } + } + #endregion + # Initialize the result object with default null values + $result = [PSCustomObject] @{ + DeploymentStackTemplateFilePath = $null + DeploymentStackSettings = $null + ReverseLookupTemplateFilePath = $null + } + } + + process { + # Handle ReverseLookup Mode + if ($ReverseLookup) { + $validatedResult = Get-AzOpsDeploymentStackSettingReverseLookup -TemplateFilePath $TemplateFilePath -result $result + if ($validatedResult) { + $result = $validatedResult + return $result + } + } + if (-not $TemplateFilePath.EndsWith('.json') -or ($TemplateFilePath.EndsWith('parameters.json')) -or $TemplateFilePath.EndsWith('.deploymentStacks.json')) { + Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoJson' -LogStringValues $TemplateFilePath + return + } + try { + # Process the template file to determine its deployment stack configuration + $templateContent = Get-Content -Path $TemplateFilePath -Raw | ConvertFrom-Json -AsHashtable + if ($templateContent.metadata._generator.name -eq "AzOps") { + Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStack.Metadata.AzOps' -LogStringValues $TemplateFilePath + return + } + } + catch { + Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsDeploymentStackSetting.Template.Error' -LogStringValues $TemplateFilePath + return + } + # Process the call + $evaluateStackTemplatePath = Get-AzOpsDeploymentStackFile -TemplateFilePath $TemplateFilePath -ParameterTemplateFilePath $ParameterTemplateFilePath -result $result -ScopeObject $ScopeObject + if ($evaluateStackTemplatePath.DeploymentStackTemplateFilePath) { + $result = $evaluateStackTemplatePath + return $result + } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' -LogStringValues $TemplateFilePath + return $result + } + } +} \ No newline at end of file diff --git a/src/internal/functions/Get-AzOpsPim.ps1 b/src/internal/functions/Get-AzOpsPim.ps1 index 8caa0b67..53b8c6e2 100644 --- a/src/internal/functions/Get-AzOpsPim.ps1 +++ b/src/internal/functions/Get-AzOpsPim.ps1 @@ -11,7 +11,7 @@ [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [Object] + [object] $ScopeObject, [Parameter(Mandatory = $true)] $StatePath diff --git a/src/internal/functions/Get-AzOpsPolicyExemption.ps1 b/src/internal/functions/Get-AzOpsPolicyExemption.ps1 index 6e5a8bb5..4d2a7c81 100644 --- a/src/internal/functions/Get-AzOpsPolicyExemption.ps1 +++ b/src/internal/functions/Get-AzOpsPolicyExemption.ps1 @@ -22,7 +22,7 @@ [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [Object] + [object] $ScopeObject, [Parameter(Mandatory = $false)] [object] diff --git a/src/internal/functions/Get-AzOpsResource.ps1 b/src/internal/functions/Get-AzOpsResource.ps1 index 17b1b95e..08254846 100644 --- a/src/internal/functions/Get-AzOpsResource.ps1 +++ b/src/internal/functions/Get-AzOpsResource.ps1 @@ -15,11 +15,17 @@ param ( [Parameter(Mandatory = $true)] [AzOpsScope] - $ScopeObject + $ScopeObject, + + [string] + $DeploymentStackName ) process { Set-AzOpsContext -ScopeObject $ScopeObject + if ($DeploymentStackName -and $ScopeObject.Resource -ne 'deploymentStacks') { + $ScopeObject.Resource = 'deploymentStacks' + } try { switch ($ScopeObject.Resource) { # Check if the resource exist @@ -44,6 +50,34 @@ 'resourceGroups' { $resource = Get-AzResourceGroup -Id $scopeObject.Scope -ErrorAction SilentlyContinue } + 'deploymentStacks' { + if ($ScopeObject.ResourceGroup) { + if ($DeploymentStackName) { + $resource = Get-AzResourceGroupDeploymentStack -Name $DeploymentStackName -ResourceGroupName $ScopeObject.ResourceGroup -ErrorAction SilentlyContinue + } + else { + $resource = Get-AzResourceGroupDeploymentStack -ResourceId $ScopeObject.Scope -ErrorAction SilentlyContinue + } + + } + elseif ($ScopeObject.Subscription) { + if ($DeploymentStackName) { + $resource = Get-AzSubscriptionDeploymentStack -Name $DeploymentStackName -ErrorAction SilentlyContinue + } + else { + $resource = Get-AzSubscriptionDeploymentStack -ResourceId $ScopeObject.Scope -ErrorAction SilentlyContinue + } + + } + elseif ($ScopeObject.ManagementGroup) { + if ($DeploymentStackName) { + $resource = Get-AzManagementGroupDeploymentStack -Name $DeploymentStackName -ManagementGroupId $ScopeObject.ManagementGroup -ErrorAction SilentlyContinue + } + else { + $resource = Get-AzManagementGroupDeploymentStack -ResourceId $ScopeObject.Scope -ErrorAction SilentlyContinue + } + } + } default { $resource = Get-AzResource -ResourceId $ScopeObject.Scope -ErrorAction SilentlyContinue } diff --git a/src/internal/functions/Get-AzOpsResourceDefinition.ps1 b/src/internal/functions/Get-AzOpsResourceDefinition.ps1 index c969f821..3591592f 100644 --- a/src/internal/functions/Get-AzOpsResourceDefinition.ps1 +++ b/src/internal/functions/Get-AzOpsResourceDefinition.ps1 @@ -390,7 +390,7 @@ } } if (Test-Path -Path $tempExportPath) { - Remove-Item -Path $tempExportPath + Remove-Item -Path $tempExportPath -Force } } Clear-PSFMessage diff --git a/src/internal/functions/Get-AzOpsResourceLock.ps1 b/src/internal/functions/Get-AzOpsResourceLock.ps1 index d5709c52..c413af57 100644 --- a/src/internal/functions/Get-AzOpsResourceLock.ps1 +++ b/src/internal/functions/Get-AzOpsResourceLock.ps1 @@ -17,7 +17,7 @@ [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [Object] + [object] $ScopeObject, [Parameter(Mandatory = $true)] $StatePath diff --git a/src/internal/functions/Get-AzOpsRole.ps1 b/src/internal/functions/Get-AzOpsRole.ps1 index e47a967d..649ebbc3 100644 --- a/src/internal/functions/Get-AzOpsRole.ps1 +++ b/src/internal/functions/Get-AzOpsRole.ps1 @@ -11,7 +11,7 @@ [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [Object] + [object] $ScopeObject, [Parameter(Mandatory = $true)] $StatePath diff --git a/src/internal/functions/Get-AzOpsRoleAssignment.ps1 b/src/internal/functions/Get-AzOpsRoleAssignment.ps1 index 75329232..292408c0 100644 --- a/src/internal/functions/Get-AzOpsRoleAssignment.ps1 +++ b/src/internal/functions/Get-AzOpsRoleAssignment.ps1 @@ -15,7 +15,7 @@ [CmdletBinding()] param ( [parameter(Mandatory = $true, ValueFromPipeline = $true)] - [Object] + [object] $ScopeObject ) diff --git a/src/internal/functions/Get-AzOpsRoleDefinition.ps1 b/src/internal/functions/Get-AzOpsRoleDefinition.ps1 index 0413b2e4..3e0e70df 100644 --- a/src/internal/functions/Get-AzOpsRoleDefinition.ps1 +++ b/src/internal/functions/Get-AzOpsRoleDefinition.ps1 @@ -15,7 +15,7 @@ [CmdletBinding()] param ( [parameter(Mandatory = $true, ValueFromPipeline = $true)] - [Object] + [object] $ScopeObject ) diff --git a/src/internal/functions/Get-AzOpsRoleEligibilityScheduleRequest.ps1 b/src/internal/functions/Get-AzOpsRoleEligibilityScheduleRequest.ps1 index 7b34bb64..98400998 100644 --- a/src/internal/functions/Get-AzOpsRoleEligibilityScheduleRequest.ps1 +++ b/src/internal/functions/Get-AzOpsRoleEligibilityScheduleRequest.ps1 @@ -15,7 +15,7 @@ [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [Object] + [object] $ScopeObject ) diff --git a/src/internal/functions/Invoke-AzOpsRestMethod.ps1 b/src/internal/functions/Invoke-AzOpsRestMethod.ps1 index c991bc98..8a5100ac 100644 --- a/src/internal/functions/Invoke-AzOpsRestMethod.ps1 +++ b/src/internal/functions/Invoke-AzOpsRestMethod.ps1 @@ -13,7 +13,7 @@ [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [Object] + [object] $Path, [Parameter(Mandatory = $true, ValueFromPipeline = $true)] $Method diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index fc6917a7..3219d198 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -34,10 +34,23 @@ filePath /root/managementgroup/subscription/resourcegroup/template.json parameterFilePath /root/managementgroup/subscription/resourcegroup/template.parameters.json results Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels.Deployments.PSWhatIfOperationResult + deployment Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels.PSDeploymentStack #> [CmdletBinding(SupportsShouldProcess = $true)] param ( + [Parameter(ValueFromPipelineByPropertyName = $true)] + [AllowEmptyString()] + [AllowNull()] + [string] + $DeploymentStackTemplateFilePath, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [AllowEmptyString()] + [AllowNull()] + [object] + $DeploymentStackSettings, + [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $DeploymentName = "azops-template-deployment", @@ -46,6 +59,12 @@ [string] $TemplateFilePath = (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate'), + [Parameter(ValueFromPipelineByPropertyName = $true)] + [AllowEmptyString()] + [AllowNull()] + [string] + $TemporaryTemplateFilePath, + [Parameter(ValueFromPipelineByPropertyName = $true)] [hashtable] $TemplateObject, @@ -76,7 +95,7 @@ #region Resolve Scope try { - if ($TemplateParameterFilePath) { + if ($TemplateParameterFilePath -and $TemplateFilePath -eq (Resolve-Path -Path (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate')).Path) { $scopeObject = New-AzOpsScope -Path $TemplateParameterFilePath -StatePath $StatePath -ErrorAction Stop -WhatIf:$false } else { @@ -108,36 +127,67 @@ #region Process Scope # Configure variables/parameters and the WhatIf/Deployment cmdlets to be used per scope $defaultDeploymentRegion = (Get-PSFConfigValue -FullName 'AzOps.Core.DefaultDeploymentRegion') - $parameters = @{ - 'TemplateObject' = $TemplateObject - 'SkipTemplateParameterPrompt' = $true - 'Location' = $defaultDeploymentRegion + if ($null -eq $DeploymentStackSettings) { + $parameters = @{ + 'TemplateObject' = $TemplateObject + 'SkipTemplateParameterPrompt' = $true + 'Location' = $defaultDeploymentRegion + } + } + elseif ($TemporaryTemplateFilePath) { + $parameters = @{ + 'TemplateFile' = $TemporaryTemplateFilePath + 'SkipTemplateParameterPrompt' = $true + 'Location' = $defaultDeploymentRegion + } + } + else { + $parameters = @{ + 'TemplateFile' = $TemplateFilePath + '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 $TemplateObject.resources[0].type -ne 'Microsoft.Resources/resourceGroups') { - Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.ResourceGroup.Processing' -LogStringValues $scopeObject -Target $scopeObject Set-AzOpsContext -ScopeObject $scopeObject $whatIfCommand = 'Get-AzResourceGroupDeploymentWhatIfResult' - $deploymentCommand = 'New-AzResourceGroupDeployment' + if ($null -ne $DeploymentStackSettings) { + $deploymentCommand = 'New-AzResourceGroupDeploymentStack' + Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.ResourceGroupDeploymentStack.Processing' -LogStringValues $scopeObject, $DeploymentStackTemplateFilePath -Target $scopeObject + } else { + $deploymentCommand = 'New-AzResourceGroupDeployment' + Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.ResourceGroup.Processing' -LogStringValues $scopeObject -Target $scopeObject + } $parameters.ResourceGroupName = $scopeObject.resourcegroup $parameters.Remove('Location') } # Subscriptions elseif ($scopeObject.subscription) { - Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.Subscription.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject Set-AzOpsContext -ScopeObject $scopeObject $whatIfCommand = 'Get-AzSubscriptionDeploymentWhatIfResult' - $deploymentCommand = 'New-AzDeployment' + if ($null -ne $DeploymentStackSettings) { + $deploymentCommand = 'New-AzSubscriptionDeploymentStack' + Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.SubscriptionDeploymentStack.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject, $DeploymentStackTemplateFilePath -Target $scopeObject + } else { + $deploymentCommand = 'New-AzDeployment' + Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.Subscription.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject + } } # Management Groups elseif ($scopeObject.managementGroup -and (-not ($scopeObject.StatePath).StartsWith('azopsscope-assume-new-resource_'))) { - Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.ManagementGroup.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject $parameters.ManagementGroupId = $scopeObject.managementgroup $whatIfCommand = 'Get-AzManagementGroupDeploymentWhatIfResult' - $deploymentCommand = 'New-AzManagementGroupDeployment' + if ($null -ne $DeploymentStackSettings) { + $deploymentCommand = 'New-AzManagementGroupDeploymentStack' + Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.ManagementGroupDeploymentStack.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject, $DeploymentStackTemplateFilePath -Target $scopeObject + } else { + $deploymentCommand = 'New-AzManagementGroupDeployment' + Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.ManagementGroup.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject + } } # Tenant deployments elseif ($scopeObject.type -eq 'root' -and $scopeObject.scope -eq '/') { @@ -149,7 +199,7 @@ elseif ($scopeObject.managementGroup -and (($scopeObject.StatePath).StartsWith('azopsscope-assume-new-resource_'))) { $resourceScopeFileContent = Get-Content -Path $addition | ConvertFrom-Json -Depth 100 $resource = ($resourceScopeFileContent.resources | Where-Object {$_.type -eq 'Microsoft.Management/managementGroups'} | Select-Object -First 1) - $pathDir = (Get-Item -Path $addition).Directory | Resolve-Path -Relative + $pathDir = (Get-Item -Path $addition -Force).Directory | Resolve-Path -Relative if ((Get-PSFConfigValue -FullName 'AzOps.Core.AutoGeneratedTemplateFolderPath') -ne '.') { $pathDir = Split-Path -Path $pathDir -Parent } @@ -158,7 +208,7 @@ # Validate parent existence with content parent scope, statepath and name match, determines file location match deployment scope if ($parentDirScopeObject -and $parentIdScope -and $parentDirScopeObject.Scope -eq $parentIdScope.Scope -and $parentDirScopeObject.StatePath -eq $parentIdScope.StatePath -and $parentDirScopeObject.Name -eq $parentIdScope.Name) { # Validate directory name match resource information - if ((Get-Item -Path $pathDir).Name -eq "$($resource.properties.displayName) ($($resource.name))") { + if ((Get-Item -Path $pathDir -Force).Name -eq "$($resource.properties.displayName) ($($resource.name))") { Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.Root.Processing' -LogStringValues $defaultDeploymentRegion, $scopeObject -Target $scopeObject $whatIfCommand = 'Get-AzTenantDeploymentWhatIfResult' $deploymentCommand = 'New-AzTenantDeployment' @@ -184,6 +234,7 @@ $deploymentResult = [PSCustomObject]@{ filePath = '' parameterFilePath = '' + deploymentStackTemplateFilePath = $DeploymentStackTemplateFilePath results = '' deployment = '' } @@ -193,58 +244,55 @@ if ($WhatifExcludedChangeTypes) { $parameters.ExcludeChangeType = $WhatifExcludedChangeTypes } - # Get predictive deployment results from WhatIf API - $results = & $whatIfCommand @parameters -ErrorAction Continue -ErrorVariable resultsError - if ($resultsError) { - $resultsErrorMessage = $resultsError.exception.InnerException.Message - # Ignore errors for bicep modules - if ($resultsErrorMessage -match 'https://aka.ms/resource-manager-parameter-files' -and $true -eq $bicepTemplate) { - Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.TemplateParameterError' -Target $scopeObject - $invalidTemplate = $true - } - # Handle WhatIf prediction errors - elseif ($resultsErrorMessage -match 'DeploymentWhatIfResourceError' -and $resultsErrorMessage -match "The request to predict template deployment") { - Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.WhatIfWarning' -LogStringValues $resultsErrorMessage -Target $scopeObject - if ($parameters.TemplateParameterFile) { - $deploymentResult.filePath = $parameters.TemplateFile - $deploymentResult.parameterFilePath = $parameters.TemplateParameterFile + # Code to execute only when -WhatIf:$true is passed + if ($WhatIfPreference) { + # Get predictive deployment results from WhatIf API + $results = & $whatIfCommand @parameters -ErrorAction Continue -ErrorVariable resultsError + if ($resultsError) { + $resultsErrorMessage = $resultsError.exception.InnerException.Message + # Ignore errors for bicep modules + if ($resultsErrorMessage -match 'https://aka.ms/resource-manager-parameter-files' -and $true -eq $bicepTemplate) { + Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.TemplateParameterError' -Target $scopeObject + $invalidTemplate = $true + } + # Handle WhatIf prediction errors + elseif ($resultsErrorMessage -match 'DeploymentWhatIfResourceError' -and $resultsErrorMessage -match "The request to predict template deployment") { + Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.WhatIfWarning' -LogStringValues $resultsErrorMessage -Target $scopeObject $deploymentResult.results = ('{0}WhatIf prediction failed with error - validate changes manually before merging:{0}{1}' -f [environment]::NewLine, $resultsErrorMessage) } else { - $deploymentResult.filePath = $parameters.TemplateFile - $deploymentResult.results = ('{0}WhatIf prediction failed with error - validate changes manually before merging:{0}{1}' -f [environment]::NewLine, $resultsErrorMessage) + Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.WhatIfWarning' -LogStringValues $resultsErrorMessage -Target $scopeObject + throw $resultsErrorMessage } } - else { - Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.WhatIfWarning' -LogStringValues $resultsErrorMessage -Target $scopeObject - throw $resultsErrorMessage - } - } - elseif ($results.Error) { - Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.TemplateError' -LogStringValues $TemplateFilePath -Target $scopeObject - return - } - else { - Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.WhatIfResults' -LogStringValues ($results | Out-String) -Target $scopeObject - Write-AzOpsMessage -LogLevel InternalComment -LogString 'New-AzOpsDeployment.WhatIfFile' -Target $scopeObject - if ($parameters.TemplateParameterFile) { - $deploymentResult.filePath = $TemplateFilePath - $deploymentResult.parameterFilePath = $parameters.TemplateParameterFile - $deploymentResult.results = $results + elseif ($results.Error) { + Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsDeployment.TemplateError' -LogStringValues $TemplateFilePath -Target $scopeObject + return } else { - $deploymentResult.filePath = $TemplateFilePath - $deploymentResult.results = $results + Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.WhatIfResults' -LogStringValues ($results | Out-String) -Target $scopeObject + Write-AzOpsMessage -LogLevel InternalComment -LogString 'New-AzOpsDeployment.WhatIfFile' -Target $scopeObject } } # Remove ExcludeChangeType parameter as it doesn't exist for deployment cmdlets if ($parameters.ExcludeChangeType) { $parameters.Remove('ExcludeChangeType') } + if ($deploymentCommand -match 'DeploymentStack$') { + # Add Force parameter for deploymentStack cmdlets + $DeploymentStackSettings['Force'] = $true + $parameters += $DeploymentStackSettings + } $parameters.Name = $DeploymentName - if ($PSCmdlet.ShouldProcess("Start $($scopeObject.type) Deployment with $deploymentCommand?")) { + if ($PSCmdlet.ShouldProcess("Start $($scopeObject.type) Deployment with $deploymentCommand")) { if (-not $invalidTemplate) { - $deploymentResult.deployment = & $deploymentCommand @parameters + try { + Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.Deployment' -LogStringValues $deploymentCommand, $($parameters | Out-String), $scopeObject.Scope + $deploymentResult.deployment = & $deploymentCommand @parameters -ErrorAction Stop + } + catch { + throw $_ + } } } else { @@ -252,8 +300,26 @@ Write-AzOpsMessage -LogLevel InternalComment -LogString 'New-AzOpsDeployment.SkipDueToWhatIf' } } + #Cleanup + if ($TemporaryTemplateFilePath) { + Write-AzOpsMessage -LogLevel InternalComment -LogString 'New-AzOpsDeployment.TemporaryDeploymentStackTemplateFilePath.Remove' -LogStringValues $TemporaryTemplateFilePath + Remove-Item -Path $TemporaryTemplateFilePath -Force -ErrorAction SilentlyContinue -WhatIf:$false + $parameters.TemplateFile = $TemplateFilePath + } #Return if ($deploymentResult) { + if ($parameters.TemplateParameterFile) { + $deploymentResult.parameterFilePath = $parameters.TemplateParameterFile + } + if ($parameters.TemplateFile) { + $deploymentResult.filePath = $parameters.TemplateFile + } + else { + $deploymentResult.filePath = $TemplateFilePath + } + if ($deploymentResult.results -eq '') { + $deploymentResult.results = $results + } return $deploymentResult } } diff --git a/src/internal/functions/New-AzOpsStateDeployment.ps1 b/src/internal/functions/New-AzOpsStateDeployment.ps1 index 20339ece..a765dcac 100644 --- a/src/internal/functions/New-AzOpsStateDeployment.ps1 +++ b/src/internal/functions/New-AzOpsStateDeployment.ps1 @@ -34,7 +34,7 @@ process { Write-AzOpsMessage -LogLevel Important -LogString 'New-AzOpsStateDeployment.Processing' -LogStringValues $FileName - $scopeObject = New-AzOpsScope -Path (Get-Item -Path $FileName).FullName -StatePath $StatePath + $scopeObject = New-AzOpsScope -Path (Get-Item -Path $FileName -Force).FullName -StatePath $StatePath if (-not $scopeObject.Type) { Write-AzOpsMessage -LogLevel Warning -LogString 'New-AzOpsStateDeployment.InvalidScope' -LogStringValues $FileName -Target $scopeObject diff --git a/src/internal/functions/Remove-AzOpsDeployment.ps1 b/src/internal/functions/Remove-AzOpsDeployment.ps1 index 2f58b28e..4f273a81 100644 --- a/src/internal/functions/Remove-AzOpsDeployment.ps1 +++ b/src/internal/functions/Remove-AzOpsDeployment.ps1 @@ -31,6 +31,14 @@ [bool] $CustomTemplateResourceDeletion = (Get-PSFConfigValue -FullName 'AzOps.Core.CustomTemplateResourceDeletion'), + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string] + $DeploymentStackTemplateFilePath, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [object] + $DeploymentStackSettings, + [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $DeploymentName = "azops-template-deployment", @@ -203,9 +211,14 @@ $TemplateFilePath = $TemplateParameterFilePath } #Deployment Name - $fileItem = Get-Item -Path $TemplateFilePath - $removeJobName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' - $removeJobName = "AzOps-RemoveResource-$removeJobName" + if ($null -ne $DeploymentStackSettings) { + $removeJobName = $DeploymentName + } + else { + $fileItem = Get-Item -Path $TemplateFilePath -Force + $removeJobName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' + $removeJobName = "AzOps-RemoveResource-$removeJobName" + } Write-AzOpsMessage -LogLevel Important -LogString 'Remove-AzOpsDeployment.Processing' -LogStringValues $removeJobName, $TemplateFilePath #region Parse Content @@ -305,7 +318,9 @@ if (-not $resourceToDelete) { 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 -Results $results -RemoveAzOpsFlag $true + if ($WhatIfPreference) { + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -RemoveAzOpsFlag $true + } return } if ($dependency) { @@ -313,18 +328,21 @@ if ($resource.Id -notin $deletionList.ScopeObject.Scope) { Write-AzOpsMessage -LogLevel Critical -LogString 'Remove-AzOpsDeployment.ResourceDependencyNotFound' -LogStringValues $resource.Id, $scopeObject.Scope $results = 'Missing resource dependency:{2}{0} for successful deletion of {1}.{2}{2}Please add dependent resource to pull request and retry.' -f $resource.Id, $scopeObject.scope, [environment]::NewLine - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true $dependencyMissing = [PSCustomObject]@{ dependencyMissing = $true } + if ($WhatIfPreference) { + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -RemoveAzOpsFlag $true + } } } } else { $results = 'What if successful:{1}Performing the operation:{1}Deletion of target resource {0}.' -f $scopeObject.scope, [environment]::NewLine Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results - Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true + if ($WhatIfPreference) { + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -RemoveAzOpsFlag $true + } } if ($dependencyMissing) { return $dependencyMissing @@ -332,8 +350,9 @@ elseif ($dependency) { $results = 'What if successful:{1}Performing the operation:{1}Deletion of target resource {0}.' -f $scopeObject.scope, [environment]::NewLine Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results - Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true + if ($WhatIfPreference) { + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -RemoveAzOpsFlag $true + } } if ($PSCmdlet.ShouldProcess("Remove $($scopeObject.Scope)?")) { $null = Remove-AzResourceRaw -ScopeObject $scopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath @@ -347,89 +366,121 @@ 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' + $allResults = @() + $retry = @() + # Check if DeploymentStackSettings exists + if ($null -ne $DeploymentStackSettings) { + # Check if the resource exists + $resource = Get-AzOpsResource -DeploymentStackName $removeJobName -ScopeObject $scopeObject -ErrorAction SilentlyContinue + if ($resource) { + $deploymentStackScopeObject = New-AzOpsScope -Scope $resource.Id -WhatIf:$false + $results = 'What if successful:{1}Performing the operation:{1}Deletion of Deployment Stack: {0}{1}Actions: resourcesCleanupAction: {2}, resourceGroupsCleanupAction: {3}, managementGroupsCleanupAction: {4}{1}Associated resources: {1}{5}' -f $deploymentStackScopeObject.Scope, [environment]::NewLine, $resource.resourcesCleanupAction, $resource.resourceGroupsCleanupAction, $resource.managementGroupsCleanupAction, ($resource.Resources.Id | Out-String) + $allResults += $results + Write-AzOpsMessage -LogLevel Verbose -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $results + # Check if the removal should be performed + if ($PSCmdlet.ShouldProcess("Remove $($deploymentStackScopeObject.Scope)?")) { + $removeAction = Remove-AzResourceRaw -ScopeObject $deploymentStackScopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath + # If removal failed, add to retry + if ($removeAction.Status -eq 'failed') { + $retry += $removeAction } } 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 + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.SkipDueToWhatIf' } - } - $baseTemplateCheck = $TemplateFilePath -replace '\.bicep$', '.json' - if ($TemplateParameterFilePath) { - $baseParameterCheck = $TemplateParameterFilePath -replace '\.bicepparam$', 'parameters.json' + else { + # Log warning if resource not found + Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $ScopeObject.Resource, $removeJobName + $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $change.FullyQualifiedResourceId, [environment]::NewLine + return } - 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 + } + else { + # 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 + $removalJobChanges = Set-AzOpsRemoveOrder -DeletionList $removalJob.results.Changes -Index { (New-AzOpsScope -Scope $_.FullyQualifiedResourceId -WhatIf:$false).Resource } + 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 + # 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' + } } - { $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 + 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 } } - # 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 + } + 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 + if ($WhatIfPreference) { + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -RemoveAzOpsFlag $true } + return } - 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 + } + $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 } } - 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 + if ($WhatIfPreference) { + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -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 } } #endregion remove resources diff --git a/src/internal/functions/Set-AzOpsRemoveOrder.ps1 b/src/internal/functions/Set-AzOpsRemoveOrder.ps1 index 1975c752..72399fc3 100644 --- a/src/internal/functions/Set-AzOpsRemoveOrder.ps1 +++ b/src/internal/functions/Set-AzOpsRemoveOrder.ps1 @@ -25,6 +25,7 @@ [string[]] $Priority = @( "locks", + "deploymentStacks", "policyExemptions", "policyAssignments", "policySetDefinitions", @@ -38,6 +39,10 @@ #Sort 'DeletionList' based on 'Priority' $deletionListSorted = $DeletionList | Sort-Object -Property { $resolvedIndex = & $Index + # Check if the item has a non-null DeploymentStackSettings + if ($null -ne $_.DeploymentStackSettings) { + $resolvedIndex = "deploymentStacks" + } $priorityIndex = $Priority.IndexOf($resolvedIndex) if ($priorityIndex -eq -1) { # Set a default priority for items not found in Priority diff --git a/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 b/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 index 171fa372..384a4cf1 100644 --- a/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 +++ b/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 @@ -17,6 +17,8 @@ Template File in scope of WhatIf .PARAMETER ParameterFilePath Parameter File in scope of WhatIf + .PARAMETER DeploymentStackTemplateFilePath + DeploymentStack File in scope of WhatIf .EXAMPLE > Set-AzOpsWhatIfOutput -Results $results > Set-AzOpsWhatIfOutput -Results $results -RemoveAzOpsFlag $true @@ -41,7 +43,10 @@ $FilePath, [Parameter(Mandatory = $false)] - $ParameterFilePath + $ParameterFilePath, + + [Parameter(Mandatory = $false)] + $DeploymentStackTemplateFilePath ) process { @@ -53,10 +58,20 @@ } if ($ParameterFilePath) { - $resultHeadline = "$($FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1]) with $($ParameterFilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1])" + if ($DeploymentStackTemplateFilePath -ne '') { + $resultHeadline = "$($FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1]) with $($ParameterFilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1]) using DeploymentStack $($DeploymentStackTemplateFilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1])" + } + else { + $resultHeadline = "$($FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1]) with $($ParameterFilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1])" + } } else { - $resultHeadline = $FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1] + if ($DeploymentStackTemplateFilePath -ne '') { + $resultHeadline = "$($FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1]) using DeploymentStack $($DeploymentStackTemplateFilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1])" + } + else { + $resultHeadline = $FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1] + } } # Measure input $Results.Changes content diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index e5bf9ddd..a0a002e0 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -89,6 +89,17 @@ 'Get-AzOpsCurrentPrincipal.AccountType' = 'Current AccountType is {0}' #$AzContext.Account.Type 'Get-AzOpsCurrentPrincipal.PrincipalId' = 'Current PrincipalId is {0}' #$principalObject.id + 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' = 'Found DeploymentStack [{0}] for template [{1}]' # $StackPath, $TemplateFilePath + 'Get-AzOpsDeploymentStackSetting.Template.Error' = 'Error reading template [{0}] during DeploymentStackSetting processing' # $TemplateFilePath + 'Get-AzOpsDeploymentStackSetting.Setting.Error' = 'Error reading DeploymentStackSetting in [{0}] for template [{1}]' # $StackPath $TemplateFilePath + 'Get-AzOpsDeploymentStackSetting.Parameter.Error' = 'Error: Invalid value: [{0}] for parameter [{1}] in [{2}] for [{3}]' # $stackContent[$key], $key, $StackPath, $command + 'Get-AzOpsDeploymentStackSetting.Scope.Error' = 'Error unable to find supported DeploymentStack scope at [{0}] for template [{1}]' # $StackPath $TemplateFilePath + 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' = 'No DeploymentStack file found for template: [{0}]' # $StackPath + 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' = 'Template: [{0}] excluded from deploymentStacks processing by DeploymentStack file: [{1}] due to exclusion match: [{2}]' # $TemplateFilePath, $StackPath, $matchedFile + 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStack.Metadata.AzOps' = 'AzOps generated template file: [{0}] excluded from deploymentStacks processing' # $TemplateFilePath + 'Get-AzOpsDeploymentStackSetting.ReverseLookup.TemplateFilePath' = 'ReverseLookup found template [{1}] for DeploymentStack [{0}]' # $TemplateFilePath, $result.ReverseLookupTemplateFilePath + 'Get-AzOpsDeploymentStackSetting.Resolve.NoJson' = 'The specified file is not a json file! Skipping {0}' # $TemplateFilePath + 'Get-AzOpsManagementGroup.Failed' = 'Get-AzManagementGroup -GroupId {0} failed' #$ManagementGroup 'Get-AzOpsPolicyAssignment.ManagementGroup' = 'Retrieving Policy Assignment for Management Group {0} ({1})' # $ScopeObject.ManagementGroupDisplayName, $ScopeObject.ManagementGroup @@ -215,6 +226,10 @@ 'Invoke-AzOpsPush.Deployment.Skip' = 'Skipping deployment of template: {0} with parameter: {1}, its already been deployed' # $deployment.TemplateFilePath, $deployment.TemplateParameterFilePath 'Invoke-AzOpsPush.Deployment.ParallelCondition' = 'Parallel deployment condition true' # 'Invoke-AzOpsPush.Deployment.ParallelGroup' = 'Identified multiple deployments with matching TemplateFilePath' # $groups + 'Invoke-AzOpsPush.Deployment.TemporaryDeploymentStackTemplateFilePath.Create' = 'Attempting to create temporary processing deployment template file of [{0}] to support DeploymentStack' # $target.TemplateFilePath + 'Invoke-AzOpsPush.Deployment.TemporaryDeploymentStackTemplateFilePath.Exist' = 'Temporary processing deployment template file [{0}] already exists for [{1}]' # $tempDeploymentFilePath, $target.TemplateFilePath + 'Invoke-AzOpsPush.Deployment.TemporaryDeploymentStackTemplateFilePath.Remove' = 'Removing temporary processing deployment template file [{0}]' # $tempDeploymentFilePath + 'Invoke-AzOpsPush.Deployment.TemporaryDeploymentStackTemplateFilePath.New' = 'Create temporary processing deployment template file [{0}] for [{1}]' # $tempDeploymentFilePath, $target.TemplateFilePath 'Invoke-AzOpsPush.Dependency.Missing' = 'Missing resource dependency for successfull deletion. Error exiting runtime.' 'Invoke-AzOpsPush.DeploymentList.NotFound' = 'Expecting deploymentList object, it was not found. Error exiting runtime.' 'Invoke-AzOpsPush.Duration' = 'AzOps Push completed in {0}' # $stopWatch.Elapsed @@ -246,14 +261,19 @@ 'New-AzOpsScope.Path.NotFound' = 'Path not found: {0}' # $Path 'New-AzOpsScope.Starting' = 'Starting creation of new scope object' # - 'New-AzOpsDeployment.ManagementGroup.Processing' = 'Attempting [Management Group] deployment in [{0}] for {1}' # $defaultDeploymentRegion, $scopeObject + 'New-AzOpsDeployment.Deployment' = 'Running: [{0}] with: [{1}] at [{2}]' # $deploymentCommand, $parameters, $scopeObject.Scope + 'New-AzOpsDeployment.TemporaryDeploymentStackTemplateFilePath.Remove' = 'Removing temporary processing deployment template file [{0}]' # $TemporaryTemplateFilePath + 'New-AzOpsDeployment.ManagementGroup.Processing' = 'Attempting [Management Group] deployment in [{0}] for [{1}]' # $defaultDeploymentRegion, $scopeObject + 'New-AzOpsDeployment.ManagementGroupDeploymentStack.Processing' = 'Attempting [Management Group] DeploymentStack deployment in [{0}] for [{1}] with [{2}]' # $defaultDeploymentRegion, $scopeObject, $DeploymentStackTemplateFilePath 'New-AzOpsDeployment.Processing' = 'Processing deployment {0} for template {1} with parameter "{2}" in mode {3}' # $DeploymentName, $TemplateFilePath, $TemplateParameterFilePath, $Mode - 'New-AzOpsDeployment.ResourceGroup.Processing' = 'Attempting [resource Group] deployment for {0}' # $scopeObject + 'New-AzOpsDeployment.ResourceGroup.Processing' = 'Attempting [resource Group] deployment for [{0}]' # $scopeObject + 'New-AzOpsDeployment.ResourceGroupDeploymentStack.Processing' = 'Attempting [resource Group] DeploymentStack deployment for [{0}] with [{1}]' # $scopeObject, $DeploymentStackTemplateFilePath 'New-AzOpsDeployment.Root.Processing' = 'Attempting [Tenant Scope] deployment in [{0}] for {1}' # $defaultDeploymentRegion, $scopeObject 'New-AzOpsDeployment.Scope.Empty' = 'Unable to determine the scope of template {0} and parameters {1}' # $TemplateFilePath, $TemplateParameterFilePath 'New-AzOpsDeployment.Scope.Failed' = 'Failed to resolve the scope for template {0} and parameters {1}' # $TemplateFilePath, $TemplateParameterFilePath 'New-AzOpsDeployment.Scope.Unidentified' = 'Unable to determine to scope type for this Az deployment : {0}' # $scopeObject - 'New-AzOpsDeployment.Subscription.Processing' = 'Attempting [Subscription] deployment in [{0}] for {1}' # $defaultDeploymentRegion, $scopeObject + 'New-AzOpsDeployment.Subscription.Processing' = 'Attempting [Subscription] deployment in [{0}] for [{1}]' # $defaultDeploymentRegion, $scopeObject + 'New-AzOpsDeployment.SubscriptionDeploymentStack.Processing' = 'Attempting [Subscription] DeploymentStack deployment in [{0}] for [{1}] with [{2}]' # $defaultDeploymentRegion, $scopeObject, $DeploymentStackTemplateFilePath 'New-AzOpsDeployment.TemplateParameterError' = 'Error due to empty parameter - will not attempt to deploy template. Error can be ignored for bicep modules.' # $ 'New-AzOpsDeployment.TemplateError' = 'Error validating template: {0}' # $TemplateFilePath 'New-AzOpsDeployment.WhatIfWarning' = 'Error returned from WhatIf API: {0}' # $resultsError diff --git a/src/tests/integration/Repository.Tests.ps1 b/src/tests/integration/Repository.Tests.ps1 index 577aec67..aec3b65d 100644 --- a/src/tests/integration/Repository.Tests.ps1 +++ b/src/tests/integration/Repository.Tests.ps1 @@ -67,11 +67,35 @@ Describe "Repository" { Location = "northeurope" } try { - New-AzDeployment -Name 'AzOps-Tests-rbacdep' -Location northeurope -TemplateFile "$($global:testRoot)/templates/rbactest.bicep" -TemplateParameterFile "$($global:testRoot)/templates/rbactest.parameters.json" - New-AzManagementGroupDeployment @params - New-AzResourceGroupDeployment -Name 'AzOps-Tests-policyuam' -ResourceGroupName App1-azopsrg -TemplateFile "$($global:testRoot)/templates/policywithuam.bicep" -TemplateParameterFile "$($global:testRoot)/templates/policywithuam.bicepparam" - # Pause for resource consistency - Start-Sleep -Seconds 120 + New-AzDeployment -Name 'AzOps-Tests-rbacdep' -Location northeurope -TemplateFile "$($global:testRoot)/templates/rbactest.bicep" -TemplateParameterFile "$($global:testRoot)/templates/rbactest.parameters.json" -ErrorAction Stop + } + catch { + Write-PSFMessage -Level Critical -Message "Deployment of repository test failed" -Exception $_.Exception + throw + } + + # Retry logic for New-AzManagementGroupDeployment + $maxRetries = 4 + $retryCount = 0 + $success = $false + while (-not $success -and $retryCount -lt $maxRetries) { + try { + New-AzManagementGroupDeployment @params -ErrorAction Stop + $success = $true + } + catch { + $retryCount++ + Write-PSFMessage -Level Warning -Message "Attempt $retryCount failed: $($_.Exception.Message)" + if ($retryCount -ge $maxRetries) { + Write-PSFMessage -Level Critical -Message "Deployment of repository test failed" -Exception $_.Exception + throw + } + Start-Sleep -Seconds 60 + } + } + + try { + New-AzResourceGroupDeployment -Name 'AzOps-Tests-policyuam' -ResourceGroupName App1-azopsrg -TemplateFile "$($global:testRoot)/templates/policywithuam.bicep" -TemplateParameterFile "$($global:testRoot)/templates/policywithuam.bicepparam" -ErrorAction Stop } catch { Write-PSFMessage -Level Critical -Message "Deployment of repository test failed" -Exception $_.Exception @@ -137,6 +161,7 @@ Describe "Repository" { $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:resourceGroupStacksDeploy = (Get-AzResourceGroup | Where-Object ResourceGroupName -eq "DeploymentStacksDeploy-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) @@ -305,6 +330,11 @@ Describe "Repository" { $script:resourceGroupParallelDeployFile = ($script:resourceGroupParallelDeployPath).FullName Write-PSFMessage -Level Debug -Message "ParallelDeployResourceGroupFile: $($script:resourceGroupParallelDeployFile)" -FunctionName "BeforeAll" + $script:resourceGroupStacksDeployPath = ($filePaths | Where-Object Name -eq "microsoft.resources_resourcegroups-$(($script:resourceGroupStacksDeploy.ResourceGroupName).toLower()).json") + $script:resourceGroupStacksDeployDirectory = ($script:resourceGroupStacksDeployPath).Directory + $script:resourceGroupStacksDeployFile = ($script:resourceGroupStacksDeployPath).FullName + Write-PSFMessage -Level Debug -Message "StacksDeployResourceGroupFile: $($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 @@ -1103,7 +1133,7 @@ Describe "Repository" { $changeSet = @( "D`t$script:policySetDefinitionsDepFile" ) - [string[]]$deleteSetContents = "-- $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 @@ -1168,6 +1198,7 @@ Describe "Repository" { Start-Sleep -Seconds 5 $script:bicepMultiParamPathDeployment = Get-AzResource -ResourceGroupName $script:resourceGroup.ResourceGroupName -ResourceType 'Microsoft.Network/routeTables' | Where-Object {$_.name -like "rtmultibasex*"} $script:bicepMultiParamPathDeployment.Count | Should -Be 2 + Set-PSFConfig -FullName AzOps.Core.AllowMultipleTemplateParameterFiles -Value $false } #endregion @@ -1185,6 +1216,7 @@ Describe "Repository" { $script:bicepRepeatSuffixPathDeployment = Get-AzResource -ResourceGroupName $script:resourceGroup.ResourceGroupName -ResourceType 'Microsoft.Network/routeTables' | Where-Object {$_.name -like "rtsuffix*"} $script:bicepRepeatSuffixPathDeployment.Count | Should -Be 2 Set-PSFConfig -FullName AzOps.Core.MultipleTemplateParameterFileSuffix -Value ".x" + Set-PSFConfig -FullName AzOps.Core.AllowMultipleTemplateParameterFiles -Value $false } #endregion @@ -1195,6 +1227,7 @@ Describe "Repository" { "A`t$($script:bicepMultiParamPath.FullName[0])" ) {Invoke-AzOpsPush -ChangeSet $changeSet} | Should -Not -Throw + Set-PSFConfig -FullName AzOps.Core.AllowMultipleTemplateParameterFiles -Value $false } #endregion @@ -1255,23 +1288,22 @@ Describe "Repository" { "A`t$($script:deployAllSta2ParamPath.FullName[0])" ) {Invoke-AzOpsPush -ChangeSet $changeSet} | Should -Not -Throw - Start-Sleep -Seconds 30 + Start-Sleep -Seconds 60 $script:deployAllStaParamPathDeployment = Get-AzResource -ResourceGroupName $script:resourceGroupParallelDeploy.ResourceGroupName -ResourceType 'Microsoft.Storage/storageAccounts' $script:deployAllStaParamPathDeployment.Count | Should -Be 4 $query = "resourcechanges | where resourceGroup =~ '$($($script:resourceGroupParallelDeploy).ResourceGroupName)' and properties.targetResourceType == 'microsoft.storage/storageaccounts' and properties.changeType == 'Create' | extend changeTime=todatetime(properties.changeAttributes.timestamp), targetResourceId=tostring(properties.targetResourceId) | summarize arg_max(changeTime, *) by targetResourceId | project changeTime, targetResourceId, properties.changeType, properties.targetResourceType | order by changeTime asc" $createTime = Search-AzGraph -Query $query -Subscription $script:subscriptionId - # Calculate differences between creation timing - $diff1 = New-TimeSpan -Start $createTime.changeTime[0] -End $createTime.changeTime[1] - $diff2 = New-TimeSpan -Start $createTime.changeTime[0] -End $createTime.changeTime[2] - $diff3 = New-TimeSpan -Start $createTime.changeTime[1] -End $createTime.changeTime[2] - $diff4 = New-TimeSpan -Start $createTime.changeTime[0] -End $createTime.changeTime[3] - # Check if time difference is within x seconds - $allowedDiff = '25' - if ($diff1.TotalSeconds -le $allowedDiff -and $diff2.TotalSeconds -le $allowedDiff -and $diff3.TotalSeconds -le $allowedDiff -and $diff4.TotalSeconds -ge $allowedDiff) { - # Time difference is within x seconds of each other - $timeTest = "good" - } - $timeTest | Should -Be 'good' + $parallelTimes = $createTime | Where-Object { $_.targetResourceId -match '^.*/p[123]azops' } | Select-Object -ExpandProperty changeTime + $maxParallel = ($parallelTimes | Measure-Object -Maximum).Maximum + $minParallel = ($parallelTimes | Measure-Object -Minimum).Minimum + $diffParallel = New-TimeSpan -Start $minParallel -End $maxParallel + $diffParallel.TotalSeconds | Should -BeLessThan 25 + $serialTime = ($createTime | Where-Object { $_.targetResourceId -match '^.*/s1azops' }).changeTime + $diffSerial = New-TimeSpan -Start $maxParallel -End $serialTime + $diffSerial.TotalSeconds | Should -BeGreaterThan 15 + Set-PSFConfig -FullName AzOps.Core.AllowMultipleTemplateParameterFiles -Value $false + Set-PSFConfig -FullName AzOps.Core.DeployAllMultipleTemplateParameterFiles -Value $false + Set-PSFConfig -FullName AzOps.Core.ParallelDeployMultipleTemplateParameterFiles -Value $false } #endregion @@ -1325,6 +1357,91 @@ Describe "Repository" { Get-AzPolicyAssignment -Id $script:policyAssignmentsDeletion.Id -ErrorAction SilentlyContinue | Should -Be $Null } #endregion + #region AzOps Managed DeploymentStacks + It "Deploy and Delete AzOps Managed DeploymentStacks at Resource Group Scope" { + Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $true + $script:deployStacksAtRgScope = Get-ChildItem -Path "$($global:testRoot)/templates/deploystacksatrg*" | Copy-Item -Destination $script:resourceGroupStacksDeployDirectory -PassThru -Force + $changeSet = @( + "A`t$($script:deployStacksAtRgScope.FullName[0])", + "A`t$($script:deployStacksAtRgScope.FullName[1])" + ) + {Invoke-AzOpsPush -ChangeSet $changeSet} | Should -Not -Throw + Get-AzResourceGroupDeploymentStack -ResourceId "/subscriptions/$script:subscriptionId/resourceGroups/$($script:resourceGroupStacksDeploy.ResourceGroupName)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatrg-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Not -Be $null + Start-Sleep -Seconds 10 + $changeSet = @( + "D`t$($script:deployStacksAtRgScope.FullName[0])", + "D`t$($script:deployStacksAtRgScope.FullName[1])" + ) + [string[]]$deleteSetContents = "-- $($script:deployStacksAtRgScope.FullName[0])" + [string[]]$deleteSetContents += (Get-Content ($script:deployStacksAtRgScope.FullName[0])) + [string[]]$deleteSetContents += "-- $($script:deployStacksAtRgScope.FullName[1])" + [string[]]$deleteSetContents += (Get-Content ($script:deployStacksAtRgScope.FullName[1])) + Remove-Item -Path $script:deployStacksAtRgScope.FullName[0] -Force + Remove-Item -Path $script:deployStacksAtRgScope.FullName[1] -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-AzResourceGroupDeploymentStack -ResourceId "/subscriptions/$script:subscriptionId/resourceGroups/$($script:resourceGroupStacksDeploy.ResourceGroupName)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatrg-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Be $null + } + It "Deploy and Delete AzOps Managed DeploymentStacks at Subscription Scope with parameter file and exclusion of direct stack, expecting usage of parent stack" { + Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $true + $script:deployStacksAtSubScope = Get-ChildItem -Path "$($global:testRoot)/templates/deploystacksatsub*" | Copy-Item -Destination $script:subscriptionDirectory -PassThru -Force + # Rename the third file in the array + $newName = ".deploymentStacks.json" + Rename-Item -Path $script:deployStacksAtSubScope[3].FullName -NewName $newName + # Optionally, update the array reference if you need to use the new path later + $script:deployStacksAtSubScope[2] = Get-Item -Path (Join-Path -Path $script:subscriptionDirectory -ChildPath $newName) -Force + $changeSet = @( + "A`t$($script:deployStacksAtSubScope.FullName[0])", + "A`t$($script:deployStacksAtSubScope.FullName[1])" + ) + {Invoke-AzOpsPush -ChangeSet $changeSet} | Should -Not -Throw + $stackConfig = Get-AzSubscriptionDeploymentStack -ResourceId "/subscriptions/$script:subscriptionId/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatsub-$deploymentLocationId" -ErrorAction SilentlyContinue + $stackConfig | Should -Not -Be $null + $stackConfig.resourcesCleanupAction | Should -Be "delete" + $stackConfig.resourceGroupsCleanupAction | Should -Be "delete" + $stackConfig.managementGroupsCleanupAction | Should -Be "delete" + Start-Sleep -Seconds 10 + $changeSet = @( + "D`t$($script:deployStacksAtSubScope.FullName[0])", + "D`t$($script:deployStacksAtSubScope.FullName[1])" + ) + [string[]]$deleteSetContents = "-- $($script:deployStacksAtSubScope.FullName[0])" + [string[]]$deleteSetContents += (Get-Content ($script:deployStacksAtSubScope.FullName[0])) + [string[]]$deleteSetContents += "-- $($script:deployStacksAtSubScope.FullName[1])" + [string[]]$deleteSetContents += (Get-Content ($script:deployStacksAtSubScope.FullName[1])) + Remove-Item -Path $script:deployStacksAtSubScope.FullName[0] -Force + Remove-Item -Path $script:deployStacksAtSubScope.FullName[1] -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-AzSubscriptionDeploymentStack -ResourceId "/subscriptions/$script:subscriptionId/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatsub-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Be $null + } + It "Deploy and Delete AzOps Managed DeploymentStacks at ManagementGroup Scope with multiparameter filename and parameter specific stackfile discovered with reverse lookup" { + Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $true + Set-PSFConfig -FullName AzOps.Core.AllowMultipleTemplateParameterFiles -Value $true + Set-PSFConfig -FullName AzOps.Core.DeployAllMultipleTemplateParameterFiles -Value $true + $script:deployStacksAtMgScope = Get-ChildItem -Path "$($global:testRoot)/templates/deploystacksatmg*" | Copy-Item -Destination $script:managementManagementGroupDirectory -PassThru -Force + $changeSet = @( + "A`t$($script:deployStacksAtMgScope.FullName[2])" + ) + {Invoke-AzOpsPush -ChangeSet $changeSet} | Should -Not -Throw + Get-AzManagementGroupDeploymentStack -ResourceId "/providers/Microsoft.Management/managementGroups/$($script:managementManagementGroup.Name)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatmg.x1-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Not -Be $null + Start-Sleep -Seconds 10 + $changeSet = @( + "D`t$($script:deployStacksAtMgScope.FullName[2])" + ) + [string[]]$deleteSetContents = "-- $($script:deployStacksAtMgScope.FullName[2])" + [string[]]$deleteSetContents += (Get-Content ($script:deployStacksAtMgScope.FullName[2])) + Remove-Item -Path $script:deployStacksAtMgScope.FullName[2] -Force + {Invoke-AzOpsPush -ChangeSet $changeSet -DeleteSetContents $deleteSetContents -WhatIf:$false} | Should -Not -Throw + Set-PSFConfig -FullName AzOps.Core.CustomTemplateResourceDeletion -Value $false + Set-PSFConfig -FullName AzOps.Core.AllowMultipleTemplateParameterFiles -Value $false + Set-PSFConfig -FullName AzOps.Core.DeployAllMultipleTemplateParameterFiles -Value $false + Start-Sleep -Seconds 30 + Get-AzManagementGroupDeploymentStack -ResourceId "/providers/Microsoft.Management/managementGroups/$($script:managementManagementGroup.Name)/providers/Microsoft.Resources/deploymentStacks/AzOps-deploystacksatmg.x1-$deploymentLocationId" -ErrorAction SilentlyContinue | Should -Be $null + } + #endregion } AfterAll { diff --git a/src/tests/templates/azuredeploy.jsonc b/src/tests/templates/azuredeploy.jsonc index 6deeffaa..b004bd5d 100644 --- a/src/tests/templates/azuredeploy.jsonc +++ b/src/tests/templates/azuredeploy.jsonc @@ -444,7 +444,7 @@ ], "copy": { "batchSize": 1, - "count": 35, + "count": 40, "mode": "Serial", "name": "waitingCompletion" }, @@ -569,6 +569,12 @@ "name": "ParallelDeploy-azopsrg", "location": "northeurope" }, + { + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2024-11-01", + "name": "DeploymentStacksDeploy-azopsrg", + "location": "northeurope" + }, { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", diff --git a/src/tests/templates/deploystacksatmg.bicep b/src/tests/templates/deploystacksatmg.bicep new file mode 100644 index 00000000..c48e1294 --- /dev/null +++ b/src/tests/templates/deploystacksatmg.bicep @@ -0,0 +1,11 @@ +targetScope = 'managementGroup' + +param policyDefinitionIdstring string + +resource policyAssignment 'Microsoft.Authorization/policyAssignments@2022-06-01' = { + name: 'stacks-audit-vm-disks' + properties: { + displayName: 'Audit VMs with managed disks' + policyDefinitionId: policyDefinitionIdstring + } +} diff --git a/src/tests/templates/deploystacksatmg.x1.bicepparam b/src/tests/templates/deploystacksatmg.x1.bicepparam new file mode 100644 index 00000000..cf106fc1 --- /dev/null +++ b/src/tests/templates/deploystacksatmg.x1.bicepparam @@ -0,0 +1,3 @@ +using './deploystacksatmg.bicep' + +param policyDefinitionIdstring = '/providers/Microsoft.Authorization/policyDefinitions/06a78e20-9358-41c9-923c-fb736d382a4d' diff --git a/src/tests/templates/deploystacksatmg.x1.deploymentStacks.json b/src/tests/templates/deploystacksatmg.x1.deploymentStacks.json new file mode 100644 index 00000000..ce06d2c1 --- /dev/null +++ b/src/tests/templates/deploystacksatmg.x1.deploymentStacks.json @@ -0,0 +1,6 @@ +{ + "actionOnUnmanage": "DeleteAll", + "bypassStackOutOfSyncError": true, + "denySettingsMode": "None", + "excludedAzOpsFiles": [] +} \ No newline at end of file diff --git a/src/tests/templates/deploystacksatrg.bicep b/src/tests/templates/deploystacksatrg.bicep new file mode 100644 index 00000000..cb8c4b6e --- /dev/null +++ b/src/tests/templates/deploystacksatrg.bicep @@ -0,0 +1,12 @@ +param name string = 'rtstackatrg' +param location string = resourceGroup().location + +resource symbolicname 'Microsoft.Network/routeTables@2024-05-01' = { + name: name + location: location + properties: { + disableBgpRoutePropagation: false + routes: [ + ] + } +} diff --git a/src/tests/templates/deploystacksatrg.deploymentStacks.json b/src/tests/templates/deploystacksatrg.deploymentStacks.json new file mode 100644 index 00000000..916d300d --- /dev/null +++ b/src/tests/templates/deploystacksatrg.deploymentStacks.json @@ -0,0 +1,6 @@ +{ + "actionOnUnmanage": "deleteResources", + "bypassStackOutOfSyncError": true, + "denySettingsMode": "DenyDelete", + "excludedAzOpsFiles": [] +} \ No newline at end of file diff --git a/src/tests/templates/deploystacksatsub.bicep b/src/tests/templates/deploystacksatsub.bicep new file mode 100644 index 00000000..9f88a4d1 --- /dev/null +++ b/src/tests/templates/deploystacksatsub.bicep @@ -0,0 +1,11 @@ +targetScope = 'subscription' + +param policyDefinitionIdstring string + +resource policyAssignment 'Microsoft.Authorization/policyAssignments@2022-06-01' = { + name: 'stacks-audit-vm-disks' + properties: { + displayName: 'Audit VMs with managed disks' + policyDefinitionId: policyDefinitionIdstring + } +} diff --git a/src/tests/templates/deploystacksatsub.bicepparam b/src/tests/templates/deploystacksatsub.bicepparam new file mode 100644 index 00000000..80e9ea58 --- /dev/null +++ b/src/tests/templates/deploystacksatsub.bicepparam @@ -0,0 +1,3 @@ +using './deploystacksatsub.bicep' + +param policyDefinitionIdstring = '/providers/Microsoft.Authorization/policyDefinitions/06a78e20-9358-41c9-923c-fb736d382a4d' diff --git a/src/tests/templates/deploystacksatsub.deploymentStacks.json b/src/tests/templates/deploystacksatsub.deploymentStacks.json new file mode 100644 index 00000000..196333f2 --- /dev/null +++ b/src/tests/templates/deploystacksatsub.deploymentStacks.json @@ -0,0 +1,8 @@ +{ + "actionOnUnmanage": "DetachAll", + "bypassStackOutOfSyncError": true, + "denySettingsMode": "DenyDelete", + "excludedAzOpsFiles": [ + "deploystacksatsub.bicep" + ] +} \ No newline at end of file diff --git a/src/tests/templates/deploystacksatsubrename.deploymentStacks.json b/src/tests/templates/deploystacksatsubrename.deploymentStacks.json new file mode 100644 index 00000000..7f46eeaa --- /dev/null +++ b/src/tests/templates/deploystacksatsubrename.deploymentStacks.json @@ -0,0 +1,6 @@ +{ + "actionOnUnmanage": "DeleteAll", + "bypassStackOutOfSyncError": true, + "denySettingsMode": "DenyDelete", + "excludedAzOpsFiles": [] +} \ No newline at end of file