diff --git a/utilities/pipelines/resourceDeployment/Get-TemplateDeploymenWhatIf.ps1 b/utilities/pipelines/resourceDeployment/Get-TemplateDeploymenWhatIf.ps1 new file mode 100644 index 0000000000..5e6e3ec0f6 --- /dev/null +++ b/utilities/pipelines/resourceDeployment/Get-TemplateDeploymenWhatIf.ps1 @@ -0,0 +1,185 @@ +<# +.SYNOPSIS +Get a template What-If deployment result using a given parameter file + +.DESCRIPTION +Get a template What-If deployment resultusing a given parameter file +Works on a resource group, subscription, managementgroup and tenant level + +.PARAMETER parametersBasePath +Mandatory. The path to the root of the parameters folder to test with + +.PARAMETER templateFilePath +Mandatory. Path to the template file from root. + +.PARAMETER parameterFilePath +Optional. Path to the parameter file from root. + +.PARAMETER location +Mandatory. Location to test in. E.g. WestEurope + +.PARAMETER resourceGroupName +Optional. Name of the resource group to deploy into. Mandatory if deploying into a resource group (resource group level) + +.PARAMETER subscriptionId +Optional. ID of the subscription to deploy into. Mandatory if deploying into a subscription (subscription level) using a Management groups service connection + +.PARAMETER managementGroupId +Optional. Name of the management group to deploy into. Mandatory if deploying into a management group (management group level) + +.PARAMETER additionalParameters +Optional. Additional parameters you can provide with the deployment. E.g. @{ resourceGroupName = 'myResourceGroup' } + +.EXAMPLE +Get-TemplateDeploymenWhatIf -templateFilePath 'C:/key-vault/vault/main.bicep' -parameterFilePath 'C:/key-vault/vault/.test/parameters.json' -location 'WestEurope' -resourceGroupName 'aLegendaryRg' + +Get What-If deployment result for the main.bicep of the KeyVault module with the parameter file 'parameters.json' using the resource group 'aLegendaryRg' in location 'WestEurope' + +.EXAMPLE +Get-TemplateDeploymenWhatIf -templateFilePath 'C:/key-vault/vault/main.bicep' -location 'WestEurope' -resourceGroupName 'aLegendaryRg' + +Get What-If deployment result for the main.bicep of the KeyVault module using the resource group 'aLegendaryRg' in location 'WestEurope' + +.EXAMPLE +Get-TemplateDeploymenWhatIf -templateFilePath 'C:/resources/resource-group/main.json' -parameterFilePath 'C:/resources/resource-group/.test/parameters.json' -location 'WestEurope' + +Get What-If deployment result for the main.json of the ResourceGroup module with the parameter file 'parameters.json' in location 'WestEurope' +#> +function Get-TemplateDeploymenWhatIf { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory)] + [string] $templateFilePath, + + [Parameter(Mandatory)] + [string] $location, + + [Parameter(Mandatory = $false)] + [string] $parameterFilePath, + + [Parameter(Mandatory = $false)] + [string] $resourceGroupName, + + [Parameter(Mandatory = $false)] + [string] $subscriptionId, + + [Parameter(Mandatory = $false)] + [string] $managementGroupId, + + [Parameter(Mandatory = $false)] + [Hashtable] $additionalParameters + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + + # Load helper + . (Join-Path (Get-Item -Path $PSScriptRoot).parent.FullName 'sharedScripts' 'Get-ScopeOfTemplateFile.ps1') + } + + process { + $DeploymentInputs = @{ + TemplateFile = $templateFilePath + Verbose = $true + OutVariable = 'ValidationErrors' + } + if (-not [String]::IsNullOrEmpty($parameterFilePath)) { + $DeploymentInputs['TemplateParameterFile'] = $parameterFilePath + } + $ValidationErrors = $null + + # Additional parameter object provided yes/no + if ($additionalParameters) { + $DeploymentInputs += $additionalParameters + } + + $deploymentScope = Get-ScopeOfTemplateFile -TemplateFilePath $templateFilePath -Verbose + + $deploymentNamePrefix = Split-Path -Path (Split-Path $templateFilePath -Parent) -LeafBase + if ([String]::IsNullOrEmpty($deploymentNamePrefix)) { + $deploymentNamePrefix = 'templateDeployment-{0}' -f (Split-Path $templateFilePath -LeafBase) + } + if ($templateFilePath -match '.*(\\|\/)Microsoft.+') { + # If we can assume we're operating in a module structure, we can further fetch the provider namespace & resource type + $shortPathElem = (($templateFilePath -split 'Microsoft\.')[1] -replace '\\', '/') -split '/' # e.g., AppConfiguration, configurationStores, .test, common, main.test.bicep + $providerNamespace = $shortPathElem[0] # e.g., AppConfiguration + $providerNamespaceShort = ($providerNamespace -creplace '[^A-Z]').ToLower() # e.g., ac + + $resourceType = $shortPathElem[1] # e.g., configurationStores + $resourceTypeShort = ('{0}{1}' -f ($resourceType.ToLower())[0], ($resourceType -creplace '[^A-Z]')).ToLower() # e.g. cs + + $testFolderShort = Split-Path (Split-Path $templateFilePath -Parent) -Leaf # e.g., common + + $deploymentNamePrefix = "$providerNamespaceShort-$resourceTypeShort-$testFolderShort" # e.g., ac-cs-common + } + + # Generate a valid deployment name. Must match ^[-\w\._\(\)]+$ + do { + $deploymentName = ('{0}-{1}' -f $deploymentNamePrefix, (Get-Date -Format 'yyyyMMddTHHMMssffffZ'))[0..63] -join '' + } while ($deploymentName -notmatch '^[-\w\._\(\)]+$') + + if ($deploymentScope -ne 'resourceGroup') { + Write-Verbose "What-If Deployment Test with deployment name [$deploymentName]" -Verbose + $DeploymentInputs['DeploymentName'] = $deploymentName + } + + ################# + ## INVOKE TEST ## + ################# + switch ($deploymentScope) { + 'resourceGroup' { + if (-not [String]::IsNullOrEmpty($subscriptionId)) { + Write-Verbose ('Setting context to subscription [{0}]' -f $subscriptionId) + $null = Set-AzContext -Subscription $subscriptionId + } + if (-not (Get-AzResourceGroup -Name $resourceGroupName -ErrorAction 'SilentlyContinue')) { + if ($PSCmdlet.ShouldProcess("Resource group [$resourceGroupName] in location [$location]", 'Create')) { + $null = New-AzResourceGroup -Name $resourceGroupName -Location $location + } + } + if ($PSCmdlet.ShouldProcess('Resource group level deployment', 'WhatIf')) { + $res = New-AzResourceGroupDeployment @DeploymentInputs -WhatIf + } + break + } + 'subscription' { + if (-not [String]::IsNullOrEmpty($subscriptionId)) { + Write-Verbose ('Setting context to subscription [{0}]' -f $subscriptionId) + $null = Set-AzContext -Subscription $subscriptionId + } + if ($PSCmdlet.ShouldProcess('Subscription level deployment', 'WhatIf')) { + $res = New-AzDeployment @DeploymentInputs -Location $Location -WhatIf + } + break + } + 'managementGroup' { + if ($PSCmdlet.ShouldProcess('Management group level deployment', 'WhatIf')) { + $res = New-AzManagementGroupDeployment @DeploymentInputs -Location $Location -ManagementGroupId $ManagementGroupId -WhatIf + } + break + } + 'tenant' { + Write-Verbose 'Handling tenant level validation' + if ($PSCmdlet.ShouldProcess('Tenant level deployment', 'WhatIf')) { + $res = New-AzTenantDeployment @DeploymentInputs -Location $location -WhatIf + } + break + } + default { + throw "[$deploymentScope] is a non-supported template scope" + } + } + if ($ValidationErrors) { + if ($res.Details) { Write-Warning ($res.Details | ConvertTo-Json -Depth 10 | Out-String) } + if ($res.Message) { Write-Warning $res.Message } + Write-Error 'Template is not valid.' + } else { + Write-Verbose 'Template is valid' -Verbose + } + } + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} diff --git a/utilities/tools/Test-ModuleLocally.ps1 b/utilities/tools/Test-ModuleLocally.ps1 index ea87659b02..4e7d37c073 100644 --- a/utilities/tools/Test-ModuleLocally.ps1 +++ b/utilities/tools/Test-ModuleLocally.ps1 @@ -24,6 +24,9 @@ Optional. A switch parameter that triggers the deployment of the module .PARAMETER ValidationTest Optional. A switch parameter that triggers the validation of the module only without deployment +.PARAMETER WhatIfTest +Optional. A switch parameter that triggers the what-if test of the module only without deployment + .PARAMETER SkipParameterFileTokens Optional. A switch parameter that enables you to skip the search for local custom parameter file tokens. @@ -37,6 +40,7 @@ $TestModuleLocallyInput = @{ ModuleTestFilePath = 'C:\network\route-table\.test\parameters.json' PesterTest = $false DeploymentTest = $false + WhatIfTest = $false ValidationTest = $true ValidateOrDeployParameters = @{ Location = 'westeurope' @@ -60,6 +64,7 @@ $TestModuleLocallyInput = @{ ModuleTestFilePath = 'C:\network\route-table\.test\common\main.test.bicep' PesterTest = $false DeploymentTest = $false + WhatIfTest = $false ValidationTest = $true ValidateOrDeployParameters = @{ Location = 'westeurope' @@ -76,12 +81,60 @@ Test-ModuleLocally @TestModuleLocallyInput -Verbose Run a Test-Az*Deployment using a test file with the provided tokens + +$TestModuleLocallyInput = @{ + TemplateFilePath = 'C:\network\route-table\main.bicep' + ModuleTestFilePath = 'C:\network\route-table\.test\parameters.json' + PesterTest = $false + DeploymentTest = $false + WhatIfTest = $true + ValidationTest = $false + ValidateOrDeployParameters = @{ + Location = 'westeurope' + ResourceGroupName = 'validation-rg' + SubscriptionId = '00000000-0000-0000-0000-000000000000' + ManagementGroupId = '00000000-0000-0000-0000-000000000000' + RemoveDeployment = $false + } + AdditionalTokens = @{ + tenantId = '00000000-0000-0000-0000-000000000000' + } +} +Test-ModuleLocally @TestModuleLocallyInput -Verbose + +Get What-If deployment result using a specific parameter-template combination with the provided tokens + +.EXAMPLE + +$TestModuleLocallyInput = @{ + TemplateFilePath = 'C:\network\route-table\main.bicep' + ModuleTestFilePath = 'C:\network\route-table\.test\common\main.test.bicep' + PesterTest = $false + DeploymentTest = $false + WhatIfTest = $true + ValidationTest = $false + ValidateOrDeployParameters = @{ + Location = 'westeurope' + ResourceGroupName = 'validation-rg' + SubscriptionId = '00000000-0000-0000-0000-000000000000' + ManagementGroupId = '00000000-0000-0000-0000-000000000000' + RemoveDeployment = $false + } + AdditionalTokens = @{ + tenantId = '00000000-0000-0000-0000-000000000000' + } +} +Test-ModuleLocally @TestModuleLocallyInput -Verbose + +Get What-If deployment result using a test file with the provided tokens + .EXAMPLE $TestModuleLocallyInput = @{ TemplateFilePath = 'C:\network\route-table\main.bicep' PesterTest = $true DeploymentTest = $false + WhatIfTest = $false ValidationTest = $true ValidateOrDeployParameters = @{ Location = 'westeurope' @@ -127,7 +180,7 @@ Run all Pester tests for the given template file including tests for the use of .NOTES - Make sure you provide the right information in the 'ValidateOrDeployParameters' parameter for this function to work. -- Ensure you have the ability to perform the deployment operations using your account (if planning to test deploy) +- Ensure you have the ability to perform the deployment operations using your account (if planning to test deploy or performing what-if validation.) #> function Test-ModuleLocally { @@ -155,7 +208,10 @@ function Test-ModuleLocally { [switch] $DeploymentTest, [Parameter(Mandatory = $false)] - [switch] $ValidationTest + [switch] $ValidationTest, + + [Parameter(Mandatory = $false)] + [switch] $WhatIfTest ) begin { @@ -168,6 +224,7 @@ function Test-ModuleLocally { # Load Modules Validation / Deployment Scripts . (Join-Path $utilitiesFolderPath 'pipelines' 'resourceDeployment' 'New-TemplateDeployment.ps1') . (Join-Path $utilitiesFolderPath 'pipelines' 'resourceDeployment' 'Test-TemplateDeployment.ps1') + . (Join-Path $utilitiesFolderPath 'pipelines' 'resourceDeployment' 'Get-TemplateDeploymenWhatIf.ps1') } process { @@ -247,7 +304,7 @@ function Test-ModuleLocally { # Validation & Deployment tests # ################################# - if (($ValidationTest -or $DeploymentTest) -and $ValidateOrDeployParameters) { + if (($ValidationTest -or $DeploymentTest -or $WhatIfTest) -and $ValidateOrDeployParameters) { # Invoke Token Replacement Functionality and Convert Tokens in Parameter Files $null = Convert-TokensInFileList @tokenConfiguration @@ -278,7 +335,20 @@ function Test-ModuleLocally { } } } - + # What-If validation for template + # ----------------- + if ($WhatIfTest) { + # Loop through test files + foreach ($moduleTestFile in $moduleTestFiles) { + Write-Verbose ('Get Deployment What-If result for module [{0}] with test file [{1}]' -f $ModuleName, (Split-Path $moduleTestFile -Leaf)) -Verbose + if ((Split-Path $moduleTestFile -Extension) -eq '.json') { + Get-TemplateDeploymenWhatIf @functionInput -ParameterFilePath $moduleTestFile + } else { + $functionInput['TemplateFilePath'] = $moduleTestFile + Get-TemplateDeploymenWhatIf @functionInput + } + } + } # Deploy template # --------------- if ($DeploymentTest) {