Add shell_plugins to abstract shell-specific functions out of runner, add winrm connection plugin, add initial Windows modules.

pull/7861/head
Chris Church 10 years ago committed by Matt Martz
parent 627ff30a6f
commit 5dcaa30476

@ -167,7 +167,7 @@ class Runner(object):
self.module_vars = utils.default(module_vars, lambda: {}) self.module_vars = utils.default(module_vars, lambda: {})
self.default_vars = utils.default(default_vars, lambda: {}) self.default_vars = utils.default(default_vars, lambda: {})
self.always_run = None self.always_run = None
self.connector = connection.Connection(self) self.connector = connection.Connector(self)
self.conditional = conditional self.conditional = conditional
self.module_name = module_name self.module_name = module_name
self.forks = int(forks) self.forks = int(forks)
@ -198,7 +198,7 @@ class Runner(object):
self.vault_pass = vault_pass self.vault_pass = vault_pass
self.no_log = no_log 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 # 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 # 'smart' is the default since 1.2.1/1.3
cmd = subprocess.Popen(['ssh','-o','ControlPersist'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) cmd = subprocess.Popen(['ssh','-o','ControlPersist'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@ -275,7 +275,7 @@ class Runner(object):
afo.flush() afo.flush()
afo.close() afo.close()
remote = os.path.join(tmp, name) remote = conn.shell.join_path(tmp, name)
try: try:
conn.put_file(afile, remote) conn.put_file(afile, remote)
finally: 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? ''' ''' what environment variables to use when running the command? '''
shell_type = inject.get('ansible_shell_type') enviro = {}
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,
)
if self.environment: if self.environment:
enviro = template.template(self.basedir, self.environment, inject, convert_bare=True) enviro = template.template(self.basedir, self.environment, inject, convert_bare=True)
enviro = utils.safe_eval(enviro) enviro = utils.safe_eval(enviro)
if type(enviro) != dict: if type(enviro) != dict:
raise errors.AnsibleError("environment must be a dictionary, received %s" % enviro) raise errors.AnsibleError("environment must be a dictionary, received %s" % enviro)
default_environment.update(enviro)
result = "" return conn.shell.env_prefix(**enviro)
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
# ***************************************************** # *****************************************************
@ -425,7 +410,7 @@ class Runner(object):
if self._late_needs_tmp_path(conn, tmp, module_style): if self._late_needs_tmp_path(conn, tmp, module_style):
tmp = self._make_tmp_path(conn) 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' if (module_style != 'new'
or async_jid is not None or async_jid is not None
@ -435,12 +420,11 @@ class Runner(object):
or self.su): or self.su):
self._transfer_str(conn, tmp, module_name, module_data) 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')): 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 # deal with possible umask issues once sudo'ed to other user
cmd_chmod = "chmod a+r %s" % remote_module_path self._remote_chmod(conn, 'a+r', remote_module_path)
self._low_level_exec_command(conn, cmd_chmod, tmp, sudoable=False)
cmd = "" cmd = ""
in_data = None 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'): 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 # deal with possible umask issues once sudo'ed to other user
cmd_args_chmod = "chmod a+r %s" % argsfile self._remote_chmod(conn, 'a+r', argsfile)
self._low_level_exec_command(conn, cmd_args_chmod, tmp, sudoable=False)
if async_jid is None: if async_jid is None:
cmd = "%s %s" % (remote_module_path, argsfile) cmd = "%s %s" % (remote_module_path, argsfile)
@ -487,14 +470,14 @@ class Runner(object):
if not shebang: if not shebang:
raise errors.AnsibleError("module is missing interpreter line") raise errors.AnsibleError("module is missing interpreter line")
rm_tmp = None
cmd = " ".join([environment_string.strip(), shebang.replace("#!","").strip(), cmd])
cmd = cmd.strip()
if "tmp" in tmp and not C.DEFAULT_KEEP_REMOTE_FILES and not persist_files and delete_remote_tmp: 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': 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 # 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 sudoable = True
if module_name == "accelerate": 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'): 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 # 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 # 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) self._low_level_exec_command(conn, cmd2, tmp, sudoable=False)
data = utils.parse_json(res['stdout']) data = utils.parse_json(res['stdout'])
@ -776,7 +759,8 @@ class Runner(object):
if not self.accelerate_port: if not self.accelerate_port:
self.accelerate_port = C.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) actual_port = inject.get('ansible_ssh_port', port)
# the delegated host may have different SSH port configured, etc # the delegated host may have different SSH port configured, etc
@ -818,6 +802,18 @@ class Runner(object):
if delegate_to or host != actual_host: if delegate_to or host != actual_host:
conn.delegate = 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: except errors.AnsibleConnectionFailed, e:
result = dict(failed=True, msg="FAILED: %s" % str(e)) result = dict(failed=True, msg="FAILED: %s" % str(e))
@ -947,6 +943,9 @@ class Runner(object):
executable=None, su=False, in_data=None): executable=None, su=False, in_data=None):
''' execute a command string over SSH, return the output ''' ''' execute a command string over SSH, return the output '''
if not cmd:
return dict(stdout='', stderr='')
if executable is None: if executable is None:
executable = C.DEFAULT_EXECUTABLE executable = C.DEFAULT_EXECUTABLE
@ -954,16 +953,11 @@ class Runner(object):
su_user = self.su_user su_user = self.su_user
# compare connection user to (su|sudo)_user and disable if the same # compare connection user to (su|sudo)_user and disable if the same
if hasattr(conn, 'user'): # assume connection type is local if no user attribute
if (not su and conn.user == sudo_user) or (su and conn.user == su_user): this_user = getattr(conn, 'user', getpass.getuser())
sudoable = False if (not su and this_user == sudo_user) or (su and this_user == su_user):
su = False sudoable = False
else: su = False
# 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
if su: if su:
rc, stdin, stdout, stderr = conn.exec_command(cmd, 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): def _remote_md5(self, conn, tmp, path):
''' takes a remote md5sum without requiring python, and returns 1 if no file ''' ''' takes a remote md5sum without requiring python, and returns 1 if no file '''
cmd = conn.shell.md5(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)
data = self._low_level_exec_command(conn, cmd, tmp, sudoable=True) data = self._low_level_exec_command(conn, cmd, tmp, sudoable=True)
data2 = utils.last_non_blank_line(data['stdout']) data2 = utils.last_non_blank_line(data['stdout'])
try: try:
@ -1039,17 +1023,16 @@ class Runner(object):
def _make_tmp_path(self, conn): def _make_tmp_path(self, conn):
''' make and return a temporary path on a remote box ''' ''' make and return a temporary path on a remote box '''
basefile = 'ansible-tmp-%s-%s' % (time.time(), random.randint(0, 2**48)) basefile = 'ansible-tmp-%s-%s' % (time.time(), random.randint(0, 2**48))
basetmp = os.path.join(C.DEFAULT_REMOTE_TMP, basefile) use_system_tmp = False
if (self.sudo and self.sudo_user != 'root') or (self.su and self.su_user != 'root') and basetmp.startswith('$HOME'): if (self.sudo and self.sudo_user != 'root') or (self.su and self.su_user != 'root'):
basetmp = os.path.join('/tmp', basefile) 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')): 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 tmp_mode = 'a+rx'
cmd += ' && echo %s' % basetmp
cmd = conn.shell.mkdtemp(basefile, use_system_tmp, tmp_mode)
result = self._low_level_exec_command(conn, cmd, None, sudoable=False) result = self._low_level_exec_command(conn, cmd, None, sudoable=False)
# error handling on this seems a little aggressive? # error handling on this seems a little aggressive?
@ -1078,9 +1061,8 @@ class Runner(object):
def _remove_tmp_path(self, conn, tmp_path): def _remove_tmp_path(self, conn, tmp_path):
''' Remove a tmp_path. ''' ''' Remove a tmp_path. '''
if "-tmp-" in 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) self._low_level_exec_command(conn, cmd, None, sudoable=False)
# If we have gotten here we have a working ssh configuration. # If we have gotten here we have a working ssh configuration.
# If ssh breaks we could leave tmp directories out on the remote system. # If ssh breaks we could leave tmp directories out on the remote system.
@ -1094,7 +1076,7 @@ class Runner(object):
module_shebang, module_shebang,
module_data module_data
) = self._configure_module(conn, module_name, module_args, inject, complex_args) ) = 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) self._transfer_str(conn, tmp, module_name, module_data)
@ -1106,7 +1088,8 @@ class Runner(object):
''' find module and configure it ''' ''' find module and configure it '''
# Search module path(s) for named module. # 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: if module_path is None:
raise errors.AnsibleFileNotFound("module %s not found in %s" % (module_name, utils.plugins.module_finder.print_paths())) raise errors.AnsibleFileNotFound("module %s not found in %s" % (module_name, utils.plugins.module_finder.print_paths()))

@ -119,7 +119,7 @@ class ActionModule(object):
# fix file permissions when the copy is done as a different user # fix file permissions when the copy is done as a different user
if self.runner.sudo and self.runner.sudo_user != 'root': 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 # 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))) module_args = "%s src=%s dest=%s original_basename=%s" % (module_args, pipes.quote(xfered), pipes.quote(dest), pipes.quote(os.path.basename(src)))

@ -37,7 +37,7 @@ class ActionModule(object):
tmp = self.runner._make_tmp_path(conn) 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) (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, return self.runner._execute_module(conn, tmp, 'async_wrapper', module_args,
async_module=module_path, async_module=module_path,

@ -169,7 +169,7 @@ class ActionModule(object):
# This is kind of optimization - if user told us destination is # This is kind of optimization - if user told us destination is
# dir, do path manipulation right away, otherwise we still check # dir, do path manipulation right away, otherwise we still check
# for dest being a dir via remote call below. # 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) dest_file = os.path.join(dest, source_rel)
else: else:
dest_file = dest dest_file = dest
@ -186,7 +186,7 @@ class ActionModule(object):
return ReturnData(conn=conn, result=result) return ReturnData(conn=conn, result=result)
else: else:
# Append the relative source location to the destination and retry remote_md5. # 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) remote_md5 = self.runner._remote_md5(conn, tmp_path, dest_file)
if remote_md5 != '1' and not force: 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 # 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: 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: if raw:
# Continue to next iteration if raw is defined. # Continue to next iteration if raw is defined.

@ -59,7 +59,7 @@ class ActionModule(object):
source = os.path.expanduser(source) source = os.path.expanduser(source)
if flat: if flat:
if dest.endswith("/"): if dest.endswith("/"): # CCTODO
# if the path ends with "/", we'll use the source filename as the # if the path ends with "/", we'll use the source filename as the
# destination filename # destination filename
base = os.path.basename(source) base = os.path.basename(source)

@ -106,7 +106,8 @@ class ActionModule(object):
# transfer the file to a remote tmp location # transfer the file to a remote tmp location
source = source.replace('\x00', '') # why does this happen here? source = source.replace('\x00', '') # why does this happen here?
args = args.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', '') tmp_src = tmp_src.replace('\x00', '')
conn.put_file(source, tmp_src) 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 # 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 if ((self.runner.sudo and self.runner.sudo_user != 'root') or
(self.runner.su and self.runner.su_user != 'root')): (self.runner.su and self.runner.su_user != 'root')):
cmd_args_chmod = "chmod a+rx %s" % tmp_src chmod_mode = 'a+rx'
sudoable = False sudoable = False
else: else:
cmd_args_chmod = "chmod +rx %s" % tmp_src chmod_mode = '+rx'
self.runner._low_level_exec_command(conn, cmd_args_chmod, tmp, sudoable=sudoable, su=self.runner.su) 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 # 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 module_args = env_string + tmp_src + ' ' + args
handler = utils.plugins.action_loader.get('raw', self.runner) handler = utils.plugins.action_loader.get('raw', self.runner)
@ -130,7 +131,7 @@ class ActionModule(object):
# clean up after # clean up after
if "tmp" in tmp and not C.DEFAULT_KEEP_REMOTE_FILES: 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 result.result['changed'] = True

@ -79,7 +79,7 @@ class ActionModule(object):
source = utils.path_dwim(self.runner.basedir, source) source = utils.path_dwim(self.runner.basedir, source)
if dest.endswith("/"): if dest.endswith("/"): # CCTODO
base = os.path.basename(source) base = os.path.basename(source)
dest = os.path.join(dest, base) 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 # fix file permissions when the copy is done as a different user
if self.runner.sudo and self.runner.sudo_user != 'root': 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 # 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))) module_args = "%s src=%s dest=%s original_basename=%s" % (module_args, pipes.quote(xfered), pipes.quote(dest), pipes.quote(os.path.basename(source)))

@ -77,7 +77,7 @@ class ActionModule(object):
# fix file permissions when the copy is done as a different user # fix file permissions when the copy is done as a different user
if copy: if copy:
if self.runner.sudo and self.runner.sudo_user != 'root': 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))) module_args = "%s src=%s original_basename=%s" % (module_args, pipes.quote(tmp_src), pipes.quote(os.path.basename(source)))
else: else:
module_args = "%s original_basename=%s" % (module_args, pipes.quote(os.path.basename(source))) module_args = "%s original_basename=%s" % (module_args, pipes.quote(os.path.basename(source)))

@ -25,18 +25,15 @@ import ansible.constants as C
import os import os
import os.path import os.path
class Connection(object): class Connector(object):
''' Handles abstract connections to remote hosts ''' ''' Handles abstract connections to remote hosts '''
def __init__(self, runner): def __init__(self, runner):
self.runner = runner self.runner = runner
def connect(self, host, port, user, password, transport, private_key_file): 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) 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: if conn is None:
raise AnsibleError("unsupported connection type: %s" % transport) raise AnsibleError("unsupported connection type: %s" % transport)
self.active = conn.connect() self.active = conn.connect()
return self.active return self.active

@ -0,0 +1,254 @@
# (c) 2014, Chris Church <chris@ninemoreminutes.com>
#
# 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 <http://www.gnu.org/licenses/>.
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': <protocol instance>
}
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

@ -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)

@ -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)

@ -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)

@ -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

@ -139,21 +139,25 @@ class PluginLoader(object):
if directory not in self._extra_dirs: if directory not in self._extra_dirs:
self._extra_dirs.append(directory) self._extra_dirs.append(directory)
def find_plugin(self, name): def find_plugin(self, name, suffixes=None):
''' Find a plugin named name ''' ''' Find a plugin named name '''
if name in self._plugin_path_cache: if not suffixes:
return self._plugin_path_cache[name] if self.class_name:
suffixes = ['.py']
else:
suffixes = ['']
suffix = ".py" for suffix in suffixes:
if not self.class_name: full_name = '%s%s' % (name, suffix)
suffix = "" if full_name in self._plugin_path_cache:
return self._plugin_path_cache[full_name]
for i in self._get_paths(): for i in self._get_paths():
path = os.path.join(i, "%s%s" % (name, suffix)) path = os.path.join(i, full_name)
if os.path.isfile(path): if os.path.isfile(path):
self._plugin_path_cache[name] = path self._plugin_path_cache[full_name] = path
return path return path
return None return None
@ -212,6 +216,13 @@ connection_loader = PluginLoader(
aliases={'paramiko': 'paramiko_ssh'} aliases={'paramiko': 'paramiko_ssh'}
) )
shell_loader = PluginLoader(
'ShellModule',
'ansible.runner.shell_plugins',
'shell_plugins',
'shell_plugins',
)
module_finder = PluginLoader( module_finder = PluginLoader(
'', '',
'', '',

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;
# _______ _ _
# |__ __| | (_)
# | | | |__ _ ___
# | | | '_ \| / __|
# | | | | | | \__ \
# __|_| |_| |_|_|___/
# |_ _|
# | | ___
# | | / __|
# _| |_\__ \
# |___/\|___/
# / \
# / /\ \
# / ____ \
# /_/ \_\
# | |
# | | __ _ _ __ __ _ ___
# | | / _` | '__/ _` |/ _ \
# | |___| (_| | | | (_| | __/
# |______\__,_|_| \__, |\___|
# __/ |
# ____ _ |___/
# | _ \| | | |
# | |_) | | ___ ___| | __
# | _ <| |/ _ \ / __| |/ /
# | |_) | | (_) | (__| <
# |____/|_|\___/ \___|_|\_\
# / __ \ / _|
# | | | | |_
# | | | | _|
# | |__| | |
# \____/|_| __ __
# / ____| | / _|/ _|
# | (___ | |_ _ _| |_| |_
# \___ \| __| | | | _| _|
# ____) | |_| |_| | | | |
# |_____/ \__|\__,_|_| |_|
# | | | |
# | |_ _ ___| |_
# _ | | | | / __| __|
# | |__| | |_| \__ \ |_
# \____/_\__,_|___/\__|
# |__ __|
# | | ___
# | |/ _ \
# | | (_) |
# __|_|\___/ _
# | \/ | | |
# | \ / | __ _| | _____
# | |\/| |/ _` | |/ / _ \
# | | | | (_| | < __/
# |_|__|_|\__,_|_|\_\___|
# |__ __| |
# | | | |__ ___
# | | | '_ \ / _ \
# | | | | | | __/
# __|_|_ |_| |_|\___|
# | ____(_) |
# | |__ _| | ___
# | __| | | |/ _ \
# | | | | | __/
# |_|__ |_|_|\___|
# | _ \(_)
# | |_) |_ __ _ __ _ ___ _ __
# | _ <| |/ _` |/ _` |/ _ \ '__|
# | |_) | | (_| | (_| | __/ |
# |____/|_|\__, |\__, |\___|_|
# __/ | __/ |
# |___/ |___/
Loading…
Cancel
Save