internal/functions/Invoke-AzOpsChange.ps1

function Invoke-AzOpsChange {

    <#
        .SYNOPSIS
            Applies a change to Azure from the AzOps configuration.
        .DESCRIPTION
            Applies a change to Azure from the AzOps configuration.
        .PARAMETER ChangeSet
            Set of changes from the last execution that need to be applied.
        .PARAMETER StatePath
            The root path to where the entire state is being built in.
        .PARAMETER AzOpsMainTemplate
            Path to the main template used by AzOps
        .EXAMPLE
            > Invoke-AzOpsChange -ChangeSet changeSet -StatePath $StatePath -AzOpsMainTemplate $templatePath
            Applies a change to Azure from the AzOps configuration.
    #>


    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string[]]
        $ChangeSet,

        [string]
        $StatePath = (Get-PSFConfigValue -FullName 'AzOps.Core.State'),

        [string]
        $AzOpsMainTemplate = (Get-PSFConfigValue -FullName 'AzOps.Core.MainTemplate')
    )

    begin {
        #region Utility Functions
        function Resolve-ArmFileAssociation {
            [CmdletBinding()]
            param (
                [AzOpsScope]
                $ScopeObject,

                [string]
                $FilePath,

                [string]
                $AzOpsMainTemplate
            )

            #region Initialization Prep
            $common = @{
                Level        = 'Host'
                Tag          = 'pwsh'
                FunctionName = 'Invoke-AzOpsChange'
                Target       = $ScopeObject
            }

            $result = [PSCustomObject] @{
                TemplateFilePath          = $null
                TemplateParameterFilePath = $null
                DeploymentName            = $null
                ScopeObject               = $ScopeObject
                Scope                     = $ScopeObject.Scope
            }

            $fileItem = Get-Item -Path $FilePath
            if ($fileItem.Extension -notin '.json' , '.bicep') {
                Write-PSFMessage -Level Warning -String 'Invoke-AzOpsChange.Resolve.NoJson' -StringValues $fileItem.FullName -Tag pwsh -FunctionName 'Invoke-AzOpsChange' -Target $ScopeObject
                return
            }
            #endregion Initialization Prep

            #region Case: Parameters File
            if ($fileItem.Name.EndsWith('.parameters.json')) {
                $result.TemplateParameterFilePath = $fileItem.FullName
                $deploymentName = $fileItem.Name -replace (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix'), ''
                if ($deploymentName.Length -gt 58) { $deploymentName = $deploymentName.SubString(0, 58) }
                $result.DeploymentName = "AzOps-$deploymentName"

                #region Directly Associated Template file exists
                $templatePath = $fileItem.FullName -replace '.parameters.json', (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix')
                if (Test-Path $templatePath) {
                    Write-PSFMessage @common -String 'Invoke-AzOpsChange.Resolve.FoundTemplate' -StringValues $FilePath, $templatePath
                    $result.TemplateFilePath = $templatePath
                    return $result
                }
                #endregion Directly Associated Template file exists

                #region Directly Associated bicep template exists
                $bicepTemplatePath = $fileItem.FullName -replace '.parameters.json', '.bicep'
                if (Test-Path $bicepTemplatePath) {
                    Write-PSFMessage @common -String 'Invoke-AzOpsChange.Resolve.FoundBicepTemplate' -StringValues $FilePath, $bicepTemplatePath
                    $result.TemplateFilePath = $bicepTemplatePath
                    return $result
                }
                #endregion Directly Associated bicep template exists

                #region Check in the main template file for a match
                Write-PSFMessage @common -String 'Invoke-AzOpsChange.Resolve.NotFoundTemplate' -StringValues $FilePath, $templatePath
                $mainTemplateItem = Get-Item $AzOpsMainTemplate
                Write-PSFMessage @common -String 'Invoke-AzOpsChange.Resolve.FromMainTemplate' -StringValues $mainTemplateItem.FullName

                # Determine Resource Type in Parameter file
                $templateParameterFileHashtable = Get-Content -Path $fileItem.FullName | ConvertFrom-Json -AsHashtable
                $effectiveResourceType = $null
                if ($templateParameterFileHashtable.Keys -contains "`$schema") {
                    if ($templateParameterFileHashtable.parameters.input.value.Keys -contains "Type") {
                        # ManagementGroup and Subscription
                        $effectiveResourceType = $templateParameterFileHashtable.parameters.input.value.Type
                    }
                    elseif ($templateParameterFileHashtable.parameters.input.value.Keys -contains "ResourceType") {
                        # Resource
                        $effectiveResourceType = $templateParameterFileHashtable.parameters.input.value.ResourceType
                    }
                }
                # Check if generic template is supporting the resource type for the deployment.
                if ($effectiveResourceType -and
                    (Get-Content $mainTemplateItem.FullName | ConvertFrom-Json -AsHashtable).variables.apiVersionLookup.Keys -contains $effectiveResourceType) {
                    Write-PSFMessage @common -String 'Invoke-AzOpsChange.Resolve.MainTemplate.Supported' -StringValues $effectiveResourceType, $AzOpsMainTemplate.FullName
                    $result.TemplateFilePath = $mainTemplateItem.FullName
                    return $result
                }
                Write-PSFMessage -Level Warning -String 'Invoke-AzOpsChange.Resolve.MainTemplate.NotSupported' -StringValues $effectiveResourceType, $AzOpsMainTemplate.FullName -Tag pwsh -FunctionName 'Invoke-AzOpsChange' -Target $ScopeObject
                return
                #endregion Check in the main template file for a match
                # All Code paths end the command
            }
            #endregion Case: Parameters File

            #region Case: Template File
            $result.TemplateFilePath = $fileItem.FullName
            $parameterPath = Join-Path $fileItem.Directory.FullName -ChildPath ($fileItem.BaseName + '.parameters' + (Get-PSFConfigValue -FullName 'AzOps.Core.TemplateParameterFileSuffix'))
            if (Test-Path -Path $parameterPath) {
                Write-PSFMessage @common -String 'Invoke-AzOpsChange.Resolve.ParameterFound' -StringValues $FilePath, $parameterPath
                $result.TemplateParameterFilePath = $parameterPath
            }
            else {
                Write-PSFMessage @common -String 'Invoke-AzOpsChange.Resolve.ParameterNotFound' -StringValues $FilePath, $parameterPath
            }

            $deploymentName = $fileItem.BaseName -replace '\.json$' -replace ' ', '_'
            if ($deploymentName.Length -gt 58) { $deploymentName = $deploymentName.SubString(0, 58) }
            $result.DeploymentName = "AzOps-$deploymentName"

            $result
            #endregion Case: Template File
        }
        #endregion Utility Functions

        $common = @{
            Level = 'Host'
            Tag   = 'git'
        }

        $WhatIfPreferenceState = $WhatIfPreference
        $WhatIfPreference = $false
    }

    process {
        if (-not $ChangeSet) { return }

        #region Categorize Input
        Write-PSFMessage @common -String 'Invoke-AzOpsChange.Deployment.Required'
        $deleteSet = @()
        $addModifySet = foreach ($change in $ChangeSet) {
            $operation, $filename = ($change -split "`t")[0, -1]
            if ($operation -eq 'D') {
                $deleteSet += $filename
                continue
            }
            if ($operation -in 'A', 'M', 'R') { $filename }
        }
        if ($deleteSet) { $deleteSet = $deleteSet | Sort-Object }
        if ($addModifySet) { $addModifySet = $addModifySet | Sort-Object }
        # TODO: Clarify what happens with the deletes - not used after reporting them

        Write-PSFMessage @common -String 'Invoke-AzOpsChange.Change.AddModify'
        foreach ($item in $addModifySet) {
            Write-PSFMessage @common -String 'Invoke-AzOpsChange.Change.AddModify.File' -StringValues $item
        }
        Write-PSFMessage @common -String 'Invoke-AzOpsChange.Change.Delete'
        foreach ($item in $deleteSet) {
            Write-PSFMessage @common -String 'Invoke-AzOpsChange.Change.Delete.File' -StringValues $item
        }
        #endregion Categorize Input

        #region Deploy State
        $common.Tag = 'pwsh'
        # Nested Pipeline allows economizing on New-AzOpsStateDeployment having to run its "begin" block once only
        $newStateDeploymentCmd = { New-AzOpsStateDeployment -StatePath $StatePath }.GetSteppablePipeline()
        $newStateDeploymentCmd.Begin($true)
        foreach ($addition in $addModifySet) {
            if ($addition -notmatch '/*.subscription.json$') { continue }
            Write-PSFMessage @common -String 'Invoke-AzOpsChange.Deploy.Subscription' -StringValues $addition -Target $addition
            $newStateDeploymentCmd.Process($addition)
        }
        foreach ($addition in $addModifySet) {
            if ($addition -notmatch '/*.providerfeatures.json$') { continue }
            Write-PSFMessage @common -String 'Invoke-AzOpsChange.Deploy.ProviderFeature' -StringValues $addition -Target $addition
            $newStateDeploymentCmd.Process($addition)
        }
        foreach ($addition in $addModifySet) {
            if ($addition -notmatch '/*.resourceproviders.json$') { continue }
            Write-PSFMessage @common -String 'Invoke-AzOpsChange.Deploy.ResourceProvider' -StringValues $addition -Target $addition
            $newStateDeploymentCmd.Process($addition)
        }
        $newStateDeploymentCmd.End()
        #endregion Deploy State

        $deploymentList = foreach ($addition in $addModifySet | Where-Object { $_ -match ((Get-Item $StatePath).Name) }) {

            # Avoid duplicate entries in the deployment list
            if ($addition.EndsWith(".parameters.json")) {
                if ($addModifySet -contains $addition.Replace(".parameters.json", ".json")) {
                    continue
                }
            }

            # Handle Bicep templates
            if ($addition.EndsWith(".bicep")) {
                Assert-AzOpsBicepDependency -Cmdlet $PSCmdlet
                $transpiledTemplatePath = $addition -replace '.bicep', '.json'
                Write-PSFMessage @common -String 'Invoke-AzOpsChange.Resolve.ConvertBicepTemplate' -StringValues $addModifySet, $transpiledTemplatePath
                Invoke-AzOpsNativeCommand -ScriptBlock { bicep build $addition --outfile $transpiledTemplatePath }
                $addition = $transpiledTemplatePath
            }

            try { $scopeObject = New-AzOpsScope -Path $addition -StatePath $StatePath -ErrorAction Stop }
            catch {
                Write-PSFMessage @common -String 'Invoke-AzOpsChange.Scope.Failed' -StringValues $addition, $StatePath -Target $addition -ErrorRecord $_
                continue
            }
            if (-not $scopeObject) {
                Write-PSFMessage @common -String 'Invoke-AzOpsChange.Scope.NotFound' -StringValues $addition, $StatePath -Target $addition
                continue
            }

            Resolve-ArmFileAssociation -ScopeObject $scopeObject -FilePath $addition -AzOpsMainTemplate $AzOpsMainTemplate
        }

        $WhatIfPreference = $WhatIfPreferenceState

        #Starting Tenant Deployment
        $uniqueProperties = 'Scope', 'DeploymentName', 'TemplateFilePath', 'TemplateParameterFilePath'
        $deploymentList | Select-Object $uniqueProperties -Unique | Sort-Object -Property TemplateParameterFilePath | New-AzOpsDeployment -WhatIf:$WhatIfPreference
    }

}