Private/Get-AACStorageDesiredState.ps1

function Get-AACStorageDesiredState {
    <#
    .SYNOPSIS
        Turns a storage account configuration (AVM parameter names) into the
        resources to manage, each with its REST path, the body to create it
        with, and the properties to compare - the desired state
        Deploy-AACStorageAccount plans against.
    .DESCRIPTION
        Defaults are AVM's (avm/res/storage/storage-account): StorageV2,
        Standard_GRS, Hot, TLS 1.2, HTTPS only, no public blob access, shared
        key access allowed, no cross-tenant replication, infrastructure
        encryption, network rules denying by default with the AzureServices
        bypass, blob soft delete (6 days) and container soft delete (7 days)
        - and, as with a Bicep deployment, those defaults are enforced on
        every run, not only at creation.
 
        Returns a list of nodes, in apply order. A node:
          Kind account, blobService, container, fileService, share,
                       queueService, queue, tableService, table,
                       managementPolicy, privateEndpoint, dnsZoneGroup,
                       diagnosticSetting, roleAssignment, lock, blob
          Label how the plan names it
          Id its resource ID (a blob: its URL)
          ApiVersion for its REST calls
          Type its resource type (policy check, PSRule)
          Name its full resource name ('st/default/logs')
          Desired the body to create it with
          Managed the properties compared on every run: @{ Path; Mode =
                       'Value' | 'Set' | 'Map' | 'Object'; Absent (what Azure
                       means when the property is missing); Immutable;
                       CaseSensitive }
          UpdateMethod Patch (only what changed) or Put (the current
                       settings with the changes merged in)
          Parent the resource it lives under (policy scope)
          Order apply order
        Role assignments carry Role (the name or ID to resolve) and
        Principal; blobs carry Container, BlobName, Source and ContentType.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Configuration,

        [Parameter(Mandatory)]
        [string] $SubscriptionId,

        [Parameter(Mandatory)]
        [string] $ResourceGroupName,

        [Parameter(Mandatory)]
        [string] $Location
    )

    $c = $Configuration
    $has = { param($Map, [string] $Key) $Map -is [System.Collections.IDictionary] -and $Map.Contains($Key) -and $null -ne $Map[$Key] }
    $pick = { param($Map, [string] $Key, $Default) if (& $has $Map $Key) { $Map[$Key] } else { $Default } }
    $nodes = [System.Collections.Generic.List[object]]::new()
    $spec = {
        param([string] $Path, [string] $Mode = 'Value', $Absent = $null, [switch] $Immutable, [switch] $CaseSensitive, [scriptblock] $ImmutableWhen)
        @{ Path = $Path; Mode = $Mode; Absent = $Absent; Immutable = [bool]$Immutable; CaseSensitive = [bool]$CaseSensitive; ImmutableWhen = $ImmutableWhen }
    }
    $node = {
        param([hashtable] $Values)
        $base = @{ Managed = @(); UpdateMethod = 'Put'; Parent = ''; Order = 0; Desired = @{} }
        foreach ($key in $Values.Keys) { $base[$key] = $Values[$key] }
        $nodes.Add($base)
    }

    $name = [string]$c['name']
    if ($name -cnotmatch '^[a-z0-9]{3,24}$') { throw "'$name' isn't a valid storage account name: 3-24 characters, lower-case letters and digits only." }
    $group = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName"
    $account = "$group/providers/Microsoft.Storage/storageAccounts/$name"
    $storageApi = '2023-05-01'
    $kind = [string](& $pick $c 'kind' 'StorageV2')
    $sku = [string](& $pick $c 'skuName' 'Standard_GRS')
    $region = ([string](& $pick $c 'location' $Location)).ToLowerInvariant() -replace '\s', ''
    $privateEndpoints = @(& $pick $c 'privateEndpoints' @())

    # --- The account ------------------------------------------------------------------------------------------
    $properties = [ordered]@{}
    $managed = [System.Collections.Generic.List[object]]::new()
    $managed.Add((& $spec 'location' -Immutable))
    $managed.Add((& $spec 'kind' -Immutable))
    # Redundancy can change in place only within its kind: LRS, GRS and RAGRS;
    # or ZRS, GZRS and RAGZRS. Zonal and back needs a conversion; Standard and
    # Premium can't be swapped at all.
    $managed.Add((& $spec 'sku.name' -ImmutableWhen {
                param($Current, $Desired)
                $tier = { param($Sku) ([string]$Sku -split '_')[0] }
                $zonal = { param($Sku) [string]$Sku -match 'ZRS$' }
                (& $tier $Current) -ne (& $tier $Desired) -or (& $zonal $Current) -ne (& $zonal $Desired)
            }))
    $setProperty = {
        param([string] $Key, $Value, $Absent, [switch] $Immutable)
        $properties[$Key] = $Value
        $managed.Add((& $spec "properties.$Key" 'Value' $Absent -Immutable:$Immutable))
    }
    if ($kind -in 'StorageV2', 'BlobStorage') { & $setProperty 'accessTier' ([string](& $pick $c 'accessTier' 'Hot')) $null }
    & $setProperty 'allowBlobPublicAccess' ([bool](& $pick $c 'allowBlobPublicAccess' $false)) $false
    & $setProperty 'allowSharedKeyAccess' ([bool](& $pick $c 'allowSharedKeyAccess' $true)) $true
    & $setProperty 'allowCrossTenantReplication' ([bool](& $pick $c 'allowCrossTenantReplication' $false)) $true
    & $setProperty 'defaultToOAuthAuthentication' ([bool](& $pick $c 'defaultToOAuthAuthentication' $false)) $false
    & $setProperty 'minimumTlsVersion' ([string](& $pick $c 'minimumTlsVersion' 'TLS1_2')) 'TLS1_0'
    & $setProperty 'supportsHttpsTrafficOnly' ([bool](& $pick $c 'supportsHttpsTrafficOnly' $true)) $true
    & $setProperty 'isSftpEnabled' ([bool](& $pick $c 'enableSftp' $false)) $false
    & $setProperty 'isLocalUserEnabled' ([bool](& $pick $c 'isLocalUserEnabled' $false)) $false
    if ($sku -in 'Standard_LRS', 'Standard_ZRS') { & $setProperty 'largeFileSharesState' ([string](& $pick $c 'largeFileSharesState' 'Disabled')) 'Disabled' }
    if (& $has $c 'enableHierarchicalNamespace') { & $setProperty 'isHnsEnabled' ([bool]$c['enableHierarchicalNamespace']) $false -Immutable }
    if (& $has $c 'enableNfsV3') { & $setProperty 'isNfsV3Enabled' ([bool]$c['enableNfsV3']) $false -Immutable }
    if (& $has $c 'allowedCopyScope') { & $setProperty 'allowedCopyScope' ([string]$c['allowedCopyScope']) $null }
    if (& $has $c 'dnsEndpointType') { & $setProperty 'dnsEndpointType' ([string]$c['dnsEndpointType']) 'Standard' -Immutable }
    # Public network access: as given; else - as AVM does - Disabled when
    # only private endpoints reach the account.
    $networkAcls = & $pick $c 'networkAcls' $null
    $publicAccess = if (& $has $c 'publicNetworkAccess') { [string]$c['publicNetworkAccess'] } elseif ($privateEndpoints.Count -and -not $networkAcls) { 'Disabled' } else { $null }
    if ($publicAccess) { & $setProperty 'publicNetworkAccess' $publicAccess 'Enabled' }
    # Network rules: as given, else AVM's default - deny, with the AzureServices bypass.
    $acl = [ordered]@{
        bypass              = [string](& $pick $networkAcls 'bypass' 'AzureServices')
        defaultAction       = [string](& $pick $networkAcls 'defaultAction' 'Deny')
        ipRules             = @(@(& $pick $networkAcls 'ipRules' @()) | ForEach-Object { if ($_ -is [System.Collections.IDictionary]) { [ordered]@{ value = [string]$_['value']; action = 'Allow' } } else { [ordered]@{ value = [string]$_; action = 'Allow' } } })
        virtualNetworkRules = @(@(& $pick $networkAcls 'virtualNetworkRules' @()) | ForEach-Object { if ($_ -is [System.Collections.IDictionary]) { [ordered]@{ id = [string]$_['id']; action = 'Allow' } } else { [ordered]@{ id = [string]$_; action = 'Allow' } } })
        resourceAccessRules = @(@(& $pick $networkAcls 'resourceAccessRules' @()) | ForEach-Object { [ordered]@{ tenantId = [string]$_['tenantId']; resourceId = [string]$_['resourceId'] } })
    }
    $properties['networkAcls'] = $acl
    $managed.Add((& $spec 'properties.networkAcls.defaultAction' 'Value' 'Allow'))
    $managed.Add((& $spec 'properties.networkAcls.bypass' 'Set' 'AzureServices'))
    $managed.Add((& $spec 'properties.networkAcls.ipRules' 'Set' @()))
    $managed.Add((& $spec 'properties.networkAcls.virtualNetworkRules' 'Set' @()))
    $managed.Add((& $spec 'properties.networkAcls.resourceAccessRules' 'Set' @()))
    # Infrastructure encryption is fixed when the account is created.
    $properties['encryption'] = [ordered]@{
        requireInfrastructureEncryption = [bool](& $pick $c 'requireInfrastructureEncryption' $true)
        keySource                       = 'Microsoft.Storage'
        services                        = [ordered]@{ blob = [ordered]@{ enabled = $true; keyType = 'Account' }; file = [ordered]@{ enabled = $true; keyType = 'Account' } }
    }
    $managed.Add((& $spec 'properties.encryption.requireInfrastructureEncryption' 'Value' $false -Immutable))
    $body = [ordered]@{ location = $region; kind = $kind; sku = [ordered]@{ name = $sku }; properties = $properties }
    if (& $has $c 'tags') { $body['tags'] = $c['tags']; $managed.Add((& $spec 'tags' 'Map' @{} -CaseSensitive)) }
    & $node @{ Kind = 'account'; Label = "Storage account $name"; Id = $account; ApiVersion = $storageApi; Type = 'Microsoft.Storage/storageAccounts'; Name = $name; Desired = $body; Managed = $managed.ToArray(); UpdateMethod = 'Patch'; Order = 0; Parent = $group }

    # --- Services and what they hold -------------------------------------------------------------------------------
    $supportsBlob = $kind -ne 'FileStorage'
    $supportsOthers = $kind -notin 'FileStorage', 'BlockBlobStorage', 'BlobStorage'
    $supportsFiles = $kind -notin 'BlockBlobStorage', 'BlobStorage'
    $diagnostic = {
        param([System.Collections.IDictionary] $Setting, [string] $Scope, [string] $ScopeLabel, [switch] $MetricsOnly)
        $settingName = [string](& $pick $Setting 'name' "$name-diagnosticSettings")
        # @( ) around the if, not inside it: assigning an if's output unrolls a
        # one-item list to its item - and Azure wants logs as a list.
        $logs = @(if (-not $MetricsOnly) { @(& $pick $Setting 'logCategoriesAndGroups' @(@{ categoryGroup = 'allLogs' })) | ForEach-Object { if ($_['category']) { [ordered]@{ category = [string]$_['category']; enabled = [bool](& $pick $_ 'enabled' $true) } } else { [ordered]@{ categoryGroup = [string]$_['categoryGroup']; enabled = [bool](& $pick $_ 'enabled' $true) } } } })
        $metrics = @(@(& $pick $Setting 'metricCategories' @(@{ category = 'AllMetrics' })) | ForEach-Object { [ordered]@{ category = [string]$_['category']; enabled = [bool](& $pick $_ 'enabled' $true) } })
        $settingProperties = [ordered]@{ logs = $logs; metrics = $metrics }
        foreach ($pair in @(@('workspaceResourceId', 'workspaceId'), @('storageAccountResourceId', 'storageAccountId'), @('eventHubAuthorizationRuleResourceId', 'eventHubAuthorizationRuleId'), @('eventHubName', 'eventHubName'), @('marketplacePartnerResourceId', 'marketplacePartnerId'), @('logAnalyticsDestinationType', 'logAnalyticsDestinationType'))) {
            if (& $has $Setting $pair[0]) { $settingProperties[$pair[1]] = [string]$Setting[$pair[0]] }
        }
        $specs = @((& $spec 'properties.logs' 'Set' @()), (& $spec 'properties.metrics' 'Set' @()))
        foreach ($key in 'workspaceId', 'storageAccountId', 'eventHubAuthorizationRuleId', 'eventHubName', 'marketplacePartnerId') { $specs += & $spec "properties.$key" 'Value' $null }
        if ($settingProperties.Contains('logAnalyticsDestinationType')) { $specs += & $spec 'properties.logAnalyticsDestinationType' 'Value' $null }
        & $node @{ Kind = 'diagnosticSetting'; Label = "Diagnostic setting $settingName on $ScopeLabel"; Id = "$Scope/providers/Microsoft.Insights/diagnosticSettings/$settingName"; ApiVersion = '2021-05-01-preview'; Type = 'Microsoft.Insights/diagnosticSettings'; Name = $settingName; Desired = [ordered]@{ properties = $settingProperties }; Managed = $specs; Order = 6; Parent = $Scope }
    }
    $metadataSpec = { param($Item) if (& $has $Item 'metadata') { & $spec 'properties.metadata' 'Map' @{} -CaseSensitive } }

    $blobServices = & $pick $c 'blobServices' $(if ($supportsBlob) { [ordered]@{ containerDeleteRetentionPolicyEnabled = $true; containerDeleteRetentionPolicyDays = 7; deleteRetentionPolicyEnabled = $true; deleteRetentionPolicyDays = 6 } } else { $null })
    if ($blobServices -is [System.Collections.IDictionary] -and $blobServices.Count) {
        if (-not $supportsBlob) { throw "A $kind account has no blob service: remove blobServices." }
        $blobService = "$account/blobServices/default"
        $serviceProperties = [ordered]@{}
        $specs = [System.Collections.Generic.List[object]]::new()
        $retention = {
            param([string] $Property, [string] $EnabledKey, [string] $DaysKey, [string] $PermanentKey)
            if (& $has $blobServices $EnabledKey) {
                $policy = [ordered]@{ enabled = [bool]$blobServices[$EnabledKey] }
                $specs.Add((& $spec "properties.$Property.enabled" 'Value' $false))
                if ($policy.enabled -and (& $has $blobServices $DaysKey)) { $policy['days'] = [int]$blobServices[$DaysKey]; $specs.Add((& $spec "properties.$Property.days" 'Value' $null)) }
                if ($PermanentKey -and (& $has $blobServices $PermanentKey)) { $policy['allowPermanentDelete'] = [bool]$blobServices[$PermanentKey]; $specs.Add((& $spec "properties.$Property.allowPermanentDelete" 'Value' $false)) }
                $serviceProperties[$Property] = $policy
            }
        }
        & $retention 'deleteRetentionPolicy' 'deleteRetentionPolicyEnabled' 'deleteRetentionPolicyDays' 'deleteRetentionPolicyAllowPermanentDelete'
        & $retention 'containerDeleteRetentionPolicy' 'containerDeleteRetentionPolicyEnabled' 'containerDeleteRetentionPolicyDays' ''
        & $retention 'restorePolicy' 'restorePolicyEnabled' 'restorePolicyDays' ''
        if (& $has $blobServices 'isVersioningEnabled') { $serviceProperties['isVersioningEnabled'] = [bool]$blobServices['isVersioningEnabled']; $specs.Add((& $spec 'properties.isVersioningEnabled' 'Value' $false)) }
        if (& $has $blobServices 'automaticSnapshotPolicyEnabled') { $serviceProperties['automaticSnapshotPolicyEnabled'] = [bool]$blobServices['automaticSnapshotPolicyEnabled']; $specs.Add((& $spec 'properties.automaticSnapshotPolicyEnabled' 'Value' $false)) }
        if (& $has $blobServices 'changeFeedEnabled') {
            $feed = [ordered]@{ enabled = [bool]$blobServices['changeFeedEnabled'] }
            $specs.Add((& $spec 'properties.changeFeed.enabled' 'Value' $false))
            if ($feed.enabled -and (& $has $blobServices 'changeFeedRetentionInDays')) { $feed['retentionInDays'] = [int]$blobServices['changeFeedRetentionInDays']; $specs.Add((& $spec 'properties.changeFeed.retentionInDays' 'Value' $null)) }
            $serviceProperties['changeFeed'] = $feed
        }
        if (& $has $blobServices 'lastAccessTimeTrackingPolicyEnabled') { $serviceProperties['lastAccessTimeTrackingPolicy'] = [ordered]@{ enable = [bool]$blobServices['lastAccessTimeTrackingPolicyEnabled'] }; $specs.Add((& $spec 'properties.lastAccessTimeTrackingPolicy.enable' 'Value' $false)) }
        & $node @{ Kind = 'blobService'; Label = "Blob service of $name"; Id = $blobService; ApiVersion = $storageApi; Type = 'Microsoft.Storage/storageAccounts/blobServices'; Name = "$name/default"; Desired = [ordered]@{ properties = $serviceProperties }; Managed = $specs.ToArray(); Order = 1; Parent = $account }
        foreach ($container in @(& $pick $blobServices 'containers' @())) {
            $containerName = if ($container -is [System.Collections.IDictionary]) { [string]$container['name'] } else { [string]$container }
            if ($containerName -cnotmatch '^(?!.*--)[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$') { throw "'$containerName' isn't a valid container name: 3-63 lower-case letters, digits and single hyphens, starting and ending with a letter or digit." }
            $item = if ($container -is [System.Collections.IDictionary]) { $container } else { @{} }
            $containerProperties = [ordered]@{ publicAccess = [string](& $pick $item 'publicAccess' 'None') }
            $specs = @(& $spec 'properties.publicAccess' 'Value' 'None')
            if (& $has $item 'metadata') { $containerProperties['metadata'] = $item['metadata']; $specs += & $metadataSpec $item }
            if (& $has $item 'defaultEncryptionScope') { $containerProperties['defaultEncryptionScope'] = [string]$item['defaultEncryptionScope']; $specs += & $spec 'properties.defaultEncryptionScope' 'Value' '$account-encryption-key' -Immutable }
            if (& $has $item 'denyEncryptionScopeOverride') { $containerProperties['denyEncryptionScopeOverride'] = [bool]$item['denyEncryptionScopeOverride']; $specs += & $spec 'properties.denyEncryptionScopeOverride' 'Value' $false -Immutable }
            $containerId = "$blobService/containers/$containerName"
            & $node @{ Kind = 'container'; Label = "Container $containerName"; Id = $containerId; ApiVersion = $storageApi; Type = 'Microsoft.Storage/storageAccounts/blobServices/containers'; Name = "$name/default/$containerName"; Desired = [ordered]@{ properties = $containerProperties }; Managed = $specs; UpdateMethod = 'Patch'; Order = 2; Parent = $blobService }
            foreach ($assignment in @(& $pick $item 'roleAssignments' @())) {
                & $node @{ Kind = 'roleAssignment'; Label = "Role $($assignment['roleDefinitionIdOrName']) on container $containerName"; Scope = $containerId; Role = [string]$assignment['roleDefinitionIdOrName']; Assignment = $assignment; ApiVersion = '2022-04-01'; Type = 'Microsoft.Authorization/roleAssignments'; Order = 7; Parent = $containerId }
            }
        }
        foreach ($setting in @(& $pick $blobServices 'diagnosticSettings' @())) { & $diagnostic $setting $blobService "the blob service" }
    }

    $fileServices = & $pick $c 'fileServices' $null
    if ($fileServices -is [System.Collections.IDictionary] -and $fileServices.Count) {
        if (-not $supportsFiles) { throw "A $kind account has no file service: remove fileServices." }
        $fileService = "$account/fileServices/default"
        $serviceProperties = [ordered]@{}
        $specs = @()
        if (& $has $fileServices 'shareDeleteRetentionPolicy') {
            $policy = $fileServices['shareDeleteRetentionPolicy']
            $serviceProperties['shareDeleteRetentionPolicy'] = [ordered]@{ enabled = [bool](& $pick $policy 'enabled' $true); days = [int](& $pick $policy 'days' 7) }
            $specs += & $spec 'properties.shareDeleteRetentionPolicy.enabled' 'Value' $false
            $specs += & $spec 'properties.shareDeleteRetentionPolicy.days' 'Value' $null
        }
        & $node @{ Kind = 'fileService'; Label = "File service of $name"; Id = $fileService; ApiVersion = $storageApi; Type = 'Microsoft.Storage/storageAccounts/fileServices'; Name = "$name/default"; Desired = [ordered]@{ properties = $serviceProperties }; Managed = $specs; Order = 1; Parent = $account }
        foreach ($share in @(& $pick $fileServices 'shares' @())) {
            $item = if ($share -is [System.Collections.IDictionary]) { $share } else { @{ name = [string]$share } }
            $shareName = [string]$item['name']
            $shareProperties = [ordered]@{ shareQuota = [int](& $pick $item 'shareQuota' 5120) }
            $specs = @(& $spec 'properties.shareQuota' 'Value' $null)
            if (& $has $item 'accessTier') { $shareProperties['accessTier'] = [string]$item['accessTier']; $specs += & $spec 'properties.accessTier' 'Value' $null }
            if (& $has $item 'enabledProtocols') { $shareProperties['enabledProtocols'] = [string]$item['enabledProtocols']; $specs += & $spec 'properties.enabledProtocols' 'Value' 'SMB' -Immutable }
            if (& $has $item 'rootSquash') { $shareProperties['rootSquash'] = [string]$item['rootSquash']; $specs += & $spec 'properties.rootSquash' 'Value' $null }
            if (& $has $item 'metadata') { $shareProperties['metadata'] = $item['metadata']; $specs += & $metadataSpec $item }
            & $node @{ Kind = 'share'; Label = "File share $shareName"; Id = "$fileService/shares/$shareName"; ApiVersion = $storageApi; Type = 'Microsoft.Storage/storageAccounts/fileServices/shares'; Name = "$name/default/$shareName"; Desired = [ordered]@{ properties = $shareProperties }; Managed = $specs; UpdateMethod = 'Patch'; Order = 2; Parent = $fileService }
        }
        foreach ($setting in @(& $pick $fileServices 'diagnosticSettings' @())) { & $diagnostic $setting $fileService "the file service" }
    }

    foreach ($pair in @(@('queueServices', 'queues', 'queue', 'queueService', 'Queue', 'queueServices'), @('tableServices', 'tables', 'table', 'tableService', 'Table', 'tableServices'))) {
        $settings = & $pick $c $pair[0] $null
        if ($settings -isnot [System.Collections.IDictionary] -or -not $settings.Count) { continue }
        if (-not $supportsOthers) { throw "A $kind account has no $($pair[2]) service: remove $($pair[0])." }
        $service = "$account/$($pair[5])/default"
        & $node @{ Kind = $pair[3]; Label = "$($pair[4]) service of $name"; Id = $service; ApiVersion = $storageApi; Type = "Microsoft.Storage/storageAccounts/$($pair[5])"; Name = "$name/default"; Desired = [ordered]@{ properties = [ordered]@{} }; Managed = @(); Order = 1; Parent = $account }
        foreach ($entry in @(& $pick $settings $pair[1] @())) {
            $item = if ($entry -is [System.Collections.IDictionary]) { $entry } else { @{ name = [string]$entry } }
            $entryName = [string]$item['name']
            $entryProperties = [ordered]@{}
            $specs = @()
            if ($pair[2] -eq 'queue' -and (& $has $item 'metadata')) { $entryProperties['metadata'] = $item['metadata']; $specs += & $metadataSpec $item }
            $childType = if ($pair[2] -eq 'queue') { 'queues' } else { 'tables' }
            & $node @{ Kind = $pair[2]; Label = "$($pair[4]) $entryName"; Id = "$service/$childType/$entryName"; ApiVersion = $storageApi; Type = "Microsoft.Storage/storageAccounts/$($pair[5])/$childType"; Name = "$name/default/$entryName"; Desired = [ordered]@{ properties = $entryProperties }; Managed = $specs; Order = 2; Parent = $service }
        }
        foreach ($setting in @(& $pick $settings 'diagnosticSettings' @())) { & $diagnostic $setting $service "the $($pair[2]) service" }
    }

    # --- Lifecycle, private endpoints, diagnostics, access, lock -------------------------------------------------------
    if (& $has $c 'managementPolicyRules') {
        & $node @{ Kind = 'managementPolicy'; Label = "Lifecycle management policy of $name"; Id = "$account/managementPolicies/default"; ApiVersion = $storageApi; Type = 'Microsoft.Storage/storageAccounts/managementPolicies'; Name = "$name/default"; Desired = [ordered]@{ properties = [ordered]@{ policy = [ordered]@{ rules = @($c['managementPolicyRules']) } } }; Managed = @(& $spec 'properties.policy.rules' 'Set' @()); Order = 3; Parent = $account }
    }
    $index = 0
    foreach ($endpoint in $privateEndpoints) {
        $index++
        $service = [string]$endpoint['service']
        if (-not $service -or -not $endpoint['subnetResourceId']) { throw "Private endpoint $index needs a service (blob, file, queue, table, dfs, web) and a subnetResourceId." }
        $endpointName = [string](& $pick $endpoint 'name' "pep-$name-$service-$index")
        $endpointGroup = if (& $has $endpoint 'resourceGroupResourceId') { [string]$endpoint['resourceGroupResourceId'] } else { $group }
        $endpointId = "$endpointGroup/providers/Microsoft.Network/privateEndpoints/$endpointName"
        $endpointBody = [ordered]@{
            location   = ([string](& $pick $endpoint 'location' $region)).ToLowerInvariant() -replace '\s', ''
            properties = [ordered]@{
                subnet                        = [ordered]@{ id = [string]$endpoint['subnetResourceId'] }
                privateLinkServiceConnections = @([ordered]@{ name = $endpointName; properties = [ordered]@{ privateLinkServiceId = $account; groupIds = @($service) } })
            }
        }
        $specs = @((& $spec 'location' -Immutable), (& $spec 'properties.subnet.id' 'Value' $null -Immutable))
        if (& $has $endpoint 'customNetworkInterfaceName') { $endpointBody.properties['customNetworkInterfaceName'] = [string]$endpoint['customNetworkInterfaceName']; $specs += & $spec 'properties.customNetworkInterfaceName' 'Value' $null -Immutable }
        if (& $has $endpoint 'tags') { $endpointBody['tags'] = $endpoint['tags']; $specs += & $spec 'tags' 'Map' @{} -CaseSensitive }
        & $node @{ Kind = 'privateEndpoint'; Label = "Private endpoint $endpointName ($service)"; Id = $endpointId; ApiVersion = '2023-11-01'; Type = 'Microsoft.Network/privateEndpoints'; Name = $endpointName; Desired = $endpointBody; Managed = $specs; Order = 4; Parent = $endpointGroup; Service = $service }
        $zones = @(& $pick (& $pick $endpoint 'privateDnsZoneGroup' @{}) 'privateDnsZoneGroupConfigs' @())
        if ($zones.Count) {
            $zoneGroupName = [string](& $pick $endpoint['privateDnsZoneGroup'] 'name' 'default')
            $configs = @(foreach ($zone in $zones) { $zoneId = [string]$zone['privateDnsZoneResourceId']; [ordered]@{ name = [string](& $pick $zone 'name' (($zoneId -split '/')[-1] -replace '\.', '-')); properties = [ordered]@{ privateDnsZoneId = $zoneId } } })
            & $node @{ Kind = 'dnsZoneGroup'; Label = "Private DNS zone group of $endpointName"; Id = "$endpointId/privateDnsZoneGroups/$zoneGroupName"; ApiVersion = '2023-11-01'; Type = 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups'; Name = "$endpointName/$zoneGroupName"; Desired = [ordered]@{ properties = [ordered]@{ privateDnsZoneConfigs = $configs } }; Managed = @(& $spec 'properties.privateDnsZoneConfigs' 'Set' @()); Order = 5; Parent = $endpointId }
        }
    }
    foreach ($setting in @(& $pick $c 'diagnosticSettings' @())) { & $diagnostic $setting $account "the account" -MetricsOnly }
    foreach ($assignment in @(& $pick $c 'roleAssignments' @())) {
        & $node @{ Kind = 'roleAssignment'; Label = "Role $($assignment['roleDefinitionIdOrName']) on $name"; Scope = $account; Role = [string]$assignment['roleDefinitionIdOrName']; Assignment = $assignment; ApiVersion = '2022-04-01'; Type = 'Microsoft.Authorization/roleAssignments'; Order = 7; Parent = $account }
    }
    $lock = & $pick $c 'lock' $null
    if ($lock -is [System.Collections.IDictionary] -and [string](& $pick $lock 'kind' 'None') -ne 'None') {
        $lockName = [string](& $pick $lock 'name' "lock-$name")
        $lockProperties = [ordered]@{ level = [string]$lock['kind'] }
        if (& $has $lock 'notes') { $lockProperties['notes'] = [string]$lock['notes'] }
        $specs = @(& $spec 'properties.level' 'Value' $null)
        if ($lockProperties.Contains('notes')) { $specs += & $spec 'properties.notes' 'Value' $null -CaseSensitive }
        & $node @{ Kind = 'lock'; Label = "$($lock['kind']) lock $lockName"; Id = "$account/providers/Microsoft.Authorization/locks/$lockName"; ApiVersion = '2020-05-01'; Type = 'Microsoft.Authorization/locks'; Name = $lockName; Desired = [ordered]@{ properties = $lockProperties }; Managed = $specs; Order = 8; Parent = $account }
    }

    # --- Blobs: data plane, after everything else ----------------------------------------------------------------------
    $containers = @(foreach ($n in $nodes) { if ($n.Kind -eq 'container') { ($n.Id -split '/')[-1] } })
    foreach ($blob in @(& $pick $c 'blobs' @())) {
        $containerName = [string]$blob['container']
        $source = [string]$blob['path']
        $blobName = [string](& $pick $blob 'name' ([System.IO.Path]::GetFileName($source)))
        if ($containers -notcontains $containerName) { throw "Blob $blobName goes to container '$containerName', which isn't in the configuration: add it to blobServices.containers." }
        & $node @{ Kind = 'blob'; Label = "Blob $containerName/$blobName"; Id = "https://$name.blob.core.windows.net/$containerName/$(($blobName -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/')"; Container = $containerName; BlobName = $blobName; Source = $source; ContentType = [string](& $pick $blob 'contentType' ''); Order = 9; Parent = "$account/blobServices/default/containers/$containerName" }
    }
    @($nodes | Sort-Object -Property Order -Stable)
}