Private/Remediation/Remediation.Plan.ps1
|
function New-TcRemediationAction { <# .SYNOPSIS The base of every action of a plan. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a value in memory.')] [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param( [string]$Tenant, [string]$Type, [string]$Service, [string]$Object, [string]$Description, [scriptblock]$Test, $Undo, [string]$Reason, [switch]$Critical, [string[]]$AlsoWrites = @() ) $run = $script:TcRemediationRun foreach ($role in @($Tenant) + $AlsoWrites) { if ($role -notin $run['Known']) { throw "Action on an unknown tenant: $role." } } [ordered]@{ id = 'A-{0:D4}' -f ($run['Actions'].Count + 1); tenant = $Tenant; alsoWrites = @($AlsoWrites); written = @(); type = $Type; service = $Service; object = $Object; description = $Description; test = $Test; undo = $Undo; reason = $Reason critical = [bool]$Critical; status = 'Planned'; error = ''; timestamp = ''; postponed = $false; method = ''; uri = ''; body = $null; command = ''; parameters = @{}; url = ''; dns = $null instruction = @(); waitUntil = $null; minutes = 0; interval = 0; who = ''; data = $null; block = $null } } function Add-TcGraphAction { <# .SYNOPSIS Plans a write to Microsoft Graph. PATCH restores the values read before it, POST deletes what it created. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][ValidateSet('POST', 'PATCH', 'PUT', 'DELETE')][string]$Method, [Parameter(Mandatory)][string]$Uri, $Body, [Parameter(Mandatory)][string]$Object, [Parameter(Mandatory)][string]$Description, [scriptblock]$Test, $Undo = 'auto', [string]$Reason, [switch]$Critical ) $action = New-TcRemediationAction -Tenant $Tenant -Type Graph -Service Graph -Object $Object -Description $Description -Test $Test -Undo $Undo -Reason $Reason -Critical:$Critical $action['method'] = $Method; $action['uri'] = $Uri; $action['body'] = $Body $script:TcRemediationRun['Actions'].Add($action) } function Add-TcCommandAction { <# .SYNOPSIS Plans a cmdlet of Exchange (Exo), Purview (Scc), Teams or SharePoint (Pnp). #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][ValidateSet('Exo', 'Scc', 'Teams', 'Pnp')][string]$Service, [Parameter(Mandatory)][string]$Command, [hashtable]$Parameters = @{}, [string]$Url, [Parameter(Mandatory)][string]$Object, [Parameter(Mandatory)][string]$Description, [scriptblock]$Test, $Undo = $null, [string]$Reason, [switch]$Critical ) $action = New-TcRemediationAction -Tenant $Tenant -Type Command -Service $Service -Object $Object -Description $Description -Test $Test -Undo $Undo -Reason $Reason -Critical:$Critical $action['command'] = $Command; $action['parameters'] = $Parameters; $action['url'] = $Url $script:TcRemediationRun['Actions'].Add($action) } function Add-TcDnsAction { <# .SYNOPSIS Plans a DNS record of a zone. It waits for the public DNS, and the undo restores the values read before. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][string]$Zone, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][ValidateSet('MX', 'TXT', 'CNAME')][string]$Type, [string]$Value, [int]$Ttl = 300, [int]$Priority = 0, [ValidateSet('Set', 'Add', 'Remove', 'TTL')][string]$Action = 'Set', [switch]$NoWait, [Parameter(Mandatory)][string]$Description, [scriptblock]$Test, [switch]$Critical ) $entry = New-TcRemediationAction -Tenant $Tenant -Type Dns -Service Dns -Object "$Type $Name" -Description $Description -Test $Test -Undo 'auto' -Critical:$Critical $entry['dns'] = [ordered]@{ zone = $Zone; name = $Name; type = $Type; value = $Value; ttl = $Ttl; priority = $Priority; action = $Action; wait = (-not $NoWait) } $script:TcRemediationRun['Actions'].Add($entry) } function Add-TcManualAction { <# .SYNOPSIS Plans what no API allows. With -WaitUntil the engine polls until the block returns true. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][string]$Object, [Parameter(Mandatory)][string]$Description, [Parameter(Mandatory)][string[]]$Instruction, [scriptblock]$WaitUntil, [int]$Minutes = 60, [int]$IntervalSeconds = 60, [string]$Who = 'Migration team', $Data ) $action = New-TcRemediationAction -Tenant $Tenant -Type Manual -Service None -Object $Object -Description $Description -Undo $null -Reason 'Done by a person, outside the script.' $action['instruction'] = @($Instruction); $action['waitUntil'] = $WaitUntil; $action['minutes'] = $Minutes; $action['interval'] = $IntervalSeconds; $action['who'] = $Who; $action['data'] = $Data $script:TcRemediationRun['Actions'].Add($action) } function Add-TcBlockAction { <# .SYNOPSIS Plans a series of calls that form one operation. The block receives the action and runs only with -Apply. .PARAMETER AlsoWrites The other tenants the block writes to. Their primary domain is asked before the first write of the run, and the block reaches them through $a.alsoWrites. A write to any other tenant is refused. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][ValidateSet('Graph', 'Exo', 'Scc', 'Teams', 'Pnp')][string]$Service, [Parameter(Mandatory)][scriptblock]$Block, $Data, [Parameter(Mandatory)][string]$Object, [Parameter(Mandatory)][string]$Description, [scriptblock]$Test, $Undo = $null, [string]$Reason, [switch]$Critical, [string[]]$AlsoWrites = @() ) $action = New-TcRemediationAction -Tenant $Tenant -Type Block -Service $Service -Object $Object -Description $Description -Test $Test -Undo $Undo -Reason $Reason -Critical:$Critical -AlsoWrites $AlsoWrites $action['block'] = $Block; $action['data'] = $Data $script:TcRemediationRun['Actions'].Add($action) } function Protect-TcRemediationAction { <# .SYNOPSIS A copy of an action that can go in a log: secrets masked, blocks removed. #> [CmdletBinding()] param( [AllowNull()]$InputObject ) if ($null -eq $InputObject) { return $null } if ($InputObject -is [System.Collections.IDictionary]) { $copy = [ordered]@{} foreach ($key in @($InputObject.Keys)) { if ($key -in 'test', 'block', 'waitUntil' -and $InputObject[$key] -is [scriptblock]) { continue } $copy[$key] = if ($key -in 'password', 'temporaryAccessPass', 'secretText', 'client_assertion', 'newPassword') { '********' } else { Protect-TcRemediationAction -InputObject $InputObject[$key] } } return $copy } if ($InputObject -is [string] -or $InputObject.GetType().IsValueType) { return $InputObject } if ($InputObject -is [scriptblock]) { return '{block}' } if ($InputObject -is [System.Collections.IEnumerable]) { return , @($InputObject | ForEach-Object { Protect-TcRemediationAction -InputObject $_ }) } [string]$InputObject } function Write-TcOperation { <# .SYNOPSIS Adds the state of an action to operations.jsonl. Undo-TcRemediation reads this file. .PARAMETER Path The journal to add to. Without it, the one of the run. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Action, [string]$Path = $script:TcRemediationRun['OperationsFile'] ) $line = (Protect-TcRemediationAction -InputObject $Action) | ConvertTo-Json -Depth 30 -Compress [System.IO.File]::AppendAllText($Path, $line + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) } function Read-TcOperation { <# .SYNOPSIS The final state of each action of a journal: the last line of each identifier. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path ) $byId = [ordered]@{} foreach ($line in [System.IO.File]::ReadAllLines($Path)) { if ($line.Trim()) { $operation = $line | ConvertFrom-Json -AsHashtable -Depth 40 $byId[$operation['id']] = $operation } } @($byId.Values) } function Get-TcAutomaticUndo { <# .SYNOPSIS Reads the current values just before a change, so the change can be undone. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Action ) if ($Action['type'] -eq 'Graph' -and $Action['method'] -eq 'PATCH' -and $Action['body']) { # $select is required: a plain GET on a user leaves out accountEnabled, and the undo would be wrong. $separator = if ($Action['uri'] -match '\?') { '&' } else { '?' } try { $current = Invoke-TcGraph -Tenant $Action['tenant'] -Uri ("{0}{1}`$select={2}" -f $Action['uri'], $separator, (@($Action['body'].Keys) -join ',')) } catch { $current = Invoke-TcGraph -Tenant $Action['tenant'] -Uri $Action['uri'] } $before = [ordered]@{} foreach ($key in @($Action['body'].Keys)) { $before[$key] = Get-TcProperty $current $key } return @{ type = 'Graph'; tenant = $Action['tenant']; method = 'PATCH'; uri = $Action['uri']; body = $before } } if ($Action['type'] -eq 'Dns') { return @{ type = 'Dns'; tenant = $Action['tenant']; dns = $Action['dns']; before = @(Resolve-TcDnsEntry -Name $Action['dns']['name'] -Type $Action['dns']['type']) } } $null } function Invoke-TcManualAction { <# .SYNOPSIS Shows the instruction. Returns true when the action is seen done, false when it is left to a person. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)]$Action ) Write-TcRemediationInstruction -Line @($Action['instruction']) Write-TcLog -Message ('{0}: {1} ({2})' -f $Action['who'], $Action['description'], ($Action['instruction'] -join ' ')) -Step $Action['id'] if (-not $Action['waitUntil']) { return $false } $end = (Get-TcRemediationNow).AddMinutes($Action['minutes']) while ($true) { if (& $Action['waitUntil'] $Action) { return $true } if ((Get-TcRemediationNow) -ge $end) { return $false } Write-TcMessage -Message (' {0}: next check in {1} s (limit {2:HH:mm})' -f $Action['object'], $Action['interval'], $end) Wait-TcRemediation -Seconds $Action['interval'] } } function Invoke-TcRemediationAction { <# .SYNOPSIS Runs one action and returns the answer of the service. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Action ) switch ($Action['type']) { 'Graph' { return (Invoke-TcGraph -Tenant $Action['tenant'] -Method $Action['method'] -Uri $Action['uri'] -Body $Action['body']) } 'Command' { return (Invoke-TcServiceCommand -Tenant $Action['tenant'] -Service $Action['service'] -Command $Action['command'] -Parameters $Action['parameters'] -Url $Action['url']) } 'Dns' { Invoke-TcDnsAction -Action $Action; return $null } 'Block' { return (& $Action['block'] $Action) } default { throw "Unknown action type: $($Action['type'])." } } } function Get-TcActionTenant { <# .SYNOPSIS Every tenant an action declares: its own, and those its block also writes to. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)]$Action ) @(@($Action['tenant']) + @($Action['alsoWrites']) | Where-Object { $_ }) } function Show-TcRemediationPreview { <# .SYNOPSIS Shows what the plan would do, tenant by tenant. #> [CmdletBinding()] param() $run = $script:TcRemediationRun foreach ($tenant in @($run['Actions'] | ForEach-Object { Get-TcActionTenant -Action $_ } | Select-Object -Unique)) { $own = @($run['Actions'] | Where-Object { $tenant -in @(Get-TcActionTenant -Action $_) }) $manual = @($own | Where-Object { $_['type'] -eq 'Manual' }).Count Write-TcRemediationLine -Status Planned -Text ('{0}: {1} action(s) planned{2}' -f $tenant, $own.Count, $(if ($manual) { ", $manual by a person" } else { '' })) -Step $run['Id'] foreach ($action in @($own | Select-Object -First 8)) { Write-TcMessage -Message " $($action['description'])" } if ($own.Count -gt 8) { Write-TcMessage -Message (' ... and {0} more, see plan.csv' -f ($own.Count - 8)) } } } function Test-TcRemovingAction { <# .SYNOPSIS True when an action of the plan deletes or removes an object. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)]$Action ) $Action['method'] -eq 'DELETE' -or $Action['command'] -match '^(Remove|Disable|Clear)-' -or ($Action['dns'] -and $Action['dns']['action'] -eq 'Remove') } function Confirm-TcRemediationUntested { <# .SYNOPSIS Asks for the word UNTESTED, once in a run. -AcknowledgeUntested replaces it. #> [CmdletBinding()] [OutputType([bool])] param() $run = $script:TcRemediationRun if ($run['Acknowledged']) { return $true } $answer = Read-TcAnswer -Prompt "$($run['Id']) has never run on a real tenant. Type UNTESTED to go on" $run['Acknowledged'] = [string]$answer -ceq 'UNTESTED' $run['Acknowledged'] } function Confirm-TcRemediationApply { <# .SYNOPSIS Asks a person to confirm before the first write. Any answer but the expected one stops the run. .DESCRIPTION First the word UNTESTED, unless the run already has it. Then the primary domain of each tenant the plan touches, which nothing replaces. Then, when the script removes objects, the word REMOVE, which nothing replaces either. The run keeps the tenants confirmed: a write to any other is refused. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Tenant, [bool]$Removal ) $run = $script:TcRemediationRun if (-not (Confirm-TcRemediationUntested)) { return $false } foreach ($role in $Tenant) { $info = Get-TcRemediationTenant -Tenant $role $answer = Read-TcAnswer -Prompt ("Type the primary domain of {0} ({1}) to write to this tenant" -f $info['Name'], $role) if (([string]$answer).Trim() -ine $info['PrimaryDomain'] -or -not $info['PrimaryDomain']) { return $false } } if ($Removal) { $answer = Read-TcAnswer -Prompt "$($run['Id']) removes objects from the tenant. Type REMOVE to confirm" if ([string]$answer -cne 'REMOVE') { return $false } } $run['ConfirmedTenants'] = @($Tenant) $true } function Invoke-TcRemediationPlan { <# .SYNOPSIS Shows the plan, and with -Apply confirms, then runs each action: test, log, run, log. .PARAMETER Verify Runs for each tenant after the actions. It returns results from New-TcRemediationResult. #> [CmdletBinding()] param( [scriptblock]$Verify ) Close-TcRemediationBlock $tcRun = $script:TcRemediationRun $tcRun['Rank']++ Write-TcStepStart -Rank $tcRun['Rank'] -Total $tcRun['Total'] -Id 'Plan' -Title $(if ($tcRun['Apply']) { 'Actions to apply' } else { 'Actions planned' }) $tcClock = [System.Diagnostics.Stopwatch]::StartNew() $tcCopy = @($tcRun['Actions'] | ForEach-Object { Protect-TcRemediationAction -InputObject $_ }) Write-TcJsonFile -Path (Join-Path $tcRun['Folder'] 'plan.json') -InputObject $tcCopy if ($tcCopy.Count) { $tcCopy | ForEach-Object { [pscustomobject]@{ Id = $_['id']; Tenant = $_['tenant']; 'Also writes' = @($_['alsoWrites']) -join ', '; Service = $_['service']; Object = ConvertTo-TcCsvCell $_['object']; Description = ConvertTo-TcCsvCell $_['description'] Undo = $(if ($_['undo']) { 'Yes' } else { 'No' }); Reason = ConvertTo-TcCsvCell $_['reason'] } } | Export-Csv -LiteralPath (Join-Path $tcRun['Folder'] 'plan.csv') -NoTypeInformation -Encoding utf8BOM } if (-not $tcRun['Actions'].Count) { Write-TcRemediationLine -Status Info -Text 'Nothing to do, from the snapshot and the decisions.' -Step $tcRun['Id'] } if (-not $tcRun['Apply']) { Show-TcRemediationPreview Write-TcStepEnd -Status Done -Seconds $tcClock.Elapsed.TotalSeconds -Detail ('{0} action(s), none applied' -f $tcRun['Actions'].Count) return } if (-not $tcRun['Actions'].Count) { return } Show-TcRemediationPreview $tcWriting = @($tcRun['Actions'] | Where-Object { $_['type'] -ne 'Manual' }) $tcRemoval = [bool]$tcRun['Entry']['removesObjects'] -or @($tcWriting | Where-Object { Test-TcRemovingAction -Action $_ }).Count -gt 0 $tcTouched = @($tcRun['Actions'] | ForEach-Object { Get-TcActionTenant -Action $_ } | Select-Object -Unique) if (-not (Confirm-TcRemediationApply -Tenant $tcTouched -Removal $tcRemoval)) { $tcRun['Stopped'] = $true Write-TcRemediationLine -Status Warning -Text 'Not confirmed. Stopped: nothing was changed.' -Step $tcRun['Id'] return } $tcIndex = 0 foreach ($tcAction in $tcRun['Actions']) { $tcIndex++ $tcRun['CurrentAction'] = $tcAction Write-Progress -Activity $tcRun['Entry']['title'] -Status $tcAction['description'] -PercentComplete (100 * $tcIndex / $tcRun['Actions'].Count) try { if ($tcAction['type'] -eq 'Manual') { $tcDone = Invoke-TcManualAction -Action $tcAction $tcAction['status'] = if ($tcDone) { 'Already in place' } else { 'For a person' } Write-TcRemediationLine -Status $(if ($tcDone) { 'OK' } else { 'Manual' }) -Text $tcAction['description'] -Step $tcAction['id'] Write-TcOperation -Action $tcAction continue } if ($tcAction['test'] -and (& $tcAction['test'] $tcAction)) { # A test can postpone the action: it is not in place, and a rerun picks it up. $tcAction['status'] = if ($tcAction['postponed']) { 'Postponed' } else { 'Already in place' } Write-TcRemediationLine -Status Skipped -Text $(if ($tcAction['postponed']) { $tcAction['description'] } else { "Already in place: $($tcAction['description'])" }) -Step $tcAction['id'] Write-TcOperation -Action $tcAction continue } if ($tcAction['undo'] -eq 'auto') { $tcAction['undo'] = Get-TcAutomaticUndo -Action $tcAction } $tcAction['status'] = 'Running' $tcAction['timestamp'] = (Get-Date).ToString('o') Write-TcOperation -Action $tcAction $tcAnswer = Invoke-TcRemediationAction -Action $tcAction # Deleting what was created only makes sense on a real collection, not on an invitation or a link. if ($tcAction['type'] -eq 'Graph' -and $tcAction['method'] -eq 'POST' -and -not $tcAction['undo'] -and -not $tcAction['reason'] -and $tcAnswer -is [System.Collections.IDictionary] -and (Get-TcProperty $tcAnswer 'id') -and $tcAction['uri'] -notmatch $script:TcNoUndoPost) { $tcAction['undo'] = @{ type = 'Graph'; tenant = $tcAction['tenant']; method = 'DELETE'; uri = ('{0}/{1}' -f ($tcAction['uri'] -replace '\?.*$', ''), $tcAnswer['id']) } } $tcAction['status'] = 'Applied' Write-TcOperation -Action $tcAction Write-TcRemediationLine -Status Applied -Text $tcAction['description'] -Step $tcAction['id'] } catch { $tcText = Get-TcErrorText -ErrorInput $_ $tcAction['status'] = 'Failed' $tcAction['error'] = $tcText Write-TcOperation -Action $tcAction Write-TcRemediationLine -Status Failed -Text ('{0}: {1}' -f $tcAction['description'], $tcText) -Step $tcAction['id'] Write-TcLog -Message ('Detail: {0}' -f (($_.ScriptStackTrace -split "`n") | Select-Object -First 1)) -Level ERROR -Step $tcAction['id'] if ($tcAction['critical']) { Write-TcRemediationLine -Status Blocking -Text 'Critical action failed: the rest of the plan does not run. Run it again after the fix: what is in place is skipped.' -Step $tcAction['id'] break } } } $tcRun['CurrentAction'] = $null Write-Progress -Activity $tcRun['Entry']['title'] -Completed $tcTally = Get-TcRemediationTally Write-TcStepEnd -Status $(if ($tcTally['Failed']) { 'Warning' } else { 'Done' }) -Seconds $tcClock.Elapsed.TotalSeconds -Detail ('{0} applied, {1} already in place, {2} failed' -f $tcTally['Applied'], $tcTally['AlreadyInPlace'], $tcTally['Failed']) if ($Verify) { Write-TcPhase -Title 'Checking the tenants' -StepCount $tcRun['Tenants'].Count foreach ($tcRole in $tcRun['Tenants']) { try { foreach ($tcResult in @(& $Verify $tcRole)) { if ($null -eq $tcResult) { continue } Write-TcRemediationLine -Status $tcResult['status'] -Text ('{0}: {1}' -f $tcRole, $tcResult['message']) -Step 'Check' $tcRun['Results'].Add($tcResult) if ($tcResult['status'] -in 'Warning', 'Blocking') { $tcRun['Alerts']++ } if ($tcResult['status'] -eq 'Blocking') { $tcRun['Blocking']++ } } } catch { $tcRun['Blocking']++ Write-TcLog -Message ('{0}: check stopped. {1}' -f $tcRole, (($_.ScriptStackTrace -split "`n") | Select-Object -First 1)) -Level WARN Write-TcRemediationAlert ('{0}: check impossible ({1})' -f $tcRole, (Get-TcErrorText -ErrorInput $_)) } } } } function New-TcRemediationResult { <# .SYNOPSIS The result of a check: OK, Warning, Blocking or Info. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a value in memory.')] [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param( [Parameter(Mandatory, Position = 0)][ValidateSet('OK', 'Warning', 'Blocking', 'Info')][string]$Status, [Parameter(Mandatory, Position = 1)][AllowEmptyString()][string]$Message ) [ordered]@{ status = $Status; message = $Message } } function Get-TcRemediationTally { <# .SYNOPSIS Counts the actions of the run by status. #> [CmdletBinding()] [OutputType([hashtable])] param() $actions = @($script:TcRemediationRun['Actions']) $count = { param([string[]]$Status) @($actions | Where-Object { $_['status'] -in $Status }).Count } @{ Total = $actions.Count; Applied = & $count 'Applied'; AlreadyInPlace = & $count 'Already in place'; Failed = & $count 'Failed' ForAPerson = @($actions | Where-Object { $_['status'] -eq 'For a person' -or ($_['type'] -eq 'Manual' -and $_['status'] -eq 'Planned') }).Count Waiting = & $count 'Running', 'Postponed' } } function Get-TcWrittenTenant { <# .SYNOPSIS The tenant an operation of the journal changed: the only one written to, otherwise its own. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)]$Operation ) $written = @(Get-TcProperty $Operation 'written' @()) if ($written.Count -eq 1) { return [string]$written[0] } [string]$Operation['tenant'] } function Get-TcUndoTenant { <# .SYNOPSIS The tenants the undo of an operation writes to: those it names, otherwise the one the journal gives. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)]$Operation ) $written = Get-TcWrittenTenant -Operation $Operation @(@($Operation['undo']) | ForEach-Object { Get-TcProperty $_ 'tenant' $written } | Select-Object -Unique) } function Test-TcRemovingUndo { <# .SYNOPSIS True when replaying the undo of an operation deletes or removes an object. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)]$Operation ) @(@($Operation['undo']) | Where-Object { (Get-TcProperty $_ 'method') -eq 'DELETE' -or (Get-TcProperty $_ 'command') -match '^(Remove|Disable|Clear)-' -or ((Get-TcProperty $_ 'type') -eq 'Dns' -and (Get-TcDnsAddedValue -Undo $_)) }).Count -gt 0 } function Invoke-TcUndoOperation { <# .SYNOPSIS Replays one undo: Graph, a command or DNS. A block can carry several. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Undo, [string]$Tenant, [string]$Service ) if ($Undo -is [System.Collections.IList]) { foreach ($item in @($Undo)) { Invoke-TcUndoOperation -Undo $item -Tenant $Tenant -Service $Service } return } $role = Get-TcProperty $Undo 'tenant' $Tenant $kind = Get-TcProperty $Undo 'type' '' if ($kind -eq 'Graph') { [void](Invoke-TcGraph -Tenant $role -Method $Undo['method'] -Uri $Undo['uri'] -Body (Get-TcProperty $Undo 'body')) return } if ($kind -eq 'Dns') { Restore-TcDnsRecord -Tenant $role -Undo $Undo return } [void](Invoke-TcServiceCommand -Tenant $role -Service (Get-TcProperty $Undo 'service' $Service) -Command $Undo['command'] -Parameters (ConvertTo-TcPlainHashtable -InputObject (Get-TcProperty $Undo 'parameters' @{})) -Url (Get-TcProperty $Undo 'url' '')) } function ConvertTo-TcPlainHashtable { <# .SYNOPSIS Copies a dictionary into a hashtable, for a splat. #> [CmdletBinding()] [OutputType([hashtable])] param( [AllowNull()]$InputObject ) $copy = @{} if ($InputObject -is [System.Collections.IDictionary]) { foreach ($key in @($InputObject.Keys)) { $copy[$key] = $InputObject[$key] } } $copy } |