From 74a666759854faf728a0e4d80b9774bbfc735dd2 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 23 Apr 2025 19:28:37 +0000 Subject: [PATCH 01/38] Init --- src/functions/Invoke-AzOpsPush.ps1 | 137 ++++++++++++++++-- src/internal/functions/Get-AzOpsPim.ps1 | 2 +- .../functions/Get-AzOpsPolicyExemption.ps1 | 2 +- .../functions/Get-AzOpsResourceLock.ps1 | 2 +- src/internal/functions/Get-AzOpsRole.ps1 | 2 +- .../functions/Get-AzOpsRoleAssignment.ps1 | 2 +- .../functions/Get-AzOpsRoleDefinition.ps1 | 2 +- ...et-AzOpsRoleEligibilityScheduleRequest.ps1 | 2 +- .../functions/Invoke-AzOpsRestMethod.ps1 | 2 +- .../functions/New-AzOpsDeployment.ps1 | 72 +++++++-- src/localized/en-us/Strings.psd1 | 9 ++ 11 files changed, 206 insertions(+), 28 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index c2ac5bda..9ec042c1 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -136,13 +136,15 @@ #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 @@ -187,6 +189,9 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope + $deploymentStack = $result.TemplateFilePath | Resolve-AzOpsDeploymentStack + $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath + $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result } elseif (Test-Path $bicepTemplatePath) { @@ -204,6 +209,9 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope + $deploymentStack = $result.TemplateFilePath | Resolve-AzOpsDeploymentStack + $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath + $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result } } @@ -232,6 +240,9 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope + $deploymentStack = $result.TemplateFilePath | Resolve-AzOpsDeploymentStack + $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath + $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result } } @@ -336,17 +347,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 = $result.TemplateFilePath | Resolve-AzOpsDeploymentStack + $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 @@ -365,6 +378,84 @@ return $true } } + function Resolve-AzOpsDeploymentStack { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [string] + $TemplateFilePath + ) + + begin { + $result = [PSCustomObject] @{ + DeploymentStackTemplateFilePath = $null + DeploymentStackSettings = $null + } + } + + process { + + $templateContent = Get-Content -Path $TemplateFilePath | ConvertFrom-Json -AsHashtable + if ($templateContent.metadata._generator.name -eq "AzOps") { + Write-AzOpsMessage -LogLevel Verbose -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStack.Metadata.AzOps' -LogStringValues $TemplateFilePath + return + } + + if ($TemplateFilePath.EndsWith('.json') -and -not $TemplateFilePath.EndsWith('parameters.json')) { + $stackTemplatePath = $TemplateFilePath -replace '\.json$', '.deploymentStacks.json' + if (Test-Path $stackTemplatePath) { + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $stackTemplatePath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $stackTemplatePath + $result.DeploymentStackSettings = Get-Content -Path $stackTemplatePath -Raw | ConvertFrom-Json -Depth 100 | + ForEach-Object { + $_.PSObject.Properties.Remove('excludedAzOpsFiles') + $_ + } | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + return $result + } + $deploymentStackPath = Join-Path -Path (Split-Path -Path $TemplateFilePath) -ChildPath ".deploymentStacks.json" + if (Test-Path $deploymentStackPath) { + $fileName = Split-Path -Path $TemplateFilePath -Leaf + $stackContent = Get-Content -Path $deploymentStackPath -Raw | ConvertFrom-Json -Depth 100 + if ($stackContent.excludedAzOpsFiles -and $stackContent.excludedAzOpsFiles.Count -gt 0) { + # Generate a list of potential file names to check + $fileVariants = @($fileName) + if ($fileName -like '*.json') { + $fileVariants += $fileName -replace '\.json$', '.bicep' + } + elseif ($fileName -like '*.bicep') { + $fileVariants += $fileName -replace '\.bicep$', '.json' + } + # Check if any of the file variants match the exclusion patterns + if ($fileVariants | Where-Object { $stackContent.excludedAzOpsFiles -match $_ }) { + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $deploymentStackPath + return $result + } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $deploymentStackPath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $deploymentStackPath + $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') + $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + return $result + } + } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $deploymentStackPath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $deploymentStackPath + $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + return $result + } + + } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.NoDeploymentStackFound' -LogStringValues $TemplateFilePath + return $result + } + } + + } + + } #endregion Utility Functions $WhatIfPreferenceState = $WhatIfPreference @@ -534,12 +625,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 +649,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" 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-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..a6909b1e 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -38,6 +38,18 @@ [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 +58,12 @@ [string] $TemplateFilePath = (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate'), + [Parameter(ValueFromPipelineByPropertyName = $true)] + [AllowEmptyString()] + [AllowNull()] + [string] + $TemporaryTemplateFilePath, + [Parameter(ValueFromPipelineByPropertyName = $true)] [hashtable] $TemplateObject, @@ -108,10 +126,26 @@ #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 @@ -121,7 +155,11 @@ 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' + } else { + $deploymentCommand = 'New-AzResourceGroupDeployment' + } $parameters.ResourceGroupName = $scopeObject.resourcegroup $parameters.Remove('Location') } @@ -130,14 +168,22 @@ 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' + } else { + $deploymentCommand = 'New-AzDeployment' + } } # 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' + } else { + $deploymentCommand = 'New-AzManagementGroupDeployment' + } } # Tenant deployments elseif ($scopeObject.type -eq 'root' -and $scopeObject.scope -eq '/') { @@ -241,10 +287,13 @@ if ($parameters.ExcludeChangeType) { $parameters.Remove('ExcludeChangeType') } + if ($null -ne $DeploymentStackSettings) { + $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 + $deploymentResult.deployment = & $deploymentCommand @parameters -Force:$true } } else { @@ -252,6 +301,11 @@ 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 + } #Return if ($deploymentResult) { return $deploymentResult diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index e5bf9ddd..7f67aa92 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -215,6 +215,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 @@ -230,6 +234,10 @@ 'Invoke-AzOpsPush.Resolve.ParameterFound' = 'Found parameter file for template {0} : {1}' # $FilePath, $parameterPath 'Invoke-AzOpsPush.Resolve.ParameterNotFound' = 'No parameter file found for template: {0}, at: {1}' # $FilePath, $parameterPath 'Invoke-AzOpsPush.Resolve.NotFoundParamFileDefaultValue' = 'Template {0} with parameter: {1} missing defaultValue and no parameter file found, skip deployment' # $FilePath, $missingString + 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' = 'Found DeploymentStack [{0}] for template [{1}]' # $stackTemplatePath, $TemplateFilePath + 'Invoke-AzOpsPush.Resolve.NoDeploymentStackFound' = 'No DeploymentStack file found for template: [{0}]' # $TemplateFilePath + 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' = 'Template: [{0}] excluded from deploymentStacks processing by DeploymentStack file: [{1}]' # $TemplateFilePath, $deploymentStackPath + 'Invoke-AzOpsPush.Resolve.DeploymentStack.Metadata.AzOps' = 'AzOps generated template file: [{0}] excluded from deploymentStacks processing' # $TemplateFilePath 'Invoke-AzOpsPush.Scope.Failed' = 'Failed to read {0} as part of {1}' # $addition, $StatePath 'Invoke-AzOpsNativeCommand' = 'Execution of ScriptBlock: {{{0}}} returned: {{{1}}}' # $ScriptBlock, $_ @@ -246,6 +254,7 @@ 'New-AzOpsScope.Path.NotFound' = 'Path not found: {0}' # $Path 'New-AzOpsScope.Starting' = 'Starting creation of new scope object' # + '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.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 From 16cea75c45c3947a4beefee88e84658f8a1f6a10 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 24 Apr 2025 08:36:52 +0000 Subject: [PATCH 02/38] AdjustExclusionLogic --- src/functions/Invoke-AzOpsPush.ps1 | 62 +++++++++++++++--------------- src/localized/en-us/Strings.psd1 | 4 +- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index 9ec042c1..53b83a44 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -402,50 +402,50 @@ } if ($TemplateFilePath.EndsWith('.json') -and -not $TemplateFilePath.EndsWith('parameters.json')) { + # Generate a list of potential file names to check + $fileName = Split-Path -Path $TemplateFilePath -Leaf + $fileVariants = @($fileName) + if ($fileName -like '*.json') { + $fileVariants += $fileName -replace '\.json$', '.bicep' + } + elseif ($fileName -like '*.bicep') { + $fileVariants += $fileName -replace '\.bicep$', '.json' + } $stackTemplatePath = $TemplateFilePath -replace '\.json$', '.deploymentStacks.json' + $parentStackPath = Join-Path -Path (Split-Path -Path $TemplateFilePath) -ChildPath ".deploymentStacks.json" if (Test-Path $stackTemplatePath) { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $stackTemplatePath, $TemplateFilePath - $result.DeploymentStackTemplateFilePath = $stackTemplatePath - $result.DeploymentStackSettings = Get-Content -Path $stackTemplatePath -Raw | ConvertFrom-Json -Depth 100 | - ForEach-Object { - $_.PSObject.Properties.Remove('excludedAzOpsFiles') - $_ - } | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable - return $result - } - $deploymentStackPath = Join-Path -Path (Split-Path -Path $TemplateFilePath) -ChildPath ".deploymentStacks.json" - if (Test-Path $deploymentStackPath) { - $fileName = Split-Path -Path $TemplateFilePath -Leaf - $stackContent = Get-Content -Path $deploymentStackPath -Raw | ConvertFrom-Json -Depth 100 + $stackContent = Get-Content -Path $stackTemplatePath -Raw | ConvertFrom-Json -Depth 100 if ($stackContent.excludedAzOpsFiles -and $stackContent.excludedAzOpsFiles.Count -gt 0) { - # Generate a list of potential file names to check - $fileVariants = @($fileName) - if ($fileName -like '*.json') { - $fileVariants += $fileName -replace '\.json$', '.bicep' - } - elseif ($fileName -like '*.bicep') { - $fileVariants += $fileName -replace '\.bicep$', '.json' - } # Check if any of the file variants match the exclusion patterns if ($fileVariants | Where-Object { $stackContent.excludedAzOpsFiles -match $_ }) { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $deploymentStackPath - return $result + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $stackTemplatePath } else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $deploymentStackPath, $TemplateFilePath - $result.DeploymentStackTemplateFilePath = $deploymentStackPath + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $stackTemplatePath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $stackTemplatePath $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable return $result } } - else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $deploymentStackPath, $TemplateFilePath - $result.DeploymentStackTemplateFilePath = $deploymentStackPath - $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable - return $result + } + if (Test-Path $parentStackPath) { + $fileName = Split-Path -Path $TemplateFilePath -Leaf + $parentStackContent = Get-Content -Path $parentStackPath -Raw | ConvertFrom-Json -Depth 100 + if ($parentStackContent.excludedAzOpsFiles -and $parentStackContent.excludedAzOpsFiles.Count -gt 0) { + # Check if any of the file variants match the exclusion patterns + if ($fileVariants | Where-Object { $parentStackContent.excludedAzOpsFiles -match $_ }) { + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $parentStackPath + return $result + } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $parentStackPath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $parentStackPath + $parentStackContent.PSObject.Properties.Remove('excludedAzOpsFiles') + $result.DeploymentStackSettings = $parentStackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + return $result + } } - } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.NoDeploymentStackFound' -LogStringValues $TemplateFilePath diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index 7f67aa92..8406ba57 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -234,9 +234,9 @@ 'Invoke-AzOpsPush.Resolve.ParameterFound' = 'Found parameter file for template {0} : {1}' # $FilePath, $parameterPath 'Invoke-AzOpsPush.Resolve.ParameterNotFound' = 'No parameter file found for template: {0}, at: {1}' # $FilePath, $parameterPath 'Invoke-AzOpsPush.Resolve.NotFoundParamFileDefaultValue' = 'Template {0} with parameter: {1} missing defaultValue and no parameter file found, skip deployment' # $FilePath, $missingString - 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' = 'Found DeploymentStack [{0}] for template [{1}]' # $stackTemplatePath, $TemplateFilePath + 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' = 'Found DeploymentStack [{0}] for template [{1}]' # $stackTemplatePath/$parentStackPath, $TemplateFilePath 'Invoke-AzOpsPush.Resolve.NoDeploymentStackFound' = 'No DeploymentStack file found for template: [{0}]' # $TemplateFilePath - 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' = 'Template: [{0}] excluded from deploymentStacks processing by DeploymentStack file: [{1}]' # $TemplateFilePath, $deploymentStackPath + 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' = 'Template: [{0}] excluded from deploymentStacks processing by DeploymentStack file: [{1}]' # $TemplateFilePath, $stackTemplatePath/$parentStackPath 'Invoke-AzOpsPush.Resolve.DeploymentStack.Metadata.AzOps' = 'AzOps generated template file: [{0}] excluded from deploymentStacks processing' # $TemplateFilePath 'Invoke-AzOpsPush.Scope.Failed' = 'Failed to read {0} as part of {1}' # $addition, $StatePath From 9e4b675309f2e28ab0bb215f4a90d5cc67ea2552 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 24 Apr 2025 09:07:31 +0000 Subject: [PATCH 03/38] Rename --- src/functions/Invoke-AzOpsPush.ps1 | 86 +---------------- .../Get-AzOpsDeploymentStackSetting.ps1 | 92 +++++++++++++++++++ src/localized/en-us/Strings.psd1 | 9 +- 3 files changed, 101 insertions(+), 86 deletions(-) create mode 100644 src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index 53b83a44..75878b7e 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -189,7 +189,7 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope - $deploymentStack = $result.TemplateFilePath | Resolve-AzOpsDeploymentStack + $deploymentStack = $result.TemplateFilePath | Get-AzOpsDeploymentStackSetting $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result @@ -209,7 +209,7 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope - $deploymentStack = $result.TemplateFilePath | Resolve-AzOpsDeploymentStack + $deploymentStack = $result.TemplateFilePath | Get-AzOpsDeploymentStackSetting $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result @@ -240,7 +240,7 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope - $deploymentStack = $result.TemplateFilePath | Resolve-AzOpsDeploymentStack + $deploymentStack = $result.TemplateFilePath | Get-AzOpsDeploymentStackSetting $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result @@ -350,7 +350,7 @@ $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 = $result.TemplateFilePath | Resolve-AzOpsDeploymentStack + $deploymentStack = $result.TemplateFilePath | Get-AzOpsDeploymentStackSetting $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings $result @@ -378,84 +378,6 @@ return $true } } - function Resolve-AzOpsDeploymentStack { - [CmdletBinding()] - param ( - [Parameter(ValueFromPipeline = $true)] - [string] - $TemplateFilePath - ) - - begin { - $result = [PSCustomObject] @{ - DeploymentStackTemplateFilePath = $null - DeploymentStackSettings = $null - } - } - - process { - - $templateContent = Get-Content -Path $TemplateFilePath | ConvertFrom-Json -AsHashtable - if ($templateContent.metadata._generator.name -eq "AzOps") { - Write-AzOpsMessage -LogLevel Verbose -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStack.Metadata.AzOps' -LogStringValues $TemplateFilePath - return - } - - if ($TemplateFilePath.EndsWith('.json') -and -not $TemplateFilePath.EndsWith('parameters.json')) { - # Generate a list of potential file names to check - $fileName = Split-Path -Path $TemplateFilePath -Leaf - $fileVariants = @($fileName) - if ($fileName -like '*.json') { - $fileVariants += $fileName -replace '\.json$', '.bicep' - } - elseif ($fileName -like '*.bicep') { - $fileVariants += $fileName -replace '\.bicep$', '.json' - } - $stackTemplatePath = $TemplateFilePath -replace '\.json$', '.deploymentStacks.json' - $parentStackPath = Join-Path -Path (Split-Path -Path $TemplateFilePath) -ChildPath ".deploymentStacks.json" - if (Test-Path $stackTemplatePath) { - $stackContent = Get-Content -Path $stackTemplatePath -Raw | ConvertFrom-Json -Depth 100 - if ($stackContent.excludedAzOpsFiles -and $stackContent.excludedAzOpsFiles.Count -gt 0) { - # Check if any of the file variants match the exclusion patterns - if ($fileVariants | Where-Object { $stackContent.excludedAzOpsFiles -match $_ }) { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $stackTemplatePath - } - else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $stackTemplatePath, $TemplateFilePath - $result.DeploymentStackTemplateFilePath = $stackTemplatePath - $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') - $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable - return $result - } - } - } - if (Test-Path $parentStackPath) { - $fileName = Split-Path -Path $TemplateFilePath -Leaf - $parentStackContent = Get-Content -Path $parentStackPath -Raw | ConvertFrom-Json -Depth 100 - if ($parentStackContent.excludedAzOpsFiles -and $parentStackContent.excludedAzOpsFiles.Count -gt 0) { - # Check if any of the file variants match the exclusion patterns - if ($fileVariants | Where-Object { $parentStackContent.excludedAzOpsFiles -match $_ }) { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $parentStackPath - return $result - } - else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $parentStackPath, $TemplateFilePath - $result.DeploymentStackTemplateFilePath = $parentStackPath - $parentStackContent.PSObject.Properties.Remove('excludedAzOpsFiles') - $result.DeploymentStackSettings = $parentStackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable - return $result - } - } - } - else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Invoke-AzOpsPush.Resolve.NoDeploymentStackFound' -LogStringValues $TemplateFilePath - return $result - } - } - - } - - } #endregion Utility Functions $WhatIfPreferenceState = $WhatIfPreference diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 new file mode 100644 index 00000000..8cf6012a --- /dev/null +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -0,0 +1,92 @@ +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. + .EXAMPLE + > $result = Get-AzOpsDeploymentStackSetting -TemplateFilePath "C:\Templates\example.bicep" + #> + + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [string] + $TemplateFilePath + ) + + begin { + $result = [PSCustomObject] @{ + DeploymentStackTemplateFilePath = $null + DeploymentStackSettings = $null + } + } + + process { + + $templateContent = Get-Content -Path $TemplateFilePath | ConvertFrom-Json -AsHashtable + if ($templateContent.metadata._generator.name -eq "AzOps") { + Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStack.Metadata.AzOps' -LogStringValues $TemplateFilePath + return + } + + if ($TemplateFilePath.EndsWith('.json') -and -not $TemplateFilePath.EndsWith('parameters.json')) { + # Generate a list of potential file names to check + $fileName = Split-Path -Path $TemplateFilePath -Leaf + $fileVariants = @($fileName) + if ($fileName -like '*.json') { + $fileVariants += $fileName -replace '\.json$', '.bicep' + } + elseif ($fileName -like '*.bicep') { + $fileVariants += $fileName -replace '\.bicep$', '.json' + } + $stackTemplatePath = $TemplateFilePath -replace '\.json$', '.deploymentStacks.json' + $parentStackPath = Join-Path -Path (Split-Path -Path $TemplateFilePath) -ChildPath ".deploymentStacks.json" + if (Test-Path $stackTemplatePath) { + $stackContent = Get-Content -Path $stackTemplatePath -Raw | ConvertFrom-Json -Depth 100 + if ($stackContent.excludedAzOpsFiles -and $stackContent.excludedAzOpsFiles.Count -gt 0) { + # Check if any of the file variants match the exclusion patterns + if ($fileVariants | Where-Object { $stackContent.excludedAzOpsFiles -match $_ }) { + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $stackTemplatePath + } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $stackTemplatePath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $stackTemplatePath + $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') + $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + return $result + } + } + } + if (Test-Path $parentStackPath) { + $fileName = Split-Path -Path $TemplateFilePath -Leaf + $parentStackContent = Get-Content -Path $parentStackPath -Raw | ConvertFrom-Json -Depth 100 + if ($parentStackContent.excludedAzOpsFiles -and $parentStackContent.excludedAzOpsFiles.Count -gt 0) { + # Check if any of the file variants match the exclusion patterns + if ($fileVariants | Where-Object { $parentStackContent.excludedAzOpsFiles -match $_ }) { + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $parentStackPath + return $result + } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $parentStackPath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $parentStackPath + $parentStackContent.PSObject.Properties.Remove('excludedAzOpsFiles') + $result.DeploymentStackSettings = $parentStackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + 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/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index 8406ba57..83689d9c 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -89,6 +89,11 @@ '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}]' # $stackTemplatePath/$parentStackPath, $TemplateFilePath + 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' = 'No DeploymentStack file found for template: [{0}]' # $TemplateFilePath + 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' = 'Template: [{0}] excluded from deploymentStacks processing by DeploymentStack file: [{1}]' # $TemplateFilePath, $stackTemplatePath/$parentStackPath + 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStack.Metadata.AzOps' = 'AzOps generated template file: [{0}] excluded from deploymentStacks processing' # $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 @@ -234,10 +239,6 @@ 'Invoke-AzOpsPush.Resolve.ParameterFound' = 'Found parameter file for template {0} : {1}' # $FilePath, $parameterPath 'Invoke-AzOpsPush.Resolve.ParameterNotFound' = 'No parameter file found for template: {0}, at: {1}' # $FilePath, $parameterPath 'Invoke-AzOpsPush.Resolve.NotFoundParamFileDefaultValue' = 'Template {0} with parameter: {1} missing defaultValue and no parameter file found, skip deployment' # $FilePath, $missingString - 'Invoke-AzOpsPush.Resolve.DeploymentStackTemplateFilePath' = 'Found DeploymentStack [{0}] for template [{1}]' # $stackTemplatePath/$parentStackPath, $TemplateFilePath - 'Invoke-AzOpsPush.Resolve.NoDeploymentStackFound' = 'No DeploymentStack file found for template: [{0}]' # $TemplateFilePath - 'Invoke-AzOpsPush.Resolve.ExcludedFromDeploymentStack' = 'Template: [{0}] excluded from deploymentStacks processing by DeploymentStack file: [{1}]' # $TemplateFilePath, $stackTemplatePath/$parentStackPath - 'Invoke-AzOpsPush.Resolve.DeploymentStack.Metadata.AzOps' = 'AzOps generated template file: [{0}] excluded from deploymentStacks processing' # $TemplateFilePath 'Invoke-AzOpsPush.Scope.Failed' = 'Failed to read {0} as part of {1}' # $addition, $StatePath 'Invoke-AzOpsNativeCommand' = 'Execution of ScriptBlock: {{{0}}} returned: {{{1}}}' # $ScriptBlock, $_ From c1e6777b55461e144d36663fb0d0ea8538a3f36f Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Fri, 25 Apr 2025 13:29:13 +0000 Subject: [PATCH 04/38] ReverseLookup --- src/functions/Invoke-AzOpsPush.ps1 | 45 +++++++++++- .../Get-AzOpsDeploymentStackSetting.ps1 | 68 ++++++++++++++++++- src/localized/en-us/Strings.psd1 | 1 + 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index 75878b7e..a2e5c226 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -92,6 +92,27 @@ $FilePath = $transpiledTemplatePaths.transpiledTemplatePath } + # Handle 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 + } + # If templates are successfully created, return them + if ($reverseLookupDeploymentStacksTemplates) { + return $reverseLookupDeploymentStacksTemplates + } + } + 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 @@ -522,7 +543,29 @@ #region Create DeletionList $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 { + # 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' + } + # 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 diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index 8cf6012a..5b7cee3d 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -10,6 +10,9 @@ 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 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" #> @@ -18,19 +21,66 @@ param ( [Parameter(ValueFromPipeline = $true)] [string] - $TemplateFilePath + $TemplateFilePath, + [Parameter(ValueFromPipeline = $true)] + [switch] + $ReverseLookup ) begin { $result = [PSCustomObject] @{ DeploymentStackTemplateFilePath = $null DeploymentStackSettings = $null + ReverseLookupTemplateFilePath = $null } } process { - $templateContent = Get-Content -Path $TemplateFilePath | ConvertFrom-Json -AsHashtable + if ($ReverseLookup -and $TemplateFilePath.EndsWith('.deploymentStacks.json')) { + 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 '*' + $files = Get-ChildItem -Path $folderPathLookup -File -Include *.bicep, *.json | Select-Object -ExpandProperty FullName + $returnFiles = @() + foreach ($file in $files) { + if ($file.EndsWith('.json')) { + $fileContent = Get-Content -Path $file | ConvertFrom-Json -AsHashtable + if ($fileContent.metadata._generator.name -eq "AzOps") { + Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStack.Metadata.AzOps' -LogStringValues $file + } + else { + $returnFiles += $file + } + } + else { + $returnFiles += $file + } + } + $result.ReverseLookupTemplateFilePath = $returnFiles + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.ReverseLookup.TemplateFilePath' -LogStringValues $TemplateFilePath, $result.ReverseLookupTemplateFilePath + return $result + } + else { + # This is a dedicated template stack file + 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 + } + } + } + elseif ($ReverseLookup) { + return + } + + $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 @@ -63,6 +113,13 @@ return $result } } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $stackTemplatePath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $stackTemplatePath + if ($stackContent.excludedAzOpsFiles) { $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') } + $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + return $result + } } if (Test-Path $parentStackPath) { $fileName = Split-Path -Path $TemplateFilePath -Leaf @@ -81,6 +138,13 @@ return $result } } + else { + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $parentStackPath, $TemplateFilePath + $result.DeploymentStackTemplateFilePath = $parentStackPath + if ($parentStackContent.excludedAzOpsFiles) { $parentStackContent.PSObject.Properties.Remove('excludedAzOpsFiles') } + $result.DeploymentStackSettings = $parentStackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + return $result + } } else { Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' -LogStringValues $TemplateFilePath diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index 83689d9c..56a75ffc 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -93,6 +93,7 @@ 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' = 'No DeploymentStack file found for template: [{0}]' # $TemplateFilePath 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' = 'Template: [{0}] excluded from deploymentStacks processing by DeploymentStack file: [{1}]' # $TemplateFilePath, $stackTemplatePath/$parentStackPath '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-AzOpsManagementGroup.Failed' = 'Get-AzManagementGroup -GroupId {0} failed' #$ManagementGroup From 99be362027bdb0720d638bddba5de768f241e74e Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 28 Apr 2025 15:03:45 +0000 Subject: [PATCH 05/38] Deletion --- src/functions/Invoke-AzOpsPush.ps1 | 16 +- src/internal/functions/Get-AzOpsResource.ps1 | 36 +++- .../functions/Remove-AzOpsDeployment.ps1 | 198 +++++++++++------- .../functions/Set-AzOpsRemoveOrder.ps1 | 5 + 4 files changed, 173 insertions(+), 82 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index a2e5c226..b77056ee 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -100,7 +100,7 @@ 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 + New-AzOpsList -FilePath $templatePath -CompareDeploymentToDeletion:$CompareDeploymentToDeletion } # If templates are successfully created, return them if ($reverseLookupDeploymentStacksTemplates) { @@ -541,17 +541,17 @@ #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 | Where-Object { - # 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' - } # Include items if TemplateFilePath or TemplateParameterFilePath matches the normalized delete set if ($_.TemplateFilePath -in $normalizedDeleteSet -or $_.TemplateFilePath -in $normalizedResolvedDeleteSet) { return $true diff --git a/src/internal/functions/Get-AzOpsResource.ps1 b/src/internal/functions/Get-AzOpsResource.ps1 index 17b1b95e..6b4cc99f 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 -ResourceIdd $ScopeObject.Scope -ErrorAction SilentlyContinue + } + } + } default { $resource = Get-AzResource -ResourceId $ScopeObject.Scope -ErrorAction SilentlyContinue } diff --git a/src/internal/functions/Remove-AzOpsDeployment.ps1 b/src/internal/functions/Remove-AzOpsDeployment.ps1 index 2f58b28e..c7c0f810 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 + $removeJobName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' + $removeJobName = "AzOps-RemoveResource-$removeJobName" + } Write-AzOpsMessage -LogLevel Important -LogString 'Remove-AzOpsDeployment.Processing' -LogStringValues $removeJobName, $TemplateFilePath #region Parse Content @@ -347,89 +360,128 @@ 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 + $results = 'What if successful:{1}Performing the operation:{1}Deletion of Deployment Stack: {0}{1}with resourcesCleanupAction: {2}, resourceGroupsCleanupAction: {3}, managementGroupsCleanupAction: {4}{1}with associated resource: {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 + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' + # 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 + 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' + } } - { $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 + 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 } } - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $allResults -RemoveAzOpsFlag $true - if ($retry.Count -gt 0) { - # Retry failed removals recursively - Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.Resource.RetryCount' -LogStringValues $retry.Count - foreach ($try in $retry) { $try.Status = $null } - $removeActionRecursive = Remove-AzResourceRaw -InputObject $retry -Recursive - $removeActionRecursiveRemaining = $removeActionRecursive | Where-Object { $_.Status -eq 'failed' } - return $removeActionRecursiveRemaining + else { + # No resource to remove was found + Write-AzOpsMessage -LogLevel Warning -LogString 'Remove-AzOpsDeployment.ResourceNotFound' -LogStringValues $scopeObject.Resource, $scopeObject.Scope + $results = 'What if operation failed:{1}Deletion of target resource {0}.{1}Resource could not be found' -f $scopeObject.Scope, [environment]::NewLine + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $results -RemoveAzOpsFlag $true + return } } - 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 + $baseTemplateCheck = $TemplateFilePath -replace '\.bicep$', '.json' + if ($TemplateParameterFilePath) { + $baseParameterCheck = $TemplateParameterFilePath -replace '\.bicepparam$', 'parameters.json' + } + if ($DeleteSet) { + $deleteSetCheck = $DeleteSet -replace '\.bicep$', '.json' + $deleteSetCheck = $deleteSetCheck -replace '\.bicepparam$', '.parameters.json' + # Check if template and parameter file exist in $DeleteSet, example AzOps has been instructed to remove template.json but not the associated parameter.json + $resultsFileAssociation = switch ($null) { + { $baseTemplateCheck -notin $deleteSetCheck -and $baseParameterCheck -notin $deleteSetCheck } { + 'Missing template and parameter file association:{2}{0} and {1} for deletion.{2}{2}Ensure that you have reviewed and confirmed the necessity of each deletion.{2}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{2}' -f $TemplateFilePath, $TemplateParameterFilePath, [environment]::NewLine + } + { $baseTemplateCheck -notin $deleteSetCheck } { + 'Missing template file association:{1}{0} for deletion.{1}{1}Ensure that you have reviewed and confirmed the necessity of each deletion.{1}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{1}' -f $TemplateFilePath, [environment]::NewLine + } + { $baseParameterCheck -notin $deleteSetCheck } { + 'Missing parameter file association:{1}{0} for deletion.{1}{1}Ensure that you have reviewed and confirmed the necessity of each deletion.{1}If you are deleting files with extension .bicep or .bicepparam, keep in mind that AzOps converts them to .json or .parameters.json for deletion processing and outputs the results from the converted files here.{1}' -f $TemplateParameterFilePath, [environment]::NewLine + } + } + # If there are $resultsFileAssociation, combine them with existing results and log a warning + if ($resultsFileAssociation) { + $finalResults = @() + $finalResults += $resultsFileAssociation + $finalResults += $allResults + $allResults = $finalResults + Write-AzOpsMessage -LogLevel Warning -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $allResults + } + } + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $allResults -RemoveAzOpsFlag $true + if ($retry.Count -gt 0) { + # Retry failed removals recursively + Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.Resource.RetryCount' -LogStringValues $retry.Count + foreach ($try in $retry) { $try.Status = $null } + $removeActionRecursive = Remove-AzResourceRaw -InputObject $retry -Recursive + $removeActionRecursiveRemaining = $removeActionRecursive | Where-Object { $_.Status -eq 'failed' } + return $removeActionRecursiveRemaining } } #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 From 03a309d1f35c9aec8ff1ad0e317e2fe28649f1a3 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Tue, 29 Apr 2025 09:27:40 +0000 Subject: [PATCH 06/38] Update --- src/functions/Invoke-AzOpsPush.ps1 | 2 +- .../functions/Get-AzOpsDeploymentStackSetting.ps1 | 13 +++++++++++++ src/internal/functions/Remove-AzOpsDeployment.ps1 | 9 --------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index b77056ee..55b9bc51 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -92,7 +92,7 @@ $FilePath = $transpiledTemplatePaths.transpiledTemplatePath } - # Handle DeploymentStacks templates + # Handle AzOps DeploymentStacks templates if ($FilePath.EndsWith(".deploymentStacks.json")) { # Retrieve reverse lookup template paths for the deployment stack $azOpsDeploymentStackReverseLookupTemplatePaths = Get-AzOpsDeploymentStackSetting -TemplateFilePath $FilePath -ReverseLookup diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index 5b7cee3d..ed70a104 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -15,6 +15,19 @@ 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" + > $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} #> [CmdletBinding()] diff --git a/src/internal/functions/Remove-AzOpsDeployment.ps1 b/src/internal/functions/Remove-AzOpsDeployment.ps1 index c7c0f810..cdb29785 100644 --- a/src/internal/functions/Remove-AzOpsDeployment.ps1 +++ b/src/internal/functions/Remove-AzOpsDeployment.ps1 @@ -428,15 +428,6 @@ $allResults += $results } } - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $allResults -RemoveAzOpsFlag $true - if ($retry.Count -gt 0) { - # Retry failed removals recursively - Write-AzOpsMessage -LogLevel InternalComment -LogString 'Remove-AzOpsDeployment.Resource.RetryCount' -LogStringValues $retry.Count - foreach ($try in $retry) { $try.Status = $null } - $removeActionRecursive = Remove-AzResourceRaw -InputObject $retry -Recursive - $removeActionRecursiveRemaining = $removeActionRecursive | Where-Object { $_.Status -eq 'failed' } - return $removeActionRecursiveRemaining - } } else { # No resource to remove was found From 01fb831025f881d746108c904835bbf28baa2c0e Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Tue, 29 Apr 2025 15:07:28 +0000 Subject: [PATCH 07/38] Update --- src/functions/Invoke-AzOpsPush.ps1 | 11 +- .../Get-AzOpsDeploymentStackSetting.ps1 | 140 +++++++++++------- 2 files changed, 97 insertions(+), 54 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index 55b9bc51..328b7685 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -102,9 +102,16 @@ $reverseLookupDeploymentStacksTemplates = foreach ($templatePath in $azOpsDeploymentStackReverseLookupTemplatePaths.ReverseLookupTemplateFilePath) { New-AzOpsList -FilePath $templatePath -CompareDeploymentToDeletion:$CompareDeploymentToDeletion } - # If templates are successfully created, return them if ($reverseLookupDeploymentStacksTemplates) { - return $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 { diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index ed70a104..5c899945 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -41,6 +41,7 @@ ) begin { + # Initialize the result object with default null values $result = [PSCustomObject] @{ DeploymentStackTemplateFilePath = $null DeploymentStackSettings = $null @@ -49,16 +50,26 @@ } process { + function Get-AzOpsDeploymentStackSettingReverseLookup { + param ( + [string] + $TemplateFilePath, + [PSCustomObject] + $result + ) - if ($ReverseLookup -and $TemplateFilePath.EndsWith('.deploymentStacks.json')) { 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 '*' - $files = Get-ChildItem -Path $folderPathLookup -File -Include *.bicep, *.json | Select-Object -ExpandProperty FullName + + # Retrieve all Bicep and JSON files in the folder + $files = Get-ChildItem -Path $folderPathLookup -File -Include *.bicep, *.json -Exclude *.deploymentStacks.json | Select-Object -ExpandProperty FullName $returnFiles = @() + foreach ($file in $files) { 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") { Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStack.Metadata.AzOps' -LogStringValues $file @@ -71,12 +82,14 @@ $returnFiles += $file } } + + # Update the result object with the resolved file paths $result.ReverseLookupTemplateFilePath = $returnFiles Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.ReverseLookup.TemplateFilePath' -LogStringValues $TemplateFilePath, $result.ReverseLookupTemplateFilePath return $result } else { - # This is a dedicated template stack file + # 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 @@ -89,16 +102,73 @@ } } } - elseif ($ReverseLookup) { - return + function Get-AzOpsDeploymentStackFile { + param ( + [string] + $StackPath, + [string] + $TemplateFilePath, + [array] + $FileVariants, + [PSCustomObject] + $result + ) + + # Check if the stack file exists + if (Test-Path $StackPath) { + $stackContent = Get-Content -Path $StackPath -Raw | ConvertFrom-Json -Depth 100 + + # Handle excluded files + if ($stackContent.excludedAzOpsFiles -and ($stackContent.excludedAzOpsFiles).Count -gt 0) { + if ($FileVariants | Where-Object { $stackContent.excludedAzOpsFiles -match $_ }) { + # Log the exclusion + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $StackPath + } + 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 + if ($stackContent.excludedAzOpsFiles) { + $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') + } + $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + } + } + 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 + if ($stackContent.excludedAzOpsFiles) { + $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') + } + $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + } + return $result + } + else { + # Log if the stack file does not exist + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' -LogStringValues $StackPath + return $result + } + + } + # Handle ReverseLookup Mode + if ($ReverseLookup) { + $validatedResult = Get-AzOpsDeploymentStackSettingReverseLookup -TemplateFilePath $TemplateFilePath -result $result + if ($validatedResult) { + $result = $validatedResult + return $result + } } + # 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 } + # Handle JSON files that are not parameter files if ($TemplateFilePath.EndsWith('.json') -and -not $TemplateFilePath.EndsWith('parameters.json')) { # Generate a list of potential file names to check $fileName = Split-Path -Path $TemplateFilePath -Leaf @@ -109,61 +179,27 @@ elseif ($fileName -like '*.bicep') { $fileVariants += $fileName -replace '\.bicep$', '.json' } + + # Check for associated stack template files $stackTemplatePath = $TemplateFilePath -replace '\.json$', '.deploymentStacks.json' $parentStackPath = Join-Path -Path (Split-Path -Path $TemplateFilePath) -ChildPath ".deploymentStacks.json" - if (Test-Path $stackTemplatePath) { - $stackContent = Get-Content -Path $stackTemplatePath -Raw | ConvertFrom-Json -Depth 100 - if ($stackContent.excludedAzOpsFiles -and $stackContent.excludedAzOpsFiles.Count -gt 0) { - # Check if any of the file variants match the exclusion patterns - if ($fileVariants | Where-Object { $stackContent.excludedAzOpsFiles -match $_ }) { - Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $stackTemplatePath - } - else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $stackTemplatePath, $TemplateFilePath - $result.DeploymentStackTemplateFilePath = $stackTemplatePath - $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') - $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable - return $result - } - } - else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $stackTemplatePath, $TemplateFilePath - $result.DeploymentStackTemplateFilePath = $stackTemplatePath - if ($stackContent.excludedAzOpsFiles) { $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') } - $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable - return $result - } + + $evaluateStackTemplatePath = Get-AzOpsDeploymentStackFile -StackPath $stackTemplatePath -TemplateFilePath $TemplateFilePath -FileVariants $fileVariants -result $result + if ($evaluateStackTemplatePath.DeploymentStackTemplateFilePath) { + $result = $evaluateStackTemplatePath + return $result } - if (Test-Path $parentStackPath) { - $fileName = Split-Path -Path $TemplateFilePath -Leaf - $parentStackContent = Get-Content -Path $parentStackPath -Raw | ConvertFrom-Json -Depth 100 - if ($parentStackContent.excludedAzOpsFiles -and $parentStackContent.excludedAzOpsFiles.Count -gt 0) { - # Check if any of the file variants match the exclusion patterns - if ($fileVariants | Where-Object { $parentStackContent.excludedAzOpsFiles -match $_ }) { - Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $parentStackPath - return $result - } - else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $parentStackPath, $TemplateFilePath - $result.DeploymentStackTemplateFilePath = $parentStackPath - $parentStackContent.PSObject.Properties.Remove('excludedAzOpsFiles') - $result.DeploymentStackSettings = $parentStackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable - return $result - } + else { + $evaluateParentStackPath = Get-AzOpsDeploymentStackFile -StackPath $parentStackPath -TemplateFilePath $TemplateFilePath -FileVariants $fileVariants -result $result + if ($evaluateParentStackPath.DeploymentStackTemplateFilePath) { + $result = $evaluateParentStackPath + return $result } else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' -LogStringValues $parentStackPath, $TemplateFilePath - $result.DeploymentStackTemplateFilePath = $parentStackPath - if ($parentStackContent.excludedAzOpsFiles) { $parentStackContent.PSObject.Properties.Remove('excludedAzOpsFiles') } - $result.DeploymentStackSettings = $parentStackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' -LogStringValues $TemplateFilePath return $result } } - else { - Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' -LogStringValues $TemplateFilePath - return $result - } } - } } \ No newline at end of file From 827ea878d216b6a8746bce1e1c7bbe82423f8754 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 30 Apr 2025 10:20:33 +0000 Subject: [PATCH 08/38] Update --- src/functions/Invoke-AzOpsPush.ps1 | 8 +- .../Get-AzOpsDeploymentStackSetting.ps1 | 106 +++++++++++++----- src/localized/en-us/Strings.psd1 | 7 +- 3 files changed, 84 insertions(+), 37 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index 328b7685..a5dea7b8 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -217,7 +217,7 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope - $deploymentStack = $result.TemplateFilePath | Get-AzOpsDeploymentStackSetting + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ScopeObject $result.ScopeObject $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result @@ -237,7 +237,7 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope - $deploymentStack = $result.TemplateFilePath | Get-AzOpsDeploymentStackSetting + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ScopeObject $result.ScopeObject $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result @@ -268,7 +268,7 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope - $deploymentStack = $result.TemplateFilePath | Get-AzOpsDeploymentStackSetting + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ScopeObject $result.ScopeObject $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result @@ -378,7 +378,7 @@ $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 = $result.TemplateFilePath | Get-AzOpsDeploymentStackSetting + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ScopeObject $result.ScopeObject $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings $result diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index 5c899945..a8a59051 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -10,11 +10,13 @@ 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 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" + > $result = Get-AzOpsDeploymentStackSetting -TemplateFilePath "C:\Templates\example.bicep" -ScopeObject (New-AzOpsScope -Path C:\Templates\example.bicep) > $result DeploymentStackTemplateFilePath : C:\Templates\example.deploymentStacks.json @@ -30,26 +32,22 @@ ReverseLookupTemplateFilePath : {C:\Templates\example1.bicep, C:\Templates\example2.json} #> + #region Parameters [CmdletBinding()] param ( - [Parameter(ValueFromPipeline = $true)] + [Parameter(Mandatory=$true, ValueFromPipeline = $true)] [string] $TemplateFilePath, [Parameter(ValueFromPipeline = $true)] + [object] + $ScopeObject, + [Parameter(ValueFromPipeline = $true)] [switch] $ReverseLookup ) + #endregion begin { - # Initialize the result object with default null values - $result = [PSCustomObject] @{ - DeploymentStackTemplateFilePath = $null - DeploymentStackSettings = $null - ReverseLookupTemplateFilePath = $null - } - } - - process { function Get-AzOpsDeploymentStackSettingReverseLookup { param ( [string] @@ -64,10 +62,10 @@ $folderPathLookup = Join-Path -Path $folderPath -ChildPath '*' # Retrieve all Bicep and JSON files in the folder - $files = Get-ChildItem -Path $folderPathLookup -File -Include *.bicep, *.json -Exclude *.deploymentStacks.json | Select-Object -ExpandProperty FullName - $returnFiles = @() + $allTemplateFiles = Get-ChildItem -Path $folderPathLookup -File -Include *.bicep, *.json -Exclude *.deploymentStacks.json | Select-Object -ExpandProperty FullName + $nonAzOpsFiles = @() - foreach ($file in $files) { + foreach ($file in $allTemplateFiles) { if ($file.EndsWith('.json')) { # Check if the JSON file has AzOps metadata $fileContent = Get-Content -Path $file | ConvertFrom-Json -AsHashtable @@ -75,16 +73,16 @@ Write-AzOpsMessage -LogLevel Verbose -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStack.Metadata.AzOps' -LogStringValues $file } else { - $returnFiles += $file + $nonAzOpsFiles += $file } } else { - $returnFiles += $file + $nonAzOpsFiles += $file } } # Update the result object with the resolved file paths - $result.ReverseLookupTemplateFilePath = $returnFiles + $result.ReverseLookupTemplateFilePath = $nonAzOpsFiles Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.ReverseLookup.TemplateFilePath' -LogStringValues $TemplateFilePath, $result.ReverseLookupTemplateFilePath return $result } @@ -111,13 +109,58 @@ [array] $FileVariants, [PSCustomObject] - $result + $result, + [object] + $ScopeObject ) # Check if the stack file exists if (Test-Path $StackPath) { - $stackContent = Get-Content -Path $StackPath -Raw | ConvertFrom-Json -Depth 100 - + 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.DeploymentStackSetting.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 { + $command = "New-AzResourceGroupDeploymentStack" + } + $allowedSettings = @( + "ActionOnUnmanage", + "DenySettingsMode", + "DenySettingsExcludedPrincipal", + "DenySettingsExcludedAction", + "DenySettingsApplyToChildScopes", + "BypassStackOutOfSyncError" + ) + # 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) { + # 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) { if ($FileVariants | Where-Object { $stackContent.excludedAzOpsFiles -match $_ }) { @@ -128,20 +171,14 @@ # 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 - if ($stackContent.excludedAzOpsFiles) { - $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') - } - $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + $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 - if ($stackContent.excludedAzOpsFiles) { - $stackContent.PSObject.Properties.Remove('excludedAzOpsFiles') - } - $result.DeploymentStackSettings = $stackContent | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + $result.DeploymentStackSettings = $finalParameters } return $result } @@ -152,6 +189,15 @@ } } + # 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 @@ -184,13 +230,13 @@ $stackTemplatePath = $TemplateFilePath -replace '\.json$', '.deploymentStacks.json' $parentStackPath = Join-Path -Path (Split-Path -Path $TemplateFilePath) -ChildPath ".deploymentStacks.json" - $evaluateStackTemplatePath = Get-AzOpsDeploymentStackFile -StackPath $stackTemplatePath -TemplateFilePath $TemplateFilePath -FileVariants $fileVariants -result $result + $evaluateStackTemplatePath = Get-AzOpsDeploymentStackFile -StackPath $stackTemplatePath -TemplateFilePath $TemplateFilePath -FileVariants $fileVariants -result $result -ScopeObject $ScopeObject if ($evaluateStackTemplatePath.DeploymentStackTemplateFilePath) { $result = $evaluateStackTemplatePath return $result } else { - $evaluateParentStackPath = Get-AzOpsDeploymentStackFile -StackPath $parentStackPath -TemplateFilePath $TemplateFilePath -FileVariants $fileVariants -result $result + $evaluateParentStackPath = Get-AzOpsDeploymentStackFile -StackPath $parentStackPath -TemplateFilePath $TemplateFilePath -FileVariants $fileVariants -result $result -ScopeObject $ScopeObject if ($evaluateParentStackPath.DeploymentStackTemplateFilePath) { $result = $evaluateParentStackPath return $result diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index 56a75ffc..34ce49e3 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -89,9 +89,10 @@ '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}]' # $stackTemplatePath/$parentStackPath, $TemplateFilePath - 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' = 'No DeploymentStack file found for template: [{0}]' # $TemplateFilePath - 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' = 'Template: [{0}] excluded from deploymentStacks processing by DeploymentStack file: [{1}]' # $TemplateFilePath, $stackTemplatePath/$parentStackPath + 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' = 'Found DeploymentStack [{0}] for template [{1}]' # $StackPath, $TemplateFilePath + 'Get-AzOpsDeploymentStackSetting.DeploymentStackSetting.Error' = 'Error reading DeploymentStackSetting in [{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}]' # $TemplateFilePath, $StackPath '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 From 01b011f73752a782460987e2262a08cf3a67d81e Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 30 Apr 2025 10:55:15 +0000 Subject: [PATCH 09/38] Update --- src/functions/Invoke-AzOpsPush.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index a5dea7b8..1eca11f8 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -126,8 +126,8 @@ } 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 From 95ef75113e23ce80ac2984d028d6d7a9c8c92bde Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 1 May 2025 10:18:50 +0000 Subject: [PATCH 10/38] Update --- src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 | 5 +++-- src/localized/en-us/Strings.psd1 | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index a8a59051..48b47bea 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -122,7 +122,7 @@ } catch { # Handle errors during JSON conversion or other operations - Write-AzOpsMessage -LogLevel Error -LogString 'Get-AzOpsDeploymentStackSetting.DeploymentStackSetting.Error' -LogStringValues $StackPath, $TemplateFilePath + Write-AzOpsMessage -LogLevel Error -LogString 'Get-AzOpsDeploymentStackSetting.Setting.Error' -LogStringValues $StackPath, $TemplateFilePath $result.DeploymentStackTemplateFilePath = $StackPath $result.DeploymentStackSettings = $null return $result @@ -137,7 +137,8 @@ $command = "New-AzManagementGroupDeploymentStack" } else { - $command = "New-AzResourceGroupDeploymentStack" + Write-AzOpsMessage -LogLevel Error -LogString 'Get-AzOpsDeploymentStackSetting.Scope.Error' -LogStringValues $StackPath, $TemplateFilePath + return $result } $allowedSettings = @( "ActionOnUnmanage", diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index 34ce49e3..beece2e1 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -90,7 +90,8 @@ 'Get-AzOpsCurrentPrincipal.PrincipalId' = 'Current PrincipalId is {0}' #$principalObject.id 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStackTemplateFilePath' = 'Found DeploymentStack [{0}] for template [{1}]' # $StackPath, $TemplateFilePath - 'Get-AzOpsDeploymentStackSetting.DeploymentStackSetting.Error' = 'Error reading DeploymentStackSetting in [{0}] for template [{1}]' # $StackPath $TemplateFilePath + 'Get-AzOpsDeploymentStackSetting.Setting.Error' = 'Error reading DeploymentStackSetting in [{0}] for template [{1}]' # $StackPath $TemplateFilePath + '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}]' # $TemplateFilePath, $StackPath 'Get-AzOpsDeploymentStackSetting.Resolve.DeploymentStack.Metadata.AzOps' = 'AzOps generated template file: [{0}] excluded from deploymentStacks processing' # $TemplateFilePath From 5fea3d89d086461389f26d9030871a145684ab3f Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 5 May 2025 08:37:26 +0000 Subject: [PATCH 11/38] Update --- src/internal/functions/New-AzOpsDeployment.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index a6909b1e..f82c6628 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -288,12 +288,13 @@ $parameters.Remove('ExcludeChangeType') } if ($null -ne $DeploymentStackSettings) { + $DeploymentStackSettings['Force'] = $true $parameters += $DeploymentStackSettings } $parameters.Name = $DeploymentName if ($PSCmdlet.ShouldProcess("Start $($scopeObject.type) Deployment with $deploymentCommand")) { if (-not $invalidTemplate) { - $deploymentResult.deployment = & $deploymentCommand @parameters -Force:$true + $deploymentResult.deployment = & $deploymentCommand @parameters } } else { From c236c38e76c9054e7b61fc330c2659755dfa0800 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 5 May 2025 13:24:39 +0000 Subject: [PATCH 12/38] Update --- src/functions/Invoke-AzOpsPush.ps1 | 8 ++- .../functions/New-AzOpsDeployment.ps1 | 72 +++++++++---------- .../functions/Remove-AzOpsDeployment.ps1 | 26 +++++-- 3 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index 1eca11f8..46ff0777 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -704,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 + } } } } diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index f82c6628..3a19f688 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -34,6 +34,7 @@ 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)] @@ -239,55 +240,42 @@ 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 ($null -ne $DeploymentStackSettings) { + if ($deploymentCommand -match 'DeploymentStack$') { + # Add Force parameter for deploymentStack cmdlets $DeploymentStackSettings['Force'] = $true $parameters += $DeploymentStackSettings } @@ -309,6 +297,18 @@ } #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/Remove-AzOpsDeployment.ps1 b/src/internal/functions/Remove-AzOpsDeployment.ps1 index cdb29785..337cb2d4 100644 --- a/src/internal/functions/Remove-AzOpsDeployment.ps1 +++ b/src/internal/functions/Remove-AzOpsDeployment.ps1 @@ -318,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 -Results $results -RemoveAzOpsFlag $true + } return } if ($dependency) { @@ -326,10 +328,12 @@ 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 -Results $results -RemoveAzOpsFlag $true + } } } } @@ -337,7 +341,9 @@ $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 -Results $results -RemoveAzOpsFlag $true + } } if ($dependencyMissing) { return $dependencyMissing @@ -346,7 +352,9 @@ $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 -Results $results -RemoveAzOpsFlag $true + } } if ($PSCmdlet.ShouldProcess("Remove $($scopeObject.Scope)?")) { $null = Remove-AzResourceRaw -ScopeObject $scopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath @@ -367,7 +375,7 @@ # Check if the resource exists $resource = Get-AzOpsResource -DeploymentStackName $removeJobName -ScopeObject $scopeObject -ErrorAction SilentlyContinue if ($resource) { - $deploymentStackScopeObject = New-AzOpsScope -Scope $resource.Id + $deploymentStackScopeObject = New-AzOpsScope -Scope $resource.Id -WhatIf:$false $results = 'What if successful:{1}Performing the operation:{1}Deletion of Deployment Stack: {0}{1}with resourcesCleanupAction: {2}, resourceGroupsCleanupAction: {3}, managementGroupsCleanupAction: {4}{1}with associated resource: {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 @@ -433,7 +441,9 @@ # 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 + if ($WhatIfPreference) { + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $results -RemoveAzOpsFlag $true + } return } } @@ -465,7 +475,9 @@ Write-AzOpsMessage -LogLevel Warning -LogString 'Set-AzOpsWhatIfOutput.WhatIfResults' -LogStringValues $allResults } } - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $allResults -RemoveAzOpsFlag $true + if ($WhatIfPreference) { + 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 From 371eaa2652084d6da2112493fc1bf9420b758215 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 5 May 2025 14:12:02 +0000 Subject: [PATCH 13/38] Update --- src/internal/functions/Remove-AzOpsDeployment.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/functions/Remove-AzOpsDeployment.ps1 b/src/internal/functions/Remove-AzOpsDeployment.ps1 index 337cb2d4..36c8405a 100644 --- a/src/internal/functions/Remove-AzOpsDeployment.ps1 +++ b/src/internal/functions/Remove-AzOpsDeployment.ps1 @@ -376,7 +376,7 @@ $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}with resourcesCleanupAction: {2}, resourceGroupsCleanupAction: {3}, managementGroupsCleanupAction: {4}{1}with associated resource: {1}{5}.' -f $deploymentStackScopeObject.Scope, [environment]::NewLine, $resource.resourcesCleanupAction, $resource.resourceGroupsCleanupAction, $resource.managementGroupsCleanupAction, ($resource.Resources.Id | Out-String) + $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 Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' From 6ebb6a7f8465f3116d258fd514d53a21bb8cd16c Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 5 May 2025 14:37:23 +0000 Subject: [PATCH 14/38] Update --- src/internal/functions/New-AzOpsDeployment.ps1 | 9 ++++++--- src/localized/en-us/Strings.psd1 | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index 3a19f688..ebdbb739 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -153,37 +153,40 @@ } # 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' 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' 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' 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 diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index beece2e1..1f9281ac 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -259,14 +259,17 @@ 'New-AzOpsScope.Starting' = 'Starting creation of new scope object' # '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.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 From c3637e966b8d9652a634d09640c4b9c43f28bcf9 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 5 May 2025 16:05:33 +0000 Subject: [PATCH 15/38] Update --- src/internal/functions/New-AzOpsDeployment.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index ebdbb739..e28284c2 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -95,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 { From 542ab9e5c28decbc20057b73c798a104999b8a5e Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 5 May 2025 16:39:54 +0000 Subject: [PATCH 16/38] Update --- src/functions/Invoke-AzOpsPush.ps1 | 2 +- src/internal/functions/New-AzOpsDeployment.ps1 | 1 + .../functions/Set-AzOpsWhatIfOutput.ps1 | 17 +++++++++++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index 46ff0777..1f2a9a7e 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -707,7 +707,7 @@ if ($WhatIfPreference) { #Process deploymentResult and output result foreach ($result in $deploymentResult) { - Set-AzOpsWhatIfOutput -FilePath $result.filePath -ParameterFilePath $result.parameterFilePath -Results $result.results + Set-AzOpsWhatIfOutput -FilePath $result.filePath -ParameterFilePath $result.parameterFilePath -Results $result.results -DeploymentStackTemplateFilePath $result.deploymentStackTemplateFilePath } } } diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index e28284c2..982d5418 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -234,6 +234,7 @@ $deploymentResult = [PSCustomObject]@{ filePath = '' parameterFilePath = '' + deploymentStackTemplateFilePath = $DeploymentStackTemplateFilePath results = '' deployment = '' } diff --git a/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 b/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 index 171fa372..e6acda30 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,9 +58,17 @@ } 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 { + if ($DeploymentStackTemplateFilePath -ne '') { + $resultHeadline = "$($FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1]) using DeploymentStack $($DeploymentStackTemplateFilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1])" + } $resultHeadline = $FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1] } From 1a4a53c555c9af82d7dfd7c3c70450583f5260c5 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 7 May 2025 13:48:00 +0000 Subject: [PATCH 17/38] Update --- src/functions/Invoke-AzOpsPush.ps1 | 10 +- .../Get-AzOpsDeploymentStackSetting.ps1 | 284 ++++++++++++------ src/localized/en-us/Strings.psd1 | 2 +- 3 files changed, 193 insertions(+), 103 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index 1f2a9a7e..e4500480 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -217,7 +217,7 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope - $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ScopeObject $result.ScopeObject + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ParameterTemplateFilePath $result.TemplateParameterFilePath -ScopeObject $result.ScopeObject $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result @@ -237,7 +237,7 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope - $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ScopeObject $result.ScopeObject + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ParameterTemplateFilePath $result.TemplateParameterFilePath -ScopeObject $result.ScopeObject $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result @@ -268,7 +268,7 @@ $newScopeObject = New-AzOpsScope -Path $result.TemplateFilePath -StatePath $StatePath -ErrorAction Stop $result.ScopeObject = $newScopeObject $result.Scope = $newScopeObject.Scope - $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ScopeObject $result.ScopeObject + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ParameterTemplateFilePath $result.TemplateParameterFilePath -ScopeObject $result.ScopeObject $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result @@ -334,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) { @@ -378,7 +378,7 @@ $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 -ScopeObject $result.ScopeObject + $deploymentStack = Get-AzOpsDeploymentStackSetting -TemplateFilePath $result.TemplateFilePath -ParameterTemplateFilePath $result.TemplateParameterFilePath -ScopeObject $result.ScopeObject $result.DeploymentStackTemplateFilePath = $deploymentStack.DeploymentStackTemplateFilePath $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings $result diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index 48b47bea..02748185 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -10,6 +10,8 @@ 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 @@ -39,6 +41,9 @@ [string] $TemplateFilePath, [Parameter(ValueFromPipeline = $true)] + [string] + $ParameterTemplateFilePath, + [Parameter(ValueFromPipeline = $true)] [object] $ScopeObject, [Parameter(ValueFromPipeline = $true)] @@ -48,7 +53,23 @@ #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, @@ -56,6 +77,7 @@ $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 @@ -86,6 +108,19 @@ 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')) { @@ -101,95 +136,175 @@ } } 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, - [array] + [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" + ) + # Get the valid parameters for the command + $validParameters = (Get-Command $command).Parameters.Keys | Where-Object { $_ -in $allowedSettings } - # 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" - ) - # 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) { - # Add the key-value pair to the prepared parameters - $finalParameters[$key] = $stackContent[$key] + # 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) { + # 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) { - if ($FileVariants | Where-Object { $stackContent.excludedAzOpsFiles -match $_ }) { - # Log the exclusion - Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.ExcludedFromDeploymentStack' -LogStringValues $TemplateFilePath, $StackPath + # 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 the file is not excluded + # 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 { - # 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 + # Log if the stack file does not exist + Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' -LogStringValues $StackPath + return $result } - return $result } else { - # Log if the stack file does not exist - Write-AzOpsMessage -LogLevel Debug -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoDeploymentStackFound' -LogStringValues $StackPath - return $result + 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 @@ -207,46 +322,21 @@ return $result } } - # 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 } - - # Handle JSON files that are not parameter files - if ($TemplateFilePath.EndsWith('.json') -and -not $TemplateFilePath.EndsWith('parameters.json')) { - # Generate a list of potential file names to check - $fileName = Split-Path -Path $TemplateFilePath -Leaf - $fileVariants = @($fileName) - if ($fileName -like '*.json') { - $fileVariants += $fileName -replace '\.json$', '.bicep' - } - elseif ($fileName -like '*.bicep') { - $fileVariants += $fileName -replace '\.bicep$', '.json' - } - - # Check for associated stack template files - $stackTemplatePath = $TemplateFilePath -replace '\.json$', '.deploymentStacks.json' - $parentStackPath = Join-Path -Path (Split-Path -Path $TemplateFilePath) -ChildPath ".deploymentStacks.json" - - $evaluateStackTemplatePath = Get-AzOpsDeploymentStackFile -StackPath $stackTemplatePath -TemplateFilePath $TemplateFilePath -FileVariants $fileVariants -result $result -ScopeObject $ScopeObject - if ($evaluateStackTemplatePath.DeploymentStackTemplateFilePath) { - $result = $evaluateStackTemplatePath - return $result - } - else { - $evaluateParentStackPath = Get-AzOpsDeploymentStackFile -StackPath $parentStackPath -TemplateFilePath $TemplateFilePath -FileVariants $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 - } - } + # 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/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index 1f9281ac..4bd549c1 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -93,7 +93,7 @@ 'Get-AzOpsDeploymentStackSetting.Setting.Error' = 'Error reading DeploymentStackSetting in [{0}] for template [{1}]' # $StackPath $TemplateFilePath '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}]' # $TemplateFilePath, $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 From f4b62a60edb5321371869bdbb20e756fdd070468 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 7 May 2025 18:50:09 +0000 Subject: [PATCH 18/38] Update --- .../functions/Get-AzOpsDeploymentStackSetting.ps1 | 15 +++++++++++---- src/localized/en-us/Strings.psd1 | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index 02748185..4efb0837 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -322,10 +322,17 @@ return $result } } - # 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 + 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 diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index 4bd549c1..8666421f 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -90,6 +90,7 @@ '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.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 From d655e397944a9b7740262cae14f27c36432b013b Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 7 May 2025 19:01:33 +0000 Subject: [PATCH 19/38] Update --- src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 | 4 ++++ src/localized/en-us/Strings.psd1 | 1 + 2 files changed, 5 insertions(+) diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index 4efb0837..90b14249 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -322,6 +322,10 @@ return $result } } + if (-not $TemplateFilePath.EndsWith('.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 diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index 8666421f..efe585e9 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -97,6 +97,7 @@ '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 From 67e630c4105fe682e7eb3eec12959e253049097c Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 7 May 2025 19:06:44 +0000 Subject: [PATCH 20/38] Update --- src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index 90b14249..cb42900d 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -333,7 +333,6 @@ 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 From a7daf0c058a134c0047e00231646a4a8db419989 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 7 May 2025 19:42:03 +0000 Subject: [PATCH 21/38] Update --- src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index cb42900d..226f7a7b 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -91,7 +91,7 @@ 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") { + 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 { @@ -322,7 +322,7 @@ return $result } } - if (-not $TemplateFilePath.EndsWith('.json')) { + if (-not $TemplateFilePath.EndsWith('.json') -or ($file.EndsWith('parameters.json'))) { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoJson' -LogStringValues $TemplateFilePath return } From 76bc98dd102f0ccc8c765a56c68d80dc62295182 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 7 May 2025 19:54:53 +0000 Subject: [PATCH 22/38] Update --- src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index 226f7a7b..f6ec2541 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -322,7 +322,7 @@ return $result } } - if (-not $TemplateFilePath.EndsWith('.json') -or ($file.EndsWith('parameters.json'))) { + if (-not $TemplateFilePath.EndsWith('.json') -or ($TemplateFilePath.EndsWith('parameters.json'))) { Write-AzOpsMessage -LogLevel Warning -LogString 'Get-AzOpsDeploymentStackSetting.Resolve.NoJson' -LogStringValues $TemplateFilePath return } From e0059e93ccc30bc95be436ee50c10c978efff141 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 8 May 2025 08:52:57 +0000 Subject: [PATCH 23/38] Update --- .../functions/Get-AzOpsDeploymentStackSetting.ps1 | 15 ++++++++++++++- src/localized/en-us/Strings.psd1 | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index f6ec2541..b8c0dc30 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -213,7 +213,8 @@ "DenySettingsExcludedPrincipal", "DenySettingsExcludedAction", "DenySettingsApplyToChildScopes", - "BypassStackOutOfSyncError" + "BypassStackOutOfSyncError", + "Location" ) # Get the valid parameters for the command $validParameters = (Get-Command $command).Parameters.Keys | Where-Object { $_ -in $allowedSettings } @@ -225,6 +226,18 @@ 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] } diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index efe585e9..27ea9f5b 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -92,6 +92,7 @@ '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 From 074aa3dbeca1a0670e24e7e058aa17a4e3c23c5f Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 8 May 2025 11:30:51 +0000 Subject: [PATCH 24/38] Update --- .../functions/Remove-AzOpsDeployment.ps1 | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/internal/functions/Remove-AzOpsDeployment.ps1 b/src/internal/functions/Remove-AzOpsDeployment.ps1 index 36c8405a..227adc13 100644 --- a/src/internal/functions/Remove-AzOpsDeployment.ps1 +++ b/src/internal/functions/Remove-AzOpsDeployment.ps1 @@ -319,7 +319,7 @@ 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 -Results $results -RemoveAzOpsFlag $true + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -RemoveAzOpsFlag $true } return } @@ -332,7 +332,7 @@ dependencyMissing = $true } if ($WhatIfPreference) { - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -RemoveAzOpsFlag $true } } } @@ -340,9 +340,8 @@ 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' if ($WhatIfPreference) { - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -RemoveAzOpsFlag $true } } if ($dependencyMissing) { @@ -351,9 +350,8 @@ 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' if ($WhatIfPreference) { - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -Results $results -RemoveAzOpsFlag $true + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -RemoveAzOpsFlag $true } } if ($PSCmdlet.ShouldProcess("Remove $($scopeObject.Scope)?")) { @@ -379,7 +377,6 @@ $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 - Write-AzOpsMessage -LogLevel InternalComment -LogString 'Set-AzOpsWhatIfOutput.WhatIfFile' # Check if the removal should be performed if ($PSCmdlet.ShouldProcess("Remove $($deploymentStackScopeObject.Scope)?")) { $removeAction = Remove-AzResourceRaw -ScopeObject $deploymentStackScopeObject -TemplateFilePath $TemplateFilePath -TemplateParameterFilePath $TemplateParameterFilePath @@ -416,7 +413,6 @@ $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 @@ -442,7 +438,7 @@ 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 -Results $results -RemoveAzOpsFlag $true + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $results -RemoveAzOpsFlag $true } return } @@ -476,7 +472,7 @@ } } if ($WhatIfPreference) { - Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -Results $allResults -RemoveAzOpsFlag $true + Set-AzOpsWhatIfOutput -FilePath $TemplateFilePath -ParameterFilePath $TemplateParameterFilePath -DeploymentStackTemplateFilePath $DeploymentStackTemplateFilePath -Results $allResults -RemoveAzOpsFlag $true } if ($retry.Count -gt 0) { # Retry failed removals recursively From 5e872b5bda1b6c5d9198b3993e3e2514cdc1c314 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 8 May 2025 14:11:47 +0000 Subject: [PATCH 25/38] invest --- src/functions/Invoke-AzOpsPush.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index e4500480..aae1d4b6 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -252,6 +252,9 @@ $bicepTemplatePath = $fileItem.FullName -replace '\.bicepparam$', '.bicep' } if (Test-Path $bicepTemplatePath) { + #removeme + + #removeme if ($CompareDeploymentToDeletion) { # Avoid adding files destined for deletion to a deployment list if ($bicepTemplatePath -in $deleteSet -or $bicepTemplatePath -in ($deleteSet | Resolve-Path).Path) { @@ -273,6 +276,11 @@ $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result } + #removeme + else { + Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPush.Resolve.FoundBicepTemplate' -LogStringValues $FilePath, $bicepTemplatePath + } + #removeme } } #endregion Directly Associated Template file exists From 176c982e5b6f7bcb1174980d0ae780d15d244a6c Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 8 May 2025 14:25:51 +0000 Subject: [PATCH 26/38] Clean --- src/functions/Invoke-AzOpsPush.ps1 | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/functions/Invoke-AzOpsPush.ps1 b/src/functions/Invoke-AzOpsPush.ps1 index aae1d4b6..e4500480 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -252,9 +252,6 @@ $bicepTemplatePath = $fileItem.FullName -replace '\.bicepparam$', '.bicep' } if (Test-Path $bicepTemplatePath) { - #removeme - - #removeme if ($CompareDeploymentToDeletion) { # Avoid adding files destined for deletion to a deployment list if ($bicepTemplatePath -in $deleteSet -or $bicepTemplatePath -in ($deleteSet | Resolve-Path).Path) { @@ -276,11 +273,6 @@ $result.DeploymentStackSettings = $deploymentStack.DeploymentStackSettings return $result } - #removeme - else { - Write-AzOpsMessage -LogLevel Warning -LogString 'Invoke-AzOpsPush.Resolve.FoundBicepTemplate' -LogStringValues $FilePath, $bicepTemplatePath - } - #removeme } } #endregion Directly Associated Template file exists From 022270605d51ab1f9f0ff0e977c5f2860509689a Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 8 May 2025 14:38:27 +0000 Subject: [PATCH 27/38] Fix --- src/internal/functions/Get-AzOpsResource.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/functions/Get-AzOpsResource.ps1 b/src/internal/functions/Get-AzOpsResource.ps1 index 6b4cc99f..08254846 100644 --- a/src/internal/functions/Get-AzOpsResource.ps1 +++ b/src/internal/functions/Get-AzOpsResource.ps1 @@ -74,7 +74,7 @@ $resource = Get-AzManagementGroupDeploymentStack -Name $DeploymentStackName -ManagementGroupId $ScopeObject.ManagementGroup -ErrorAction SilentlyContinue } else { - $resource = Get-AzManagementGroupDeploymentStack -ResourceIdd $ScopeObject.Scope -ErrorAction SilentlyContinue + $resource = Get-AzManagementGroupDeploymentStack -ResourceId $ScopeObject.Scope -ErrorAction SilentlyContinue } } } From c6ccf40a1921242c6163c501e0fd421d67993dd0 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 8 May 2025 18:35:21 +0000 Subject: [PATCH 28/38] Fix --- src/internal/functions/Set-AzOpsWhatIfOutput.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 b/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 index e6acda30..384a4cf1 100644 --- a/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 +++ b/src/internal/functions/Set-AzOpsWhatIfOutput.ps1 @@ -69,7 +69,9 @@ if ($DeploymentStackTemplateFilePath -ne '') { $resultHeadline = "$($FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1]) using DeploymentStack $($DeploymentStackTemplateFilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1])" } - $resultHeadline = $FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1] + else { + $resultHeadline = $FilePath.split([System.IO.Path]::DirectorySeparatorChar)[-1] + } } # Measure input $Results.Changes content From f39ec1d14e5110ae802321067cea11201ba474cb Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Fri, 9 May 2025 08:47:41 +0000 Subject: [PATCH 29/38] Update --- src/internal/functions/New-AzOpsDeployment.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index 982d5418..05d4c764 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -298,6 +298,7 @@ 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) { From 079ae0d09bac106983234a69e0cbbfdc60ba9b3b Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 12 May 2025 12:53:09 +0000 Subject: [PATCH 30/38] WikiUpdate --- docs/wiki/DeploymentStacks.md | 188 ++++++++++++++++++++++++++++++++++ docs/wiki/_Sidebar.md | 1 + 2 files changed, 189 insertions(+) create mode 100644 docs/wiki/DeploymentStacks.md diff --git a/docs/wiki/DeploymentStacks.md b/docs/wiki/DeploymentStacks.md new file mode 100644 index 00000000..4a438f3b --- /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 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 `.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** + +Yes, let's create a 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 file name) is prioritized. +- **Exclusion Handling**: Files listed in the `excludedAzOpsFiles` setting of a `.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 file names 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 file name] + B -- No --> D[Find specific stack file
matching template file name] + C --> E[Was a specific stack file found?] + D --> E + E -- Yes --> F[Check exclusions in the stack file
based on parameter or template file name] + 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 file name] + 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 file names. 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 life-cycle 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) From 050e7f35dd433dda67d9657be044a4434309cf29 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 12 May 2025 13:08:40 +0000 Subject: [PATCH 31/38] Adjust Wiki --- docs/wiki/DeploymentStacks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/wiki/DeploymentStacks.md b/docs/wiki/DeploymentStacks.md index 4a438f3b..c0f4ff1b 100644 --- a/docs/wiki/DeploymentStacks.md +++ b/docs/wiki/DeploymentStacks.md @@ -147,7 +147,7 @@ The most specific deploymentstack configuration (at scope) will be selected by c - **Exclusion Handling**: Files listed in the `excludedAzOpsFiles` setting of a `.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 file names are used to locate specific stack files. +- **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 @@ -174,7 +174,7 @@ flowchart TD #### Deployment Stacks Name -AzOps constructs the deployment stack name deterministically based on the associated template and parameter file names. 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`. +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` @@ -185,4 +185,4 @@ 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 life-cycle management of the stack and its resources._** \ No newline at end of file +**_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 From 970c1c1434cb212ed0d8ea3f81d10404af01b048 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Mon, 12 May 2025 13:12:07 +0000 Subject: [PATCH 32/38] Update --- docs/wiki/DeploymentStacks.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/wiki/DeploymentStacks.md b/docs/wiki/DeploymentStacks.md index c0f4ff1b..427fdf0a 100644 --- a/docs/wiki/DeploymentStacks.md +++ b/docs/wiki/DeploymentStacks.md @@ -143,7 +143,7 @@ graph TD ### 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 file name) is prioritized. +- **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 `.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. @@ -152,14 +152,14 @@ The most specific deploymentstack configuration (at scope) will be selected by c ```mermaid flowchart TD A[Start: Template and Parameter File Set] --> B[Is Core.AllowMultipleTemplateParameterFiles true?] - B -- Yes --> C[Find specific stack file
matching parameter file name] - B -- No --> D[Find specific stack file
matching template file name] + 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 file name] + 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 file name] + 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 From c9b302299938c04e20bb5d652908ab1e9156dc79 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Tue, 13 May 2025 10:48:01 +0000 Subject: [PATCH 33/38] Update --- src/internal/functions/New-AzOpsDeployment.ps1 | 1 + src/localized/en-us/Strings.psd1 | 1 + 2 files changed, 2 insertions(+) diff --git a/src/internal/functions/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index 05d4c764..a48d06b4 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -286,6 +286,7 @@ $parameters.Name = $DeploymentName if ($PSCmdlet.ShouldProcess("Start $($scopeObject.type) Deployment with $deploymentCommand")) { if (-not $invalidTemplate) { + Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.Deployment' -LogStringValues $deploymentCommand, $($parameters | Out-String), $scopeObject.Scope $deploymentResult.deployment = & $deploymentCommand @parameters } } diff --git a/src/localized/en-us/Strings.psd1 b/src/localized/en-us/Strings.psd1 index 27ea9f5b..a0a002e0 100644 --- a/src/localized/en-us/Strings.psd1 +++ b/src/localized/en-us/Strings.psd1 @@ -261,6 +261,7 @@ 'New-AzOpsScope.Path.NotFound' = 'Path not found: {0}' # $Path 'New-AzOpsScope.Starting' = 'Starting creation of new scope object' # + '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 From 46e4dd3195cac400eff2fd5ffd5a72faf2e92c7e Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 14 May 2025 17:14:12 +0000 Subject: [PATCH 34/38] AddingTestCase --- scripts/Remove-AzOpsTestsDeployment.ps1 | 1 + src/functions/Invoke-AzOpsPush.ps1 | 2 +- .../functions/Get-AzOpsResourceDefinition.ps1 | 2 +- .../functions/New-AzOpsDeployment.ps1 | 13 ++- .../functions/New-AzOpsStateDeployment.ps1 | 2 +- .../functions/Remove-AzOpsDeployment.ps1 | 2 +- src/tests/integration/Repository.Tests.ps1 | 99 ++++++++++++++++++- src/tests/templates/azuredeploy.jsonc | 6 ++ src/tests/templates/deploystacksatmg.bicep | 11 +++ .../templates/deploystacksatmg.x1.bicepparam | 3 + .../deploystacksatmg.x1.deploymentStacks.json | 6 ++ src/tests/templates/deploystacksatrg.bicep | 12 +++ .../deploystacksatrg.deploymentStacks.json | 6 ++ src/tests/templates/deploystacksatsub.bicep | 11 +++ .../templates/deploystacksatsub.bicepparam | 3 + .../deploystacksatsub.deploymentStacks.json | 8 ++ ...loystacksatsubrename.deploymentStacks.json | 6 ++ 17 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 src/tests/templates/deploystacksatmg.bicep create mode 100644 src/tests/templates/deploystacksatmg.x1.bicepparam create mode 100644 src/tests/templates/deploystacksatmg.x1.deploymentStacks.json create mode 100644 src/tests/templates/deploystacksatrg.bicep create mode 100644 src/tests/templates/deploystacksatrg.deploymentStacks.json create mode 100644 src/tests/templates/deploystacksatsub.bicep create mode 100644 src/tests/templates/deploystacksatsub.bicepparam create mode 100644 src/tests/templates/deploystacksatsub.deploymentStacks.json create mode 100644 src/tests/templates/deploystacksatsubrename.deploymentStacks.json diff --git a/scripts/Remove-AzOpsTestsDeployment.ps1 b/scripts/Remove-AzOpsTestsDeployment.ps1 index ad289212..785b29a6 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 -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 e4500480..4437fa9d 100644 --- a/src/functions/Invoke-AzOpsPush.ps1 +++ b/src/functions/Invoke-AzOpsPush.ps1 @@ -175,7 +175,7 @@ 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 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/New-AzOpsDeployment.ps1 b/src/internal/functions/New-AzOpsDeployment.ps1 index a48d06b4..3219d198 100644 --- a/src/internal/functions/New-AzOpsDeployment.ps1 +++ b/src/internal/functions/New-AzOpsDeployment.ps1 @@ -199,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 } @@ -208,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' @@ -286,8 +286,13 @@ $parameters.Name = $DeploymentName if ($PSCmdlet.ShouldProcess("Start $($scopeObject.type) Deployment with $deploymentCommand")) { if (-not $invalidTemplate) { - Write-AzOpsMessage -LogLevel Verbose -LogString 'New-AzOpsDeployment.Deployment' -LogStringValues $deploymentCommand, $($parameters | Out-String), $scopeObject.Scope - $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 { 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 227adc13..4f273a81 100644 --- a/src/internal/functions/Remove-AzOpsDeployment.ps1 +++ b/src/internal/functions/Remove-AzOpsDeployment.ps1 @@ -215,7 +215,7 @@ $removeJobName = $DeploymentName } else { - $fileItem = Get-Item -Path $TemplateFilePath + $fileItem = Get-Item -Path $TemplateFilePath -Force $removeJobName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_' $removeJobName = "AzOps-RemoveResource-$removeJobName" } diff --git a/src/tests/integration/Repository.Tests.ps1 b/src/tests/integration/Repository.Tests.ps1 index 577aec67..4bbef162 100644 --- a/src/tests/integration/Repository.Tests.ps1 +++ b/src/tests/integration/Repository.Tests.ps1 @@ -137,6 +137,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 +306,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 +1109,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 +1174,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 +1192,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 +1203,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 @@ -1272,6 +1281,9 @@ Describe "Repository" { $timeTest = "good" } $timeTest | Should -Be 'good' + 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 +1337,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..7114a950 100644 --- a/src/tests/templates/azuredeploy.jsonc +++ b/src/tests/templates/azuredeploy.jsonc @@ -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 From fd74194200452e3bc97425ece1fceee3276f08d2 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Wed, 14 May 2025 17:27:08 +0000 Subject: [PATCH 35/38] Update --- scripts/Remove-AzOpsTestsDeployment.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Remove-AzOpsTestsDeployment.ps1 b/scripts/Remove-AzOpsTestsDeployment.ps1 index 785b29a6..625176f7 100644 --- a/scripts/Remove-AzOpsTestsDeployment.ps1 +++ b/scripts/Remove-AzOpsTestsDeployment.ps1 @@ -69,7 +69,7 @@ foreach ($subscription in $cleanupSub) { $null = Set-AzContext -SubscriptionId $subscription.Id $null = Get-AzResourceLock | Remove-AzResourceLock -Force - $null = Get-AzSubscriptionDeploymentStack | Remove-AzSubscriptionDeploymentStack -Force -BypassStackOutOfSyncError + $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 "/"} From f04466e5c03993acc02a90455a2f24ca7f640b80 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 15 May 2025 12:19:56 +0000 Subject: [PATCH 36/38] Update --- docs/wiki/DeploymentStacks.md | 10 ++-- .../Get-AzOpsDeploymentStackSetting.ps1 | 2 +- src/tests/integration/Repository.Tests.ps1 | 54 +++++++++++++------ src/tests/templates/azuredeploy.jsonc | 2 +- 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/docs/wiki/DeploymentStacks.md b/docs/wiki/DeploymentStacks.md index 427fdf0a..321e9623 100644 --- a/docs/wiki/DeploymentStacks.md +++ b/docs/wiki/DeploymentStacks.md @@ -9,7 +9,7 @@ ## Introduction -As a part of **AzOps Push**, the module handles creation of deployment stacks and deletion 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. +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`. @@ -62,7 +62,7 @@ The allowed value for each of the parameter except for `excludedAzOpsFiles` are 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 `.deploymentStacks.json` and have all custom templates at that scope use it** +**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? @@ -97,9 +97,9 @@ Yes, let's edit the root `.deploymentStacks.json` file placed at that scope leve } ``` -**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** +**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 file called `template2.deploymentStacks.json` file placed at that scope level. +Yes, let's create a new file called `template2.deploymentStacks.json` file placed at that scope level. ```bash Folder/ ├── .deploymentStacks.json @@ -144,7 +144,7 @@ graph TD 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 `.deploymentStacks.json` file are skipped during processing. +- **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. diff --git a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 index b8c0dc30..7a6d548e 100644 --- a/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 +++ b/src/internal/functions/Get-AzOpsDeploymentStackSetting.ps1 @@ -335,7 +335,7 @@ return $result } } - if (-not $TemplateFilePath.EndsWith('.json') -or ($TemplateFilePath.EndsWith('parameters.json'))) { + 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 } diff --git a/src/tests/integration/Repository.Tests.ps1 b/src/tests/integration/Repository.Tests.ps1 index 4bbef162..909addd1 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 @@ -1269,18 +1293,14 @@ Describe "Repository" { $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 10 + $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 diff --git a/src/tests/templates/azuredeploy.jsonc b/src/tests/templates/azuredeploy.jsonc index 7114a950..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" }, From 55d1beed94e5fbc814873d1402955d11c9d4a1da Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Thu, 15 May 2025 15:36:14 +0000 Subject: [PATCH 37/38] Update --- src/tests/integration/Repository.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/integration/Repository.Tests.ps1 b/src/tests/integration/Repository.Tests.ps1 index 909addd1..c838163d 100644 --- a/src/tests/integration/Repository.Tests.ps1 +++ b/src/tests/integration/Repository.Tests.ps1 @@ -1297,7 +1297,7 @@ Describe "Repository" { $maxParallel = ($parallelTimes | Measure-Object -Maximum).Maximum $minParallel = ($parallelTimes | Measure-Object -Minimum).Minimum $diffParallel = New-TimeSpan -Start $minParallel -End $maxParallel - $diffParallel.TotalSeconds | Should -BeLessThan 10 + $diffParallel.TotalSeconds | Should -BeLessThan 15 $serialTime = ($createTime | Where-Object { $_.targetResourceId -match '^.*/s1azops' }).changeTime $diffSerial = New-TimeSpan -Start $maxParallel -End $serialTime $diffSerial.TotalSeconds | Should -BeGreaterThan 15 From 1dc133c1b97e30a9db114551b6dc331ed7b43ed9 Mon Sep 17 00:00:00 2001 From: Jesper Fajers Date: Sat, 17 May 2025 12:45:54 +0000 Subject: [PATCH 38/38] Update --- src/tests/integration/Repository.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/integration/Repository.Tests.ps1 b/src/tests/integration/Repository.Tests.ps1 index c838163d..aec3b65d 100644 --- a/src/tests/integration/Repository.Tests.ps1 +++ b/src/tests/integration/Repository.Tests.ps1 @@ -1288,7 +1288,7 @@ 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" @@ -1297,7 +1297,7 @@ Describe "Repository" { $maxParallel = ($parallelTimes | Measure-Object -Maximum).Maximum $minParallel = ($parallelTimes | Measure-Object -Minimum).Minimum $diffParallel = New-TimeSpan -Start $minParallel -End $maxParallel - $diffParallel.TotalSeconds | Should -BeLessThan 15 + $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