functions/Build-DeploymentPlans.ps1

function Build-DeploymentPlans {

<#
    Builds the deployment plans for the Policy as Code (PAC) environment.
 
    Defines which Policy as Code (PAC) environment we are using, if omitted, the script prompts for a value. The values are read from `$DefinitionsRootFolder/global-settings.jsonc.
 
    Definitions folder path. Defaults to environment variable `$env:PAC_DEFINITIONS_FOLDER or './Definitions'.
 
    Output folder path for plan files. Defaults to environment variable `$env:PAC_OUTPUT_FOLDER or './Output'.
 
    If set, only build the exemptions plan.
 
    If set, do not build the exemptions plan.
 
    Script is used interactively. Script can prompt the interactive user for input.
 
    If set, outputs variables consumable by conditions in a DevOps pipeline. Valid values are '', 'ado' and 'gitlab'.
 
    If set, skip exemptions that are not scoped.
 
    .\Build-DeploymentPlans.ps1 -PacEnvironmentSelector "dev"
 
    Builds the deployment plans for the Policy as Code (PAC) environment 'dev'.
 
    .\Build-DeploymentPlans.ps1 -PacEnvironmentSelector "dev" -DevOpsType "ado"
 
    Builds the deployment plans for the Policy as Code (PAC) environment 'dev' and outputs variables consumable by conditions in an Azure DevOps pipeline.
 
    https://azure.github.io/enterprise-azure-policy-as-code/#deployment-scripts
 
#>


[CmdletBinding()]
param (
    [parameter(HelpMessage = "Defines which Policy as Code (PAC) environment we are using, if omitted, the script prompts for a value. The values are read from `$DefinitionsRootFolder/global-settings.jsonc.", Position = 0)]
    [string] $PacEnvironmentSelector = "",

    [Parameter(HelpMessage = "Definitions folder path. Defaults to environment variable `$env:PAC_DEFINITIONS_FOLDER or './Definitions'.")]
    [string]$DefinitionsRootFolder,

    [Parameter(HelpMessage = "Output folder path for plan files. Defaults to environment variable `$env:PAC_OUTPUT_FOLDER or './Output'.")]
    [string]$OutputFolder,

    [Parameter(HelpMessage = "If set, only build the exemptions plan.")]
    [switch] $BuildExemptionsOnly,

    [Parameter(HelpMessage = "If set, do not build the exemptions plan.")]
    [switch] $SkipExemptions,

    [Parameter(HelpMessage = "Script is used interactively. Script can prompt the interactive user for input.")]
    [switch] $Interactive,

    [Parameter(HelpMessage = "If set, outputs variables consumable by conditions in a DevOps pipeline.")]
    [ValidateSet("ado", "gitlab", "")]
    [string] $DevOpsType = "",

    [switch]$SkipNotScopedExemptions
)

$PSDefaultParameterValues = @{
    "Write-Information:InformationVariable" = "+global:epacInfoStream"
}

Clear-Variable -Name epacInfoStream -Scope global -Force -ErrorAction SilentlyContinue

# Dot Source Helper Scripts

# Initialize
$InformationPreference = "Continue"

$pacEnvironment = Select-PacEnvironment $PacEnvironmentSelector -DefinitionsRootFolder $DefinitionsRootFolder -OutputFolder $OutputFolder -Interactive $Interactive
$null = Set-AzCloudTenantSubscription -Cloud $pacEnvironment.cloud -TenantId $pacEnvironment.tenantId -Interactive $pacEnvironment.interactive -DeploymentDefaultContext $pacEnvironment.defaultContext

# Telemetry
if ($pacEnvironment.telemetryEnabled) {
    Write-Information "Telemetry is enabled"
    Submit-EPACTelemetry -Cuapid "pid-3c88f740-55a8-4a96-9fba-30a81b52151a" -DeploymentRootScope $pacEnvironment.deploymentRootScope
}
else {
    Write-Information "Telemetry is disabled"
}
Write-Information ""

#region plan data structures
$buildSelections = @{
    buildAny                  = $false
    buildPolicyDefinitions    = $false
    buildPolicySetDefinitions = $false
    buildPolicyAssignments    = $false
    buildPolicyExemptions     = $false
}
$policyDefinitions = @{
    new             = @{}
    update          = @{}
    replace         = @{}
    delete          = @{}
    numberOfChanges = 0
    numberUnchanged = 0
}
$policyRoleIds = @{}
$allDefinitions = @{
    policydefinitions    = @{}
    policysetdefinitions = @{}
}
$replaceDefinitions = @{}
$policySetDefinitions = @{
    new             = @{}
    update          = @{}
    replace         = @{}
    delete          = @{}
    numberOfChanges = 0
    numberUnchanged = 0
}
$assignments = @{
    new             = @{}
    update          = @{}
    replace         = @{}
    delete          = @{}
    numberOfChanges = 0
    numberUnchanged = 0
}
$roleAssignments = @{
    numberOfChanges = 0
    added           = [System.Collections.ArrayList]::new()
    updated         = [System.Collections.ArrayList]::new()
    removed         = [System.Collections.ArrayList]::new()
}
$allAssignments = @{}
$exemptions = @{
    new             = @{}
    update          = @{}
    replace         = @{}
    delete          = @{}
    numberOfOrphans = 0
    numberOfExpired = 0
    numberOfChanges = 0
    numberUnchanged = 0
}
$pacOwnerId = $pacEnvironment.pacOwnerId
$timestamp = Get-Date -AsUTC -Format "u"
$policyPlan = @{
    createdOn            = $timestamp
    pacOwnerId           = $pacOwnerId
    policyDefinitions    = $policyDefinitions
    policySetDefinitions = $policySetDefinitions
    assignments          = $assignments
    exemptions           = $exemptions
}
$rolesPlan = @{
    createdOn       = $timestamp
    pacOwnerId      = $pacOwnerId
    roleAssignments = $roleAssignments
}
$policyDefinitionsFolder = $pacEnvironment.policyDefinitionsFolder
$policySetDefinitionsFolder = $pacEnvironment.policySetDefinitionsFolder
$policyAssignmentsFolder = $pacEnvironment.policyAssignmentsFolder
$policyExemptionsFolder = $pacEnvironment.policyExemptionsFolder
$policyExemptionsFolderForPacEnvironment = "$($policyExemptionsFolder)/$($pacEnvironment.pacSelector)"
#endregion plan data structures

#region calculate which plans need to be built
$warningMessages = [System.Collections.ArrayList]::new()
$exemptionsAreNotManagedMessage = $null
$exemptionsAreManaged = $true
if (!(Test-Path $policyExemptionsFolder -PathType Container)) {
    $exemptionsAreNotManagedMessage = "Policy Exemptions folder '$policyExemptionsFolder not found. Exemptions not managed by this EPAC instance."
    $exemptionsAreManaged = $false
}
elseif (!(Test-Path $policyExemptionsFolderForPacEnvironment -PathType Container)) {
    $exemptionsAreNotManagedMessage = "Policy Exemptions folder '$policyExemptionsFolderForPacEnvironment' for PaC environment $($pacEnvironment.pacSelector) not found. Exemptions not managed by this EPAC instance."
    $exemptionsAreManaged = $false
}

# Validate parameter conflicts
if ($BuildExemptionsOnly -and $SkipExemptions) {
    Write-Error -Message "The parameters -BuildExemptionsOnly and -SkipExemptions cannot be used together. Exiting..."
    exit
}

# Define resource types and their configuration
$resourceTypes = @(
    @{
        Name                    = "Policy definitions"
        BuildFlag               = "buildPolicyDefinitions"
        Folder                  = $policyDefinitionsFolder
        IncludeInExemptionsOnly = $false
        IncludeInSkipExemptions = $true
    },
    @{
        Name                    = "Policy Set definitions"
        BuildFlag               = "buildPolicySetDefinitions"
        Folder                  = $policySetDefinitionsFolder
        IncludeInExemptionsOnly = $false
        IncludeInSkipExemptions = $true
    },
    @{
        Name                    = "Policy Assignments"
        BuildFlag               = "buildPolicyAssignments"
        Folder                  = $policyAssignmentsFolder
        IncludeInExemptionsOnly = $false
        IncludeInSkipExemptions = $true
    },
    @{
        Name                    = "Policy Exemptions"
        BuildFlag               = "buildPolicyExemptions"
        Folder                  = $null  # Special handling required
        IncludeInExemptionsOnly = $true
        IncludeInSkipExemptions = $false
        IsManaged               = $exemptionsAreManaged
        NotManagedMessage       = $exemptionsAreNotManagedMessage
    }
)

# Determine build mode and add appropriate warning
if ($BuildExemptionsOnly) {
    $null = $warningMessages.Add("Building only the Exemptions plan. Policy, Policy Set, and Assignment plans will not be built.")
}
elseif ($SkipExemptions) {
    $null = $warningMessages.Add("Building only Policy, Policy Set, and Assignment plans. Exemption plans will not be built.")
}

# Process each resource type based on build mode
foreach ($resourceType in $resourceTypes) {
    $shouldInclude = $false
    # Determine if this resource type should be included based on build mode
    if ($BuildExemptionsOnly) {
        $shouldInclude = $resourceType.IncludeInExemptionsOnly
    }
    elseif ($SkipExemptions) {
        $shouldInclude = $resourceType.IncludeInSkipExemptions
    }
    else {
        # Default mode - include all managed resources
        $shouldInclude = $true
    }
    if ($shouldInclude) {
        # Special handling for exemptions
        if ($resourceType.Name -eq "Policy Exemptions") {
            if ($resourceType.IsManaged) {
                $buildSelections[$resourceType.BuildFlag] = $true
                $buildSelections.buildAny = $true
            }
            else {
                $null = $warningMessages.Add($resourceType.NotManagedMessage)
                if ($BuildExemptionsOnly) {
                    $null = $warningMessages.Add("Policy Exemptions plan will not be built. Exiting...")
                }
            }
        }
        else {
            # Standard folder-based resource types
            if (Test-Path $resourceType.Folder -PathType Container) {
                $buildSelections[$resourceType.BuildFlag] = $true
                $buildSelections.buildAny = $true
            }
            else {
                $null = $warningMessages.Add("$($resourceType.Name) '$($resourceType.Folder)' folder not found. $($resourceType.Name) not managed by this EPAC instance.")
            }
        }
    }
}

# Final validation - ensure at least one resource type is being built
if (-not $buildSelections.buildAny) {
    $null = $warningMessages.Add("No Policies, Policy Set, Assignment, or Exemptions managed by this EPAC instance found. No plans will be built. Exiting...")
}

if ($warningMessages.Count -gt 0) {
    foreach ($warningMessage in $warningMessages) {
        Write-Warning $warningMessage

        if ($DevOpsType -eq "ado") {
            Write-Host "##vso[task.logissue type=warning]$warningMessage"
        }
    }
}
#endregion calculate which plans need to be built

if ($buildSelections.buildAny) {
    
    # get the scope table for the deployment root scope amd the resources
    $scopeTable = Build-ScopeTableForDeploymentRootScope -PacEnvironment $pacEnvironment
    $skipExemptions = -not $buildSelections.buildPolicyExemptions
    $skipRoleAssignments = -not $buildSelections.buildPolicyAssignments
    $deployedPolicyResources = Get-AzPolicyResources `
        -PacEnvironment $pacEnvironment `
        -ScopeTable $scopeTable `
        -SkipExemptions:$skipExemptions `
        -SkipRoleAssignments:$skipRoleAssignments

    # Calculate roleDefinitionIds for built-in and inherited Policies
    $readOnlyPolicyDefinitions = $deployedPolicyResources.policydefinitions.readOnly
    foreach ($id in $readOnlyPolicyDefinitions.Keys) {
        $deployedDefinitionProperties = Get-PolicyResourceProperties -PolicyResource $readOnlyPolicyDefinitions.$id
        if ($deployedDefinitionProperties.policyRule.then.details -and $deployedDefinitionProperties.policyRule.then.details.roleDefinitionIds) {
            $roleIds = $deployedDefinitionProperties.policyRule.then.details.roleDefinitionIds
            $null = $policyRoleIds.Add($id, $roleIds)
        }
    }

    # Populate allDefinitions.policydefinitions with all deployed definitions
    $allDeployedDefinitions = $deployedPolicyResources.policydefinitions.all
    foreach ($id in $allDeployedDefinitions.Keys) {
        $allDefinitions.policydefinitions[$id] = $allDeployedDefinitions.$id
    }

    if ($buildSelections.buildPolicyDefinitions) {
        # Process Policies
        Build-PolicyPlan `
            -DefinitionsRootFolder $policyDefinitionsFolder `
            -PacEnvironment $pacEnvironment `
            -DeployedDefinitions $deployedPolicyResources.policydefinitions `
            -Definitions $policyDefinitions `
            -AllDefinitions $allDefinitions `
            -ReplaceDefinitions $replaceDefinitions `
            -PolicyRoleIds $policyRoleIds
    }

    # Calculate roleDefinitionIds for built-in and inherited PolicySets
    $readOnlyPolicySetDefinitions = $deployedPolicyResources.policysetdefinitions.readOnly
    foreach ($id in $readOnlyPolicySetDefinitions.Keys) {
        $policySetProperties = Get-PolicyResourceProperties -PolicyResource $readOnlyPolicySetDefinitions.$id
        $roleIds = @{}
        foreach ($policyDefinition in $policySetProperties.policyDefinitions) {
            $policyId = $policyDefinition.policyDefinitionId
            if ($policyRoleIds.ContainsKey($policyId)) {
                $addRoleDefinitionIds = $PolicyRoleIds.$policyId
                foreach ($roleDefinitionId in $addRoleDefinitionIds) {
                    $roleIds[$roleDefinitionId] = "added"
                }
            }
        }
        if ($roleIds.psbase.Count -gt 0) {
            $null = $policyRoleIds.Add($id, $roleIds.Keys)
        }
    }

    # Populate allDefinitions.policysetdefinitions with deployed definitions
    $allDeployedDefinitions = $deployedPolicyResources.policysetdefinitions.all
    foreach ($id in $allDeployedDefinitions.Keys) {
        $allDefinitions.policysetdefinitions[$id] = $allDeployedDefinitions.$id
    }

    if ($buildSelections.buildPolicySetDefinitions) {
        # Process Policy Sets
        Build-PolicySetPlan `
            -DefinitionsRootFolder $policySetDefinitionsFolder `
            -PacEnvironment $pacEnvironment `
            -DeployedDefinitions $deployedPolicyResources.policysetdefinitions `
            -Definitions $policySetDefinitions `
            -AllDefinitions $allDefinitions `
            -ReplaceDefinitions $replaceDefinitions `
            -PolicyRoleIds $policyRoleIds
    }

    # Convert Policy and PolicySetDefinition to detailed Info
    $combinedPolicyDetails = Convert-PolicyResourcesToDetails `
        -AllPolicyDefinitions $allDefinitions.policydefinitions `
        -AllPolicySetDefinitions $allDefinitions.policysetdefinitions

    # Populate allAssignments
    $deployedPolicyAssignments = $deployedPolicyResources.policyassignments.managed
    foreach ($id  in $deployedPolicyAssignments.Keys) {
        $allAssignments[$id] = $deployedPolicyAssignments.$id
    }

    #region Process Deprecated
    $deprecatedHash = @{}
    foreach ($key in $combinedPolicyDetails.policies.keys) {
        if ($combinedPolicyDetails.policies.$key.isDeprecated) {
            $deprecatedHash[$combinedPolicyDetails.policies.$key.name] = $combinedPolicyDetails.policies.$key
        }
    }

    if ($buildSelections.buildPolicyAssignments) {
        # Process Assignment JSON files
        Build-AssignmentPlan `
            -AssignmentsRootFolder $policyAssignmentsFolder `
            -PacEnvironment $pacEnvironment `
            -ScopeTable $scopeTable `
            -DeployedPolicyResources $deployedPolicyResources `
            -Assignments $assignments `
            -RoleAssignments $roleAssignments `
            -AllAssignments $allAssignments `
            -ReplaceDefinitions $replaceDefinitions `
            -PolicyRoleIds $policyRoleIds `
            -CombinedPolicyDetails $combinedPolicyDetails `
            -DeprecatedHash $deprecatedHash
    }

    if ($buildSelections.buildPolicyExemptions) {
        # Process Exemption JSON files
        if ($SkipNotScopedExemptions) {
            Build-ExemptionsPlan `
                -ExemptionsRootFolder $policyExemptionsFolderForPacEnvironment `
                -ExemptionsAreNotManagedMessage $exemptionsAreNotManagedMessage `
                -PacEnvironment $pacEnvironment `
                -ScopeTable $scopeTable `
                -AllDefinitions $allDefinitions `
                -AllAssignments $allAssignments `
                -CombinedPolicyDetails $combinedPolicyDetails `
                -Assignments $assignments `
                -DeployedExemptions $deployedPolicyResources.policyExemptions `
                -Exemptions $exemptions `
                -SkipNotScopedExemptions
        }
        else {
            Build-ExemptionsPlan `
                -ExemptionsRootFolder $policyExemptionsFolderForPacEnvironment `
                -ExemptionsAreNotManagedMessage $exemptionsAreNotManagedMessage `
                -PacEnvironment $pacEnvironment `
                -ScopeTable $scopeTable `
                -AllDefinitions $allDefinitions `
                -AllAssignments $allAssignments `
                -CombinedPolicyDetails $combinedPolicyDetails `
                -Assignments $assignments `
                -DeployedExemptions $deployedPolicyResources.policyExemptions `
                -Exemptions $exemptions
        }
    }

    Write-Information "==================================================================================================="
    Write-Information "Summary"
    Write-Information "==================================================================================================="

    if ($buildSelections.buildPolicyDefinitions) {
        Write-Information "Policy counts:"
        Write-Information " $($policyDefinitions.numberUnchanged) unchanged"
        if ($policyDefinitions.numberOfChanges -eq 0) {
            Write-Information " $($policyDefinitions.numberOfChanges) changes"
        }
        else {
            Write-Information " $($policyDefinitions.numberOfChanges) changes:"
            Write-Information " new = $($policyDefinitions.new.psbase.Count)"
            Write-Information " update = $($policyDefinitions.update.psbase.Count)"
            Write-Information " replace = $($policyDefinitions.replace.psbase.Count)"
            Write-Information " delete = $($policyDefinitions.delete.psbase.Count)"
        }
    }

    if ($buildSelections.buildPolicySetDefinitions) {
        Write-Information "Policy Set counts:"
        Write-Information " $($policySetDefinitions.numberUnchanged) unchanged"
        if ($policySetDefinitions.numberOfChanges -eq 0) {
            Write-Information " $($policySetDefinitions.numberOfChanges) changes"
        }
        else {
            Write-Information " $($policySetDefinitions.numberOfChanges) changes:"
            Write-Information " new = $($policySetDefinitions.new.psbase.Count)"
            Write-Information " update = $($policySetDefinitions.update.psbase.Count)"
            Write-Information " replace = $($policySetDefinitions.replace.psbase.Count)"
            Write-Information " delete = $($policySetDefinitions.delete.psbase.Count)"
        }
    }

    if ($buildSelections.buildPolicyAssignments) {
        Write-Information "Policy Assignment counts:"
        Write-Information " $($assignments.numberUnchanged) unchanged"
        if ($assignments.numberOfChanges -eq 0) {
            Write-Information " $($assignments.numberOfChanges) changes"
        }
        else {
            Write-Information " $($assignments.numberOfChanges) changes:"
            Write-Information " new = $($assignments.new.psbase.Count)"
            Write-Information " update = $($assignments.update.psbase.Count)"
            Write-Information " replace = $($assignments.replace.psbase.Count)"
            Write-Information " delete = $($assignments.delete.psbase.Count)"
        }
        Write-Information "Role Assignment counts:"
        if ($roleAssignments.numberOfChanges -eq 0) {
            Write-Information " $($roleAssignments.numberOfChanges) changes"
        }
        else {
            Write-Information " $($roleAssignments.numberOfChanges) changes:"
            Write-Information " add = $($roleAssignments.added.psbase.Count)"
            Write-Information " update = $($roleAssignments.updated.psbase.Count)"
            Write-Information " remove = $($roleAssignments.removed.psbase.Count)"
        }
    }

    if ($buildSelections.buildPolicyExemptions) {
        Write-Information "Policy Exemption counts:"
        Write-Information " $($exemptions.numberUnchanged) unchanged"
        Write-Information " $($exemptions.numberOfOrphans) orphaned"
        Write-Information " $($exemptions.numberOfExpired) expired"
        if ($exemptions.numberOfChanges -eq 0) {
            Write-Information " $($exemptions.numberOfChanges) changes"
        }
        else {
            Write-Information " $($exemptions.numberOfChanges) changes:"
            Write-Information " new = $($exemptions.new.psbase.Count)"
            Write-Information " update = $($exemptions.update.psbase.Count)"
            Write-Information " replace = $($exemptions.replace.psbase.Count)"
            Write-Information " delete = $($exemptions.delete.psbase.Count)"
        }
    }

}

Write-Information "---------------------------------------------------------------------------------------------------"
Write-Information "Output plan(s); if any, will be written to the following file(s):"
$policyResourceChanges = $policyDefinitions.numberOfChanges
$policyResourceChanges += $policySetDefinitions.numberOfChanges
$policyResourceChanges += $assignments.numberOfChanges
$policyResourceChanges += $exemptions.numberOfChanges

$policyStage = "no"
$planFile = $pacEnvironment.policyPlanOutputFile
if ($policyResourceChanges -gt 0) {
    Write-Information " Policy resource deployment required; writing Policy plan file '$planFile'"
    if (-not (Test-Path $planFile)) {
        $null = (New-Item $planFile -Force)
    }
    $null = $policyPlan | ConvertTo-Json -Depth 100 | Out-File -FilePath $planFile -Force
    $policyStage = "yes"
}
else {
    if (Test-Path $planFile) {
        $null = (Remove-Item $planFile)
    }
    Write-Information " Skipping Policy deployment stage/step - no changes"
}

$roleStage = "no"
$planFile = $pacEnvironment.rolesPlanOutputFile
if ($roleAssignments.numberOfChanges -gt 0) {
    Write-Information " Role assignment changes required; writing Policy plan file '$planFile'"
    if (-not (Test-Path $planFile)) {
        $null = (New-Item $planFile -Force)
    }
    $null = $rolesPlan | ConvertTo-Json -Depth 100 | Out-File -FilePath $planFile -Force
    $roleStage = "yes"
}
else {
    if (Test-Path $planFile) {
        $null = (Remove-Item $planFile)
    }
    Write-Information " Skipping Role Assignment stage/step - no changes"
}
Write-Information "---------------------------------------------------------------------------------------------------"
Write-Information ""

switch ($DevOpsType) {
    ado {
        Write-Host "##vso[task.setvariable variable=deployPolicyChanges;isOutput=true]$($policyStage)"
        Write-Host "##vso[task.setvariable variable=deployRoleChanges;isOutput=true]$($roleStage)"
        break
    }
    gitlab {
        Add-Content "build.env" "deployPolicyChanges=$($policyStage)"
        Add-Content "build.env" "deployRoleChanges=$($roleStage)"
    }
    default {
    }
}
}