Public/Invoke-TcAssessment.ps1

function Invoke-TcAssessment {
    <#
    .SYNOPSIS
    Collects the tenants of a project, read-only, and writes a snapshot.
 
    .DESCRIPTION
    Reads the target and each source, workload by workload, and writes a run folder in the output folder
    of the project: snapshot.json, findings.json with what the rules found, plan.json with the migration
    plan, journal.log, events.jsonl, exports\*.csv and a draft identity matrix for each source. It changes
    nothing in any tenant.
 
    A missing module or a refused access does not stop the run: the coverage of the snapshot says what
    was not collected, why, and what to do about it. An empty table means the service is not used, unless
    its collector was partial. A missing table means it was not collected.
 
    A stopped run leaves snapshot.partial.json. The next run picks it up and only redoes what is missing,
    unless you add -Fresh. Every tenant of that file is kept, even with -Tenant. The run stops if a tenant
    of that file no longer matches the project: add -Fresh to start over.
 
    .PARAMETER Project
    The project file.
 
    .PARAMETER Tenant
    The roles to collect. The default is the target and every source.
 
    .PARAMETER Depth
    Complete reads everything. Standard skips the collectors that read each site, team or mailbox.
 
    .PARAMETER Workload
    The workloads to collect. The default is all of them.
 
    .PARAMETER Fresh
    Starts a new run, even if a run was interrupted.
 
    .PARAMETER Unattended
    Never opens a sign-in window. A service that would need one is not collected.
 
    .EXAMPLE
    Invoke-TcAssessment -Project ./project.json
 
    Collects every tenant of the project.
 
    .EXAMPLE
    Invoke-TcAssessment -Project ./project.json -Tenant S2 -Workload Intune -Depth Standard
 
    Collects Intune for the second source only.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Project,
        [ValidateSet('Target', 'S1', 'S2', 'S3')][string[]]$Tenant,
        [ValidateSet('Complete', 'Standard')][string]$Depth = 'Complete',
        [ValidateSet('Entra ID', 'Exchange Online', 'Domains and DNS', 'SharePoint and OneDrive', 'Teams', 'Teams Phone', 'Intune', 'Purview', 'Power Platform', 'Azure', 'Other services')]
        [string[]]$Workload = @('Entra ID', 'Exchange Online', 'Domains and DNS', 'SharePoint and OneDrive', 'Teams', 'Teams Phone', 'Intune', 'Purview', 'Power Platform', 'Azure', 'Other services'),
        [switch]$Fresh,
        [switch]$Unattended
    )

    $projectPath = Resolve-TcPath -Path $Project
    $projectData = Read-TcProjectFile -Path $projectPath
    Initialize-TcSession -Project $projectData -Access (Read-TcAccessFile -ProjectPath $projectPath) -Unattended:$Unattended
    $script:TcProjectPath = $projectPath

    $roles = @(if ($Tenant) { $Tenant | Where-Object { $script:TcTenants.Contains($_) } } else { $script:TcTenants.Keys })
    if ($roles.Count -eq 0) { throw 'No tenant of this project matches -Tenant.' }

    if ((Get-TcProperty (Get-TcProperty $projectData 'access') 'mode') -eq 'Application') {
        $notReady = @($roles | Where-Object { -not (Test-TcApplicationReady -Tenant $script:TcTenants[$_]) })
        if ($notReady.Count) {
            throw "These tenants have no read-only application, or its certificate is not on this computer: $($notReady -join ', '). Run Initialize-TcAccess -Project '$Project', once, with a Global Administrator."
        }
    }

    Resolve-TcProjectTenant

    $outputRoot = [string](Get-TcProperty (Get-TcProperty $projectData 'output') 'path')
    if (-not [System.IO.Path]::IsPathRooted($outputRoot)) { $outputRoot = Join-Path (Split-Path -Path $projectPath -Parent) $outputRoot }
    $outputRoot = [System.IO.Path]::GetFullPath($outputRoot)

    $service = Get-TcSyncRisk -Path $outputRoot
    if ($service) {
        if ($Unattended) { throw "The output folder $outputRoot is synced by $service and the run will hold personal data. Choose a local folder." }
        Write-TcMessage -Message "The output folder $outputRoot is synced by $service. The run will hold personal data: a local folder is safer." -Level Warning
    }

    $interrupted = if ($Fresh) { $null } else { Find-TcInterruptedRun -Path $outputRoot }
    $started = (Get-Date).ToString('s')
    if ($interrupted) {
        $folder = $interrupted
        $previous = Get-TcProperty (Get-TcProperty (Read-TcSnapshot -Path (Join-Path $folder 'snapshot.partial.json')) 'meta') 'started'
        if ($previous) { $started = [string]$previous }
    }
    else {
        $folder = Join-Path $outputRoot ('{0:yyyyMMdd-HHmmss}-assessment' -f (Get-Date))
    }

    $projectName = [string](Get-TcProperty $projectData 'name')
    Initialize-TcLog -Path $folder
    Initialize-TcCollection -Depth $Depth -ProjectName $projectName -Started $started
    if ($interrupted) {
        $count = Import-TcPartialSnapshot -Folder $folder
        Write-TcMessage -Message "Resuming the run in $folder ($count tenants already started)."
        Write-TcLog -Message "Resuming an interrupted run: $count tenants."
    }

    Write-TcLog -Message "Assessment of $projectName, depth $Depth, tenants $($roles -join ', ')."

    try {
        $notCollected = Invoke-TcCollection -Folder $folder -Role $roles -Workload $Workload
    }
    catch {
        Save-TcPartialSnapshot -Folder $folder
        throw
    }

    $snapshot = Get-TcCollectionSnapshot -ProjectName $projectName -Started $started -Finished (Get-Date).ToString('s')
    $snapshotPath = Join-Path $folder 'snapshot.json'
    Write-TcSnapshot -Path $snapshotPath -Snapshot $snapshot
    Remove-Item -LiteralPath (Join-Path $folder 'snapshot.partial.json') -ErrorAction SilentlyContinue
    Export-TcTableCsv -Folder $folder
    Export-TcIdentityMatrix -Snapshot $snapshot -Folder $folder
    $ruleSet = Invoke-TcRuleSet -Snapshot $snapshot
    Write-TcJsonFile -Path (Join-Path $folder 'findings.json') -InputObject $ruleSet

    # The snapshot and the findings are saved by now: a plan that fails must not cost them.
    $planPath = Join-Path $folder 'plan.json'
    try {
        Write-TcJsonFile -Path $planPath -InputObject (New-TcPlan -Project $projectData -Snapshot $snapshot -RuleSet $ruleSet)
    }
    catch {
        $planPath = $null
        Write-TcMessage -Message "The plan could not be computed: $(Get-TcErrorText $_). The snapshot and the findings are saved. Fix the cause, then run New-TcPlan on the snapshot." -Level Warning
        Write-TcLog -Message "The plan could not be computed: $(Get-TcErrorText $_)" -Level 'WARN'
    }

    $partial = @(@($snapshot['coverage']) | Where-Object { (Get-TcProperty $_ 'status') -eq 'Partial' }).Count
    $stopped = @(Get-TcProperty $ruleSet 'errors' @()).Count
    $gaps = @(
        if ($notCollected) { "$notCollected collectors did not collect: see the coverage in the snapshot." }
        if ($partial) { "$partial collectors read only part of their data: see the coverage in the snapshot." }
        if ($stopped) { "$stopped rule block(s) stopped, so what they check is unknown: see errors in findings.json." }
    )
    $note = if ($gaps.Count) { $gaps -join ' ' } else { 'Everything was collected.' }
    Write-TcMessage -Message "Assessment finished in $folder. $note" -Level $(if ($gaps.Count) { 'Warning' } else { 'Success' })
    Write-TcLog -Message "Assessment finished. $note"

    [pscustomobject][ordered]@{ Folder = $folder; Snapshot = $snapshotPath; Plan = $planPath; NotCollected = [int]$notCollected; Partial = $partial; RulesStopped = $stopped }
}