From 5dcaa30476b03f3ea150053a13eb2864cce0ce45 Mon Sep 17 00:00:00 2001 From: Chris Church Date: Tue, 17 Jun 2014 15:15:18 -0500 Subject: [PATCH] Add shell_plugins to abstract shell-specific functions out of runner, add winrm connection plugin, add initial Windows modules. --- lib/ansible/runner/__init__.py | 129 ++++----- lib/ansible/runner/action_plugins/assemble.py | 2 +- lib/ansible/runner/action_plugins/async.py | 2 +- lib/ansible/runner/action_plugins/copy.py | 6 +- lib/ansible/runner/action_plugins/fetch.py | 2 +- lib/ansible/runner/action_plugins/script.py | 13 +- lib/ansible/runner/action_plugins/template.py | 4 +- .../runner/action_plugins/unarchive.py | 2 +- lib/ansible/runner/connection.py | 5 +- .../runner/connection_plugins/winrm.py | 254 ++++++++++++++++++ lib/ansible/runner/shell_plugins/__init__.py | 0 lib/ansible/runner/shell_plugins/csh.py | 6 + lib/ansible/runner/shell_plugins/fish.py | 6 + .../runner/shell_plugins/powershell.py | 72 +++++ lib/ansible/runner/shell_plugins/sh.py | 71 +++++ lib/ansible/utils/plugins.py | 33 ++- library/windows/assemble.ps1 | 13 + library/windows/async_wrapper.ps1 | 13 + library/windows/command.ps1 | 13 + library/windows/copy.ps1 | 13 + library/windows/file.ps1 | 13 + library/windows/ping.ps1 | 17 ++ library/windows/slurp.ps1 | 33 +++ library/windows/stat.ps1 | 51 ++++ library/windows/win_ping | 87 ++++++ 25 files changed, 757 insertions(+), 103 deletions(-) create mode 100644 lib/ansible/runner/connection_plugins/winrm.py create mode 100644 lib/ansible/runner/shell_plugins/__init__.py create mode 100644 lib/ansible/runner/shell_plugins/csh.py create mode 100644 lib/ansible/runner/shell_plugins/fish.py create mode 100644 lib/ansible/runner/shell_plugins/powershell.py create mode 100644 lib/ansible/runner/shell_plugins/sh.py create mode 100644 library/windows/assemble.ps1 create mode 100644 library/windows/async_wrapper.ps1 create mode 100644 library/windows/command.ps1 create mode 100644 library/windows/copy.ps1 create mode 100644 library/windows/file.ps1 create mode 100644 library/windows/ping.ps1 create mode 100644 library/windows/slurp.ps1 create mode 100644 library/windows/stat.ps1 create mode 100644 library/windows/win_ping diff --git a/lib/ansible/runner/__init__.py b/lib/ansible/runner/__init__.py index adec55dc1b5..4bc78bc3b11 100644 --- a/lib/ansible/runner/__init__.py +++ b/lib/ansible/runner/__init__.py @@ -167,7 +167,7 @@ class Runner(object): self.module_vars = utils.default(module_vars, lambda: {}) self.default_vars = utils.default(default_vars, lambda: {}) self.always_run = None - self.connector = connection.Connection(self) + self.connector = connection.Connector(self) self.conditional = conditional self.module_name = module_name self.forks = int(forks) @@ -198,7 +198,7 @@ class Runner(object): self.vault_pass = vault_pass self.no_log = no_log - if self.transport == 'smart': + if self.transport == 'smart': # FIXME # if the transport is 'smart' see if SSH can support ControlPersist if not use paramiko # 'smart' is the default since 1.2.1/1.3 cmd = subprocess.Popen(['ssh','-o','ControlPersist'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -275,7 +275,7 @@ class Runner(object): afo.flush() afo.close() - remote = os.path.join(tmp, name) + remote = conn.shell.join_path(tmp, name) try: conn.put_file(afile, remote) finally: @@ -284,32 +284,17 @@ class Runner(object): # ***************************************************** - def _compute_environment_string(self, inject=None): + def _compute_environment_string(self, conn, inject=None): # CCTODO: Changed this method signature ''' what environment variables to use when running the command? ''' - shell_type = inject.get('ansible_shell_type') - if not shell_type: - shell_type = os.path.basename(C.DEFAULT_EXECUTABLE) - - default_environment = dict( - LANG = C.DEFAULT_MODULE_LANG, - LC_CTYPE = C.DEFAULT_MODULE_LANG, - ) - + enviro = {} if self.environment: enviro = template.template(self.basedir, self.environment, inject, convert_bare=True) enviro = utils.safe_eval(enviro) if type(enviro) != dict: raise errors.AnsibleError("environment must be a dictionary, received %s" % enviro) - default_environment.update(enviro) - result = "" - for (k,v) in default_environment.iteritems(): - if shell_type in ('csh', 'fish'): - result = "env %s=%s %s" % (k, pipes.quote(unicode(v)), result) - else: - result = "%s=%s %s" % (k, pipes.quote(unicode(v)), result) - return result + return conn.shell.env_prefix(**enviro) # ***************************************************** @@ -425,7 +410,7 @@ class Runner(object): if self._late_needs_tmp_path(conn, tmp, module_style): tmp = self._make_tmp_path(conn) - remote_module_path = os.path.join(tmp, module_name) + remote_module_path = conn.shell.join_path(tmp, module_name) if (module_style != 'new' or async_jid is not None @@ -435,12 +420,11 @@ class Runner(object): or self.su): self._transfer_str(conn, tmp, module_name, module_data) - environment_string = self._compute_environment_string(inject) + environment_string = self._compute_environment_string(conn, inject) if "tmp" in tmp and ((self.sudo and self.sudo_user != 'root') or (self.su and self.su_user != 'root')): # deal with possible umask issues once sudo'ed to other user - cmd_chmod = "chmod a+r %s" % remote_module_path - self._low_level_exec_command(conn, cmd_chmod, tmp, sudoable=False) + self._remote_chmod(conn, 'a+r', remote_module_path) cmd = "" in_data = None @@ -468,8 +452,7 @@ class Runner(object): if (self.sudo and self.sudo_user != 'root') or (self.su and self.su_user != 'root'): # deal with possible umask issues once sudo'ed to other user - cmd_args_chmod = "chmod a+r %s" % argsfile - self._low_level_exec_command(conn, cmd_args_chmod, tmp, sudoable=False) + self._remote_chmod(conn, 'a+r', argsfile) if async_jid is None: cmd = "%s %s" % (remote_module_path, argsfile) @@ -487,14 +470,14 @@ class Runner(object): if not shebang: raise errors.AnsibleError("module is missing interpreter line") - - cmd = " ".join([environment_string.strip(), shebang.replace("#!","").strip(), cmd]) - cmd = cmd.strip() - + rm_tmp = None if "tmp" in tmp and not C.DEFAULT_KEEP_REMOTE_FILES and not persist_files and delete_remote_tmp: if not self.sudo or self.su or self.sudo_user == 'root' or self.su_user == 'root': # not sudoing or sudoing to root, so can cleanup files in the same step - cmd = cmd + "; rm -rf %s >/dev/null 2>&1" % tmp + rm_tmp = tmp + + cmd = conn.shell.build_module_command(environment_string, shebang, cmd, rm_tmp) + cmd = cmd.strip() sudoable = True if module_name == "accelerate": @@ -511,7 +494,7 @@ class Runner(object): if (self.sudo and self.sudo_user != 'root') or (self.su and self.su_user != 'root'): # not sudoing to root, so maybe can't delete files as that other user # have to clean up temp files as original user in a second step - cmd2 = "rm -rf %s >/dev/null 2>&1" % tmp + cmd2 = conn.shell.remove(tmp, recurse=True) self._low_level_exec_command(conn, cmd2, tmp, sudoable=False) data = utils.parse_json(res['stdout']) @@ -776,7 +759,8 @@ class Runner(object): if not self.accelerate_port: self.accelerate_port = C.ACCELERATE_PORT - if actual_transport in [ 'paramiko', 'ssh', 'accelerate' ]: + # CCTODO: Any reason not to do this regardless of connection type, and let the connection plugin ignore it? + if True:#actual_transport in [ 'paramiko', 'ssh', 'accelerate', 'winrm' ]: actual_port = inject.get('ansible_ssh_port', port) # the delegated host may have different SSH port configured, etc @@ -818,6 +802,18 @@ class Runner(object): if delegate_to or host != actual_host: conn.delegate = host + default_shell = getattr(conn, 'default_shell', '') + shell_type = inject.get('ansible_shell_type') + if not shell_type: + if default_shell: + shell_type = default_shell + else: + shell_type = os.path.basename(C.DEFAULT_EXECUTABLE) + + shell_plugin = utils.plugins.shell_loader.get(shell_type) + if shell_plugin is None: + shell_plugin = utils.plugins.shell_loader.get('sh') + conn.shell = shell_plugin except errors.AnsibleConnectionFailed, e: result = dict(failed=True, msg="FAILED: %s" % str(e)) @@ -947,6 +943,9 @@ class Runner(object): executable=None, su=False, in_data=None): ''' execute a command string over SSH, return the output ''' + if not cmd: + return dict(stdout='', stderr='') + if executable is None: executable = C.DEFAULT_EXECUTABLE @@ -954,16 +953,11 @@ class Runner(object): su_user = self.su_user # compare connection user to (su|sudo)_user and disable if the same - if hasattr(conn, 'user'): - if (not su and conn.user == sudo_user) or (su and conn.user == su_user): - sudoable = False - su = False - else: - # assume connection type is local if no user attribute - this_user = getpass.getuser() - if (not su and this_user == sudo_user) or (su and this_user == su_user): - sudoable = False - su = False + # assume connection type is local if no user attribute + this_user = getattr(conn, 'user', getpass.getuser()) + if (not su and this_user == sudo_user) or (su and this_user == su_user): + sudoable = False + su = False if su: rc, stdin, stdout, stderr = conn.exec_command(cmd, @@ -997,26 +991,16 @@ class Runner(object): # ***************************************************** + def _remote_chmod(self, conn, mode, path, tmp, sudoable=False, su=False): + ''' issue a remote chmod command ''' + cmd = conn.shell.chmod(mode, path) + return self._low_level_exec_command(conn, cmd, tmp, sudoable=sudoable, su=su) + + # ***************************************************** + def _remote_md5(self, conn, tmp, path): ''' takes a remote md5sum without requiring python, and returns 1 if no file ''' - - path = pipes.quote(path) - # The following test needs to be SH-compliant. BASH-isms will - # not work if /bin/sh points to a non-BASH shell. - test = "rc=0; [ -r \"%s\" ] || rc=2; [ -f \"%s\" ] || rc=1; [ -d \"%s\" ] && echo 3 && exit 0" % ((path,) * 3) - md5s = [ - "(/usr/bin/md5sum %s 2>/dev/null)" % path, # Linux - "(/sbin/md5sum -q %s 2>/dev/null)" % path, # ? - "(/usr/bin/digest -a md5 %s 2>/dev/null)" % path, # Solaris 10+ - "(/sbin/md5 -q %s 2>/dev/null)" % path, # Freebsd - "(/usr/bin/md5 -n %s 2>/dev/null)" % path, # Netbsd - "(/bin/md5 -q %s 2>/dev/null)" % path, # Openbsd - "(/usr/bin/csum -h MD5 %s 2>/dev/null)" % path, # AIX - "(/bin/csum -h MD5 %s 2>/dev/null)" % path # AIX also - ] - - cmd = " || ".join(md5s) - cmd = "%s; %s || (echo \"${rc} %s\")" % (test, cmd, path) + cmd = conn.shell.md5(path) data = self._low_level_exec_command(conn, cmd, tmp, sudoable=True) data2 = utils.last_non_blank_line(data['stdout']) try: @@ -1039,17 +1023,16 @@ class Runner(object): def _make_tmp_path(self, conn): ''' make and return a temporary path on a remote box ''' - basefile = 'ansible-tmp-%s-%s' % (time.time(), random.randint(0, 2**48)) - basetmp = os.path.join(C.DEFAULT_REMOTE_TMP, basefile) - if (self.sudo and self.sudo_user != 'root') or (self.su and self.su_user != 'root') and basetmp.startswith('$HOME'): - basetmp = os.path.join('/tmp', basefile) + use_system_tmp = False + if (self.sudo and self.sudo_user != 'root') or (self.su and self.su_user != 'root'): + use_system_tmp = True - cmd = 'mkdir -p %s' % basetmp + tmp_mode = None if self.remote_user != 'root' or ((self.sudo and self.sudo_user != 'root') or (self.su and self.su_user != 'root')): - cmd += ' && chmod a+rx %s' % basetmp - cmd += ' && echo %s' % basetmp + tmp_mode = 'a+rx' + cmd = conn.shell.mkdtemp(basefile, use_system_tmp, tmp_mode) result = self._low_level_exec_command(conn, cmd, None, sudoable=False) # error handling on this seems a little aggressive? @@ -1078,9 +1061,8 @@ class Runner(object): def _remove_tmp_path(self, conn, tmp_path): ''' Remove a tmp_path. ''' - if "-tmp-" in tmp_path: - cmd = "rm -rf %s >/dev/null 2>&1" % tmp_path + cmd = conn.shell.remove(tmp_path, recurse=True) self._low_level_exec_command(conn, cmd, None, sudoable=False) # If we have gotten here we have a working ssh configuration. # If ssh breaks we could leave tmp directories out on the remote system. @@ -1094,7 +1076,7 @@ class Runner(object): module_shebang, module_data ) = self._configure_module(conn, module_name, module_args, inject, complex_args) - module_remote_path = os.path.join(tmp, module_name) + module_remote_path = conn.shell.join_path(tmp, module_name) self._transfer_str(conn, tmp, module_name, module_data) @@ -1106,7 +1088,8 @@ class Runner(object): ''' find module and configure it ''' # Search module path(s) for named module. - module_path = utils.plugins.module_finder.find_plugin(module_name) + module_suffixes = getattr(conn, 'default_suffixes', None) + module_path = utils.plugins.module_finder.find_plugin(module_name, module_suffixes) if module_path is None: raise errors.AnsibleFileNotFound("module %s not found in %s" % (module_name, utils.plugins.module_finder.print_paths())) diff --git a/lib/ansible/runner/action_plugins/assemble.py b/lib/ansible/runner/action_plugins/assemble.py index d99d202e246..1a980c1df4a 100644 --- a/lib/ansible/runner/action_plugins/assemble.py +++ b/lib/ansible/runner/action_plugins/assemble.py @@ -119,7 +119,7 @@ class ActionModule(object): # fix file permissions when the copy is done as a different user if self.runner.sudo and self.runner.sudo_user != 'root': - self.runner._low_level_exec_command(conn, "chmod a+r %s" % xfered, tmp) + self.runner._remote_chmod(conn, 'a+r', xfered, tmp) # run the copy module module_args = "%s src=%s dest=%s original_basename=%s" % (module_args, pipes.quote(xfered), pipes.quote(dest), pipes.quote(os.path.basename(src))) diff --git a/lib/ansible/runner/action_plugins/async.py b/lib/ansible/runner/action_plugins/async.py index ac0d6e84928..dc53d6fa6cb 100644 --- a/lib/ansible/runner/action_plugins/async.py +++ b/lib/ansible/runner/action_plugins/async.py @@ -37,7 +37,7 @@ class ActionModule(object): tmp = self.runner._make_tmp_path(conn) (module_path, is_new_style, shebang) = self.runner._copy_module(conn, tmp, module_name, module_args, inject, complex_args=complex_args) - self.runner._low_level_exec_command(conn, "chmod a+rx %s" % module_path, tmp) + self.runner._remote_chmod(conn, 'a+rx', module_path, tmp) return self.runner._execute_module(conn, tmp, 'async_wrapper', module_args, async_module=module_path, diff --git a/lib/ansible/runner/action_plugins/copy.py b/lib/ansible/runner/action_plugins/copy.py index df5266c4c04..d84b3f550e6 100644 --- a/lib/ansible/runner/action_plugins/copy.py +++ b/lib/ansible/runner/action_plugins/copy.py @@ -169,7 +169,7 @@ class ActionModule(object): # This is kind of optimization - if user told us destination is # dir, do path manipulation right away, otherwise we still check # for dest being a dir via remote call below. - if dest.endswith("/"): + if dest.endswith("/"): # CCTODO: Fixme for powershell dest_file = os.path.join(dest, source_rel) else: dest_file = dest @@ -186,7 +186,7 @@ class ActionModule(object): return ReturnData(conn=conn, result=result) else: # Append the relative source location to the destination and retry remote_md5. - dest_file = os.path.join(dest, source_rel) + dest_file = os.path.join(dest, source_rel) # CCTODO remote_md5 = self.runner._remote_md5(conn, tmp_path, dest_file) if remote_md5 != '1' and not force: @@ -228,7 +228,7 @@ class ActionModule(object): # fix file permissions when the copy is done as a different user if self.runner.sudo and self.runner.sudo_user != 'root' and not raw: - self.runner._low_level_exec_command(conn, "chmod a+r %s" % tmp_src, tmp_path) + self.runner._remote_chmod(conn, 'a+r', tmp_src, tmp_path) if raw: # Continue to next iteration if raw is defined. diff --git a/lib/ansible/runner/action_plugins/fetch.py b/lib/ansible/runner/action_plugins/fetch.py index 205023fad96..2d7f7e974d8 100644 --- a/lib/ansible/runner/action_plugins/fetch.py +++ b/lib/ansible/runner/action_plugins/fetch.py @@ -59,7 +59,7 @@ class ActionModule(object): source = os.path.expanduser(source) if flat: - if dest.endswith("/"): + if dest.endswith("/"): # CCTODO # if the path ends with "/", we'll use the source filename as the # destination filename base = os.path.basename(source) diff --git a/lib/ansible/runner/action_plugins/script.py b/lib/ansible/runner/action_plugins/script.py index 6951d6154ad..2fac86a3f27 100644 --- a/lib/ansible/runner/action_plugins/script.py +++ b/lib/ansible/runner/action_plugins/script.py @@ -106,7 +106,8 @@ class ActionModule(object): # transfer the file to a remote tmp location source = source.replace('\x00', '') # why does this happen here? args = args.replace('\x00', '') # why does this happen here? - tmp_src = os.path.join(tmp, os.path.basename(source)) + #tmp_src = os.path.join(tmp, os.path.basename(source)) # CCTODO + tmp_src = conn.shell.join_path(tmp, os.path.basename(source)) tmp_src = tmp_src.replace('\x00', '') conn.put_file(source, tmp_src) @@ -115,14 +116,14 @@ class ActionModule(object): # set file permissions, more permisive when the copy is done as a different user if ((self.runner.sudo and self.runner.sudo_user != 'root') or (self.runner.su and self.runner.su_user != 'root')): - cmd_args_chmod = "chmod a+rx %s" % tmp_src + chmod_mode = 'a+rx' sudoable = False else: - cmd_args_chmod = "chmod +rx %s" % tmp_src - self.runner._low_level_exec_command(conn, cmd_args_chmod, tmp, sudoable=sudoable, su=self.runner.su) + chmod_mode = '+rx' + self.runner._remote_chmod(conn, chmod_mode, tmp_src, tmp, sudoable=sudoable, su=self.runner.su) # add preparation steps to one ssh roundtrip executing the script - env_string = self.runner._compute_environment_string(inject) + env_string = self.runner._compute_environment_string(conn, inject) module_args = env_string + tmp_src + ' ' + args handler = utils.plugins.action_loader.get('raw', self.runner) @@ -130,7 +131,7 @@ class ActionModule(object): # clean up after if "tmp" in tmp and not C.DEFAULT_KEEP_REMOTE_FILES: - self.runner._low_level_exec_command(conn, 'rm -rf %s >/dev/null 2>&1' % tmp, tmp) + self.runner._remove_tmp_path(conn, tmp) result.result['changed'] = True diff --git a/lib/ansible/runner/action_plugins/template.py b/lib/ansible/runner/action_plugins/template.py index 96d8f97a3aa..774a2be6ff7 100644 --- a/lib/ansible/runner/action_plugins/template.py +++ b/lib/ansible/runner/action_plugins/template.py @@ -79,7 +79,7 @@ class ActionModule(object): source = utils.path_dwim(self.runner.basedir, source) - if dest.endswith("/"): + if dest.endswith("/"): # CCTODO base = os.path.basename(source) dest = os.path.join(dest, base) @@ -114,7 +114,7 @@ class ActionModule(object): # fix file permissions when the copy is done as a different user if self.runner.sudo and self.runner.sudo_user != 'root': - self.runner._low_level_exec_command(conn, "chmod a+r %s" % xfered, tmp) + self.runner._remote_chmod(conn, 'a+r', xfered, tmp) # run the copy module module_args = "%s src=%s dest=%s original_basename=%s" % (module_args, pipes.quote(xfered), pipes.quote(dest), pipes.quote(os.path.basename(source))) diff --git a/lib/ansible/runner/action_plugins/unarchive.py b/lib/ansible/runner/action_plugins/unarchive.py index c943cab514b..be0070fca16 100644 --- a/lib/ansible/runner/action_plugins/unarchive.py +++ b/lib/ansible/runner/action_plugins/unarchive.py @@ -77,7 +77,7 @@ class ActionModule(object): # fix file permissions when the copy is done as a different user if copy: if self.runner.sudo and self.runner.sudo_user != 'root': - self.runner._low_level_exec_command(conn, "chmod a+r %s" % tmp_src, tmp) + self.runner._remote_chmod(conn, 'a+r', tmp_src, tmp) module_args = "%s src=%s original_basename=%s" % (module_args, pipes.quote(tmp_src), pipes.quote(os.path.basename(source))) else: module_args = "%s original_basename=%s" % (module_args, pipes.quote(os.path.basename(source))) diff --git a/lib/ansible/runner/connection.py b/lib/ansible/runner/connection.py index ad49d1e0b70..d7def56fbff 100644 --- a/lib/ansible/runner/connection.py +++ b/lib/ansible/runner/connection.py @@ -25,18 +25,15 @@ import ansible.constants as C import os import os.path -class Connection(object): +class Connector(object): ''' Handles abstract connections to remote hosts ''' def __init__(self, runner): self.runner = runner def connect(self, host, port, user, password, transport, private_key_file): - conn = None conn = utils.plugins.connection_loader.get(transport, self.runner, host, port, user=user, password=password, private_key_file=private_key_file) if conn is None: raise AnsibleError("unsupported connection type: %s" % transport) self.active = conn.connect() return self.active - - diff --git a/lib/ansible/runner/connection_plugins/winrm.py b/lib/ansible/runner/connection_plugins/winrm.py new file mode 100644 index 00000000000..10a9872fa4d --- /dev/null +++ b/lib/ansible/runner/connection_plugins/winrm.py @@ -0,0 +1,254 @@ +# (c) 2014, Chris Church +# +# This file is (not yet) 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 . + +from __future__ import absolute_import + +import base64 +import hashlib +import imp +import os +import re +import shlex +import traceback +import urlparse +from ansible import errors +from ansible import utils +from ansible.callbacks import vvv, vvvv + +try: + from winrm import Response + from winrm.exceptions import WinRMTransportError + from winrm.protocol import Protocol +except ImportError: + raise errors.AnsibleError("winrm is not installed") + +# When running with unmodified Ansible (1.6.x), load local hacks. +try: + _winrm_hacks = imp.load_source('_winrm_hacks', os.path.join(os.path.dirname(__file__), '_winrm_hacks.py')) +except (ImportError, IOError): + _winrm_hacks = None + +_winrm_cache = { + # 'user:pwhash@host:port': +} + +class Connection(object): + '''WinRM connections over HTTP/HTTPS.''' + + def __init__(self, runner, host, port, user, password, *args, **kwargs): + self.runner = runner + self.host = host + self.port = port + self.user = user + self.password = password + self.has_pipelining = False + self.default_shell = 'powershell' + self.default_suffixes = ['.ps1', ''] + self.protocol = None + self.shell_id = None + self.delegate = None + if _winrm_hacks: + _winrm_hacks.patch_module_finder(self) + + def _winrm_connect(self): + ''' + Establish a WinRM connection over HTTP/HTTPS. + ''' + if _winrm_hacks: + port = _winrm_hacks.get_port(self) + else: + port = self.port or 5986 + vvv("ESTABLISH WINRM CONNECTION FOR USER: %s on PORT %s TO %s" % \ + (self.user, port, self.host), host=self.host) + netloc = '%s:%d' % (self.host, port) + cache_key = '%s:%s@%s:%d' % (self.user, hashlib.md5(self.password).hexdigest(), self.host, port) + if cache_key in _winrm_cache: + vvvv('WINRM REUSE EXISTING CONNECTION: %s' % cache_key, host=self.host) + return _winrm_cache[cache_key] + transport_schemes = [('plaintext', 'https'), ('plaintext', 'http')] # FIXME: ssl/kerberos + if port == 5985: + transport_schemes = reversed(transport_schemes) + exc = None + for transport, scheme in transport_schemes: + endpoint = urlparse.urlunsplit((scheme, netloc, '/wsman', '', '')) + vvvv('WINRM CONNECT: transport=%s endpoint=%s' % (transport, endpoint), + host=self.host) + protocol = Protocol(endpoint, transport=transport, + username=self.user, password=self.password) + try: + protocol.send_message('') + _winrm_cache[cache_key] = protocol + return protocol + except WinRMTransportError, exc: + err_msg = str(exc.args[0]) + if re.search(r'Operation\s+?timed\s+?out', err_msg, re.I): + raise + m = re.search(r'Code\s+?(\d{3})', err_msg) + if m: + code = int(m.groups()[0]) + if code == 411: + _winrm_cache[cache_key] = protocol + return protocol + vvvv('WINRM CONNECTION ERROR: %s' % err_msg, host=self.host) + continue + # FIXME: Cache connection!!! + if exc: + raise exc + + def _winrm_escape(self, value, include_vars=False): + ''' + Return value escaped for use in PowerShell command. + ''' + # http://www.techotopia.com/index.php/Windows_PowerShell_1.0_String_Quoting_and_Escape_Sequences + # http://stackoverflow.com/questions/764360/a-list-of-string-replacements-in-python + subs = [('\n', '`n'), ('\r', '`r'), ('\t', '`t'), ('\a', '`a'), + ('\b', '`b'), ('\f', '`f'), ('\v', '`v'), ('"', '`"'), + ('\'', '`\''), ('`', '``'), ('\x00', '`0')] + if include_vars: + subs.append(('$', '`$')) + pattern = '|'.join('(%s)' % re.escape(p) for p, s in subs) + substs = [s for p, s in subs] + replace = lambda m: substs[m.lastindex - 1] + return re.sub(pattern, replace, value) + + def _winrm_get_script_cmd(self, script): + ''' + Convert a PowerShell script to a single base64-encoded command. + ''' + vvvv('WINRM SCRIPT: %s' % script, host=self.host) + encoded_script = base64.b64encode(script.encode('utf-16-le')) + return ['PowerShell', '-NoProfile', '-NonInteractive', + '-EncodedCommand', encoded_script] + + def _winrm_exec(self, command, args): + vvvv("WINRM EXEC %r %r" % (command, args), host=self.host) + if not self.protocol: + self.protocol = self._winrm_connect() + if not self.shell_id: + self.shell_id = self.protocol.open_shell() + command_id = None + try: + command_id = self.protocol.run_command(self.shell_id, command, args) + response = Response(self.protocol.get_command_output(self.shell_id, command_id)) + vvvv('WINRM RESULT %r' % response, host=self.host) + vvvv('WINRM STDERR %s' % response.std_err, host=self.host) + return response + finally: + if command_id: + self.protocol.cleanup_command(self.shell_id, command_id) + + def connect(self): + if not _winrm_hacks: + if not self.protocol: + self.protocol = self._winrm_connect() + # When using hacks, connect lazily on first command, to allow for + # runner to set self.delegate, needed if actual host vs. host name are + # different. + return self + + def exec_command(self, cmd, tmp_path, sudo_user=None, sudoable=False, executable='/bin/sh', in_data=None, su=None, su_user=None): + cmd = cmd.encode('utf-8') + vvv("EXEC %s" % cmd, host=self.host) + cmd_parts = shlex.split(cmd, posix=False) + vvvv("WINRM PARTS %r" % cmd_parts, host=self.host) + # For script/raw support. + if len(cmd_parts) == 1 and cmd_parts[0].lower().endswith('.ps1'): + cmd_parts = ['PowerShell', '-ExecutionPolicy', 'Unrestricted', '-File', cmd_parts[0]] + if _winrm_hacks: + cmd_parts = _winrm_hacks.filter_cmd_parts(self, cmd_parts) + if not cmd_parts: + vvv('WINRM NOOP') + return (0, '', '', '') + try: + result = self._winrm_exec(cmd_parts[0], cmd_parts[1:]) + except Exception, e: + traceback.print_exc() + raise errors.AnsibleError("failed to exec cmd %s" % cmd) + return (result.status_code, '', result.std_out.encode('utf-8'), result.std_err.encode('utf-8')) + + def put_file(self, in_path, out_path): + if _winrm_hacks: + out_path = _winrm_hacks.fix_slashes(out_path) + vvv("PUT %s TO %s" % (in_path, out_path), host=self.host) + if not os.path.exists(in_path): + raise errors.AnsibleFileNotFound("file or module does not exist: %s" % in_path) + buffer_size = 1024 # FIXME: Find max size or optimize. + with open(in_path) as in_file: + in_size = os.path.getsize(in_path) + for offset in xrange(0, in_size, buffer_size): + try: + out_data = in_file.read(buffer_size) + if offset == 0: + if out_data.lower().startswith('#!powershell') and not out_path.lower().endswith('.ps1'): + out_path = out_path + '.ps1' + b64_data = base64.b64encode(out_data) + script = ''' + $bufferSize = %d; + $stream = [System.IO.File]::OpenWrite("%s"); + $stream.Seek(%d, [System.IO.SeekOrigin]::Begin) | Out-Null; + $data = "%s"; + $buffer = [System.Convert]::FromBase64String($data); + $stream.Write($buffer, 0, $buffer.length) | Out-Null; + $stream.SetLength(%d) | Out-Null; + $stream.Close() | Out-Null; + ''' % (buffer_size, self._winrm_escape(out_path), offset, b64_data, in_size) + cmd_parts = self._winrm_get_script_cmd(script) + result = self._winrm_exec(cmd_parts[0], cmd_parts[1:]) + if result.status_code != 0: + raise RuntimeError(result.std_err.encode('utf-8')) + script = u'' + except Exception: # IOError? + traceback.print_exc() + raise errors.AnsibleError("failed to transfer file to %s" % out_path) + + def fetch_file(self, in_path, out_path): + if _winrm_hacks: + in_path = _winrm_hacks.fix_slashes(in_path) + out_path = out_path.replace('\\', '/') + vvv("FETCH %s TO %s" % (in_path, out_path), host=self.host) + buffer_size = 2**20 # 1MB chunks + if not os.path.exists(os.path.dirname(out_path)): + os.makedirs(os.path.dirname(out_path)) + with open(out_path, 'wb') as out_file: + offset = 0 + while True: + try: + script = ''' + $bufferSize = %d; + $stream = [System.IO.File]::OpenRead("%s"); + $stream.Seek(%d, [System.IO.SeekOrigin]::Begin) | Out-Null; + $buffer = New-Object Byte[] $bufferSize; + $bytesRead = $stream.Read($buffer, 0, $bufferSize); + $bytes = $buffer[0..($bytesRead-1)]; + [System.Convert]::ToBase64String($bytes); + $stream.Close() | Out-Null; + ''' % (buffer_size, self._winrm_escape(in_path), offset) + cmd_parts = self._winrm_get_script_cmd(script) + result = self._winrm_exec(cmd_parts[0], cmd_parts[1:]) + data = base64.b64decode(result.std_out.strip()) + out_file.write(data) + if len(data) < buffer_size: + break + offset += len(data) + except Exception: # IOError? + traceback.print_exc() + raise errors.AnsibleError("failed to transfer file to %s" % out_path) + + def close(self): + if self.protocol and self.shell_id: + self.protocol.close_shell(self.shell_id) + self.shell_id = None diff --git a/lib/ansible/runner/shell_plugins/__init__.py b/lib/ansible/runner/shell_plugins/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/ansible/runner/shell_plugins/csh.py b/lib/ansible/runner/shell_plugins/csh.py new file mode 100644 index 00000000000..e52ad555284 --- /dev/null +++ b/lib/ansible/runner/shell_plugins/csh.py @@ -0,0 +1,6 @@ +from ansible.runner.shell_plugins.sh import ShellModule as ShModule + +class ShellModule(ShModule): + + def env_prefix(self, **kwargs): + return 'env %s' % super(ShellModule, self).env_prefix(**kwargs) diff --git a/lib/ansible/runner/shell_plugins/fish.py b/lib/ansible/runner/shell_plugins/fish.py new file mode 100644 index 00000000000..e52ad555284 --- /dev/null +++ b/lib/ansible/runner/shell_plugins/fish.py @@ -0,0 +1,6 @@ +from ansible.runner.shell_plugins.sh import ShellModule as ShModule + +class ShellModule(ShModule): + + def env_prefix(self, **kwargs): + return 'env %s' % super(ShellModule, self).env_prefix(**kwargs) diff --git a/lib/ansible/runner/shell_plugins/powershell.py b/lib/ansible/runner/shell_plugins/powershell.py new file mode 100644 index 00000000000..185116a4066 --- /dev/null +++ b/lib/ansible/runner/shell_plugins/powershell.py @@ -0,0 +1,72 @@ +import base64 +import os +import re +import random +import shlex +import time + +class ShellModule(object): + + def __init__(self): + pass + + def _escape(self, value, include_vars=False): + ''' + Return value escaped for use in PowerShell command. + ''' + # http://www.techotopia.com/index.php/Windows_PowerShell_1.0_String_Quoting_and_Escape_Sequences + # http://stackoverflow.com/questions/764360/a-list-of-string-replacements-in-python + subs = [('\n', '`n'), ('\r', '`r'), ('\t', '`t'), ('\a', '`a'), + ('\b', '`b'), ('\f', '`f'), ('\v', '`v'), ('"', '`"'), + ('\'', '`\''), ('`', '``'), ('\x00', '`0')] + if include_vars: + subs.append(('$', '`$')) + pattern = '|'.join('(%s)' % re.escape(p) for p, s in subs) + substs = [s for p, s in subs] + replace = lambda m: substs[m.lastindex - 1] + return re.sub(pattern, replace, value) + + def _get_script_cmd(self, script): + ''' + Convert a PowerShell script to a single base64-encoded command. + ''' + encoded_script = base64.b64encode(script.encode('utf-16-le')) + return ' '.join(['PowerShell', '-NoProfile', '-NonInteractive', + '-EncodedCommand', encoded_script]) + + def env_prefix(self, **kwargs): + return '' + + def join_path(self, *args): + return os.path.join(*args).replace('/', '\\') + + def chmod(self, mode, path): + return '' + + def remove(self, path, recurse=False): + path = self._escape(path) + if recurse: + return self._get_script_cmd('''Remove-Item "%s" -Force -Recurse;''' % path) + else: + return self._get_script_cmd('''Remove-Item "%s" -Force;''' % path) + + def mkdtemp(self, basefile=None, system=False, mode=None): + if not basefile: + basefile = 'ansible-tmp-%s-%s' % (time.time(), random.randint(0, 2**48)) + basefile = self._escape(basefile) + # FIXME: Support system temp path! + return self._get_script_cmd('''(New-Item -Type Directory -Path $env:temp -Name "%s").FullName;''' % basefile) + + def md5(self, path): + path = self._escape(path) + return self._get_script_cmd('''(Get-FileHash -Path "%s" -Algorithm MD5).Hash.ToLower();''' % path) + + def build_module_command(self, env_string, shebang, cmd, rm_tmp=None): + cmd_parts = shlex.split(cmd, posix=False) + if not cmd_parts[0].lower().endswith('.ps1'): + cmd_parts[0] = '%s.ps1' % cmd_parts[0] + cmd_parts = ['PowerShell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Unrestricted', '-File'] + ['"%s"' % x for x in cmd_parts] + script = ' '.join(cmd_parts) + if rm_tmp: + script = '%s; Remove-Item "%s" -Force -Recurse;' % (script, self._escape(rm_tmp)) + return self._get_script_cmd(script) diff --git a/lib/ansible/runner/shell_plugins/sh.py b/lib/ansible/runner/shell_plugins/sh.py new file mode 100644 index 00000000000..9634f972056 --- /dev/null +++ b/lib/ansible/runner/shell_plugins/sh.py @@ -0,0 +1,71 @@ + +import os +import pipes +import ansible.constants as C + +class ShellModule(object): + + def __init__(self): + pass + + def env_prefix(self, **kwargs): + '''Build command prefix with environment variables.''' + env = dict( + LANG = C.DEFAULT_MODULE_LANG, + LC_CTYPE = C.DEFAULT_MODULE_LANG, + ) + env.update(kwargs) + return ' '.join(['%s=%s' % (k, pipes.quote(unicode(v))) for k,v in env.items()]) + + def join_path(self, *args): + return os.path.join(*args) + + def chmod(self, mode, path): + path = pipes.quote(path) + return 'chmod %s %s' % (mode, path) + + def remove(self, path, recurse=False): + path = pipes.quote(path) + if recurse: + return "rm -rf %s >/dev/null 2>&1" % path + else: + return "rm -f %s >/dev/null 2>&1" % path + + def mkdtemp(self, basefile=None, system=False, mode=None): + if not basefile: + basefile = 'ansible-tmp-%s-%s' % (time.time(), random.randint(0, 2**48)) + basetmp = self.join_path(C.DEFAULT_REMOTE_TMP, basefile) + if system and basetmp.startswith('$HOME'): + basetmp = self.join_path('/tmp', basefile) + cmd = 'mkdir -p %s' % basetmp + if mode: + cmd += ' && chmod %s %s' % (mode, basetmp) + cmd += ' && echo %s' % basetmp + return cmd + + def md5(self, path): + path = pipes.quote(path) + # The following test needs to be SH-compliant. BASH-isms will + # not work if /bin/sh points to a non-BASH shell. + test = "rc=0; [ -r \"%s\" ] || rc=2; [ -f \"%s\" ] || rc=1; [ -d \"%s\" ] && echo 3 && exit 0" % ((path,) * 3) + md5s = [ + "(/usr/bin/md5sum %s 2>/dev/null)" % path, # Linux + "(/sbin/md5sum -q %s 2>/dev/null)" % path, # ? + "(/usr/bin/digest -a md5 %s 2>/dev/null)" % path, # Solaris 10+ + "(/sbin/md5 -q %s 2>/dev/null)" % path, # Freebsd + "(/usr/bin/md5 -n %s 2>/dev/null)" % path, # Netbsd + "(/bin/md5 -q %s 2>/dev/null)" % path, # Openbsd + "(/usr/bin/csum -h MD5 %s 2>/dev/null)" % path, # AIX + "(/bin/csum -h MD5 %s 2>/dev/null)" % path # AIX also + ] + + cmd = " || ".join(md5s) + cmd = "%s; %s || (echo \"${rc} %s\")" % (test, cmd, path) + return cmd + + def build_module_command(self, env_string, shebang, cmd, rm_tmp=None): + cmd_parts = [env_string.strip(), shebang.replace("#!", "").strip(), cmd] + new_cmd = " ".join(cmd_parts) + if rm_tmp: + new_cmd = '%s; rm -rf %s >/dev/null 2>&1' % (new_cmd, rm_tmp) + return new_cmd diff --git a/lib/ansible/utils/plugins.py b/lib/ansible/utils/plugins.py index 22d74c185a3..3e2d15ded81 100644 --- a/lib/ansible/utils/plugins.py +++ b/lib/ansible/utils/plugins.py @@ -139,21 +139,25 @@ class PluginLoader(object): if directory not in self._extra_dirs: self._extra_dirs.append(directory) - def find_plugin(self, name): + def find_plugin(self, name, suffixes=None): ''' Find a plugin named name ''' - if name in self._plugin_path_cache: - return self._plugin_path_cache[name] + if not suffixes: + if self.class_name: + suffixes = ['.py'] + else: + suffixes = [''] - suffix = ".py" - if not self.class_name: - suffix = "" + for suffix in suffixes: + full_name = '%s%s' % (name, suffix) + if full_name in self._plugin_path_cache: + return self._plugin_path_cache[full_name] - for i in self._get_paths(): - path = os.path.join(i, "%s%s" % (name, suffix)) - if os.path.isfile(path): - self._plugin_path_cache[name] = path - return path + for i in self._get_paths(): + path = os.path.join(i, full_name) + if os.path.isfile(path): + self._plugin_path_cache[full_name] = path + return path return None @@ -212,6 +216,13 @@ connection_loader = PluginLoader( aliases={'paramiko': 'paramiko_ssh'} ) +shell_loader = PluginLoader( + 'ShellModule', + 'ansible.runner.shell_plugins', + 'shell_plugins', + 'shell_plugins', +) + module_finder = PluginLoader( '', '', diff --git a/library/windows/assemble.ps1 b/library/windows/assemble.ps1 new file mode 100644 index 00000000000..77385b56100 --- /dev/null +++ b/library/windows/assemble.ps1 @@ -0,0 +1,13 @@ +#!powershell +# WANT_JSON + +If ($args.Length -gt 0) +{ + $params = Get-Content $args[0] | ConvertFrom-Json; +} + +$data = 'FIXME'; + +$result = '{}' | ConvertFrom-Json; +$result | Add-Member -MemberType NoteProperty -Name fixme -Value $data; +echo $result | ConvertTo-Json; diff --git a/library/windows/async_wrapper.ps1 b/library/windows/async_wrapper.ps1 new file mode 100644 index 00000000000..77385b56100 --- /dev/null +++ b/library/windows/async_wrapper.ps1 @@ -0,0 +1,13 @@ +#!powershell +# WANT_JSON + +If ($args.Length -gt 0) +{ + $params = Get-Content $args[0] | ConvertFrom-Json; +} + +$data = 'FIXME'; + +$result = '{}' | ConvertFrom-Json; +$result | Add-Member -MemberType NoteProperty -Name fixme -Value $data; +echo $result | ConvertTo-Json; diff --git a/library/windows/command.ps1 b/library/windows/command.ps1 new file mode 100644 index 00000000000..77385b56100 --- /dev/null +++ b/library/windows/command.ps1 @@ -0,0 +1,13 @@ +#!powershell +# WANT_JSON + +If ($args.Length -gt 0) +{ + $params = Get-Content $args[0] | ConvertFrom-Json; +} + +$data = 'FIXME'; + +$result = '{}' | ConvertFrom-Json; +$result | Add-Member -MemberType NoteProperty -Name fixme -Value $data; +echo $result | ConvertTo-Json; diff --git a/library/windows/copy.ps1 b/library/windows/copy.ps1 new file mode 100644 index 00000000000..77385b56100 --- /dev/null +++ b/library/windows/copy.ps1 @@ -0,0 +1,13 @@ +#!powershell +# WANT_JSON + +If ($args.Length -gt 0) +{ + $params = Get-Content $args[0] | ConvertFrom-Json; +} + +$data = 'FIXME'; + +$result = '{}' | ConvertFrom-Json; +$result | Add-Member -MemberType NoteProperty -Name fixme -Value $data; +echo $result | ConvertTo-Json; diff --git a/library/windows/file.ps1 b/library/windows/file.ps1 new file mode 100644 index 00000000000..77385b56100 --- /dev/null +++ b/library/windows/file.ps1 @@ -0,0 +1,13 @@ +#!powershell +# WANT_JSON + +If ($args.Length -gt 0) +{ + $params = Get-Content $args[0] | ConvertFrom-Json; +} + +$data = 'FIXME'; + +$result = '{}' | ConvertFrom-Json; +$result | Add-Member -MemberType NoteProperty -Name fixme -Value $data; +echo $result | ConvertTo-Json; diff --git a/library/windows/ping.ps1 b/library/windows/ping.ps1 new file mode 100644 index 00000000000..02560873f5d --- /dev/null +++ b/library/windows/ping.ps1 @@ -0,0 +1,17 @@ +#!powershell +# WANT_JSON + +If ($args.Length -gt 0) +{ + $params = Get-Content $args[0] | ConvertFrom-Json; +} + +$data = 'pong'; +If (($params | Get-Member | Select-Object -ExpandProperty Name) -contains 'data') +{ + $data = $params.data; +} + +$result = '{}' | ConvertFrom-Json; +$result | Add-Member -MemberType NoteProperty -Name ping -Value $data; +echo $result | ConvertTo-Json; diff --git a/library/windows/slurp.ps1 b/library/windows/slurp.ps1 new file mode 100644 index 00000000000..93a698491fc --- /dev/null +++ b/library/windows/slurp.ps1 @@ -0,0 +1,33 @@ +#!powershell +# WANT_JSON + +$params = '{}' | ConvertFrom-Json; +If ($args.Length -gt 0) +{ + $params = Get-Content $args[0] | ConvertFrom-Json; +} + +$src = ''; +If (($params | Get-Member | Select-Object -ExpandProperty Name) -contains 'src') +{ + $src = $params.src; +} +Else +{ + If (($params | Get-Member | Select-Object -ExpandProperty Name) -contains 'path') + { + $src = $params.path; + } +} +If (-not $src) +{ + +} + +$bytes = [System.IO.File]::ReadAllBytes($src); +$content = [System.Convert]::ToBase64String($bytes); + +$result = '{}' | ConvertFrom-Json; +$result | Add-Member -MemberType NoteProperty -Name content -Value $content; +$result | Add-Member -MemberType NoteProperty -Name encoding -Value 'base64'; +echo $result | ConvertTo-Json; diff --git a/library/windows/stat.ps1 b/library/windows/stat.ps1 new file mode 100644 index 00000000000..f779b2531e3 --- /dev/null +++ b/library/windows/stat.ps1 @@ -0,0 +1,51 @@ +#!powershell +# WANT_JSON + +$params = '{}' | ConvertFrom-Json; +If ($args.Length -gt 0) +{ + $params = Get-Content $args[0] | ConvertFrom-Json; +} + +$path = ''; +If (($params | Get-Member | Select-Object -ExpandProperty Name) -contains 'path') +{ + $path = $params.path; +} + +$get_md5 = $TRUE; +If (($params | Get-Member | Select-Object -ExpandProperty Name) -contains 'get_md5') +{ + $get_md5 = $params.get_md5; +} + +$stat = '{}' | ConvertFrom-Json; +If (Test-Path $path) +{ + $stat | Add-Member -MemberType NoteProperty -Name exists -Value $TRUE; + $info = Get-Item $path; + If ($info.Directory) # Only files have the .Directory attribute. + { + $stat | Add-Member -MemberType NoteProperty -Name isdir -Value $FALSE; + $stat | Add-Member -MemberType NoteProperty -Name size -Value $info.Length; + } + Else + { + $stat | Add-Member -MemberType NoteProperty -Name isdir -Value $TRUE; + } +} +Else +{ + $stat | Add-Member -MemberType NoteProperty -Name exists -Value $FALSE; +} + +If ($get_md5 -and $stat.exists -and -not $stat.isdir) +{ + $path_md5 = (Get-FileHash -Path $path -Algorithm MD5).Hash.ToLower(); + $stat | Add-Member -MemberType NoteProperty -Name md5 -Value $path_md5; +} + +$result = '{}' | ConvertFrom-Json; +$result | Add-Member -MemberType NoteProperty -Name stat -Value $stat; +$result | Add-Member -MemberType NoteProperty -Name changed -Value $FALSE; +echo $result | ConvertTo-Json; diff --git a/library/windows/win_ping b/library/windows/win_ping new file mode 100644 index 00000000000..f64134454db --- /dev/null +++ b/library/windows/win_ping @@ -0,0 +1,87 @@ +#!powershell +# WANT_JSON + +If ($args.Length -gt 0) +{ + $params = Get-Content $args[0] | ConvertFrom-Json; +} + +$data = 'pong'; +If (($params | Get-Member | Select-Object -ExpandProperty Name) -contains 'data') +{ + $data = $params.data; +} + +$result = '{}' | ConvertFrom-Json; +$result | Add-Member -MemberType NoteProperty -Name ping -Value $data; +echo $result | ConvertTo-Json; + +# _______ _ _ +# |__ __| | (_) +# | | | |__ _ ___ +# | | | '_ \| / __| +# | | | | | | \__ \ +# __|_| |_| |_|_|___/ +# |_ _| +# | | ___ +# | | / __| +# _| |_\__ \ +# |___/\|___/ +# / \ +# / /\ \ +# / ____ \ +# /_/ \_\ +# | | +# | | __ _ _ __ __ _ ___ +# | | / _` | '__/ _` |/ _ \ +# | |___| (_| | | | (_| | __/ +# |______\__,_|_| \__, |\___| +# __/ | +# ____ _ |___/ +# | _ \| | | | +# | |_) | | ___ ___| | __ +# | _ <| |/ _ \ / __| |/ / +# | |_) | | (_) | (__| < +# |____/|_|\___/ \___|_|\_\ +# / __ \ / _| +# | | | | |_ +# | | | | _| +# | |__| | | +# \____/|_| __ __ +# / ____| | / _|/ _| +# | (___ | |_ _ _| |_| |_ +# \___ \| __| | | | _| _| +# ____) | |_| |_| | | | | +# |_____/ \__|\__,_|_| |_| +# | | | | +# | |_ _ ___| |_ +# _ | | | | / __| __| +# | |__| | |_| \__ \ |_ +# \____/_\__,_|___/\__| +# |__ __| +# | | ___ +# | |/ _ \ +# | | (_) | +# __|_|\___/ _ +# | \/ | | | +# | \ / | __ _| | _____ +# | |\/| |/ _` | |/ / _ \ +# | | | | (_| | < __/ +# |_|__|_|\__,_|_|\_\___| +# |__ __| | +# | | | |__ ___ +# | | | '_ \ / _ \ +# | | | | | | __/ +# __|_|_ |_| |_|\___| +# | ____(_) | +# | |__ _| | ___ +# | __| | | |/ _ \ +# | | | | | __/ +# |_|__ |_|_|\___| +# | _ \(_) +# | |_) |_ __ _ __ _ ___ _ __ +# | _ <| |/ _` |/ _` |/ _ \ '__| +# | |_) | | (_| | (_| | __/ | +# |____/|_|\__, |\__, |\___|_| +# __/ | __/ | +# |___/ |___/