src/PowerShell/Exec.ps1

# cdp PowerShell domain: Exec.ps1
# Loaded by src/cdp.psm1; do not dot-source peer files.

function New-CdpExecTempDirectory {
    $path = Join-Path ([IO.Path]::GetTempPath()) "cdp-exec-$([Guid]::NewGuid().ToString('N'))"
    [void](New-Item -ItemType Directory -Path $path -ErrorAction Stop)
    $path
}

function ConvertTo-CdpWindowsCommandLineArgument {
    param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)

    if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { return $Value }
    $escaped = [regex]::Replace($Value, '(\\*)"', {
        param($match)
        ($match.Groups[1].Value * 2) + '\"'
    })
    $escaped = [regex]::Replace($escaped, '(\\+)$', '$1$1')
    '"' + $escaped + '"'
}

function Join-CdpWindowsCommandLineArguments {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()][AllowEmptyString()][string[]]$Arguments
    )

    (@($Arguments | ForEach-Object {
        ConvertTo-CdpWindowsCommandLineArgument -Value ([string]$_)
    }) -join ' ')
}

function Get-CdpExecWorkerScript {
    {
        param($WorkingDirectory, $Executable, [string[]]$Arguments, $ArgumentLine, $StdoutPath, $StderrPath)
        $watch = [Diagnostics.Stopwatch]::StartNew()
        $process = $null
        try {
            $startInfo = New-Object System.Diagnostics.ProcessStartInfo
            $startInfo.FileName = $Executable
            $startInfo.WorkingDirectory = $WorkingDirectory
            $startInfo.UseShellExecute = $false
            $startInfo.CreateNoWindow = $true
            $startInfo.RedirectStandardOutput = $true
            $startInfo.RedirectStandardError = $true
            if ($startInfo.PSObject.Properties['ArgumentList']) {
                foreach ($argument in $Arguments) { [void]$startInfo.ArgumentList.Add($argument) }
            } else { $startInfo.Arguments = $ArgumentLine }
            $process = New-Object System.Diagnostics.Process
            $process.StartInfo = $startInfo
            [void]$process.Start()
            $stdoutTask = $process.StandardOutput.ReadToEndAsync()
            $stderrTask = $process.StandardError.ReadToEndAsync()
            $process.WaitForExit()
            $encoding = New-Object System.Text.UTF8Encoding($false)
            [IO.File]::WriteAllText($StdoutPath, $stdoutTask.Result, $encoding)
            [IO.File]::WriteAllText($StderrPath, $stderrTask.Result, $encoding)
            [PSCustomObject]@{ ExitCode=[int]$process.ExitCode; ElapsedMs=[int]$watch.ElapsedMilliseconds; Error='' }
        } catch {
            [PSCustomObject]@{ ExitCode=$null; ElapsedMs=[int]$watch.ElapsedMilliseconds; Error=$_.Exception.Message }
        } finally {
            if ($null -ne $process) {
                try { if (-not $process.HasExited) { $process.Kill() } } catch {}
                $process.Dispose()
            }
            $watch.Stop()
        }
    }
}

function Start-CdpExecWorker {
    param(
        [Parameter(Mandatory = $true)][object]$Item,
        [Parameter(Mandatory = $true)][object]$Plan,
        [Parameter(Mandatory = $true)][System.Management.Automation.Runspaces.RunspacePool]$Pool
    )

    $tempDirectory = New-CdpExecTempDirectory
    $stdoutPath = Join-Path $tempDirectory 'stdout.txt'
    $stderrPath = Join-Path $tempDirectory 'stderr.txt'
    $pipeline = [PowerShell]::Create()
    try {
        $pipeline.RunspacePool = $Pool
        $worker = Get-CdpExecWorkerScript
        $argumentLine = Join-CdpWindowsCommandLineArguments -Arguments @($Plan.Arguments)
        [void]$pipeline.AddScript($worker.ToString())
        [void]$pipeline.AddArgument([string]$Item.ResolvedPath)
        [void]$pipeline.AddArgument([string]$Plan.Executable)
        [void]$pipeline.AddArgument([string[]]@($Plan.Arguments))
        [void]$pipeline.AddArgument($argumentLine)
        [void]$pipeline.AddArgument($stdoutPath)
        [void]$pipeline.AddArgument($stderrPath)
        $startedAt = [DateTime]::UtcNow
        [PSCustomObject]@{
            Item=$Item; PowerShell=$pipeline; Async=$pipeline.BeginInvoke()
            Deadline=$startedAt.AddSeconds($Plan.TimeoutSeconds)
            TempDirectory=$tempDirectory; StdoutPath=$stdoutPath; StderrPath=$stderrPath
        }
    } catch {
        $pipeline.Dispose()
        Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue
        throw
    }
}

function Read-CdpExecCapture {
    param([Parameter(Mandatory = $true)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' }
    $content = Get-Content -LiteralPath $Path -Raw -ErrorAction SilentlyContinue
    if ($null -eq $content) { return '' }
    ([string]$content).TrimEnd("`r", "`n")
}

function Complete-CdpExecWorker {
    param([Parameter(Mandatory = $true)][object]$Task)

    try {
        $remaining = [Math]::Max(0, [int][Math]::Ceiling(($Task.Deadline - [DateTime]::UtcNow).TotalMilliseconds))
        if (-not $Task.Async.AsyncWaitHandle.WaitOne($remaining)) {
            try { $Task.PowerShell.Stop() } catch {}
            $Task.Item.Status = 'timed_out'
            $Task.Item.Error = 'Command timed out.'
            $Task.Item.ElapsedMs = [int][Math]::Max(0, $Task.PlanTimeoutMs)
        } else {
            $values = @($Task.PowerShell.EndInvoke($Task.Async))
            $workerResult = if ($values.Count -gt 0) { $values[-1] } else { $null }
            Set-CdpExecWorkerResult -Item $Task.Item -WorkerResult $workerResult
        }
        $Task.Item.Stdout = Read-CdpExecCapture -Path $Task.StdoutPath
        $Task.Item.Stderr = Read-CdpExecCapture -Path $Task.StderrPath
        $Task.Item
    } catch {
        $Task.Item.Status = 'failed'
        $Task.Item.Error = $_.Exception.Message
        $Task.Item
    } finally {
        $Task.PowerShell.Dispose()
        Remove-Item -LiteralPath $Task.TempDirectory -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Set-CdpExecWorkerResult {
    param([Parameter(Mandatory = $true)][object]$Item, [AllowNull()][object]$WorkerResult)

    if (-not $WorkerResult) {
        $Item.Status = 'failed'
        $Item.Error = 'Exec worker returned no result.'
        return
    }
    $Item.ElapsedMs = [int]$WorkerResult.ElapsedMs
    $Item.ExitCode = if ($null -eq $WorkerResult.ExitCode) { $null } else { [int]$WorkerResult.ExitCode }
    if (-not [string]::IsNullOrWhiteSpace([string]$WorkerResult.Error)) {
        $Item.Status = 'failed'
        $Item.Error = [string]$WorkerResult.Error
    } elseif ($Item.ExitCode -eq 0) {
        $Item.Status = 'succeeded'
    } else {
        $Item.Status = 'failed'
        $Item.Error = "Command exited with code $($Item.ExitCode)."
    }
}

function Set-CdpExecPreflightFailFast {
    param([Parameter(Mandatory = $true)][object[]]$Items)

    $failureSeen = $false
    foreach ($item in $Items) {
        if ($failureSeen -and $item.Status -eq 'planned') {
            $item.Status = 'canceled'
            $item.Error = 'Canceled by fail-fast before execution.'
        } elseif ($item.Status -ne 'planned') {
            $failureSeen = $true
        }
    }
}

function Stop-CdpExecFutureItems {
    param([Parameter(Mandatory = $true)][object[]]$Items)

    foreach ($item in $Items) {
        if ($item.Status -eq 'planned') {
            $item.Status = 'canceled'
            $item.Error = 'Canceled by fail-fast after an earlier failure.'
        }
    }
}

function Invoke-CdpExecWorkers {
    param([Parameter(Mandatory = $true)][object]$Plan)

    if ($Plan.FailFast) { Set-CdpExecPreflightFailFast -Items @($Plan.Items) }
    $runnable = @($Plan.Items | Where-Object Status -eq 'planned')
    if ($runnable.Count -eq 0) { return }
    $pool = [RunspaceFactory]::CreateRunspacePool(1, [Math]::Min($Plan.Jobs, $runnable.Count))
    try {
        $pool.Open()
        for ($offset = 0; $offset -lt $runnable.Count; $offset += $Plan.Jobs) {
            $batch = @($runnable | Select-Object -Skip $offset -First $Plan.Jobs)
            $tasks = @($batch | ForEach-Object {
                $task = Start-CdpExecWorker -Item $_ -Plan $Plan -Pool $pool
                $task | Add-Member -NotePropertyName PlanTimeoutMs -NotePropertyValue ($Plan.TimeoutSeconds * 1000)
                $task
            })
            foreach ($task in $tasks) { [void](Complete-CdpExecWorker -Task $task) }
            if ($Plan.FailFast -and @($batch | Where-Object { $_.Status -in @('failed', 'timed_out') }).Count -gt 0) {
                Stop-CdpExecFutureItems -Items @($Plan.Items)
                break
            }
        }
    } finally { $pool.Dispose() }
}

function Invoke-CdpExecPlan {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param([Parameter(Mandatory = $true)][object]$Plan, [switch]$Json)

    $watch = [Diagnostics.Stopwatch]::StartNew()
    try {
        if (-not $Plan.DryRun) {
            if ($PSCmdlet.ShouldProcess("$($Plan.Items.Count) projects", 'Execute native command')) {
                Invoke-CdpExecWorkers -Plan $Plan
            } else {
                foreach ($item in @($Plan.Items | Where-Object Status -eq 'planned')) {
                    $item.Status = 'canceled'
                    $item.Error = 'Execution was not approved.'
                }
            }
        }
    } catch {
        $watch.Stop()
        throw
    }
    $watch.Stop()
    Write-CdpExecResult -Plan $Plan -DurationMs ([int]$watch.ElapsedMilliseconds) -Json:$Json
}

function Invoke-CdpExecInvocation {
    param([Parameter(Mandatory = $true)][object]$Invocation)

    try {
        $plan = New-CdpExecPlan -Invocation $Invocation
        $parameters = @{ Plan=$plan; Json=[bool]$Invocation.Json }
        if ($Invocation.Yes) { $parameters.Confirm = $false }
        Invoke-CdpExecPlan @parameters
    } catch {
        Write-CdpExecFatal -Message $_.Exception.Message -Json:$Invocation.Json
    }
}