From 6099e6d19e43c8529d7f0c058c4131674b20ab01 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Tue, 6 Sep 2016 13:26:40 -0700 Subject: [PATCH] Windows async module support (#4710) Powershell impls of async_wrapper, async_status- associated tests and async action changes are in https://github.com/ansible/ansible/pull/17400. --- lib/ansible/modules/windows/async_status.ps1 | 69 ++++ lib/ansible/modules/windows/async_wrapper.ps1 | 358 ++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 lib/ansible/modules/windows/async_status.ps1 create mode 100644 lib/ansible/modules/windows/async_wrapper.ps1 diff --git a/lib/ansible/modules/windows/async_status.ps1 b/lib/ansible/modules/windows/async_status.ps1 new file mode 100644 index 00000000000..efde748fb97 --- /dev/null +++ b/lib/ansible/modules/windows/async_status.ps1 @@ -0,0 +1,69 @@ +#!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 + +$results = @{changed=$false} + +$parsed_args = Parse-Args $args +$jid = Get-AnsibleParam $parsed_args "jid" -failifempty $true -resultobj $results +$mode = Get-AnsibleParam $parsed_args "mode" -Default "status" -ValidateSet "status","cleanup" + +# setup logging directory +$log_path = [System.IO.Path]::Combine($env:LOCALAPPDATA, ".ansible_async", $jid) + +If(-not $(Test-Path $log_path)) +{ + Fail-Json @{ansible_job_id=$jid; started=1; finished=1} "could not find job" +} + +If($mode -eq "cleanup") { + Remove-Item $log_path -Recurse + Exit-Json @{ansible_job_id=$jid; erased=$log_path} +} + +# NOT in cleanup mode, assume regular status mode +# no remote kill mode currently exists, but probably should +# consider log_path + ".pid" file and also unlink that above + +$data = $null +Try { + $data_raw = Get-Content $log_path + + # TODO: move this into module_utils/powershell.ps1? + $jss = New-Object System.Web.Script.Serialization.JavaScriptSerializer + $data = $jss.DeserializeObject($data_raw) +} +Catch { + If(-not $data_raw) { + # file not written yet? That means it is running + Exit-Json @{results_file=$log_path; ansible_job_id=$jid; started=1; finished=0} + } + Else { + Fail-Json @{ansible_job_id=$jid; results_file=$log_path; started=1; finished=1} "Could not parse job output: $data" + } +} + +If (-not $data.ContainsKey("started")) { + $data['finished'] = 1 + $data['ansible_job_id'] = $jid +} +ElseIf (-not $data.ContainsKey("finished")) { + $data['finished'] = 0 +} + +Exit-Json $data diff --git a/lib/ansible/modules/windows/async_wrapper.ps1 b/lib/ansible/modules/windows/async_wrapper.ps1 new file mode 100644 index 00000000000..03ba5d72e91 --- /dev/null +++ b/lib/ansible/modules/windows/async_wrapper.ps1 @@ -0,0 +1,358 @@ +#!powershell +# This file is part of Ansible +# +# Copyright (c)2016, Matt Davis +# +# 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 . + +Param( + [string]$jid, + [int]$max_exec_time_sec, + [string]$module_path, + [string]$argfile_path, + [switch]$preserve_tmp +) + +# WANT_JSON +# POWERSHELL_COMMON + +Set-StrictMode -Version 2 +$ErrorActionPreference = "Stop" + +Function Start-Watchdog { + Param( + [string]$module_tempdir, + [string]$module_path, + [int]$max_exec_time_sec, + [string]$resultfile_path, + [string]$argfile_path, + [switch]$preserve_tmp, + [switch]$start_suspended + ) + + $native_process_util = @" + using Microsoft.Win32.SafeHandles; + using System; + using System.ComponentModel; + using System.Diagnostics; + using System.Linq; + using System.Runtime.InteropServices; + + namespace Ansible.Async { + + public static class NativeProcessUtil + { + [DllImport("kernel32.dll", SetLastError=true)] + static extern SafeFileHandle OpenThread( + ThreadAccessRights dwDesiredAccess, + bool bInheritHandle, + int dwThreadId); + + [DllImport("kernel32.dll", SetLastError=true)] + static extern int ResumeThread(SafeHandle hThread); + + [Flags] + enum ThreadAccessRights : uint + { + SUSPEND_RESUME = 0x0002 + } + + public static void ResumeThreadById(int threadId) + { + var threadHandle = OpenThread(ThreadAccessRights.SUSPEND_RESUME, false, threadId); + if(threadHandle.IsInvalid) + throw new Exception(String.Format("Thread ID {0} is invalid ({1})", threadId, new Win32Exception(Marshal.GetLastWin32Error()).Message)); + + try + { + if(ResumeThread(threadHandle) == -1) + throw new Exception(String.Format("Thread ID {0} cannot be resumed ({1})", threadId, new Win32Exception(Marshal.GetLastWin32Error()).Message)); + } + finally + { + threadHandle.Dispose(); + } + } + + public static void ResumeProcessById(int pid) + { + var proc = Process.GetProcessById(pid); + + foreach(var thread in proc.Threads.OfType().Where(t => t.WaitReason == ThreadWaitReason.Suspended)) + ResumeThreadById(thread.Id); + } + } + } +"@ + + Add-Type -TypeDefinition $native_process_util + + $watchdog_script = { + Set-StrictMode -Version 2 + $ErrorActionPreference = "Stop" + + Function Log { + Param( + [string]$msg + ) + + If(Get-Variable -Name log_path -ErrorAction SilentlyContinue) { + Add-Content $log_path $msg + } + } + + Add-Type -AssemblyName System.Web.Extensions + + # -EncodedCommand won't allow us to pass args, so they have to be templated into the script + $jsonargs = @" + <> +"@ + Function Deserialize-Json { + Param( + [Parameter(ValueFromPipeline=$true)] + [string]$json + ) + + # FUTURE: move this into module_utils/powershell.ps1 and use for everything (sidestep PSCustomObject issues) + # FUTURE: won't work w/ Nano Server/.NET Core- fallback to DataContractJsonSerializer (which can't handle dicts on .NET 4.0) + + Log "Deserializing:`n$json" + + $jss = New-Object System.Web.Script.Serialization.JavaScriptSerializer + return $jss.DeserializeObject($json) + } + + Function Write-Result { + [hashtable]$result, + [string]$resultfile_path + + $result | ConvertTo-Json | Set-Content -Path $resultfile_path + } + + Function Exec-Module { + Param( + [string]$module_tempdir, + [string]$module_path, + [int]$max_exec_time_sec, + [string]$resultfile_path, + [string]$argfile_path, + [switch]$preserve_tmp + ) + + Log "in watchdog exec" + + Try + { + Log "deserializing existing resultfile args" + # read in existing resultsfile to merge w/ module output (it should be written by the time we're unsuspended and running) + $result = Get-Content $resultfile_path -Raw | Deserialize-Json + + Log "deserialized result is $($result | Out-String)" + + Log "creating runspace" + + $rs = [runspacefactory]::CreateRunspace() + $rs.Open() + $rs.SessionStateProxy.Path.SetLocation($module_tempdir) | Out-Null + + Log "creating Powershell object" + + $job = [powershell]::Create() + $job.Runspace = $rs + + Log "adding scripts" + + if($module_path.EndsWith(".ps1")) { + $job.AddScript($module_path) | Out-Null + } + else { + $job.AddCommand($module_path) | Out-Null + $job.AddArgument($argfile_path) | Out-Null + } + + Log "job BeginInvoke()" + + $job_asyncresult = $job.BeginInvoke() + + Log "waiting $max_exec_time_sec seconds for job to complete" + + $signaled = $job_asyncresult.AsyncWaitHandle.WaitOne($max_exec_time_sec * 1000) + + $result["finished"] = 1 + + If($job_asyncresult.IsCompleted) { + Log "job completed, calling EndInvoke()" + + $job_output = $job.EndInvoke($job_asyncresult) + $job_error = $job.Streams.Error + + Log "raw module stdout: \r\n$job_output" + If($job_error) { + Log "raw module stderr: \r\n$job_error" + } + + # write success/output/error to result object + + # TODO: cleanse leading/trailing junk + Try { + $module_result = Deserialize-Json $job_output + # TODO: check for conflicting keys + $result = $result + $module_result + } + Catch { + $excep = $_ + + $result.failed = $true + $result.msg = "failed to parse module output: $excep" + } + + # TODO: determine success/fail, or always include stderr if nonempty? + Write-Result $result $resultfile_path + + Log "wrote output to $resultfile_path" + } + Else { + $job.Stop() + # write timeout to result object + $result.failed = $true + $result.msg = "timed out waiting for module completion" + Write-Result $result $resultfile_path + + Log "wrote timeout to $resultfile_path" + } + + $rs.Close() | Out-Null + } + Catch { + $excep = $_ + + $result = @{failed=$true; msg="module execution failed: $($excep.ToString())`n$($excep.InvocationInfo.PositionMessage)"} + + Write-Result $result $resultfile_path + } + Finally + { + If(-not $preserve_tmp -and $module_tempdir -imatch "-tmp-") { + Try { + Log "deleting tempdir, cwd is $(Get-Location)" + Set-Location $env:USERPROFILE + $res = Remove-Item $module_tempdir -recurse 2>&1 + Log "delete output was $res" + } + Catch { + $excep = $_ + Log "error deleting tempdir: $excep" + } + } + Else { + Log "skipping tempdir deletion" + } + } + } + + Try { + Log "deserializing args" + + # deserialize the JSON args that should've been templated in before execution + $ext_args = Deserialize-Json $jsonargs + + Log "exec module" + + Exec-Module @ext_args + + Log "exec done" + } + Catch { + $excep = $_ + + Log $excep + } + } + + $bp = [hashtable] $MyInvocation.BoundParameters + # convert switch types to bool so they'll serialize as simple bools + $bp["preserve_tmp"] = [bool]$bp["preserve_tmp"] + $bp["start_suspended"] = [bool]$bp["start_suspended"] + + # serialize this function's args to JSON so we can template them verbatim into the script(block) + $jsonargs = $bp | ConvertTo-Json + + $raw_script = $watchdog_script.ToString() + $raw_script = $raw_script.Replace("<>", $jsonargs) + + $encoded_command = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($raw_script)) + + $exec_path = "powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded_command" + + # FUTURE: create under new job to ensure we kill all children on exit? + + # start process suspended + breakaway so we can record the watchdog pid without worrying about a completion race + Set-Variable CREATE_BREAKAWAY_FROM_JOB -Value ([uint32]0x01000000) -Option Constant + Set-Variable CREATE_SUSPENDED -Value ([uint32]0x00000004) -Option Constant + + $pstartup_flags = $CREATE_BREAKAWAY_FROM_JOB + If($start_suspended) { + $pstartup_flags = $pstartup_flags -bor $CREATE_SUSPENDED + } + + $pstartup = ([wmiclass]"Win32_ProcessStartup") + $pstartup.Properties['CreateFlags'].Value = $pstartup_flags + + # execute the dynamic watchdog as a breakway process, which will in turn exec the module + # FUTURE: use CreateProcess + stream redirection to watch for/return quick watchdog failures? + $result = $([wmiclass]"Win32_Process").Create($exec_path, $null, $pstartup) + + $watchdog_pid = $result.ProcessId + + return $watchdog_pid +} + +$local_jid = $jid + "." + $pid + +$results_path = [System.IO.Path]::Combine($env:LOCALAPPDATA, ".ansible_async", $local_jid) + +[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($results_path)) | Out-Null + +$watchdog_args = @{ + module_tempdir=$([System.IO.Path]::GetDirectoryName($module_path)); + module_path=$module_path; + max_exec_time_sec=$max_exec_time_sec; + resultfile_path=$results_path; + argfile_path=$argfile_path; + start_suspended=$true; +} + +If($preserve_tmp) { + $watchdog_args["preserve_tmp"] = $true +} + +# start watchdog/module-exec +$watchdog_pid = Start-Watchdog @watchdog_args + +# populate initial results before we resume the process to avoid result race +$result = @{ + started=1; + finished=0; + results_file=$results_path; + ansible_job_id=$local_jid; + _suppress_tmpdir_delete=$true; + ansible_async_watchdog_pid=$watchdog_pid +} + +$result_json = ConvertTo-Json $result +Set-Content $results_path -Value $result_json + +[Ansible.Async.NativeProcessUtil]::ResumeProcessById($watchdog_pid) + +return $result_json