Public/New-TcPlan.ps1

function New-TcPlan {
    <#
    .SYNOPSIS
    Computes the migration plan of a project from a snapshot: actions, target configurations, tasks and decisions.
 
    .DESCRIPTION
    Works from the project and the snapshot alone, with no connection. The same input always gives the
    same plan: the dates come from the snapshot.
 
    The result has these keys:
 
    schemaVersion, meta: the version, the project, the snapshot dates, the moments in order, and the
    number of blocking actions and unconfirmed tasks.
 
    tenants, waves: the tenants by role, with their wave, and the sources of each wave.
 
    actions: one per finding, plus the identity matrix and the go/no-go of each source. An action has an
    id such as S1-ENT-MFA, and role is Target, S1 to S3, or All. A source whose accounts could not be read
    keeps a blocking matrix action that says why, and its go/no-go says what could not be read.
 
    targetConfigurations: the settings to put in place, with their current and target values.
 
    tasks: the task templates of Data\tasks.json, expanded by scope. The id such as ID-02.S1 stays the
    same from one run to the next, so tracking survives a rerun. A wave task takes the roles of its
    members, as in CUT-01.S1 or CUT-01.S1-S2, so it keeps its meaning when sources swap waves, and a wave
    whose members change gets a new id, with the old tracking row left as an orphan. confirmation is
    Confirmed, or Unconfirmed when the plan could not read what the task depends on: reason then says what
    is missing. A service the tenant does not have, or leaves empty, drops its task. A service that was
    not collected never does.
 
    decisions: the questions to settle, with the action or task they come from.
 
    procedures: the procedures that the actions and tasks point to, by identifier.
 
    remediations: the remediation scripts this plan links, with their nature, their risk and whether a
    real tenant ever ran them. An action or a task they serve lists their identifiers in remediations.
 
    byTenant, byWave: the identifiers of the actions, tasks and decisions of each tenant and each wave.
 
    .PARAMETER Project
    The project file, or a project as read from it.
 
    .PARAMETER Snapshot
    The snapshot, as read from snapshot.json. A source of the project that the snapshot has not reached
    yet stays in the plan, with its tasks unconfirmed.
 
    .PARAMETER RuleSet
    The result of Invoke-TcRuleSet on this snapshot. Without it, the rules run first.
 
    .PARAMETER IgnoreNameSuffix
    Words to remove from the end of a display name when looking for duplicates, for a tenant that marks
    its accounts that way.
 
    .EXAMPLE
    $snapshot = Get-Content ./snapshot.json -Raw | ConvertFrom-Json -AsHashtable
    $plan = New-TcPlan -Project ./project.json -Snapshot $snapshot
    $plan.tasks | Where-Object { $_.confirmation -eq 'Unconfirmed' } | ForEach-Object { "$($_.id): $($_.reason)" }
 
    Computes the plan and shows the tasks that wait for missing data.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns a value and changes nothing: no file, no tenant.')]
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory)][object]$Project,
        [Parameter(Mandatory)][System.Collections.IDictionary]$Snapshot,
        [System.Collections.IDictionary]$RuleSet,
        [AllowEmptyCollection()][string[]]$IgnoreNameSuffix = @()
    )

    $projectData = if ($Project -is [string]) { Read-TcProjectFile -Path (Resolve-TcPath -Path $Project) } else { $Project }
    $tenants = Get-TcProperty $Snapshot 'tenants'
    if ($null -eq $tenants) { throw 'This snapshot has no tenants.' }

    $target = Get-TcProperty $projectData 'target'
    $sources = @(@(Get-TcProperty $projectData 'sources' @()) | Sort-Object { Get-TcProperty $_ 'role' })
    $roles = @($sources | ForEach-Object { Get-TcProperty $_ 'role' })

    $strangers = @($tenants.Keys | Where-Object { $_ -ne 'Target' -and $_ -notin $roles })
    if ($strangers.Count) { throw "This snapshot has tenants that are not in the project: $($strangers -join ', '). Check that the snapshot belongs to this project." }

    if (-not $RuleSet) { $RuleSet = Invoke-TcRuleSet -Snapshot $Snapshot }

    $names = @{ All = 'All tenants'; Target = [string](Get-TcProperty $target 'displayName') }
    $waveOf = @{}
    foreach ($source in $sources) {
        $role = Get-TcProperty $source 'role'
        $names[$role] = [string](Get-TcProperty $source 'displayName')
        $waveOf[$role] = [int](Get-TcProperty $source 'wave')
    }

    $joinNames = {
        param([string[]]$Name)
        if ($Name.Count -lt 2) { return $Name -join '' }
        '{0} and {1}' -f ($Name[0..($Name.Count - 2)] -join ', '), $Name[-1]
    }

    $waveNumbers = @($waveOf.Values | Sort-Object -Unique)
    $waves = @(foreach ($number in $waveNumbers) {
            $members = @($roles | Where-Object { $waveOf[$_] -eq $number })
            $together = & $joinNames @($members | ForEach-Object { $names[$_] })
            [ordered]@{ wave = $number; sources = $members; label = $(if ($waveNumbers.Count -gt 1) { "wave $number ($together)" } else { $together }) }
        })

    $fired = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($finding in @(Get-TcProperty $RuleSet 'findings' @())) { [void]$fired.Add("$($finding['id'])|$($finding['code'])") }

    $context = @{
        Snapshot = $Snapshot; RuleSet = $RuleSet; Names = $names; WaveOf = $waveOf; Waves = $waves; Sources = $roles
        TargetState = [string](Get-TcProperty $target 'state'); Fired = $fired; Unread = @(Get-TcProperty $RuleSet 'unread' @()); RuleErrors = @(Get-TcProperty $RuleSet 'errors' @())
        Moments = @('Now', 'Preparation', 'Before cutover', 'Cutover', 'After cutover', 'Closure')
    }

    $actions = @(Get-TcPlanAction -Context $context -IgnoreNameSuffix $IgnoreNameSuffix)
    $tasks = @(Get-TcPlanTask -Context $context)
    $configurations = @(Get-TcPlanConfiguration -Context $context -Action $actions)
    $decisions = @(Get-TcPlanDecision -Action $actions -Task $tasks)

    $catalog = Get-TcProcedureCatalog
    $procedures = [ordered]@{}
    foreach ($id in @(@($actions | ForEach-Object { $_['procedure'] }) + @($tasks | ForEach-Object { $_['procedure'] }) | Where-Object { $_ } | Sort-Object -Unique -Culture 'en-US')) { $procedures[$id] = $catalog[$id] }

    $tenantRows = @([ordered]@{ role = 'Target'; kind = 'Target'; displayName = $names['Target']; primaryDomain = [string](Get-TcProperty $target 'primaryDomain'); wave = $null })
    foreach ($source in $sources) {
        $role = Get-TcProperty $source 'role'
        $tenantRows += [ordered]@{ role = $role; kind = 'Source'; displayName = $names[$role]; primaryDomain = [string](Get-TcProperty $source 'primaryDomain'); wave = $waveOf[$role] }
    }

    $remediations = @(Add-TcPlanRemediation -Action $actions -Task $tasks)

    $idsOf = { param([object[]]$Row, [scriptblock]$Filter) @($Row | Where-Object $Filter | ForEach-Object { $_['id'] }) }

    $byTenant = [ordered]@{}
    foreach ($role in @('Target') + $roles + 'All') {
        $mine = [scriptblock]::Create("`$_['role'] -eq '$role'")
        $byTenant[$role] = [ordered]@{
            actions = & $idsOf $actions $mine; tasks = & $idsOf $tasks $mine; decisions = & $idsOf $decisions $mine
            targetConfigurations = @($configurations | Where-Object $mine).Count
        }
    }

    $byWave = [ordered]@{}
    foreach ($wave in $waves) {
        $members = @($wave['sources'])
        $number = $wave['wave']
        $inWave = { $_['role'] -in $members -or $_['wave'] -eq $number }.GetNewClosure()
        $byWave["$number"] = [ordered]@{ sources = $members; actions = & $idsOf $actions $inWave; tasks = & $idsOf $tasks $inWave; decisions = & $idsOf $decisions $inWave }
    }

    $meta = Get-TcProperty $Snapshot 'meta'
    [ordered]@{
        schemaVersion        = 1
        meta                 = [ordered]@{
            project = [string](Get-TcProperty $projectData 'name'); targetState = $context['TargetState']; sourceCount = $roles.Count; moduleVersion = [string](Get-TcProperty $meta 'moduleVersion')
            snapshotStarted = [string](Get-TcProperty $meta 'started'); snapshotFinished = [string](Get-TcProperty $meta 'finished'); moments = $context['Moments']
            blockingActions = @($actions | Where-Object { $_['blocking'] }).Count; unconfirmedTasks = @($tasks | Where-Object { $_['confirmation'] -eq 'Unconfirmed' }).Count
        }
        tenants              = $tenantRows
        waves                = $waves
        actions              = $actions
        targetConfigurations = $configurations
        tasks                = $tasks
        decisions            = $decisions
        procedures           = $procedures
        remediations         = $remediations
        byTenant             = $byTenant
        byWave               = $byWave
    }
}