Test-AzTemplate.ps1
function Test-AzTemplate { [Alias('Test-AzureRMTemplate')] # Added for backward compat with MP <# .Synopsis Tests an Azure Resource Manager Template .Description Validates one or more Azure Resource Manager Templates. .Notes Test-AzTemplate validates an Azure Resource Manager template using a number of small test scripts. Test scripts can be found in /testcases/GroupName, or provided with the -TestScript parameter. Each test script has access to a set of well-known variables: * TemplateFullPath (The full path to the template file) * TemplateFileName (The name of the template file) * TemplateText (The template text) * TemplateObject (The template object) * FolderName (The name of the directory containing the template file) * FolderFiles (a hashtable of each file in the folder) * IsMainTemplate (a boolean indicating if the template file name is mainTemplate.json) * CreateUIDefintionFullPath (the full path to createUIDefintion.json) * CreateUIDefinitionText (the text of createUIDefintion.json) * CreateUIDefinitionObject ( the createUIDefintion object) * HasCreateUIDefintion (a boolean indicating if the directory includes createUIDefintion.json) * MainTemplateText (the text of the main template file) * MainTemplateObject (the main template file, converted from JSON) * MainTemplateResources (the resources and child resources of the main template) * MainTemplateParameters (a hashtable containing the parameters found in the main template) * MainTemplateVariables (a hashtable containing the variables found in the main template) * MainTemplateOutputs (a hashtable containing the outputs found in the main template) #> [CmdletBinding(DefaultParameterSetName='NearbyTemplate')] param( # The path to an Azure resource manager template [Parameter(Mandatory=$true,Position=0,ValueFromPipelineByPropertyName=$true,ParameterSetName='SpecificTemplate')] [Alias('Fullname','Path')] [string] $TemplatePath, # One or more test cases or groups. If this parameter is provided, only those test cases and groups will be run. [Parameter(Position=1)] [Alias('Tests')] [string[]] $Test, # If provided, will only validate files in the template directory matching one of these wildcards. [Parameter(Position=2)] [Alias('Files')] [string[]] $File, # A set of test cases. If not provided, the files in /testcases will be used as input. [Parameter(ValueFromPipelineByPropertyName=$true)] [ValidateScript({ foreach ($k in $_.Keys) { if ($k -isnot [string]) { throw "All keys must be strings" } } foreach ($v in $_.Values) { if ($v -isnot [ScriptBlock] -and $v -isnot [string]) { throw "All values must be script blocks or strings" } } return $true })] [Alias('TestCases')] [Collections.IDictionary] $TestCase = [Ordered]@{}, # A set of test groups. Test groups will be automatically populated by the directory names in /testcases. [Parameter(ValueFromPipelineByPropertyName=$true)] [ValidateScript({ foreach ($k in $_.Keys) { if ($k -isnot [string]) { throw "All keys must be strings" } } foreach ($v in $_.Values) { if ($v -isnot [string]) { throw "All values must be strings" } } return $true })] [Collections.IDictionary] [Alias('TestGroups')] $TestGroup = [Ordered]@{}, # Any additional parameters to pass to each test. # This can be used to supply custom information to validate. # For example, passing -TestParameter @{testDate=[DateTime]::Now.AddYears(-1)} # will pass a a custom value to any test with the parameter $TestDate. # If the parameter does not exist for a given test case, it will be ignored. [Collections.IDictionary] [Alias('TestParameters')] $TestParameter, # If provided, will skip any tests in this list. [string[]] $Skip, # If set, will run tests in Pester. [switch] $Pester) begin { # First off, let's get all of the built-in test scripts. $testCaseSubdirectory = 'testcases' $myLocation = $MyInvocation.MyCommand.ScriptBlock.File $testScripts= @($myLocation| # To do that, we start from the current file, Split-Path | # get the current directory, Get-ChildItem -Filter $testCaseSubdirectory | # get the cases directory, Get-ChildItem -Filter *.test.ps1 -Recurse) # and get all test.ps1 files within it. $builtInTestCases = @{} # Next we'll define some human-friendly built-in groups. $builtInGroups = @{ 'all' = 'deploymentTemplate', 'createUIDefinition' 'mainTemplateTests' = 'deploymentTemplate' } # Now we loop over each potential test script foreach ($testScript in $testScripts) { # The test file name (minus .test.ps1) becomes the name of the test. $TestName = $testScript.Name -ireplace '\.test\.ps1$', '' -replace '_', ' ' -replace '-', ' ' $testDirName = $testScript.Directory.Name if ($testDirName -ne $testCaseSubdirectory) { # If the test case was in a subdirectory if (-not $builtInGroups.$testDirName) { $builtInGroups.$testDirName = @() } # then the subdirectory name is the name of the test group. $builtInGroups.$testDirName += $TestName } else { # If there was no subdirectory, put the test in a special group called "ungrouped". if (-not $builtInGroups.Ungrouped) { $builtInGroups.Ungrouped = @() } $builtInGroups.Ungrouped += $TestName } $builtInTestCases[$testName] = $testScript.Fullname } # This lets our built-in groups be automatically defined by their file structure. if (-not $script:AlreadyLoadedCache) { $script:AlreadyLoadedCache = @{} } # Next we want to load the cached items $cacheDir = $myLocation | Split-Path | Join-Path -ChildPath cache $cacheItemNames = @(foreach ($cacheFile in (Get-ChildItem -Path $cacheDir -Filter *.cache.json)) { $cacheName = $cacheFile.Name -replace '\.cache\.json', '' if (-not $script:AlreadyLoadedCache[$cacheFile.Name]) { $script:AlreadyLoadedCache[$cacheFile.Name] = [IO.File]::ReadAllText($cacheFile.Fullname) | Microsoft.PowerShell.Utility\ConvertFrom-Json } $cacheData = $script:AlreadyLoadedCache[$cacheFile.Name] $ExecutionContext.SessionState.PSVariable.Set($cacheName, $cacheData) $cacheName }) # Next we want to declare some internal functions: #*Test-Case (executes a test, given a set of parameters) function Test-Case($TheTest, $TestParameters = @{}) { $testCommandParameters = if ($TheTest -is [ScriptBlock]) { $function:f = $TheTest ([Management.Automation.CommandMetaData]$function:f).Parameters Remove-Item function:f } elseif ($TheTest -is [string]) { $testCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand($TheTest, 'ExternalScript') if (-not $testCmd) { return } ([Management.Automation.CommandMetaData]$testCmd).Parameters } else { return } $testInput = @{} + $TestParameters foreach ($k in @($testInput.Keys)) { if (-not $testCommandParameters.ContainsKey($k)) { $testInput.Remove($k) } } :IfNotMissingMandatory do { foreach ($tcp in $testCommandParameters.GetEnumerator()) { foreach ($attr in $tcp.Value.Attributes) { if ($attr.Mandatory -and -not $testInput[$tcp.Key]) { Write-Warning "Skipped because $($tcp.Key) was missing" break IfNotMissingMandatory } } } if (-not $Pester) { & $TheTest @testInput 2>&1 3>&1 } else { & $TheTest @testInput } } while ($false) } #*Test-Group (executes a group of tests) function Test-Group { $testQueue = [Collections.Queue]::new(@($GroupName)) while ($testQueue.Count) { $dq = $testQueue.Dequeue() if ($TestGroup.$dq) { foreach ($_ in $TestGroup.$dq) { $testQueue.Enqueue($_) } continue } if ($ValidTestList -and $ValidTestList -notcontains $dq) { continue } if (-not $Pester) { $testStartedAt = [DateTime]::Now $testCaseOutput = Test-Case $testCase.$dq $TestInput 2>&1 3>&1 $testTook = [DateTime]::Now - $testStartedAt $testErrors = [Collections.ArrayList]::new() $testWarnings = [Collections.ArrayList]::new() $testOutput = [Collections.ArrayList]::new() foreach ($_ in $testCaseOutput) { $null= if ($_ -is [Exception] -or $_ -is [Management.Automation.ErrorRecord]) { $testErrors.Add($_) } elseif ($_ -is [Management.Automation.WarningRecord]) { $testWarnings.Add($_) } else { $testOutput.Add($_) } } New-Object PSObject -Property ([Ordered]@{ pstypename = 'Template.Validation.Test.Result' Errors = $testErrors Warnings = $testWarnings Output = $testOutput AllOutput = $testCaseOutput Passed = $testErrors.Count -lt 1 Group = $GroupName Name = $dq Timespan = $testTook File = $fileInfo }) } else { it $dq { # Pester tests only fail on a terminating error, $errorMessages = Test-Case $testCase.$dq $TestInput 2>&1 | Where-Object { $_ -is [Management.Automation.ErrorRecord] } | # so collect all non-terminating errors. Select-Object -ExpandProperty Exception | Select-Object -ExpandProperty Message if ($errorMessages) { # If any were found, throw ($errorMessages -join ([Environment]::NewLine)) # throw. } } } } } #*Test-FileList (tests a list of files) function Test-FileList { foreach ($fileInfo in $FolderFiles) { # We loop over each file in the folder. $matchingGroups = @(if ($fileInfo.Schema) { # If a given file has a schema, foreach ($key in $TestGroup.Keys) { # and it matches the name of the testgroup if ("$key".StartsWith("_") -or "$key".StartsWith('.')) { continue } if ($fileInfo.Schema -match $key) { $key # then run that group of tests. } } } else { foreach ($key in $TestGroup.Keys) { # If it didn't have a schema if ($fileInfo.Extension -eq '.json') { # and it was a JSON file $fn = $fileInfo.Name -ireplace '\.json$','' if ($fn -match $key) { # check to see if it's name matches the key $key; continue # (this handles CreateUIDefinition.json, even if no schema is present). } if ($key -eq 'DeploymentTemplate' -and # Otherwise, if we're checking the deploymentTemplate 'mainTemplate.json', 'azuredeploy.json', 'prereq.azuredeploy.json' -contains $fn) { # and the file name is something we _know_ will be an ARM template $key; continue # then run the deployment tests regardless of schema. } } if (-not ("$key".StartsWith('_') -or "$key".StartsWith('.'))) { continue } # Last, check if the test group is for a file extension. if ($fileInfo.Extension -eq "$key".Replace('_', '.')) { # If it was, run tests associated with that extension. $key } } }) if ($TestGroup.Ungrouped) { $matchingGroups += 'Ungrouped' } if (-not $matchingGroups) { continue } if ($fileInfo.Schema -like '*deploymentTemplate*') { $isMainTemplate = 'mainTemplate.json', 'azureDeploy.json', 'prereq.azuredeploy.json' -contains $fileInfo.Name $templateFileName = $fileInfo.Name $TemplateObject = $fileInfo.Object $TemplateText = $fileInfo.Text } foreach ($groupName in $matchingGroups) { $testInput = @{} foreach ($_ in $WellKnownVariables) { $testInput[$_] = $ExecutionContext.SessionState.PSVariable.Get($_).Value } $ValidTestList = if ($test) { $testList = @(Get-TestGroups ($test -replace '[_-]',' ') -includeTest) if (-not $testList) { Write-Warning "Test '$test' was not found, all tests will be run" } if ($skip) { foreach ($tl in $testList) { if ($skip -replace '[_-]', ' ' -notcontains $tl) { $tl } } } else { $testList } } elseif ($skip) { $testList = @(Get-TestGroups -GroupName $groupName -includeTest) foreach ($tl in $testList) { if ($skip -replace '[_-]', ' ' -notcontains $tl) { $tl } } } else { $null } if (-not $Pester) { $context = "$($fileInfo.Name)->$groupName" Test-Group } else { context "$($fileInfo.Name)->$groupName" ${function:Test-Group} } } } } #*Get-TestGroups (expands nested test groups) function Get-TestGroups([string[]]$GroupName, [switch]$includeTest) { foreach ($gn in $GroupName) { if ($TestGroup[$gn]) { Get-TestGroups $testGroup[$gn] -includeTest:$includeTest } elseif ($IncludeTest -and $TestCase[$gn]) { $gn } } } $accumulatedTemplates = [Collections.Arraylist]::new() } process { # If no template was passed, if ($PSCmdlet.ParameterSetName -eq 'NearbyTemplate') { # attempt to find one in the current directory and it's subdirectories $possibleJsonFiles = @(Get-ChildItem -Filter *.json -Recurse | Sort-Object Name -Descending | # (sort by name descending so that MainTemplate.json comes first). Where-Object { 'azureDeploy.json', 'mainTemplate.json' -contains $_.Name }) # If more than one template was found, warn which one we'll be testing. if ($possibleJsonFiles.Count -gt 1) { Write-Error "More than one potential template file found beneath '$pwd'. Please have only azureDeploy.json or mainTemplate.json, not both." return } # If no potential files were found, write and error and return. if (-not $possibleJsonFiles) { Write-Error "No potential templates found beneath '$pwd'. Templates should be named azureDeploy.json or mainTemplate.json." return } # If we could find a potential json file, recursively call yourself. $possibleJsonFiles | Select-Object -First 1 | Test-AzTemplate @PSBoundParameters return } # First, merge the built-in groups and test cases with any supplied by the user. foreach ($kv in $builtInGroups.GetEnumerator()) { if (-not $testGroup[$kv.Key]) { $TestGroup[$kv.Key] = $kv.Value } } foreach ($kv in $builtInTestCases.GetEnumerator()) { if (-not $testCase[$kv.Key]) { $TestCase[$kv.Key]= $kv.Value } } $null = $accumulatedTemplates.Add($TemplatePath) } end { $c, $t = 0, $accumulatedTemplates.Count $progId = Get-Random foreach ($TemplatePath in $accumulatedTemplates) { $C++ $p = $c * 100 / $t $templateFileName = $TemplatePath | Split-Path -Leaf Write-Progress "Validating Templates" "$templateFileName" -PercentComplete $p -Id $progId $expandedTemplate =Expand-AzTemplate -TemplatePath $templatePath if (-not $expandedTemplate) { continue } foreach ($kv in $expandedTemplate.GetEnumerator()) { $ExecutionContext.SessionState.PSVariable.Set($kv.Key, $kv.Value) } $wellKnownVariables = @($expandedTemplate.Keys) + $cacheItemNames if ($testParameter) { $wellKnownVariables += foreach ($kv in $testParameter.GetEnumerator()) { $ExecutionContext.SessionState.PSVariable.Set($kv.Key, $kv.Value) $kv.Key } } # If a file list was provided, if ($PSBoundParameters.File) { $FolderFiles = @(foreach ($ff in $FolderFiles) { # filter the folder files. $matched = @(foreach ($_ in $file) { $ff.Name -like $_ # If file the name matched any of valid patterns. }) if ($matched -eq $true) { $ff # then we include it. } }) } # Now that the filelist and test groups are set up, we use Test-FileList to test the list of files. if ($Pester) { $IsPesterLoaded? = $( $loadedModules = Get-module foreach ($_ in $loadedModules) { if ($_.Name -eq 'Pester') { $true break } } ) $DoesPesterExist? = if ($IsPesterLoaded?) { $true } else { if ($PSVersionTable.Platform -eq 'Unix') { $delimiter = ':' # used for bash } else { $delimiter = ';' # used for windows } $env:PSModulePath -split $delimiter | Get-ChildItem -Filter Pester | Import-Module -Global -PassThru } if (-not $DoesPesterExist?){ Write-Warning "Pester not found. Please install Pester (Install-Module Pester)" $Pester = $false } } if (-not $Pester) { # If we're not running Pester, Test-FileList # we just call it directly. } else { # If we're running Pester, we pass the function defintion as a parameter to describe. describe "Validating Azure Template $TemplateName" ${function:Test-FileList} } } Write-Progress "Validating Templates" "Complete" -Completed -Id $progId } } |