Private/Invoke-Step.ps1
|
function Invoke-Step { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Write-Host is the user interface of a console maintenance tool. Its output is progress a person watches, not data a caller consumes, and the summary uses colour to separate failures from noise.')] param( [Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][scriptblock] $Action, [string] $RequiresCommand, # Steps that cannot work without administrator rights. Reported as # skipped with the reason rather than left to fail with a permissions # error that reads like a bug. [switch] $RequiresAdmin, # What this step is about, for -Tag and -ExcludeTag. Declared on the call # rather than in a table elsewhere, so a tag and its step cannot drift. [string[]] $Tag = @(), # Native exit codes this step should treat as success. [int[]] $AllowedExitCodes = @() ) # Tag filter first: a step the caller excluded should not even be checked for # administrator rights or for its tool, because neither is why it is skipped. if (-not (Test-StepTagMatch -StepTag $Tag -Tag $script:TagFilter -ExcludeTag $script:ExcludeTagFilter)) { # Which filter actually refused it. Exclusion wins when both are given, # so asking "was -Tag set" first would blame -Tag for an exclusion and # send someone looking at the wrong parameter. $refused = @($Tag | Where-Object { $_ -and $_ -in @($script:ExcludeTagFilter | Where-Object { $_ }) }) $reason = if ($refused.Count) { "excluded by -ExcludeTag $($refused -join ',')" } else { "does not match -Tag $(@($script:TagFilter | Where-Object { $_ }) -join ',')" } Add-SkippedStep -Name $Name -Reason $reason return } # Step names carry spaces, parens and '=' -- legal on NTFS but awkward on # disk. The run stamp keeps each run's step logs together and stops any one # file growing without bound. $safeName = ($Name -replace '[^\w\-]+', '-').Trim('-') if (-not $safeName) { $safeName = 'step' } $stepLog = Join-Path $script:logDir "$safeName-$script:runStamp.log" # Command availability is checked before administrator rights: an absent # tool is absent whether or not the run is elevated, and reporting it as # "requires Administrator" would send someone to elevate for a tool that is # simply not installed. if ($RequiresCommand -and -not (Get-Command $RequiresCommand -ErrorAction SilentlyContinue)) { $msg = "SKIP $Name (command '$RequiresCommand' not found)" Write-Host $msg -ForegroundColor DarkGray Write-StepLog -Path $stepLog -Message $msg $script:Results.Add([pscustomobject]@{ Step = $Name; Status = 'Skipped'; Seconds = 0; Log = $stepLog }) return } if ($RequiresAdmin -and -not $script:isAdmin) { $msg = "SKIP $Name (requires Administrator; this run is not elevated)" Write-Host $msg -ForegroundColor DarkGray Write-StepLog -Path $stepLog -Message $msg $script:Results.Add([pscustomobject]@{ Step = $Name; Status = 'Skipped'; Seconds = 0; Log = $stepLog }) return } Write-Host ("`n=== STARTING: $Name ===") -ForegroundColor Cyan Write-StepLog -Path $stepLog -Message "STARTING $Name" $sw = [System.Diagnostics.Stopwatch]::StartNew() # Function-local: the assignment is scoped to this function, so the caller's # own preference is untouched once it returns. # # A caller that prefers Stop makes a native command's stderr terminating, and # most of these steps drive tools that write to stderr as a matter of course: # npm its deprecation notices, winget its progress, wsl its "not installed" # message. Every one of those steps then throws before reaching the exit-code # check written to handle exactly that case, so the run's result depends on a # preference set outside it. # # Non-terminating errors still reach $stepOutput and are still counted below. $ErrorActionPreference = 'Continue' try { # Reset so a cmdlet-only step can't inherit a stale exit code from an earlier native command $global:LASTEXITCODE = 0 # *>&1 captures every stream, not just warnings: Write-Host writes to the # information stream and native stderr to the error stream. The first # ForEach-Object accumulates output for the error-record check below and # passes each object through, so the console and the transcript see it # live during long-running steps. # # Out-String -Stream renders the Format* records a table arrives as into # the table itself, and leaves Write-StepLog as the file's only writer. # Tee-Object is not usable here: it writes UTF-16LE in Windows PowerShell # and has no -Encoding parameter there. # # Where-Object drops progress repaints. PowerShell splits captured native # output on carriage returns, so a tool that redraws a progress bar in # place -- winget above all -- arrives as one line per repaint. Nothing # upstream prevents it: --disable-interactivity governs prompts, not # rendering. # # Out-Host displays the output rather than returning it. Inside a # function, unassigned pipeline output is the return value, so without it # every line a step produced would reach the caller alongside the result # object and the transcript would record none of it. # # $stepOutput is filled before the filter, so the error-record check # below still sees everything the step produced. $stepOutput = [System.Collections.Generic.List[object]]::new() & $Action *>&1 | ForEach-Object { $stepOutput.Add($_); $_ } | Out-String -Stream | Where-Object { -not (Test-ProgressRepaint -Line $_) } | ForEach-Object { Write-StepLog -Path $stepLog -Raw $_; $_ } | Out-Host $code = $LASTEXITCODE if ($null -ne $code -and $code -ne 0) { if ($AllowedExitCodes -contains $code) { Write-StepLog -Path $stepLog -Message ('Exit code {0} (0x{0:X8}) is expected for this step; treating as success.' -f $code) } else { throw ('External command exited with code {0} (0x{0:X8})' -f $code) } } # Merging streams means non-terminating errors land here as objects rather # than reaching catch. Native stderr arrives as error records too and is # usually just progress chatter, so only count errors raised by PowerShell. $errorRecords = @($stepOutput | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] -and $_.FullyQualifiedErrorId -notmatch 'NativeCommand' }) $sw.Stop() $secs = [math]::Round($sw.Elapsed.TotalSeconds, 1) if ($errorRecords.Count -gt 0) { Write-Warning "COMPLETED WITH ERRORS: $Name ($secs s, $($errorRecords.Count) error record(s))" Write-StepLog -Path $stepLog -Message "COMPLETED WITH ERRORS | $($errorRecords.Count) error record(s) | Duration: ${secs}s" $script:Results.Add([pscustomobject]@{ Step = $Name; Status = 'Warning'; Seconds = $secs; Log = $stepLog }) } else { Write-Host "COMPLETED: $Name ($secs s)" -ForegroundColor Green Write-StepLog -Path $stepLog -Message "COMPLETED | Duration: ${secs}s" $script:Results.Add([pscustomobject]@{ Step = $Name; Status = 'OK'; Seconds = $secs; Log = $stepLog }) } } catch { $sw.Stop() $secs = [math]::Round($sw.Elapsed.TotalSeconds, 1) # Stop-StepAsSkipped throws this prefix to end a step deliberately -- # a declined install, for instance. That is a decision, not a fault, and # reporting it as Failed would put it in the exit code. $message = "$_" if ($message -like 'STEP-SKIPPED:*') { $reason = $message -replace '^STEP-SKIPPED:\s*', '' Write-Host "SKIP $Name ($reason)" -ForegroundColor DarkGray Write-StepLog -Path $stepLog -Message "SKIPPED | $reason" $script:Results.Add([pscustomobject]@{ Step = $Name; Status = 'Skipped'; Seconds = $secs; Log = $stepLog }) return } Write-Warning ("FAILED: $Name | $_") Write-StepLog -Path $stepLog -Message "FAILED | $_" $script:Results.Add([pscustomobject]@{ Step = $Name; Status = 'Failed'; Seconds = $secs; Log = $stepLog }) } } |