rules/Azure.Template.Rule.ps1

# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

#
# Validation rules for Azure template and parameter files
#

#region Template

# Synopsis: Use ARM template file structure.
Rule 'Azure.Template.TemplateFile' -Type 'System.IO.FileInfo','.json' -If { (IsTemplateFile) } -Tag @{ release = 'GA'; ruleSet = '2020_06' } {
    $jsonObject = ReadJsonFile -Path $TargetObject.FullName;
    $jsonObject | Exists '$schema', 'contentVersion', 'resources' -All
    $jsonObject.PSObject.Properties | Within 'Name' '$schema', 'contentVersion', 'metadata', 'parameters', 'functions', 'variables', 'resources', 'outputs'
}

# Synopsis: Use template parameter descriptions.
Rule 'Azure.Template.ParameterMetadata' -Type 'System.IO.FileInfo','.json' -If { (IsTemplateFile) } -Tag @{ release = 'GA'; ruleSet = '2020_09' } {
    $jsonObject = ReadJsonFile -Path $TargetObject.FullName;
    $parameters = @($jsonObject.parameters.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' });
    if ($parameters.Length -eq 0) {
        return $Assert.Pass();
    }
    foreach ($parameter in $parameters) {
        $Assert.
            HasFieldValue($parameter.value, 'metadata.description').
            Reason($LocalizedData.TemplateParameterDescription, $parameter.name);
    }
}

# Synopsis: ARM templates should include at least one resource.
Rule 'Azure.Template.Resources' -Type 'System.IO.FileInfo','.json' -If { (IsTemplateFile) } -Tag @{ release = 'GA'; ruleSet = '2020_09' } {
    $jsonObject = ReadJsonFile -Path $TargetObject.FullName;
    $Assert.GreaterOrEqual($jsonObject, 'resources', 1);
}

# Synopsis: ARM template parameters should be used at least once.
Rule 'Azure.Template.UseParameters' -Type 'System.IO.FileInfo','.json' -If { (IsTemplateFile) } -Tag @{ release = 'GA'; ruleSet = '2020_09' } {
    $jsonObject = ReadJsonFile -Path $TargetObject.FullName;
    $jsonContent = Get-Content -Path $TargetObject.FullName -Raw;
    $parameters = @($jsonObject.parameters.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' });
    if ($parameters.Length -eq 0) {
        return $Assert.Pass();
    }
    foreach ($parameter in $parameters) {
        $Assert.
            Match($jsonContent, '.', "\`"\[.*parameters\(\s{0,}'$($parameter.name)'\s{0,}\).*\]\`"").
            Reason($LocalizedData.ParameterNotFound, $parameter.name);
    }
}

# Synopsis: ARM template variables should be used at least once.
Rule 'Azure.Template.UseVariables' -Type 'System.IO.FileInfo','.json' -If { (IsTemplateFile) } -Tag @{ release = 'GA'; ruleSet = '2020_09' } {
    $jsonObject = ReadJsonFile -Path $TargetObject.FullName;
    $jsonContent = Get-Content -Path $TargetObject.FullName -Raw;
    $variableNames = @($jsonObject.variables.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' } | ForEach-Object {
        $variable = $_;
        if ($variable.name -eq 'copy') {
            $variable.value | ForEach-Object {
                $_.name;
            }
        }
        else {
            $variable.name;
        }
    });
    if ($variableNames.Length -eq 0) {
        return $Assert.Pass();
    }
    foreach ($variableName in $variableNames) {
        $Assert.
            Match($jsonContent, '.', "\`"\[.*variables\(\s{0,}'$($variableName)'\s{0,}\).*\]\`"").
            Reason($LocalizedData.VariableNotFound, $variableName);
    }
}

# Synopsis: Set the default value for location parameters within ARM template to the default value to `[resourceGroup().location]`.
Rule 'Azure.Template.LocationDefault' -Type 'System.IO.FileInfo','.json' -If { (HasLocationParameter) } -Tag @{ release = 'GA'; ruleSet = '2021_03' } {
    $jsonObject = $PSRule.GetContent([System.IO.FileInfo]$TargetObject.FullName);
    $parameters = @($jsonObject.parameters.PSObject.Properties | Where-Object {
        $_.Name -eq 'location'
    });
    if ($parameters.Length -eq 0) {
        return $Assert.Pass();
    }
    foreach ($parameter in $parameters) {
        if ($Assert.HasFieldValue($parameter.Value, 'defaultValue', 'global').Result) {
            $Assert.Pass();
        }
        else {
            $Assert.HasFieldValue($parameter.Value, 'defaultValue', '[resourceGroup().location]');
        }
    }
}

# Synopsis: Set the parameter default value to a value of the same type.
Rule 'Azure.Template.ParameterDataTypes' -Type 'System.IO.FileInfo','.json' -If { (IsTemplateFile) } -Tag @{ release = 'GA'; ruleSet = '2021_03'; } {
    $jsonObject = $PSRule.GetContent([System.IO.FileInfo]$TargetObject.FullName);
    $parameters = @($jsonObject.parameters.PSObject.Properties);
    if ($parameters.Length -eq 0) {
        return $Assert.Pass();
    }
    foreach ($parameter in $parameters) {
        if (!$Assert.HasField($parameter.Value, 'defaultValue').Result) {
            # No defaultValue
            $Assert.Pass();
        }
        elseif ($parameter.Value.defaultValue -is [string] -and $parameter.Value.defaultValue.StartsWith('[') -and $parameter.Value.defaultValue.EndsWith(']')) {
            # Is function
            $Assert.Pass();
        }
        elseif ($parameter.Value.type -eq 'bool') {
            Write-Debug -Message "Parameter default value is '$($parameter.Value.defaultValue.GetType().Name)'";
            $Assert.Create($parameter.Value.defaultValue -is [bool], ($LocalizedData.ParameterDefaultTypeMismatch -f $parameter.Name, $parameter.Value.type));
        }
        elseif ($parameter.Value.type -eq 'int') {
            Write-Debug -Message "Parameter default value is '$($parameter.Value.defaultValue.GetType().Name)'";
            $Assert.Create($parameter.Value.defaultValue -is [int] -or $parameter.Value.defaultValue -is [long], ($LocalizedData.ParameterDefaultTypeMismatch -f $parameter.Name, $parameter.Value.type));
        }
        elseif ($parameter.Value.type -eq 'array') {
            Write-Debug -Message "Parameter default value is '$($parameter.Value.defaultValue.GetType().Name)'";
            $Assert.Create($parameter.Value.defaultValue -is [array], ($LocalizedData.ParameterDefaultTypeMismatch -f $parameter.Name, $parameter.Value.type));
        }
        elseif ($parameter.Value.type -eq 'string' -or $parameter.Value.type -eq 'secureString') {
            Write-Debug -Message "Parameter default value is '$($parameter.Value.defaultValue.GetType().Name)'";
            $Assert.Create($parameter.Value.defaultValue -is [string], ($LocalizedData.ParameterDefaultTypeMismatch -f $parameter.Name, $parameter.Value.type));
        }
        elseif ($parameter.Value.type -eq 'object' -or $parameter.Value.type -eq 'secureObject') {
            Write-Debug -Message "Parameter default value is '$($parameter.Value.defaultValue.GetType().Name)'";
            $Assert.Create($parameter.Value.defaultValue -is [PSObject], ($LocalizedData.ParameterDefaultTypeMismatch -f $parameter.Name, $parameter.Value.type));
        }
    }
}

#endregion Template

#region Parameters

# Synopsis: Use ARM parameter file structure.
Rule 'Azure.Template.ParameterFile' -Type 'System.IO.FileInfo','.json' -If { (IsParameterFile) } -Tag @{ release = 'GA'; ruleSet = '2020_06' } {
    $jsonObject = ReadJsonFile -Path $TargetObject.FullName;
    $jsonObject | Exists '$schema', 'contentVersion', 'parameters' -All
    $jsonObject.PSObject.Properties | Within 'Name' '$schema', 'contentVersion', 'metadata', 'parameters'
}

#endregion Parameters

#region Helper functions

# Determines if the object is a Azure Resource Manager template file
function global:IsTemplateFile {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $False)]
        [String]$Suffix
    )
    process {
        if ($TargetObject.Extension -ne '.json') {
            return $False;
        }
        try {
            $jsonObject = ReadJsonFile -Path $TargetObject.FullName;
            [String]$targetSchema = $jsonObject.'$schema';
            $schemas = @(
                # Https
                "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json`#"
                "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json`#"
                "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json`#"
                "https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json`#"
                "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json`#"

                # Http
                "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json`#"
                "http://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json`#"
                "http://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json`#"
                "http://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json`#"
                "http://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json`#"
            )
            return $targetSchema -in $schemas -and ([String]::IsNullOrEmpty($Suffix) -or $targetSchema.Trim("`#").EndsWith($Suffix));
        }
        catch {
            return $False;
        }
    }
}

# Determines if the object is a Azure Resource Manager parameter file
function global:IsParameterFile {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param ()
    process {
        if ($TargetObject.Extension -ne '.json') {
            return $False;
        }
        try {
            $jsonObject = ReadJsonFile -Path $TargetObject.FullName;
            $schemas = @(
                # Https
                "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json`#"
                "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json`#"

                # Http
                "http://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json`#"
                "http://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json`#"
            )
            return $jsonObject.'$schema' -in $schemas;
        }
        catch {
            return $False;
        }
    }
}

# Read a file as JSON
function global:ReadJsonFile {
    [CmdletBinding()]
    [OutputType([PSObject])]
    param (
        [Parameter(Mandatory = $True)]
        [String]$Path
    )
    process {
        # return $PSRule.GetContent([System.IO.FileInfo]$Path);
        return Get-Content -Path $TargetObject.FullName -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json -ErrorAction SilentlyContinue;
    }
}

function global:HasLocationParameter {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param ()
    process {
        if (!(IsTemplateFile -Suffix '/deploymentTemplate.json')) {
            return $False;
        }
        return $Assert.HasField((ReadJsonFile -Path $TargetObject.FullName), 'parameters.location').Result;
    }
}

#endregion Helper functions