Public/Plugins/Get-DataversePluginDrift.ps1

function Get-DataversePluginDrift {
    <#
    .SYNOPSIS
        Compares plugin registration configuration with Dataverse state to detect drift.

    .DESCRIPTION
        Analyzes differences between the registrations.json configuration and actual
        Dataverse plugin registrations. Reports orphaned components, missing components,
        and configuration differences.

    .PARAMETER RegistrationFile
        Path to the registrations.json file to compare.

    .PARAMETER Connection
        CrmServiceClient connection object. If not provided, uses current PAC CLI context.

    .PARAMETER AssemblyName
        Specific assembly name to check. If not provided, checks all assemblies in the file.

    .EXAMPLE
        Get-DataversePluginDrift -RegistrationFile "./registrations.json"
        Checks drift for all assemblies in the registration file.

    .EXAMPLE
        $conn = Connect-DataverseEnvironment -Interactive
        Get-DataversePluginDrift -RegistrationFile "./registrations.json" -Connection $conn

    .OUTPUTS
        Array of drift report objects.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$RegistrationFile,

        [Parameter()]
        [Microsoft.Xrm.Tooling.Connector.CrmServiceClient]$Connection,

        [Parameter()]
        [string]$AssemblyName
    )

    if (-not (Test-Path $RegistrationFile)) {
        throw "Registration file not found: $RegistrationFile"
    }

    $registrations = Read-RegistrationJson -Path $RegistrationFile
    if (-not $registrations -or -not $registrations.assemblies) {
        throw "Invalid or empty registrations.json"
    }

    # Get API connection
    if ($Connection) {
        $apiUrl = Get-WebApiBaseUrl -Connection $Connection
        $authHeaders = Get-AuthHeaders -Connection $Connection
    }
    else {
        throw "Connection required. Use Connect-DataverseEnvironment first."
    }

    $allDriftReports = @()

    foreach ($asmReg in $registrations.assemblies) {
        if ($AssemblyName -and $asmReg.name -ne $AssemblyName) {
            continue
        }

        Write-Log "Checking drift for: $($asmReg.name)"

        $drift = Get-AssemblyDrift -ApiUrl $apiUrl -AuthHeaders $authHeaders `
            -AssemblyName $asmReg.name -ConfiguredPlugins $asmReg.plugins

        Write-DriftReport -Drift $drift
        $allDriftReports += $drift
    }

    return $allDriftReports
}

function Get-AssemblyDrift {
    <#
    .SYNOPSIS
        Internal function to get drift for a single assembly.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$ApiUrl,
        [Parameter(Mandatory = $true)]
        [hashtable]$AuthHeaders,
        [Parameter(Mandatory = $true)]
        [string]$AssemblyName,
        [Parameter(Mandatory = $true)]
        [array]$ConfiguredPlugins
    )

    $drift = [PSCustomObject]@{
        AssemblyName = $AssemblyName
        HasDrift = $false
        OrphanedSteps = @()
        MissingSteps = @()
        ModifiedSteps = @()
        OrphanedImages = @()
        MissingImages = @()
        ModifiedImages = @()
    }

    $assembly = Get-PluginAssembly -ApiUrl $ApiUrl -AuthHeaders $AuthHeaders -Name $AssemblyName
    if (-not $assembly) {
        foreach ($plugin in $ConfiguredPlugins) {
            foreach ($step in $plugin.steps) {
                $drift.MissingSteps += [PSCustomObject]@{
                    StepName = $step.name
                    PluginType = $plugin.typeName
                    Message = $step.message
                    Entity = $step.entity
                    Reason = "Assembly not registered"
                }
            }
        }
        $drift.HasDrift = $drift.MissingSteps.Count -gt 0
        return $drift
    }

    $dataverseSteps = Get-ProcessingStepsForAssembly -ApiUrl $ApiUrl -AuthHeaders $AuthHeaders -AssemblyId $assembly.pluginassemblyid

    $configuredStepLookup = @{}
    foreach ($plugin in $ConfiguredPlugins) {
        foreach ($step in $plugin.steps) {
            $configuredStepLookup[$step.name] = @{
                Step = $step
                Plugin = $plugin
            }
        }
    }

    # Check for orphaned steps
    foreach ($dvStep in $dataverseSteps) {
        if (-not $configuredStepLookup.ContainsKey($dvStep.name)) {
            $drift.OrphanedSteps += [PSCustomObject]@{
                StepName = $dvStep.name
                StepId = $dvStep.sdkmessageprocessingstepid
                Stage = $script:PluginStageMap[[int]$dvStep.stage]
                Mode = $script:PluginModeMap[[int]$dvStep.mode]
            }
        }
    }

    # Check for missing steps and configuration differences
    foreach ($stepName in $configuredStepLookup.Keys) {
        $config = $configuredStepLookup[$stepName]
        $configStep = $config.Step
        $configPlugin = $config.Plugin

        $dvStep = $dataverseSteps | Where-Object { $_.name -eq $stepName } | Select-Object -First 1

        if (-not $dvStep) {
            $drift.MissingSteps += [PSCustomObject]@{
                StepName = $stepName
                PluginType = $configPlugin.typeName
                Message = $configStep.message
                Entity = $configStep.entity
                Reason = "Step not registered"
            }
        }
        else {
            $differences = @()

            $configStageValue = $script:DataverseStageValues[$configStep.stage]
            if ($dvStep.stage -ne $configStageValue) {
                $differences += "Stage: Dataverse=$($script:PluginStageMap[[int]$dvStep.stage]), Config=$($configStep.stage)"
            }

            $configModeValue = $script:DataverseModeValues[$configStep.mode]
            if ($dvStep.mode -ne $configModeValue) {
                $differences += "Mode: Dataverse=$($script:PluginModeMap[[int]$dvStep.mode]), Config=$($configStep.mode)"
            }

            if ($dvStep.rank -ne $configStep.executionOrder) {
                $differences += "ExecutionOrder: Dataverse=$($dvStep.rank), Config=$($configStep.executionOrder)"
            }

            $dvFiltering = if ($dvStep.filteringattributes) { $dvStep.filteringattributes } else { "" }
            $configFiltering = if ($configStep.filteringAttributes) { $configStep.filteringAttributes } else { "" }
            if ($dvFiltering -ne $configFiltering) {
                $differences += "FilteringAttributes: Dataverse='$dvFiltering', Config='$configFiltering'"
            }

            if ($differences.Count -gt 0) {
                $drift.ModifiedSteps += [PSCustomObject]@{
                    StepName = $stepName
                    StepId = $dvStep.sdkmessageprocessingstepid
                    Differences = $differences
                }
            }

            # Check images
            $dvImages = Get-StepImages -ApiUrl $ApiUrl -AuthHeaders $AuthHeaders -StepId $dvStep.sdkmessageprocessingstepid

            $configuredImageLookup = @{}
            foreach ($image in $configStep.images) {
                $configuredImageLookup[$image.name] = $image
            }

            foreach ($dvImage in $dvImages) {
                if (-not $configuredImageLookup.ContainsKey($dvImage.name)) {
                    $drift.OrphanedImages += [PSCustomObject]@{
                        ImageName = $dvImage.name
                        StepName = $stepName
                        ImageId = $dvImage.sdkmessageprocessingstepimageid
                        ImageType = $script:PluginImageTypeMap[[int]$dvImage.imagetype]
                    }
                }
            }

            foreach ($imageName in $configuredImageLookup.Keys) {
                $configImage = $configuredImageLookup[$imageName]
                $dvImage = $dvImages | Where-Object { $_.name -eq $imageName } | Select-Object -First 1

                if (-not $dvImage) {
                    $drift.MissingImages += [PSCustomObject]@{
                        ImageName = $imageName
                        StepName = $stepName
                        ImageType = $configImage.imageType
                        Attributes = $configImage.attributes
                    }
                }
                else {
                    $imageDiffs = @()

                    $configImageTypeValue = $script:DataverseImageTypeValues[$configImage.imageType]
                    if ($dvImage.imagetype -ne $configImageTypeValue) {
                        $imageDiffs += "ImageType: Dataverse=$($script:PluginImageTypeMap[[int]$dvImage.imagetype]), Config=$($configImage.imageType)"
                    }

                    $dvAttrs = if ($dvImage.attributes) { $dvImage.attributes } else { "" }
                    $configAttrs = if ($configImage.attributes) { $configImage.attributes } else { "" }
                    if ($dvAttrs -ne $configAttrs) {
                        $imageDiffs += "Attributes: Dataverse='$dvAttrs', Config='$configAttrs'"
                    }

                    if ($imageDiffs.Count -gt 0) {
                        $drift.ModifiedImages += [PSCustomObject]@{
                            ImageName = $imageName
                            StepName = $stepName
                            ImageId = $dvImage.sdkmessageprocessingstepimageid
                            Differences = $imageDiffs
                        }
                    }
                }
            }
        }
    }

    $drift.HasDrift = (
        $drift.OrphanedSteps.Count -gt 0 -or
        $drift.MissingSteps.Count -gt 0 -or
        $drift.ModifiedSteps.Count -gt 0 -or
        $drift.OrphanedImages.Count -gt 0 -or
        $drift.MissingImages.Count -gt 0 -or
        $drift.ModifiedImages.Count -gt 0
    )

    return $drift
}

function Write-DriftReport {
    <#
    .SYNOPSIS
        Outputs a formatted drift detection report.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [PSCustomObject]$Drift
    )

    Write-Log ""
    Write-Log ("=" * 60)
    Write-Log "Drift Report: $($Drift.AssemblyName)"
    Write-Log ("=" * 60)

    if (-not $Drift.HasDrift) {
        Write-LogSuccess "No drift detected - configuration matches Dataverse"
        return
    }

    if ($Drift.OrphanedSteps.Count -gt 0) {
        Write-Log ""
        Write-LogWarning "ORPHANED STEPS ($($Drift.OrphanedSteps.Count)) - In Dataverse but not in config:"
        foreach ($step in $Drift.OrphanedSteps) {
            Write-Log " - $($step.StepName)"
            Write-Log " Stage: $($step.Stage), Mode: $($step.Mode)"
        }
    }

    if ($Drift.MissingSteps.Count -gt 0) {
        Write-Log ""
        Write-LogWarning "MISSING STEPS ($($Drift.MissingSteps.Count)) - In config but not in Dataverse:"
        foreach ($step in $Drift.MissingSteps) {
            Write-Log " - $($step.StepName)"
            Write-Log " Plugin: $($step.PluginType)"
        }
    }

    if ($Drift.ModifiedSteps.Count -gt 0) {
        Write-Log ""
        Write-LogWarning "MODIFIED STEPS ($($Drift.ModifiedSteps.Count)) - Configuration differs:"
        foreach ($step in $Drift.ModifiedSteps) {
            Write-Log " - $($step.StepName)"
            foreach ($diff in $step.Differences) {
                Write-Log " $diff"
            }
        }
    }

    if ($Drift.OrphanedImages.Count -gt 0) {
        Write-Log ""
        Write-LogWarning "ORPHANED IMAGES ($($Drift.OrphanedImages.Count)):"
        foreach ($image in $Drift.OrphanedImages) {
            Write-Log " - $($image.ImageName) on step: $($image.StepName)"
        }
    }

    if ($Drift.MissingImages.Count -gt 0) {
        Write-Log ""
        Write-LogWarning "MISSING IMAGES ($($Drift.MissingImages.Count)):"
        foreach ($image in $Drift.MissingImages) {
            Write-Log " - $($image.ImageName) on step: $($image.StepName)"
        }
    }

    if ($Drift.ModifiedImages.Count -gt 0) {
        Write-Log ""
        Write-LogWarning "MODIFIED IMAGES ($($Drift.ModifiedImages.Count)):"
        foreach ($image in $Drift.ModifiedImages) {
            Write-Log " - $($image.ImageName) on step: $($image.StepName)"
            foreach ($diff in $image.Differences) {
                Write-Log " $diff"
            }
        }
    }

    $totalDrift = $Drift.OrphanedSteps.Count + $Drift.MissingSteps.Count + $Drift.ModifiedSteps.Count +
                  $Drift.OrphanedImages.Count + $Drift.MissingImages.Count + $Drift.ModifiedImages.Count
    Write-Log ""
    Write-LogWarning "Total drift items: $totalDrift"
}