Private/Compare-AACResourceState.ps1

function Compare-AACResourceState {
    <#
    .SYNOPSIS
        Compares one resource's desired state with what Azure has, property
        by property, and works out what to send: the heart of
        Deploy-AACStorageAccount's idempotency.
    .DESCRIPTION
        -Node is one of Get-AACStorageDesiredState's nodes; -Current the
        resource as Azure returns it, or $null when it doesn't exist.
 
        Only the node's Managed properties are compared, each by its Mode:
          Value one value; strings case-insensitive (CaseSensitive for
                   tag and metadata values)
          Map a dictionary, keys case-insensitive (tags, metadata)
          Set a list compared regardless of order (IP rules, subnets,
                   diagnostic categories, lifecycle rules)
          Object a nested object
        A missing current value counts as the spec's Absent value - what
        Azure means when it leaves a property out (no infrastructure
        encryption, shared key access allowed). Inside objects and list
        items only the keys the desired state names are compared, so fields
        Azure adds by itself (a rule's state, a category's retention policy)
        don't show as changes; disabled entries Azure echoes back are
        dropped when the desired list names only enabled ones.
 
        Returns @{ Action; Differences; Method; Body }:
          Create it doesn't exist: PUT the desired body
          Update something differs: PATCH the changed top-level
                     properties, or PUT the current settings with the
                     desired ones merged in (Put nodes - a service's other
                     settings stay as they are) or the desired body as it
                     is (settings, locks, policies - replaced whole)
          Replace a property Azure can't change in place differs: nothing
                     is sent, and the plan stops
          NoChange
        Differences: @{ Property; Current; Desired; Immutable } per property
        that differs (on Create: every managed property, Current empty).
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Node,

        [System.Collections.IDictionary] $Current
    )

    $get = {
        param($Object, [string] $Path)
        $value = $Object
        foreach ($part in $Path.Split('.')) {
            if ($value -isnot [System.Collections.IDictionary]) { return $null }
            $key = @($value.Keys | Where-Object { [string]$_ -ieq $part }) | Select-AACFirst 1
            if ($null -eq $key) { return $null }
            $value = $value[$key]
        }
        , $value
    }
    # The current value narrowed to the keys the desired value names, at
    # every level.
    $project = {
        param($CurrentValue, $DesiredValue)
        if ($DesiredValue -is [System.Collections.IDictionary] -and $CurrentValue -is [System.Collections.IDictionary]) {
            $out = [ordered]@{}
            foreach ($key in $DesiredValue.Keys) {
                $match = @($CurrentValue.Keys | Where-Object { [string]$_ -ieq [string]$key }) | Select-AACFirst 1
                # Not through $( ): it would unroll a one-item list to its item.
                $value = $null
                if ($null -ne $match) { $value = $CurrentValue[$match] }
                $out[[string]$key] = & $project $value $DesiredValue[$key]
            }
            return , $out
        }
        if ($DesiredValue -is [System.Collections.IList] -and $DesiredValue -isnot [string] -and $CurrentValue -is [System.Collections.IList] -and $CurrentValue -isnot [string]) {
            $shape = @($DesiredValue | Where-Object { $_ -is [System.Collections.IDictionary] })
            if (-not $shape.Count) { return , @($CurrentValue) }
            $keys = [ordered]@{}
            foreach ($item in $shape) { foreach ($key in $item.Keys) { $keys[[string]$key] = $item[$key] } }
            return , @(foreach ($item in $CurrentValue) { & $project $item $keys })
        }
        $CurrentValue
    }
    $canonical = {
        param($Value, [bool] $CaseSensitive, [bool] $AsSet)
        if ($null -eq $Value) { return 'null' }
        if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) }
        if ($Value -is [string]) { $text = $Value; if (-not $CaseSensitive) { $text = $text.ToLowerInvariant() }; return [System.Text.Json.JsonEncodedText]::Encode($text).ToString() }
        if ($Value -is [System.Collections.IDictionary]) {
            $keys = @($Value.Keys | ForEach-Object { [string]$_ } | Sort-Object { $_.ToLowerInvariant() })
            return '{' + (@(foreach ($key in $keys) { $key.ToLowerInvariant() + ':' + (& $canonical $Value[$key] $CaseSensitive $false) }) -join ',') + '}'
        }
        if ($Value -is [System.Collections.IList]) {
            $items = @(foreach ($item in $Value) { & $canonical $item $CaseSensitive $false })
            if ($AsSet) { $items = @($items | Sort-Object) }
            return '[' + ($items -join ',') + ']'
        }
        if ($Value -is [System.IFormattable]) { return $Value.ToString($null, [cultureinfo]::InvariantCulture) }
        [string]$Value
    }
    $dropDisabled = {
        param($List, $Desired)
        $named = @($Desired | Where-Object { $_ -is [System.Collections.IDictionary] })
        if (-not $named.Count -or @($named | Where-Object { -not $_.Contains('enabled') -or -not $_['enabled'] }).Count) { return , @($List) }
        , @($List | Where-Object { -not ($_ -is [System.Collections.IDictionary] -and $_.Contains('enabled') -and $false -eq $_['enabled']) })
    }
    $show = {
        param($Value)
        if ($null -eq $Value) { return '' }
        if ($Value -is [string]) { return $Value }
        if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) }
        ConvertTo-Json -InputObject $Value -Depth 20 -Compress
    }

    $desiredBody = $Node.Desired
    $differences = [System.Collections.Generic.List[object]]::new()
    if (-not $Current) {
        foreach ($spec in $Node.Managed) {
            $desired = & $get $desiredBody $spec.Path
            if ($null -eq $desired) { continue }
            $differences.Add([pscustomobject]@{ Property = $spec.Path; Current = ''; Desired = (& $show $desired); Immutable = $false })
        }
        return @{ Action = 'Create'; Differences = $differences.ToArray(); Method = 'Put'; Body = $desiredBody }
    }

    foreach ($spec in $Node.Managed) {
        $want = & $get $desiredBody $spec.Path
        if ($null -eq $want -and $spec.Mode -ne 'Map') { continue }
        $have = & $get $Current $spec.Path
        if ($null -eq $have -or ($have -is [string] -and $have -eq '' -and $null -ne $spec.Absent)) { $have = $spec.Absent }
        $asSet = $spec.Mode -eq 'Set'
        if ($spec.Mode -in 'Set', 'Object') {
            $have = & $project $have $want
            if ($asSet) { $have = & $dropDisabled $have $want; $want = & $dropDisabled $want $want }
        }
        if ($spec.Mode -eq 'Set' -and $null -eq $have) { $have = @() }
        if ($spec.Mode -eq 'Set' -and $have -is [string]) { $have = @($have -split '\s*,\s*' | Where-Object { $_ }) }
        if ($spec.Mode -eq 'Set' -and $want -is [string]) { $want = @($want -split '\s*,\s*' | Where-Object { $_ }) }
        if ($spec.Mode -eq 'Map' -and $null -eq $have) { $have = @{} }
        if ($spec.Mode -eq 'Map' -and $null -eq $want) { $want = @{} }
        if ((& $canonical $have $spec.CaseSensitive $asSet) -cne (& $canonical $want $spec.CaseSensitive $asSet)) {
            $immutable = $spec.Immutable -or ($spec.Contains('ImmutableWhen') -and $spec.ImmutableWhen -and [bool](& $spec.ImmutableWhen $have $want))
            $differences.Add([pscustomobject]@{ Property = $spec.Path; Current = (& $show $have); Desired = (& $show $want); Immutable = $immutable })
        }
    }
    if (-not $differences.Count) { return @{ Action = 'NoChange'; Differences = @(); Method = ''; Body = $null } }
    if (@($differences | Where-Object Immutable).Count) { return @{ Action = 'Replace'; Differences = $differences.ToArray(); Method = ''; Body = $null } }

    if ($Node.UpdateMethod -eq 'Patch') {
        # The changed top-level properties - a nested one is sent whole, the
        # current value with the desired merged in.
        $body = [ordered]@{}
        foreach ($difference in $differences) {
            $parts = $difference.Property.Split('.')
            if ($parts[0] -eq 'properties' -and $parts.Count -gt 1) {
                if (-not $body.Contains('properties')) { $body['properties'] = [ordered]@{} }
                $key = $parts[1]
                $desiredValue = & $get $desiredBody "properties.$key"
                $currentValue = & $get $Current "properties.$key"
                $body['properties'][$key] = if ($desiredValue -is [System.Collections.IDictionary] -and $currentValue -is [System.Collections.IDictionary]) { Merge-AACObject -Base $currentValue -Overlay $desiredValue } else { $desiredValue }
            }
            else {
                $body[$parts[0]] = & $get $desiredBody $parts[0]
            }
        }
        return @{ Action = 'Update'; Differences = $differences.ToArray(); Method = 'Patch'; Body = $body }
    }
    $body = if ($Node.Kind -in 'blobService', 'fileService', 'queueService', 'tableService') {
        [ordered]@{ properties = Merge-AACObject -Base $(if ($Current['properties'] -is [System.Collections.IDictionary]) { $Current['properties'] } else { @{} }) -Overlay $desiredBody['properties'] }
    }
    else { $desiredBody }
    @{ Action = 'Update'; Differences = $differences.ToArray(); Method = 'Put'; Body = $body }
}