Private/Remediation/Remediation.Run.ps1

function Get-TcRemediationCatalog {
    <#
    .SYNOPSIS
    Reads Data\remediations.json once, by identifier.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param()

    if ($null -eq $script:TcRemediationCatalog) {
        $data = Read-TcJsonFile -Path (Join-Path $PSScriptRoot '..\..\Data\remediations.json')
        $catalog = [ordered]@{}
        foreach ($entry in @($data['remediations'])) { $catalog[$entry['id']] = $entry }
        $script:TcRemediationCatalog = $catalog
    }
    $script:TcRemediationCatalog
}

function Get-TcRemediationEntry {
    <#
    .SYNOPSIS
    Returns one entry of the manifest, and refuses an identifier it does not know.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Id
    )

    $catalog = Get-TcRemediationCatalog
    if (-not $catalog.Contains($Id)) { throw "Unknown remediation: $Id. Get-TcRemediation lists them." }
    $catalog[$Id]
}

function Get-TcRemediationDefault {
    <#
    .SYNOPSIS
    The settings a remediation reads, with their defaults.
 
    .DESCRIPTION
    Names of the objects a script creates, thresholds, and the accounts it must never touch. Nothing here
    names a project. A person changes them in <output>\decisions\settings.json, or with -Setting.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    @{
        ObjectPrefix = 'TenantConverge'; EmergencyAccountPrefix = 'emergency'; EmergencyAccountCount = 2; EmergencyAccountDomain = ''
        ProtectedAccountPattern = '^(emergency|breakglass|rescue)'; MigrationAccountPattern = ''; ProtectedApplicationPattern = '^TenantConverge '
        KeptAccounts = @{}; InactiveDays = 90; StaleDeviceDays = 90; LargeMailboxGb = 45; LargeSiteGb = 100; SecretExpiryDays = 60; LicenseSku = ''; UsageLocation = 'US'
        StorageMarginPercent = 20; DomainRemovalMinutes = 1440; DeviceNamePattern = 'DEV-%RAND:4%'; SourceGroups = @{}; UsualCountries = @('US')
        DnsServer = '1.1.1.1'; DnsTtl = 300; DnsWaitMinutes = 120; DnsPollSeconds = 30; DnsApiTokenVariable = 'CF_API_TOKEN'; DnsHosts = @{}
        MaxSnapshotAgeDays = 14; TestRecipients = @(); TestSender = ''; SharePointAdminUrl = @{}; PnpClientId = ''
    }
}

function Get-TcRemediationOption {
    <#
    .SYNOPSIS
    Reads one setting of the running remediation.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Name
    )

    $options = $script:TcRemediationRun['Options']
    if (-not $options.ContainsKey($Name)) { throw "Unknown remediation setting: $Name." }
    $options[$Name]
}

function Resolve-TcRemediationOption {
    <#
    .SYNOPSIS
    Merges the defaults, the file <output>\decisions\settings.json and -Setting. An unknown name stops the run.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)][string]$DecisionFolder,
        [hashtable]$Setting = @{}
    )

    $options = Get-TcRemediationDefault
    $file = Join-Path $DecisionFolder 'settings.json'
    $layers = @()
    if (Test-Path -LiteralPath $file) { $layers += , (Read-TcJsonFile -Path $file) }
    $layers += , $Setting
    foreach ($layer in $layers) {
        foreach ($key in @($layer.Keys)) {
            if (-not $options.ContainsKey($key)) { throw "Unknown remediation setting: $key. Known settings: $(@($options.Keys | Sort-Object) -join ', ')." }
            $options[$key] = $layer[$key]
        }
    }
    $options
}

function Write-TcUntestedNotice {
    <#
    .SYNOPSIS
    Shows the notice every run must show, on screen and in the log.
    #>

    [CmdletBinding()]
    param()

    Write-TcMessage -Message $script:TcUntestedNotice -Level Warning
    Write-TcLog -Message $script:TcUntestedNotice -Level WARN
}

function New-TcRemediationRun {
    <#
    .SYNOPSIS
    Prepares what a remediation reads: its tenants, its folder, its settings and its data.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Prepares a local run. No tenant is touched.')]
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)][System.Collections.IDictionary]$Entry,
        [Parameter(Mandatory)][object]$Project,
        [Parameter(Mandatory)][System.Collections.IDictionary]$Snapshot,
        [Parameter(Mandatory)][System.Collections.IDictionary]$Plan,
        [string[]]$Tenant,
        [bool]$Apply,
        [bool]$Acknowledged,
        [hashtable]$Setting = @{},
        [Parameter(Mandatory)][string]$Output
    )

    $decisionFolder = Join-Path $Output 'decisions'
    $options = Resolve-TcRemediationOption -DecisionFolder $decisionFolder -Setting $Setting
    $sources = @(@(Get-TcProperty $Project 'sources' @()) | ForEach-Object { Get-TcProperty $_ 'role' } | Sort-Object)
    $known = @('Target') + $sources

    $roles = @($Tenant | Where-Object { $_ } | ForEach-Object { $_ -split '[,; ]+' } | Where-Object { $_ })
    foreach ($role in $roles) { if ($role -notin $known) { throw "Unknown tenant: $role. This project has $($known -join ', ')." } }
    if (-not $roles.Count) {
        $roles = switch ([string](Get-TcProperty $Entry 'scope' 'Plan')) {
            'Target' { @('Target') }
            'Source' { $sources }
            'All' { $known }
            default { Get-TcRemediationPlanRole -Plan $Plan -RuleId @(Get-TcProperty $Entry 'ruleIds' @()) -Known $known }
        }
    }

    $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
    $folder = Join-Path (Join-Path $Output 'remediations') ('{0}-{1}-{2}' -f $stamp, $Entry['id'], $(if ($Apply) { 'Apply' } else { 'Preview' }))
    $number = 2
    while (Test-Path -LiteralPath $folder) { $folder = Join-Path (Join-Path $Output 'remediations') ('{0}-{1}-{2}-{3}' -f $stamp, $Entry['id'], $(if ($Apply) { 'Apply' } else { 'Preview' }), $number); $number++ }

    @{
        Entry = $Entry; Id = $Entry['id']; Project = $Project; Snapshot = $Snapshot; PlanData = $Plan; Tenants = @($roles); Known = $known; Sources = $sources; Apply = $Apply; Acknowledged = $Acknowledged
        ConfirmedTenants = @(); CurrentAction = $null; Stopped = $false; Refused = $false; Folder = $folder; Output = $Output; DecisionFolder = $decisionFolder; Options = $options; Started = Get-Date; OperationsFile = Join-Path $folder 'operations.jsonl'
        Actions = [System.Collections.Generic.List[object]]::new(); Results = [System.Collections.Generic.List[object]]::new(); Alerts = 0; Pending = 0; Reports = 0; Blocking = 0; Rank = 0; Total = @($roles).Count + 1
        BlockOpen = $false; Clock = $null; Family = $null; Connections = @{}; Postponed = @{}; UnreadSaid = @{}; AcceptedDomains = $null
    }
}

function Open-TcRemediation {
    <#
    .SYNOPSIS
    Opens the run for the script that starts: the first line of every remediation.
    #>

    [CmdletBinding()]
    param()

    $run = $script:TcRemediationRun
    Initialize-TcLog -Path $run['Folder']
    Write-TcUntestedNotice
    Write-TcMessage -Message ('{0}: {1}' -f $run['Id'], $run['Entry']['title'])
    Write-TcMessage -Message ('Tenants: {0}' -f $(if ($run['Tenants'].Count) { $run['Tenants'] -join ', ' } else { 'none in the plan' }))
    Write-TcMessage -Message $(if ($run['Apply']) { 'Mode: APPLY. The confirmed changes are written to the tenants.' } else { 'Mode: PREVIEW. No connection and no write.' })
    Write-TcMessage -Message "Log: $($run['Folder'])"
    Write-TcLog -Message ('Start {0}, tenants {1}, apply {2}' -f $run['Id'], ($run['Tenants'] -join ','), $run['Apply'])

    # Here, not with the other confirmations: some scripts read a tenant to build their plan, and that read signs in.
    if ($run['Apply'] -and -not (Confirm-TcRemediationUntested)) {
        $run['Stopped'] = $true
        $run['Refused'] = $true
        Write-TcRemediationLine -Status Warning -Text 'Not confirmed. Stopped: nothing was changed.' -Step $run['Id']
        throw 'Not confirmed.'
    }

    [pscustomobject]@{ Id = $run['Id']; Tenants = @($run['Tenants']); Apply = $run['Apply']; Folder = $run['Folder'] }
}

function Close-TcRemediationBlock {
    <#
    .SYNOPSIS
    Ends the block of the current tenant on screen.
    #>

    [CmdletBinding()]
    param()

    $run = $script:TcRemediationRun
    if (-not $run['BlockOpen']) { return }
    Write-TcStepEnd -Status Done -Seconds $run['Clock'].Elapsed.TotalSeconds
    $run['BlockOpen'] = $false
}

function Write-TcRemediationTenant {
    <#
    .SYNOPSIS
    Announces the tenant the next lines are about.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Tenant,
        [Parameter(Position = 1)][string]$Title = ''
    )

    Close-TcRemediationBlock
    $run = $script:TcRemediationRun
    $run['Rank']++
    Write-TcStepStart -Rank $run['Rank'] -Total $run['Total'] -Id $Tenant -Title $(if ($Title) { $Title } else { (Get-TcRemediationTenant -Tenant $Tenant)['Name'] })
    $run['Clock'] = [System.Diagnostics.Stopwatch]::StartNew()
    $run['BlockOpen'] = $true
}

function Write-TcRemediationLine {
    <#
    .SYNOPSIS
    Shows one result on screen and in the log, with the status of the run.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('OK', 'Warning', 'Blocking', 'Info', 'Planned', 'Applied', 'Failed', 'Manual', 'Skipped')][string]$Status,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [string]$Step = ''
    )

    $shown = switch ($Status) {
        { $_ -in 'OK', 'Applied' } { 'OK' }
        { $_ -in 'Warning', 'Blocking', 'Failed', 'Manual' } { 'Warning' }
        default { 'Info' }
    }
    Write-TcStepLine -Status $shown -Text "$Status`: $Text" -Step $Step
}

function Write-TcRemediationNote {
    <#
    .SYNOPSIS
    Writes a fact about the tenant.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][AllowEmptyString()][string]$Text
    )

    Write-TcRemediationLine -Status Info -Text $Text -Step $script:TcRemediationRun['Id']
}

function Write-TcRemediationAlert {
    <#
    .SYNOPSIS
    Writes a point to look at. The run then ends with code 1.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][AllowEmptyString()][string]$Text
    )

    $script:TcRemediationRun['Alerts']++
    Write-TcRemediationLine -Status Warning -Text $Text -Step $script:TcRemediationRun['Id']
}

function Write-TcRemediationInstruction {
    <#
    .SYNOPSIS
    Shows what a person must do.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string[]]$Line
    )

    foreach ($text in $Line) { Write-TcMessage -Message " > $text" -Level Warning }
}

function Export-TcRemediationReport {
    <#
    .SYNOPSIS
    Writes a CSV to exports\ in the run folder and returns its path. Excel never runs a cell of it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Name,
        [Parameter(Position = 1)][AllowNull()][AllowEmptyCollection()][object[]]$Rows
    )

    $run = $script:TcRemediationRun
    $path = Join-Path (Join-Path $run['Folder'] 'exports') "$Name.csv"
    $lines = @($Rows | Where-Object { $null -ne $_ } | ForEach-Object {
            $source = if ($_ -is [System.Collections.IDictionary]) { $_ } else { $copy = [ordered]@{}; foreach ($property in $_.PSObject.Properties) { $copy[$property.Name] = $property.Value }; $copy }
            $line = [ordered]@{}
            foreach ($key in @($source.Keys)) { $line[$key] = ConvertTo-TcCsvCell $source[$key] }
            [pscustomobject]$line
        })
    if ($lines.Count) { $lines | Export-Csv -LiteralPath $path -NoTypeInformation -Encoding utf8BOM }
    else { [System.IO.File]::WriteAllText($path, '', [System.Text.UTF8Encoding]::new($true)) }
    $run['Reports']++
    Write-TcRemediationLine -Status OK -Text ('Report: {0} ({1} row(s))' -f $path, $lines.Count) -Step $run['Id']
    $path
}

function Close-TcRemediation {
    <#
    .SYNOPSIS
    Ends the run: writes summary.json and sets the code. 0 done, 1 points left open, 2 failed, 3 stopped at a confirmation.
    #>

    [CmdletBinding()]
    param()

    Close-TcRemediationBlock
    $run = $script:TcRemediationRun
    $tally = Get-TcRemediationTally
    $code = if ($tally['Failed'] -or $run['Blocking']) { 2 } elseif ($run['Stopped']) { 3 } elseif ($run['Alerts'] -or $run['Pending'] -or $tally['ForAPerson'] -or $tally['Waiting']) { 1 } else { 0 }
    $run['Code'] = $code

    Write-TcJsonFile -Path (Join-Path $run['Folder'] 'summary.json') -InputObject ([ordered]@{
            id = $run['Id']; title = $run['Entry']['title']; nature = $run['Entry']['nature']; mode = $(if ($run['Apply']) { 'Apply' } else { 'Preview' }); tenants = @($run['Tenants'])
            actions = $tally['Total']; applied = $tally['Applied']; alreadyInPlace = $tally['AlreadyInPlace']; failed = $tally['Failed']; forAPerson = $tally['ForAPerson']; waiting = $tally['Waiting']
            pendingDecisions = $run['Pending']; alerts = $run['Alerts']; blocking = $run['Blocking']; reports = $run['Reports']; code = $code; seconds = [math]::Round(((Get-Date) - $run['Started']).TotalSeconds, 1); folder = $run['Folder']
            testedOnRealTenant = $false
        })

    $line = if ($run['Apply']) { '{0} applied, {1} already in place, {2} failed' -f $tally['Applied'], $tally['AlreadyInPlace'], $tally['Failed'] } else { '{0} planned, none applied' -f $tally['Total'] }
    $label = @{ 0 = 'DONE'; 1 = 'DONE, POINTS LEFT OPEN'; 2 = 'FAILED'; 3 = 'STOPPED, NOTHING CHANGED' }[$code]
    Write-TcMessage -Message ('Result: {0}. Actions: {1}.' -f $label, $line) -Level $(if ($code -eq 0) { 'Success' } else { 'Warning' })
    Write-TcMessage -Message ('Decisions pending: {0}. For a person: {1}. Alerts: {2}.' -f $run['Pending'], $tally['ForAPerson'], $run['Alerts'])
    Write-TcLog -Message "End, code $code" -Level $(if ($code -eq 2) { 'ERROR' } else { 'OK' })
    Disconnect-TcRemediationService
}