При создании собственной методики управления резервными копиями на множестве серверов MS-SQL я потратил кучу времени на изучение механизма передачи значений в powershell при удаленных вызовах, поэтому пишу самому себе памятку, а вдруг кому-то еще пригодится.

Итак, возьмем для начала простейший скрипт и запустим его локально:

$exitcode = $args[0]
Write-Host 'Out to host.'
Write-Output 'Out to output.'
Write-Host ('ExitCode: ' + $exitcode)
Write-Output $exitcode
$host.SetShouldExit($exitcode)

Для запуска скриптов я буду пользоваться следующим CMD-файлом, каждый раз его приводить не стану:

@Echo OFF
PowerShell .\TestOutput1.ps1 1
ECHO ERRORLEVEL=%ERRORLEVEL%

На экране мы увидим следующее:

Out to host.
Out to output.
ExitCode: 1
1
ERRORLEVEL=1

Теперь запустим этот же скрипт через WSMAN (удаленно):

Invoke-Command -ComputerName . -ScriptBlock { &'D:\sqlagent\TestOutput1.ps1' $args[0] } -ArgumentList $args[0]

И вот вам результат:

Out to host.
Out to output.
ExitCode: 2
2
ERRORLEVEL=0

Чудесно, Errorlevel куда-то пропал, но нам ведь нужно получить значение из скрипта! Пробуем следующую конструкцию:

$res=Invoke-Command -ComputerName . -ScriptBlock { &'D:\sqlagent\TestOutput1.ps1' $args[0] } -ArgumentList $args[0]

Тут еще интереснее. Весть вывод в Output куда-то исчез:

Out to host.
ExitCode: 2
ERRORLEVEL=0

Теперь в качестве лирического отступления отмечу, что если внутри функции Powershell Вы напишете Write-Output или просто выражение без присваивания его какой-либо переменной (а это неявно подразумевает вывод в канал Output), то даже при локальном запуске на экран ничего не будет выведено! Это следствие конвейерной архитектуры powershell — каждая функция имеет собственный конвейер Output, для него создается массив, и все, что в него попадает, считается результатом выполнения функции, оператор Return добавляет возвращаемое значение в этот же конвейер последним элементом и передает управление в вызвавшую функцию. Для иллюстрации выполним локально следующий скрипт:

Function Write-Log {
  Param( [Parameter(Mandatory=$false, ValueFromPipeline=$true)] [String[]] $OutString = "`r`n" )
  Write-Output ("Function: "+$OutString)
  Return "ReturnValue"
}
Write-Output ("Main: "+"ParameterValue")
$res = Write-Log "ParameterValue"
$res.GetType()
$res.Length
$res | Foreach-Object { Write-Host ("Main: "+$_) }

И вот его результат:

Main: ParameterValue

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array
2
Main: Function: ParameterValue
Main: ReturnValue

Главная функция (тело скрипта) также имеет свой конвейер Output, и если мы запустим первый скрипт из CMD, перенаправив вывод в файл,

PowerShell .\TestOutput1.ps1 1 > TestOutput1.txt

то на экране мы увидим

ERRORLEVEL=1

а в файле
Out to host.
Out to output.
ExitCode: 1
1

если же сделаем аналогичный вызов из powershell

PS D:\sqlagent> .\TestOutput1.ps1 1 > TestOutput1.txt


то на экране будет

Out to host.
ExitCode: 1

а в файле

Out to output.
1

Это происходит потому, что CMD запускает powershell, который при отсутствии других указаний смешивает два потока (Host и Output) и отдает их CMD, который отправляет в файл все, что получил, а в случае запуска из powershell эти два потока существуют отдельно, и символ перенаправления влияет только на Output.

Возвращаясь к основной теме, вспомним, что объектная модель .NET внутри powershell полноценно существует в рамках одного компьютера (одной ОС), при удаленном запуске кода через WSMAN передача объектов происходит через XML-сериализацию, что вносит много дополнительного интереса в наши исследования. Продолжим эксперименты запуском следующего кода:

$res=Invoke-Command -ComputerName . -ScriptBlock { &'D:\sqlagent\TestOutput1.ps1' $args[0] } -ArgumentList $args[0]
$res.GetType()
$host.SetShouldExit($res)

И вот что у нас на экране:

Out to host.

ExitCode: 3

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array
Не удается преобразовать аргумент "exitCode", со значением: "System.Object[]", для "SetShouldExit" в тип "System.Int32": "Не удается преобразовать значение "System.Object[]" типа "System.Object[]" в тип "System
.Int32"."
D:\sqlagent\TestOutput3.ps1:3 знак:1
+ $host.SetShouldExit($res)
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], MethodException
    + FullyQualifiedErrorId : MethodArgumentConversionInvalidCastArgument

ERRORLEVEL=0

Прекрасный результат! Он означает, что при вызове Invoke-Command сохраняется деление конвейеров на два потока (Host и Output), что дает нам надежду на успех. Попробуем оставить в потоке Output только одно значение, для чего изменим самый первый скрипт, который мы запускаем удаленно:

$exitcode = $args[0]
Write-Host 'Out to host.'
#Write-Output 'Out to output.'
Write-Host ('ExitCode: ' + $exitcode)
Write-Output $exitcode
$host.SetShouldExit($exitcode)

Запустим его так:

$res=Invoke-Command -ComputerName . -ScriptBlock { &'D:\sqlagent\TestOutput1.ps1' $args[0] } -ArgumentList $args[0]
$host.SetShouldExit($res)

и… ДА, похоже, это победа!

Out to host.
ExitCode: 4

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Int32                                    System.ValueType


ERRORLEVEL=4

Попытаемся разобраться, что у нас произошло. Мы вызвали локально powershell, который, в свою очередь, вызвал powershell на удаленном компьютере и выполнил там наш скрипт. Два потока (Host и Output) с удаленной машины были сериализованы и переданы обратно, при этом поток Output при наличии в нем одного цифрового значения был преобразован к типу Int32 и в таком виде передан принимающей стороне, а принимающая сторона использовала его в качестве кода завершения вызывающего powershell-а.

И в качестве последней проверки создадим на сервере SQL задание из одного шага с типом «Операционная система (cmdexec)» с таким текстом:

PowerShell -NonInteractive -NoProfile "$res=Invoke-Command -ComputerName BACKUPSERVER -ConfigurationName SQLAgent -ScriptBlock {&'D:\sqlagent\TestOutput1.ps1' 6}; $host.SetShouldExit($res)"

УРА! Задание завершилось с ошибкой, текст в журнале:

Выполняется от имени пользователя: DOMAIN\agentuser. Out to host. ExitCode: 6.  Код завершения процесса 6.  Шаг завершился с ошибкой.

Выводы:

  • Избегайте использования Write-Output и указания выражений без присваивания. Помните, что перенос этого кода в другое место скрипта может привести к неожиданным результатам.
  • В скриптах, предназначенных не для ручного запуска, а для использования в Ваших механизмах автоматизации, особенно для удаленных вызовов через WINRM, делайте ручную обработку ошибок через Try/Catch, и добивайтесь того, чтобы при любом развитии событий этот скрипт отправил в поток Output ровно одно значение примитивного типа. Если хотите получить классический Errorlevel — это значение должно быть числовым.