Public/Invoke-PVEVMScript.ps1

function Invoke-PVEVMScript {
  <#
  .SYNOPSIS
  Runs a command inside running VMs through the QEMU guest agent and returns its output.
  .DESCRIPTION
  Starts the command with POST /nodes/{node}/qemu/{vmid}/agent/exec on every VM, then polls
  GET .../agent/exec-status for each until it exits or -TimeoutSec runs out. Writes one
  PVEPS.VMScriptResult per VM with the exit code, standard output and standard error. A non-zero exit
  code is a result, not an error. A command still running at the timeout is an error
  (PVEGuestCommandTimeout); it keeps running in the guest.

  -Command runs a program directly with the given arguments. -ScriptText runs text through an
  interpreter chosen by -ScriptType:
  - Bash: /bin/bash -c <text> - Sh: /bin/sh -c <text>
  - PowerShell: powershell.exe -NoProfile -NonInteractive -Command <text>
  - Pwsh: pwsh -NoProfile -NonInteractive -Command <text>
  - Cmd: cmd.exe /c <text>

  The VM must be running, with the agent installed and running in the guest. The API documentation
  requires the VM.GuestAgent.Unrestricted privilege for exec.
  .PARAMETER VMId
  VM ID(s). Binds from piped PVEPS.VM objects.
  .PARAMETER VMName
  VM name(s). Wildcards allowed.
  .PARAMETER Command
  Program and arguments, one per element, for example 'ls', '-l', '/etc'.
  .PARAMETER ScriptText
  Script text to run through the -ScriptType interpreter.
  .PARAMETER ScriptType
  Bash (the default), Sh, PowerShell, Pwsh or Cmd.
  .PARAMETER InputData
  Text passed to the command as standard input.
  .PARAMETER TimeoutSec
  Seconds to wait for the command to exit. Defaults to 300.
  .PARAMETER Server
  Connection(s) to use. Defaults to the default connection; binds from piped objects.
  .EXAMPLE
  Invoke-PVEVMScript -VMId 100 -ScriptText 'uptime; df -h /'
  .EXAMPLE
  Get-PVEVM 'web*' | Invoke-PVEVMScript -Command systemctl, is-active, nginx | Select-Object VMName, ExitCode, StdOut
  .EXAMPLE
  Invoke-PVEVMScript -VMName win01 -ScriptType PowerShell -ScriptText 'Get-Service W32Time'
  #>

  [CmdletBinding(DefaultParameterSetName = 'IdScript', SupportsShouldProcess, ConfirmImpact = 'Medium')]
  [OutputType('PVEPS.VMScriptResult')]
  param(
    [Parameter(Mandatory, ParameterSetName = 'IdScript', ValueFromPipelineByPropertyName)]
    [Parameter(Mandatory, ParameterSetName = 'IdCommand', ValueFromPipelineByPropertyName)]
    [ValidateRange(100, 999999999)]
    [int[]]$VMId,

    [Parameter(Mandatory, ParameterSetName = 'NameScript', Position = 0)]
    [Parameter(Mandatory, ParameterSetName = 'NameCommand', Position = 0)]
    [SupportsWildcards()]
    [ValidateNotNullOrEmpty()]
    [string[]]$VMName,

    [Parameter(Mandatory, ParameterSetName = 'IdCommand')]
    [Parameter(Mandatory, ParameterSetName = 'NameCommand')]
    [ValidateNotNullOrEmpty()]
    [string[]]$Command,

    [Parameter(Mandatory, ParameterSetName = 'IdScript')]
    [Parameter(Mandatory, ParameterSetName = 'NameScript')]
    [ValidateNotNullOrEmpty()]
    [string]$ScriptText,

    [Parameter(ParameterSetName = 'IdScript')]
    [Parameter(ParameterSetName = 'NameScript')]
    [ValidateSet('Bash', 'Sh', 'PowerShell', 'Pwsh', 'Cmd')]
    [string]$ScriptType = 'Bash',

    [string]$InputData,

    [ValidateRange(1, [int]::MaxValue)]
    [int]$TimeoutSec = 300,

    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('Connection')]
    [object[]]$Server
  )

  begin {
    $argv = if ($Command) {
      $Command
    }
    else {
      switch ($ScriptType) {
        'Bash' { '/bin/bash', '-c', $ScriptText }
        'Sh' { '/bin/sh', '-c', $ScriptText }
        'PowerShell' { 'powershell.exe', '-NoProfile', '-NonInteractive', '-Command', $ScriptText }
        'Pwsh' { 'pwsh', '-NoProfile', '-NonInteractive', '-Command', $ScriptText }
        'Cmd' { 'cmd.exe', '/c', $ScriptText }
      }
    }
    $argv = [string[]]@($argv)
    $body = @{ command = $argv }
    if ($PSBoundParameters.ContainsKey('InputData')) {
      $body['input-data'] = $InputData
    }
    $shown = if ($Command) { $Command -join ' ' } else { "$ScriptType script" }
    $targets = [System.Collections.Generic.List[psobject]]::new()
  }

  process {
    Add-PVEGuestTarget -Cmdlet $PSCmdlet -Target $targets -Server $Server -VMId $VMId -Name $VMName
  }

  end {
    $running = [System.Collections.Generic.List[psobject]]::new()
    foreach ($guest in @(Resolve-PVEGuestTarget -Cmdlet $PSCmdlet -Type qemu -Target $targets)) {
      $label = Get-PVEGuestLabel -Guest $guest
      try {
        $null = Update-PVEGuestStatus -Guest $guest
      }
      catch {
        $PSCmdlet.WriteError($_)
        continue
      }
      if ($guest.Status -ne 'running') {
        $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
            [System.InvalidOperationException]::new("$label is $($guest.Status); the guest agent only runs in a running VM."),
            'PVEGuestNotRunning',
            [System.Management.Automation.ErrorCategory]::InvalidOperation,
            $guest))
        continue
      }
      if (-not $PSCmdlet.ShouldProcess($label, "Run in the guest: $shown")) {
        continue
      }
      try {
        $started = Invoke-PVEAgentRequest -Guest $guest -Command exec -Method POST -Parameters $body
      }
      catch {
        $PSCmdlet.WriteError($_)
        continue
      }
      $processId = Get-PVEValue $started 'pid'
      Write-Verbose "$label`: started $shown as guest PID $processId"
      $running.Add([pscustomobject]@{ Guest = $guest; ProcessId = $processId })
    }

    $deadline = (Get-Date).AddSeconds($TimeoutSec)
    $delayMs = 250
    while ($running.Count -gt 0) {
      foreach ($item in @($running)) {
        try {
          $status = Invoke-PVEAgentRequest -Guest $item.Guest -Command exec-status -Parameters @{ pid = $item.ProcessId }
        }
        catch {
          $PSCmdlet.WriteError($_)
          $null = $running.Remove($item)
          continue
        }
        if (-not [bool][int](Get-PVEValue $status 'exited')) {
          continue
        }
        $null = $running.Remove($item)
        [pscustomobject]@{
          PSTypeName   = 'PVEPS.VMScriptResult'
          VMId         = $item.Guest.VMId
          VMName       = $item.Guest.Name
          Node         = $item.Guest.Node
          Command      = $argv
          ExitCode     = Get-PVEValue $status 'exitcode'
          Signal       = Get-PVEValue $status 'signal'
          StdOut       = [string](Get-PVEValue $status 'out-data')
          StdErr       = [string](Get-PVEValue $status 'err-data')
          OutTruncated = [bool][int](Get-PVEValue $status 'out-truncated')
          ErrTruncated = [bool][int](Get-PVEValue $status 'err-truncated')
          Connection   = $item.Guest.Connection
        }
      }
      if ($running.Count -eq 0) {
        break
      }
      if ((Get-Date) -ge $deadline) {
        foreach ($item in $running) {
          $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
              [System.TimeoutException]::new("$(Get-PVEGuestLabel -Guest $item.Guest): $shown (guest PID $($item.ProcessId)) was still running after $TimeoutSec seconds; it keeps running in the guest."),
              'PVEGuestCommandTimeout',
              [System.Management.Automation.ErrorCategory]::OperationTimeout,
              $item.Guest))
        }
        break
      }
      Start-Sleep -Milliseconds $delayMs
      $delayMs = [math]::Min($delayMs * 2, 2000)
    }
  }
}