From 6d196eaa982fca33a4d6394e1466941c7e96877e Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Thu, 14 Sep 2017 02:58:49 +1000 Subject: [PATCH] windows command changed to use CreateProcess (#30253) * windows command changed to use CreateProcess * change to get become to work --- .../Ansible.ModuleUtils.CommandUtil.psm1 | 442 ++++++++++++++++++ lib/ansible/modules/windows/win_command.ps1 | 147 +----- lib/ansible/modules/windows/win_shell.ps1 | 117 +---- .../targets/win_command/tasks/main.yml | 46 +- .../library/command_util_test.ps1 | 114 +++++ .../targets/win_module_utils/tasks/main.yml | 24 + 6 files changed, 669 insertions(+), 221 deletions(-) create mode 100644 lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 create mode 100644 test/integration/targets/win_module_utils/library/command_util_test.ps1 diff --git a/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 b/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 new file mode 100644 index 0000000000..c2cb669dd9 --- /dev/null +++ b/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 @@ -0,0 +1,442 @@ +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +$process_util = @" +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +namespace Ansible +{ + [StructLayout(LayoutKind.Sequential)] + public class SECURITY_ATTRIBUTES + { + public int nLength; + public IntPtr lpSecurityDescriptor; + public bool bInheritHandle = false; + public SECURITY_ATTRIBUTES() + { + nLength = Marshal.SizeOf(this); + } + } + + [StructLayout(LayoutKind.Sequential)] + public class STARTUPINFO + { + public Int32 cb; + public IntPtr lpReserved; + public IntPtr lpDesktop; + public IntPtr lpTitle; + public Int32 dwX; + public Int32 dwY; + public Int32 dwXSize; + public Int32 dwYSize; + public Int32 dwXCountChars; + public Int32 dwYCountChars; + public Int32 dwFillAttribute; + public Int32 dwFlags; + public Int16 wShowWindow; + public Int16 cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + public STARTUPINFO() + { + cb = Marshal.SizeOf(this); + } + } + + [StructLayout(LayoutKind.Sequential)] + public class STARTUPINFOEX + { + public STARTUPINFO startupInfo; + public IntPtr lpAttributeList; + public STARTUPINFOEX() + { + startupInfo = new STARTUPINFO(); + startupInfo.cb = Marshal.SizeOf(this); + } + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [Flags] + public enum StartupInfoFlags : uint + { + USESTDHANDLES = 0x00000100 + } + + public enum HandleFlags : uint + { + None = 0, + INHERIT = 1 + } + + class NativeWaitHandle : WaitHandle + { + public NativeWaitHandle(IntPtr handle) + { + this.Handle = handle; + } + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2})", message, base.Message, errorCode); + } + + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class CommandUtil + { + private static UInt32 CREATE_UNICODE_ENVIRONMENT = 0x000000400; + private static UInt32 CREATE_NEW_CONSOLE = 0x00000010; + private static UInt32 EXTENDED_STARTUPINFO_PRESENT = 0x00080000; + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode, BestFitMapping = false)] + public static extern bool CreateProcess( + [MarshalAs(UnmanagedType.LPWStr)] + string lpApplicationName, + StringBuilder lpCommandLine, + IntPtr lpProcessAttributes, + IntPtr lpThreadAttributes, + bool bInheritHandles, + uint dwCreationFlags, + IntPtr lpEnvironment, + [MarshalAs(UnmanagedType.LPWStr)] + string lpCurrentDirectory, + STARTUPINFOEX lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll")] + public static extern bool CreatePipe( + out IntPtr hReadPipe, + out IntPtr hWritePipe, + SECURITY_ATTRIBUTES lpPipeAttributes, + uint nSize); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetHandleInformation( + IntPtr hObject, + HandleFlags dwMask, + int dwFlags); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool InitializeProcThreadAttributeList( + IntPtr lpAttributeList, + int dwAttributeCount, + int dwFlags, + ref int lpSize); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool UpdateProcThreadAttribute( + IntPtr lpAttributeList, + uint dwFlags, + IntPtr Attribute, + IntPtr lpValue, + IntPtr cbSize, + IntPtr lpPreviousValue, + IntPtr lpReturnSize); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess( + IntPtr hProcess, + out uint lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle( + IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern uint SearchPath( + string lpPath, + string lpFileName, + string lpExtension, + int nBufferLength, + [MarshalAs (UnmanagedType.LPTStr)] + StringBuilder lpBuffer, + out IntPtr lpFilePart); + + [DllImport("shell32.dll", SetLastError = true)] + static extern IntPtr CommandLineToArgvW( + [MarshalAs(UnmanagedType.LPWStr)] + string lpCmdLine, + out int pNumArgs); + + public static string[] ParseCommandLine(string lpCommandLine) + { + int numArgs; + IntPtr ret = CommandLineToArgvW(lpCommandLine, out numArgs); + + if (ret == IntPtr.Zero) + throw new Win32Exception("Error parsing command line"); + + IntPtr[] strptrs = new IntPtr[numArgs]; + Marshal.Copy(ret, strptrs, 0, numArgs); + string[] cmdlineParts = strptrs.Select(s => Marshal.PtrToStringUni(s)).ToArray(); + + Marshal.FreeHGlobal(ret); + + return cmdlineParts; + } + + public static string SearchPath(string lpFileName) + { + StringBuilder sbOut = new StringBuilder(1024); + IntPtr filePartOut; + + if (SearchPath(null, lpFileName, null, sbOut.Capacity, sbOut, out filePartOut) == 0) + throw new FileNotFoundException(String.Format("Could not locate the following executable {0}", lpFileName)); + + return sbOut.ToString(); + } + + public static Tuple RunCommand(string lpApplicationName, string lpCommandLine, string lpCurrentDirectory, string stdinInput, string environmentBlock) + { + UInt32 startup_flags = CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE | EXTENDED_STARTUPINFO_PRESENT; + STARTUPINFOEX si = new STARTUPINFOEX(); + si.startupInfo.dwFlags = (int)StartupInfoFlags.USESTDHANDLES; + + SECURITY_ATTRIBUTES pipesec = new SECURITY_ATTRIBUTES(); + pipesec.bInheritHandle = true; + + // Create the stdout, stderr and stdin pipes used in the process and add to the startupInfo + IntPtr stdout_read, stdout_write, stderr_read, stderr_write, stdin_read, stdin_write = IntPtr.Zero; + if (!CreatePipe(out stdout_read, out stdout_write, pipesec, 0)) + throw new Win32Exception("STDOUT pipe setup failed"); + if (!SetHandleInformation(stdout_read, HandleFlags.INHERIT, 0)) + throw new Win32Exception("STDOUT pipe handle setup failed"); + + if (!CreatePipe(out stderr_read, out stderr_write, pipesec, 0)) + throw new Win32Exception("STDERR pipe setup failed"); + if (!SetHandleInformation(stderr_read, HandleFlags.INHERIT, 0)) + throw new Win32Exception("STDERR pipe handle setup failed"); + + if (!CreatePipe(out stdin_read, out stdin_write, pipesec, 0)) + throw new Win32Exception("STDIN pipe setup failed"); + if (!SetHandleInformation(stdin_write, HandleFlags.INHERIT, 0)) + throw new Win32Exception("STDIN pipe handle setup failed"); + + si.startupInfo.hStdOutput = stdout_write; + si.startupInfo.hStdError = stderr_write; + si.startupInfo.hStdInput = stdin_read; + + // Handle the inheritance for the pipes so the process can access them + Int32 buf_sz = 0; + if (!InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref buf_sz)) + { + int last_err = Marshal.GetLastWin32Error(); + if (last_err != 122) // ERROR_INSUFFICIENT_BUFFER + throw new Win32Exception(last_err, "Attribute list size query failed"); + } + si.lpAttributeList = Marshal.AllocHGlobal(buf_sz); + if (!InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, ref buf_sz)) + throw new Win32Exception("Attribute list init failed"); + + + IntPtr[] handles_to_inherit = new IntPtr[3]; + handles_to_inherit[0] = stdin_read; + handles_to_inherit[1] = stdout_write; + handles_to_inherit[2] = stderr_write; + GCHandle pinned_handles = GCHandle.Alloc(handles_to_inherit, GCHandleType.Pinned); + + if (!UpdateProcThreadAttribute(si.lpAttributeList, 0, + (IntPtr)0x20002, // PROC_THREAD_ATTRIBUTE_HANDLE_LIST + pinned_handles.AddrOfPinnedObject(), + (IntPtr)(Marshal.SizeOf(typeof(IntPtr)) * handles_to_inherit.Length), + IntPtr.Zero, IntPtr.Zero)) + { + throw new Win32Exception("Attribute list update failed"); + } + + // Setup the stdin buffer + UTF8Encoding utf8_encoding = new UTF8Encoding(false); + FileStream stdin_fs = new FileStream(stdin_write, FileAccess.Write, true, 32768); + StreamWriter stdin = new StreamWriter(stdin_fs, utf8_encoding, 32768); + + // If lpCurrentDirectory is set to null in PS it will be an empty + // string here, we need to convert it + if (lpCurrentDirectory == "") + lpCurrentDirectory = null; + + // Create the environment block if set + IntPtr lpEnvironment = IntPtr.Zero; + if (environmentBlock != "") + lpEnvironment = Marshal.StringToHGlobalUni(environmentBlock); + + // Create new process and run + StringBuilder argument_string = new StringBuilder(lpCommandLine); + PROCESS_INFORMATION pi = new PROCESS_INFORMATION(); + if (!CreateProcess( + lpApplicationName, + argument_string, + IntPtr.Zero, + IntPtr.Zero, + true, + startup_flags, + lpEnvironment, + lpCurrentDirectory, + si, + out pi)) + { + throw new Win32Exception("Failed to create new process"); + } + + // Setup the output buffers and get stdout/stderr + FileStream stdout_fs = new FileStream(stdout_read, FileAccess.Read, true, 4096); + StreamReader stdout = new StreamReader(stdout_fs, utf8_encoding, true, 4096); + FileStream stderr_fs = new FileStream(stderr_read, FileAccess.Read, true, 4096); + StreamReader stderr = new StreamReader(stderr_fs, utf8_encoding, true, 4096); + CloseHandle(stdout_write); + CloseHandle(stderr_write); + + stdin.WriteLine(stdinInput); + stdin.Close(); + + string stdout_str, stderr_str = null; + GetProcessOutput(stdout, stderr, out stdout_str, out stderr_str); + uint rc = GetProcessExitCode(pi.hProcess); + + return Tuple.Create(stdout_str, stderr_str, rc); + } + + private static void GetProcessOutput(StreamReader stdoutStream, StreamReader stderrStream, out string stdout, out string stderr) + { + var sowait = new EventWaitHandle(false, EventResetMode.ManualReset); + var sewait = new EventWaitHandle(false, EventResetMode.ManualReset); + string so = null, se = null; + ThreadPool.QueueUserWorkItem((s) => + { + so = stdoutStream.ReadToEnd(); + sowait.Set(); + }); + ThreadPool.QueueUserWorkItem((s) => + { + se = stderrStream.ReadToEnd(); + sewait.Set(); + }); + foreach (var wh in new WaitHandle[] { sowait, sewait }) + wh.WaitOne(); + stdout = so; + stderr = se; + } + + private static uint GetProcessExitCode(IntPtr processHandle) + { + new NativeWaitHandle(processHandle).WaitOne(); + uint exitCode; + if (!GetExitCodeProcess(processHandle, out exitCode)) + throw new Win32Exception("Error getting process exit code"); + return exitCode; + } + } +} +"@ + +$ErrorActionPreference = 'Stop' + +Function Load-CommandUtils { + # makes the following static functions available + # [Ansible.CommandUtil]::ParseCommandLine(string lpCommandLine) + # [Ansible.CommandUtil]::SearchPath(string lpFileName) + # [Ansible.CommandUtil]::RunCommand(string lpApplicationName, string lpCommandLine, string lpCurrentDirectory, string stdinInput, string environmentBlock) + # + # there are also numerous P/Invoke methods that can be called if you are feeling adventurous + Add-Type -TypeDefinition $process_util -IgnoreWarnings +} + +Function Get-ExecutablePath($executable, $directory) { + # lpApplicationName requires the full path to a file, we need to find it + # ourselves. + + # we need to add .exe if it doesn't have an extension already + if (-not [System.IO.Path]::HasExtension($executable)) { + $executable = "$($executable).exe" + } + $full_path = [System.IO.Path]::GetFullPath($executable) + + if ($full_path -ne $executable -and $directory -ne $null) { + $file = Get-Item -Path "$directory\$executable" -Force -ErrorAction SilentlyContinue + } else { + $file = Get-Item -Path $executable -Force -ErrorAction SilentlyContinue + } + + if ($file -ne $null) { + $executable_path = $file.FullName + } else { + $executable_path = [Ansible.CommandUtil]::SearchPath($executable) + } + return $executable_path +} + +Function Run-Command { + Param( + [string]$command, # the full command to run including the executable + [string]$working_directory = $null, # the working directory to run under, will default to the current dir + [string]$stdin = $null, # a string to send to the stdin pipe when executing the command + [hashtable]$environment = @{} # a hashtable of environment values to run the command under, this will replace all the other environment variables with these + ) + + # load the C# code we call in this function + Load-CommandUtils + + # need to validate the working directory if it is set + if ($working_directory) { + # validate working directory is a valid path + if (-not (Test-Path -Path $working_directory)) { + throw "invalid working directory path '$working_directory'" + } + } + + # lpApplicationName needs to be the full path to an executable, we do this + # by getting the executable as the first arg and then getting the full path + $arguments = [Ansible.CommandUtil]::ParseCommandLine($command) + $executable = Get-ExecutablePath -executable $arguments[0] -directory $working_directory + + # set the extra environment variables + $environment_string = $null + if ($environment.Count -gt 0) { + $environment_string = "" + } + foreach ($environment_entry in $environment.GetEnumerator()){ + $environment_key = $environment_entry.Name + $environment_value = $environment_entry.Value + $environment_string += "$environment_key=$environment_value`0" + } + if ($environment_string) { + $environment_string += "`0" + } + + # run the command and get the results + $command_result = [Ansible.CommandUtil]::RunCommand($executable, $command, $working_directory, $stdin, $environment_string) + + # RunCommand returns a tuple, we will convert to a hashtable + return ,@{ + executable = $executable + stdout = $command_result.Item1 + stderr = $command_result.Item2 + rc = $command_result.Item3 + } +} + +# this line must stay at the bottom to ensure all defined module parts are exported +Export-ModuleMember -Alias * -Function * -Cmdlet * diff --git a/lib/ansible/modules/windows/win_command.ps1 b/lib/ansible/modules/windows/win_command.ps1 index f2763d0295..4570ffc31f 100644 --- a/lib/ansible/modules/windows/win_command.ps1 +++ b/lib/ansible/modules/windows/win_command.ps1 @@ -1,88 +1,16 @@ #!powershell # This file is part of Ansible -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . -# WANT_JSON -# POWERSHELL_COMMON +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy.psm1 +#Requires -Module Ansible.ModuleUtils.CommandUtil.psm1 # TODO: add check mode support Set-StrictMode -Version 2 -$ErrorActionPreference = "Stop" - -$helper_def = @' -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using System.Threading; - -namespace Ansible.Command -{ - public static class NativeUtil - { - [DllImport("shell32.dll", SetLastError = true)] - static extern IntPtr CommandLineToArgvW([MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine, out int pNumArgs); - - public static string[] ParseCommandLine(string cmdline) - { - int numArgs; - IntPtr ret = CommandLineToArgvW(cmdline, out numArgs); - - if (ret == IntPtr.Zero) - throw new Exception(String.Format("Error parsing command line: {0}", new Win32Exception(Marshal.GetLastWin32Error()).Message)); - - IntPtr[] strptrs = new IntPtr[numArgs]; - Marshal.Copy(ret, strptrs, 0, numArgs); - string[] cmdlineParts = strptrs.Select(s=>Marshal.PtrToStringUni(s)).ToArray(); - - Marshal.FreeHGlobal(ret); - - return cmdlineParts; - } - - public static void GetProcessOutput(StreamReader stdoutStream, StreamReader stderrStream, out string stdout, out string stderr) - { - var sowait = new EventWaitHandle(false, EventResetMode.ManualReset); - var sewait = new EventWaitHandle(false, EventResetMode.ManualReset); - - string so = null, se = null; - - ThreadPool.QueueUserWorkItem((s)=> - { - so = stdoutStream.ReadToEnd(); - sowait.Set(); - }); - - ThreadPool.QueueUserWorkItem((s) => - { - se = stderrStream.ReadToEnd(); - sewait.Set(); - }); - - foreach(var wh in new WaitHandle[] { sowait, sewait }) - wh.WaitOne(); - - stdout = so; - stderr = se; - } - } -} -'@ +$ErrorActionPreference = 'Stop' $params = Parse-Args $args -supports_check_mode $false @@ -106,61 +34,24 @@ If($removes -and -not $(Test-Path -Path $removes)) { Exit-Json @{msg="skipped, since $removes does not exist";cmd=$raw_command_line;changed=$false;skipped=$true;rc=0} } -Add-Type -TypeDefinition $helper_def - -$exec_args = $null - -# FUTURE: extract this code to separate module_utils as Windows module API version of run_command - -# Parse the command-line with the Win32 parser to get the application name to run. The Win32 parser -# will deal with quoting/escaping for us... -# FUTURE: no longer necessary once we switch to raw Win32 CreateProcess -$parsed_command_line = [Ansible.Command.NativeUtil]::ParseCommandLine($raw_command_line); -$exec_application = $parsed_command_line[0] -If($parsed_command_line.Length -gt 1) { - # lop the application off, then rejoin the args as a single string - $exec_args = $parsed_command_line[1..$($parsed_command_line.Length-1)] -join " " -} - -$proc = New-Object System.Diagnostics.Process -$psi = $proc.StartInfo -$psi.FileName = $exec_application -$psi.Arguments = $exec_args -$psi.RedirectStandardOutput = $true -$psi.RedirectStandardError = $true -$psi.UseShellExecute = $false - -If ($chdir) { - $psi.WorkingDirectory = $chdir -} - $start_datetime = [DateTime]::UtcNow - -Try { - $proc.Start() | Out-Null # will always return $true for non shell-exec cases -} -Catch [System.ComponentModel.Win32Exception] { - # fail nicely for "normal" error conditions - # FUTURE: this probably won't work on Nano Server - $excep = $_ - Exit-Json @{msg = $excep.Exception.Message; cmd = $raw_command_line; changed = $false; rc = $excep.Exception.NativeErrorCode} +try { + $command_result = Run-Command -command $raw_command_line -working_directory $chdir +} catch { + $result.changed = $false + try { + $result.rc = $_.Exception.NativeErrorCode + } catch { + $result.rc = 2 + } + Fail-Json -obj $result -message $_.Exception.Message } -$stdout = $stderr = [string] $null - -[Ansible.Command.NativeUtil]::GetProcessOutput($proc.StandardOutput, $proc.StandardError, [ref] $stdout, [ref] $stderr) | Out-Null - -$result.stdout = $stdout -$result.stderr = $stderr - -# TODO: decode CLIXML stderr output (and other streams?) - -$proc.WaitForExit() | Out-Null - -$result.rc = $proc.ExitCode +$result.stdout = $command_result.stdout +$result.stderr = $command_result.stderr +$result.rc = $command_result.rc $end_datetime = [DateTime]::UtcNow - $result.start = $start_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") $result.end = $end_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") $result.delta = $($end_datetime - $start_datetime).ToString("h\:mm\:ss\.ffffff") diff --git a/lib/ansible/modules/windows/win_shell.ps1 b/lib/ansible/modules/windows/win_shell.ps1 index 3e4f4dc441..e25678d2dc 100644 --- a/lib/ansible/modules/windows/win_shell.ps1 +++ b/lib/ansible/modules/windows/win_shell.ps1 @@ -1,65 +1,17 @@ #!powershell # This file is part of Ansible -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . -# WANT_JSON -# POWERSHELL_COMMON +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy.psm1 +#Requires -Module Ansible.ModuleUtils.CommandUtil.psm1 # TODO: add check mode support Set-StrictMode -Version 2 $ErrorActionPreference = "Stop" -$helper_def = @" -using System.Diagnostics; -using System.IO; -using System.Threading; - -namespace Ansible.Shell -{ - public class ProcessUtil - { - public static void GetProcessOutput(StreamReader stdoutStream, StreamReader stderrStream, out string stdout, out string stderr) - { - var sowait = new EventWaitHandle(false, EventResetMode.ManualReset); - var sewait = new EventWaitHandle(false, EventResetMode.ManualReset); - - string so = null, se = null; - - ThreadPool.QueueUserWorkItem((s)=> - { - so = stdoutStream.ReadToEnd(); - sowait.Set(); - }); - - ThreadPool.QueueUserWorkItem((s) => - { - se = stderrStream.ReadToEnd(); - sewait.Set(); - }); - - foreach(var wh in new WaitHandle[] { sowait, sewait }) - wh.WaitOne(); - - stdout = so; - stderr = se; - } - } -} -"@ - # Cleanse CLIXML from stderr (sift out error stream data, discard others for now) Function Cleanse-Stderr($raw_stderr) { Try { @@ -110,12 +62,9 @@ If($removes -and -not $(Test-Path $removes)) { Exit-Json @{msg="skipped, since $removes does not exist";cmd=$raw_command_line;changed=$false;skipped=$true;rc=0} } -Add-Type -TypeDefinition $helper_def - $exec_args = $null - If(-not $executable -or $executable -eq "powershell") { - $exec_application = "powershell" + $exec_application = "powershell.exe" # force input encoding to preamble-free UTF8 so PS sub-processes (eg, Start-Job) don't blow up $raw_command_line = "[Console]::InputEncoding = New-Object Text.UTF8Encoding `$false; " + $raw_command_line @@ -123,53 +72,37 @@ If(-not $executable -or $executable -eq "powershell") { # Base64 encode the command so we don't have to worry about the various levels of escaping $encoded_command = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($raw_command_line)) - $exec_args = @("-noninteractive", "-encodedcommand", $encoded_command) + $exec_args = "-noninteractive -encodedcommand $encoded_command" } Else { # FUTURE: support arg translation from executable (or executable_args?) to process arguments for arbitrary interpreter? $exec_application = $executable - $exec_args = @("/c", $raw_command_line) -} - -$proc = New-Object System.Diagnostics.Process -$psi = $proc.StartInfo -$psi.FileName = $exec_application -$psi.Arguments = $exec_args -$psi.RedirectStandardOutput = $true -$psi.RedirectStandardError = $true -$psi.UseShellExecute = $false - -If ($chdir) { - $psi.WorkingDirectory = $chdir + if (-not ($exec_application.EndsWith(".exe"))) { + $exec_application = "$($exec_application).exe" + } + $exec_args = "/c $raw_command_line" } +$command = "$exec_application $exec_args" $start_datetime = [DateTime]::UtcNow - -Try { - $proc.Start() | Out-Null # will always return $true for non shell-exec cases +try { + $command_result = Run-Command -command $command -working_directory $chdir +} catch { + $result.changed = $false + try { + $result.rc = $_.Exception.NativeErrorCode + } catch { + $result.rc = 2 + } + Fail-Json -obj $result -message $_.Exception.Message } -Catch [System.ComponentModel.Win32Exception] { - # fail nicely for "normal" error conditions - # FUTURE: this probably won't work on Nano Server - $excep = $_ - Exit-Json @{msg = $excep.Exception.Message; cmd = $raw_command_line; changed = $false; rc = $excep.Exception.NativeErrorCode} -} - -$stdout = $stderr = [string] $null - -[Ansible.Shell.ProcessUtil]::GetProcessOutput($proc.StandardOutput, $proc.StandardError, [ref] $stdout, [ref] $stderr) | Out-Null - -$result.stdout = $stdout -$result.stderr = Cleanse-Stderr $stderr # TODO: decode CLIXML stderr output (and other streams?) - -$proc.WaitForExit() | Out-Null - -$result.rc = $proc.ExitCode +$result.stdout = $command_result.stdout +$result.stderr = Cleanse-Stderr $command_result.stderr +$result.rc = $command_result.rc $end_datetime = [DateTime]::UtcNow - $result.start = $start_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") $result.end = $end_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") $result.delta = $($end_datetime - $start_datetime).ToString("h\:mm\:ss\.ffffff") diff --git a/test/integration/targets/win_command/tasks/main.yml b/test/integration/targets/win_command/tasks/main.yml index afd02510f8..b49d19301d 100644 --- a/test/integration/targets/win_command/tasks/main.yml +++ b/test/integration/targets/win_command/tasks/main.yml @@ -28,7 +28,7 @@ - not cmdout|changed - cmdout.cmd == 'bogus_command1234' - cmdout.rc == 2 - - cmdout.msg is search('cannot find the file specified') + - "'Could not locate the following executable bogus_command1234' in cmdout.msg" - name: execute something with error output win_command: cmd /c "echo some output & echo some error 1>&2" @@ -134,3 +134,47 @@ - cmdout.stdout is search("doneout") - cmdout.stderr is search("starterror") - cmdout.stderr is search("doneerror") + +- name: create testing folder for argv binary + win_file: + path: C:\ansible testing + state: directory + +- name: download binary the outputs argv to stdout + win_get_url: + url: https://s3.amazonaws.com/ansible-ci-files/test/integration/roles/test_win_module_utils/PrintArgv.exe + dest: C:\ansible testing\PrintArgv.exe + +- name: call argv binary with absolute path + win_command: '"C:\ansible testing\PrintArgv.exe" arg1 "arg 2" C:\path\arg "\"quoted arg\""' + register: cmdout + +- name: assert call to argv binary with absolute path + assert: + that: + - cmdout|changed + - cmdout.rc == 0 + - cmdout.stdout_lines[0] == 'arg1' + - cmdout.stdout_lines[1] == 'arg 2' + - cmdout.stdout_lines[2] == 'C:\\path\\arg' + - cmdout.stdout_lines[3] == '"quoted arg"' + +- name: call argv binary with relative path + win_command: 'PrintArgv.exe C:\path\end\slash\ ADDLOCAL="msi,example" two\\slashes' + args: + chdir: C:\ansible testing + register: cmdout + +- name: assert call to argv binary with relative path + assert: + that: + - cmdout|changed + - cmdout.rc == 0 + - cmdout.stdout_lines[0] == 'C:\\path\\end\\slash\\' + - cmdout.stdout_lines[1] == 'ADDLOCAL=msi,example' + - cmdout.stdout_lines[2] == 'two\\\\slashes' + +- name: remove testing folder + win_file: + path: C:\ansible testing + state: absent diff --git a/test/integration/targets/win_module_utils/library/command_util_test.ps1 b/test/integration/targets/win_module_utils/library/command_util_test.ps1 new file mode 100644 index 0000000000..0a0826cd54 --- /dev/null +++ b/test/integration/targets/win_module_utils/library/command_util_test.ps1 @@ -0,0 +1,114 @@ +#!powershell + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.CommandUtil + +$ErrorActionPreference = 'Stop' + +$params = Parse-Args $args +$exe = Get-AnsibleParam -obj $params -name "exe" -type "path" -failifempty $true + +$result = @{ + changed = $false +} + +$exe_directory = Split-Path -Path $exe -Parent +$exe_filename = Split-Path -Path $exe -Leaf +$test_name = $null + +Function Assert-Equals($actual, $expected) { + if ($actual -cne $expected) { + Fail-Json -obj $result -message "Test $test_name failed`nActual: '$actual' != Expected: '$expected'" + } +} + +$test_name = "full exe path" +$actual = Run-Command -command "`"$exe`" arg1 arg2 `"arg 3`"" +Assert-Equals -actual $actual.rc -expected 0 +Assert-Equals -actual $actual.stdout -expected "arg1`r`narg2`r`narg 3`r`n" +Assert-Equals -actual $actual.stderr -expected "" +Assert-Equals -actual $actual.executable -expected $exe + +$test_name = "invalid exe path" +try { + $actual = Run-Command -command "C:\fakepath\$exe_filename arg1" + Fail-Json -obj $result -message "Test $test_name failed`nCommand should have thrown an exception" +} catch { + Assert-Equals -actual $_.Exception.Message -expected "Exception calling `"SearchPath`" with `"1`" argument(s): `"Could not locate the following executable C:\fakepath\$exe_filename`"" +} + +$test_name = "exe in current folder" +$actual = Run-Command -command "$exe_filename arg1" -working_directory $exe_directory +Assert-Equals -actual $actual.rc -expected 0 +Assert-Equals -actual $actual.stdout -expected "arg1`r`n" +Assert-Equals -actual $actual.stderr -expected "" +Assert-Equals -actual $actual.executable -expected $exe + +$test_name = "no working directory set" +$actual = Run-Command -command "cmd.exe /c cd" +Assert-Equals -actual $actual.rc -expected 0 +Assert-Equals -actual $actual.stdout -expected "$($pwd.Path)`r`n" +Assert-Equals -actual $actual.stderr -expected "" +Assert-Equals -actual $actual.executable.ToUpper() -expected "$env:SystemRoot\System32\cmd.exe".ToUpper() + +$test_name = "working directory override" +$actual = Run-Command -command "cmd.exe /c cd" -working_directory $env:SystemRoot +Assert-Equals -actual $actual.rc -expected 0 +Assert-Equals -actual $actual.stdout -expected "$env:SystemRoot`r`n" +Assert-Equals -actual $actual.stderr -expected "" +Assert-Equals -actual $actual.executable.ToUpper() -expected "$env:SystemRoot\System32\cmd.exe".ToUpper() + +$test_name = "working directory invalid path" +try { + $actual = Run-Command -command "doesn't matter" -working_directory "invalid path here" + Fail-Json -obj $result -message "Test $test_name failed`nCommand should have thrown an exception" +} catch { + Assert-Equals -actual $_.Exception.Message -expected "invalid working directory path 'invalid path here'" +} + +$test_name = "invalid arguments" +$actual = Run-Command -command "ipconfig.exe /asdf" +Assert-Equals -actual $actual.rc -expected 1 + +$test_name = "test stdout and stderr streams" +$actual = Run-Command -command "cmd.exe /c echo stdout && echo stderr 1>&2" +Assert-Equals -actual $actual.rc -expected 0 +Assert-Equals -actual $actual.stdout -expected "stdout `r`n" +Assert-Equals -actual $actual.stderr -expected "stderr `r`n" + +$test_name = "test default environment variable" +Set-Item -Path env:TESTENV -Value "test" +$actual = Run-Command -command "cmd.exe /c set" +$env_present = $actual.stdout -split "`r`n" | Where-Object { $_ -eq "TESTENV=test" } +if ($env_present -eq $null) { + Fail-Json -obj $result -message "Test $test_name failed`nenvironment variable TESTENV not found in stdout`n$($actual.stdout)" +} + +$test_name = "test custom environment variable1" +$actual = Run-Command -command "cmd.exe /c set" -environment @{ TESTENV2 = "testing" } +$env_not_present = $actual.stdout -split "`r`n" | Where-Object { $_ -eq "TESTENV=test" } +$env_present = $actual.stdout -split "`r`n" | Where-Object { $_ -eq "TESTENV2=testing" } +if ($env_not_present -ne $null) { + Fail-Json -obj $result -message "Test $test_name failed`nenvironment variabel TESTENV found in stdout when it should be`n$($actual.stdout)" +} +if ($env_present -eq $null) { + Fail-json -obj $result -message "Test $test_name failed`nenvironment variable TESTENV2 not found in stdout`n$($actual.stdout)" +} + +$test_name = "input test" +$wrapper = @" +begin { + `$string = "" +} process { + `$current_input = [string]`$input + `$string += `$current_input +} end { + Write-Host `$string +} +"@ +$encoded_wrapper = [System.Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wrapper)) +$actual = Run-Command -command "powershell.exe -ExecutionPolicy ByPass -EncodedCommand $encoded_wrapper" -stdin "Ansible" +Assert-Equals -actual $actual.stdout -expected "Ansible`n" + +$result.data = "success" +Exit-Json -obj $result diff --git a/test/integration/targets/win_module_utils/tasks/main.yml b/test/integration/targets/win_module_utils/tasks/main.yml index 3c1b9d7fa6..baf43177fc 100644 --- a/test/integration/targets/win_module_utils/tasks/main.yml +++ b/test/integration/targets/win_module_utils/tasks/main.yml @@ -47,3 +47,27 @@ - assert: that: - sid_test.data == 'success' + +- name: create testing folder for argv binary + win_file: + path: C:\ansible testing + state: directory + +- name: download binary the outputs argv to stdout + win_get_url: + url: https://s3.amazonaws.com/ansible-ci-files/test/integration/roles/test_win_module_utils/PrintArgv.exe + dest: C:\ansible testing\PrintArgv.exe + +- name: call module with CommandUtil tests + command_util_test: + exe: C:\ansible testing\PrintArgv.exe + register: command_util + +- assert: + that: + - command_util.data == 'success' + +- name: remove testing folder + win_file: + path: C:\ansible testing + state: absent