diff options
Diffstat (limited to 'lib/ansible/plugins/shell/powershell.py')
-rw-r--r-- | lib/ansible/plugins/shell/powershell.py | 287 |
1 files changed, 287 insertions, 0 deletions
diff --git a/lib/ansible/plugins/shell/powershell.py b/lib/ansible/plugins/shell/powershell.py new file mode 100644 index 0000000..de5e705 --- /dev/null +++ b/lib/ansible/plugins/shell/powershell.py @@ -0,0 +1,287 @@ +# Copyright (c) 2014, Chris Church <chris@ninemoreminutes.com> +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +name: powershell +version_added: historical +short_description: Windows PowerShell +description: +- The only option when using 'winrm' or 'psrp' as a connection plugin. +- Can also be used when using 'ssh' as a connection plugin and the C(DefaultShell) has been configured to PowerShell. +extends_documentation_fragment: +- shell_windows +''' + +import base64 +import os +import re +import shlex +import pkgutil +import xml.etree.ElementTree as ET +import ntpath + +from ansible.module_utils._text import to_bytes, to_text +from ansible.plugins.shell import ShellBase + + +_common_args = ['PowerShell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Unrestricted'] + + +def _parse_clixml(data, stream="Error"): + """ + Takes a byte string like '#< CLIXML\r\n<Objs...' and extracts the stream + message encoded in the XML data. CLIXML is used by PowerShell to encode + multiple objects in stderr. + """ + lines = [] + + # There are some scenarios where the stderr contains a nested CLIXML element like + # '<# CLIXML\r\n<# CLIXML\r\n<Objs>...</Objs><Objs>...</Objs>'. + # Parse each individual <Objs> element and add the error strings to our stderr list. + # https://github.com/ansible/ansible/issues/69550 + while data: + end_idx = data.find(b"</Objs>") + 7 + current_element = data[data.find(b"<Objs "):end_idx] + data = data[end_idx:] + + clixml = ET.fromstring(current_element) + namespace_match = re.match(r'{(.*)}', clixml.tag) + namespace = "{%s}" % namespace_match.group(1) if namespace_match else "" + + strings = clixml.findall("./%sS" % namespace) + lines.extend([e.text.replace('_x000D__x000A_', '') for e in strings if e.attrib.get('S') == stream]) + + return to_bytes('\r\n'.join(lines)) + + +class ShellModule(ShellBase): + + # Common shell filenames that this plugin handles + # Powershell is handled differently. It's selected when winrm is the + # connection + COMPATIBLE_SHELLS = frozenset() # type: frozenset[str] + # Family of shells this has. Must match the filename without extension + SHELL_FAMILY = 'powershell' + + _SHELL_REDIRECT_ALLNULL = '> $null' + _SHELL_AND = ';' + + # Used by various parts of Ansible to do Windows specific changes + _IS_WINDOWS = True + + # TODO: add binary module support + + def env_prefix(self, **kwargs): + # powershell/winrm env handling is handled in the exec wrapper + return "" + + def join_path(self, *args): + # use normpath() to remove doubled slashed and convert forward to backslashes + parts = [ntpath.normpath(self._unquote(arg)) for arg in args] + + # Because ntpath.join treats any component that begins with a backslash as an absolute path, + # we have to strip slashes from at least the beginning, otherwise join will ignore all previous + # path components except for the drive. + return ntpath.join(parts[0], *[part.strip('\\') for part in parts[1:]]) + + def get_remote_filename(self, pathname): + # powershell requires that script files end with .ps1 + base_name = os.path.basename(pathname.strip()) + name, ext = os.path.splitext(base_name.strip()) + if ext.lower() not in ['.ps1', '.exe']: + return name + '.ps1' + + return base_name.strip() + + def path_has_trailing_slash(self, path): + # Allow Windows paths to be specified using either slash. + path = self._unquote(path) + return path.endswith('/') or path.endswith('\\') + + def chmod(self, paths, mode): + raise NotImplementedError('chmod is not implemented for Powershell') + + def chown(self, paths, user): + raise NotImplementedError('chown is not implemented for Powershell') + + def set_user_facl(self, paths, user, mode): + raise NotImplementedError('set_user_facl is not implemented for Powershell') + + def remove(self, path, recurse=False): + path = self._escape(self._unquote(path)) + if recurse: + return self._encode_script('''Remove-Item '%s' -Force -Recurse;''' % path) + else: + return self._encode_script('''Remove-Item '%s' -Force;''' % path) + + def mkdtemp(self, basefile=None, system=False, mode=None, tmpdir=None): + # Windows does not have an equivalent for the system temp files, so + # the param is ignored + if not basefile: + basefile = self.__class__._generate_temp_dir_name() + basefile = self._escape(self._unquote(basefile)) + basetmpdir = tmpdir if tmpdir else self.get_option('remote_tmp') + + script = ''' + $tmp_path = [System.Environment]::ExpandEnvironmentVariables('%s') + $tmp = New-Item -Type Directory -Path $tmp_path -Name '%s' + Write-Output -InputObject $tmp.FullName + ''' % (basetmpdir, basefile) + return self._encode_script(script.strip()) + + def expand_user(self, user_home_path, username=''): + # PowerShell only supports "~" (not "~username"). Resolve-Path ~ does + # not seem to work remotely, though by default we are always starting + # in the user's home directory. + user_home_path = self._unquote(user_home_path) + if user_home_path == '~': + script = 'Write-Output (Get-Location).Path' + elif user_home_path.startswith('~\\'): + script = "Write-Output ((Get-Location).Path + '%s')" % self._escape(user_home_path[1:]) + else: + script = "Write-Output '%s'" % self._escape(user_home_path) + return self._encode_script(script) + + def exists(self, path): + path = self._escape(self._unquote(path)) + script = ''' + If (Test-Path '%s') + { + $res = 0; + } + Else + { + $res = 1; + } + Write-Output '$res'; + Exit $res; + ''' % path + return self._encode_script(script) + + def checksum(self, path, *args, **kwargs): + path = self._escape(self._unquote(path)) + script = ''' + If (Test-Path -PathType Leaf '%(path)s') + { + $sp = new-object -TypeName System.Security.Cryptography.SHA1CryptoServiceProvider; + $fp = [System.IO.File]::Open('%(path)s', [System.IO.Filemode]::Open, [System.IO.FileAccess]::Read); + [System.BitConverter]::ToString($sp.ComputeHash($fp)).Replace("-", "").ToLower(); + $fp.Dispose(); + } + ElseIf (Test-Path -PathType Container '%(path)s') + { + Write-Output "3"; + } + Else + { + Write-Output "1"; + } + ''' % dict(path=path) + return self._encode_script(script) + + def build_module_command(self, env_string, shebang, cmd, arg_path=None): + bootstrap_wrapper = pkgutil.get_data("ansible.executor.powershell", "bootstrap_wrapper.ps1") + + # pipelining bypass + if cmd == '': + return self._encode_script(script=bootstrap_wrapper, strict_mode=False, preserve_rc=False) + + # non-pipelining + + cmd_parts = shlex.split(cmd, posix=False) + cmd_parts = list(map(to_text, cmd_parts)) + if shebang and shebang.lower() == '#!powershell': + if not self._unquote(cmd_parts[0]).lower().endswith('.ps1'): + # we're running a module via the bootstrap wrapper + cmd_parts[0] = '"%s.ps1"' % self._unquote(cmd_parts[0]) + wrapper_cmd = "type " + cmd_parts[0] + " | " + self._encode_script(script=bootstrap_wrapper, strict_mode=False, preserve_rc=False) + return wrapper_cmd + elif shebang and shebang.startswith('#!'): + cmd_parts.insert(0, shebang[2:]) + elif not shebang: + # The module is assumed to be a binary + cmd_parts[0] = self._unquote(cmd_parts[0]) + cmd_parts.append(arg_path) + script = ''' + Try + { + %s + %s + } + Catch + { + $_obj = @{ failed = $true } + If ($_.Exception.GetType) + { + $_obj.Add('msg', $_.Exception.Message) + } + Else + { + $_obj.Add('msg', $_.ToString()) + } + If ($_.InvocationInfo.PositionMessage) + { + $_obj.Add('exception', $_.InvocationInfo.PositionMessage) + } + ElseIf ($_.ScriptStackTrace) + { + $_obj.Add('exception', $_.ScriptStackTrace) + } + Try + { + $_obj.Add('error_record', ($_ | ConvertTo-Json | ConvertFrom-Json)) + } + Catch + { + } + Echo $_obj | ConvertTo-Json -Compress -Depth 99 + Exit 1 + } + ''' % (env_string, ' '.join(cmd_parts)) + return self._encode_script(script, preserve_rc=False) + + def wrap_for_exec(self, cmd): + return '& %s; exit $LASTEXITCODE' % cmd + + def _unquote(self, value): + '''Remove any matching quotes that wrap the given value.''' + value = to_text(value or '') + m = re.match(r'^\s*?\'(.*?)\'\s*?$', value) + if m: + return m.group(1) + m = re.match(r'^\s*?"(.*?)"\s*?$', value) + if m: + return m.group(1) + return value + + def _escape(self, value): + '''Return value escaped for use in PowerShell single quotes.''' + # There are 5 chars that need to be escaped in a single quote. + # https://github.com/PowerShell/PowerShell/blob/b7cb335f03fe2992d0cbd61699de9d9aafa1d7c1/src/System.Management.Automation/engine/parser/CharTraits.cs#L265-L272 + return re.compile(u"(['\u2018\u2019\u201a\u201b])").sub(u'\\1\\1', value) + + def _encode_script(self, script, as_list=False, strict_mode=True, preserve_rc=True): + '''Convert a PowerShell script to a single base64-encoded command.''' + script = to_text(script) + + if script == u'-': + cmd_parts = _common_args + ['-Command', '-'] + + else: + if strict_mode: + script = u'Set-StrictMode -Version Latest\r\n%s' % script + # try to propagate exit code if present- won't work with begin/process/end-style scripts (ala put_file) + # NB: the exit code returned may be incorrect in the case of a successful command followed by an invalid command + if preserve_rc: + script = u'%s\r\nIf (-not $?) { If (Get-Variable LASTEXITCODE -ErrorAction SilentlyContinue) { exit $LASTEXITCODE } Else { exit 1 } }\r\n'\ + % script + script = '\n'.join([x.strip() for x in script.splitlines() if x.strip()]) + encoded_script = to_text(base64.b64encode(script.encode('utf-16-le')), 'utf-8') + cmd_parts = _common_args + ['-EncodedCommand', encoded_script] + + if as_list: + return cmd_parts + return ' '.join(cmd_parts) |