Merge pull request #12613 from plastikos/improvement-python_thin_shim

IMPROVEMENT: Use Python for the thin shim instead of /bin/sh @thatch45
This commit is contained in:
Thomas S Hatch 2014-05-08 11:13:09 -06:00
commit be67d9d22d
3 changed files with 409 additions and 197 deletions

View File

@ -22,6 +22,8 @@ import salt.client.ssh.shell
import salt.client.ssh.wrapper
import salt.config
import salt.exceptions
import salt.exitcodes
import salt.log
import salt.loader
import salt.minion
import salt.roster
@ -34,143 +36,115 @@ import salt.utils.thin
import salt.utils.verify
from salt._compat import string_types
# This is just a delimiter to distinguish the beginning of salt STDOUT. There
# is no special meaning
# The directory where salt thin is deployed
DEFAULT_THIN_DIR = '/tmp/.salt'
# RSTR is just a delimiter to distinguish the beginning of salt STDOUT
# and STDERR. There is no special meaning. Messages prior to RSTR in
# stderr and stdout are either from SSH or from the shim.
#
# RSTR on both stdout and stderr:
# no errors in SHIM - output after RSTR is from salt
# No RSTR in stderr, RSTR in stdout:
# no errors in SSH_SH_SHIM, but SHIM commands for salt master are after
# RSTR in stdout
# No RSTR in stderr, no RSTR in stdout:
# Failure in SHIM
# RSTR in stderr, No RSTR in stdout:
# Undefined behavior
RSTR = '_edbc7885e4f9aac9b83b35999b68d015148caf467b78fa39c05f669c0ff89878'
# The regex to find RSTR in output - Must be on an output line by itself
# NOTE - must use non-grouping match groups or output splitting will fail.
RSTR_RE = r'(?:^|\r?\n)' + RSTR + '(?:\r?\n|$)'
# This shim facilitates remote salt-call operations
# - Uses /bin/sh for maximum compatibility
# METHODOLOGY:
#
# 1) Make the _thinnest_ /bin/sh shim (SSH_SH_SHIM) to find the python
# interpreter and get it invoked
# 2) Once a qualified python is found start it with the SSH_PY_SHIM
# NOTE:
# * SSH_SH_SHIM is generic and can be used to load+exec *any* python
# script on the target.
# * SSH_PY_SHIM is in a separate file rather than stuffed in a string
# in salt/client/ssh/__init__.py - this makes testing *easy* because
# it can be invoked directly.
# * SSH_PY_SHIM is base64 encoded and formatted into the SSH_SH_SHIM
# string. This makes the python script "armored" so that it can
# all be passed in the SSH command and will not need special quoting
# (which likely would be impossibe to do anyway)
# * The formatted SSH_SH_SHIM with the SSH_PY_SHIM payload is a bit
# big (~7.5k). If this proves problematic for an SSH command we
# might try simply invoking "/bin/sh -s" and passing the formatted
# SSH_SH_SHIM on SSH stdin.
# NOTE: there are two passes of formatting:
# 1) Substitute in static values
# - EX_THIN_PYTHON_OLD - exit code if a suitable python is not found
# 2) Substitute in instance-specific commands
# - DEBUG - enable shim debugging (any non-zero string enables)
# - SUDO - load python and execute as root (any non-zero string enables)
# - SSH_PY_CODE - base64-encoded python code to execute
# - SSH_PY_ARGS - arguments to pass to python code
SSH_SH_SHIM = r'''/bin/sh << 'EOF'
# This shim generically loads python code . . . and *no* more.
# - Uses /bin/sh for maximum compatibility - then jumps to
# python for ultra-maximum compatibility.
#
# 1. Identify a suitable python
# 2. Test for remote salt-call and version if present
# 3. Signal to (re)deploy if missing or out of date
# - If this is a a first deploy, then test python version
# 4. Perform salt-call
# 2. Jump to python
# Note there are two levels of formatting.
# - First format pass inserts salt version and delimiter
# - Second pass at run-time and inserts optional "sudo" and command
SSH_SHIM = r'''/bin/sh << 'EOF'
#!/bin/sh
set -e
set -u
MISS_PKG=""
DEBUG="{{DEBUG}}"
if [ -n "$DEBUG" ]; then
set -x
fi
command -v tar >/dev/null
if [ $? -ne 0 ]
then
MISS_PKG="$MISS_PKG tar"
fi
SUDO=""
if [ -n "{{SUDO}}" ]; then
SUDO="sudo root -c"
fi
for py_candidate in \
python27 \
python2.7 \
python26 \
python2.6 \
python2 \
python ;
do
command -v $py_candidate >/dev/null
if [ $? -eq 0 ]
then
PYTHON="$py_candidate"
break
fi
done
EX_PYTHON_OLD={EX_THIN_PYTHON_OLD} # Python interpreter is too old and incompatible
if [ -z "$PYTHON" ]
then
MISS_PKG="$MISS_PKG python"
fi
PYTHON_CMDS="
python27
python2.7
python26
python2.6
python2
python
"
SALT="/tmp/.salt/salt-call"
if [ "{{2}}" = "md5" ]
then
for md5_candidate in \
md5sum \
md5 \
csum \
digest ;
do
command -v $md5_candidate >/dev/null
if [ $? -eq 0 ]
then
SUMCHECK="$md5_candidate"
break
fi
done
else
command -v "{{2}}" >/dev/null
if [ $? -eq 0 ]
then
SUMCHECK="{{2}}"
fi
fi
main()
{{{{
local py_cmd
local py_cmd_path
for py_cmd in $PYTHON_CMDS; do
if "$py_cmd" -c 'import sys; sys.exit(not sys.hexversion >= 0x02060000);' >/dev/null 2>&1; then
local py_cmd_path
py_cmd_path=`"$py_cmd" -c 'import sys; print sys.executable;'`
echo "FOUND: $py_cmd_path" >&2
exec $SUDO "$py_cmd_path" -c 'exec """{{SSH_PY_CODE}}""".decode("base64")' -- {{SSH_PY_ARGS}}
else
echo "WARNING: $py_cmd not found or too old" >&2
fi
done
if [ -z "$SUMCHECK" ]
then
MISS_PKG="$MISS_PKG md5sum/md5/csum"
fi
echo "ERROR: Unable to locate appropriate python command" >&2
exit $EX_PYTHON_OLD
}}}}
if [ -n "$MISS_PKG" ]
then
echo "The following required Packages are missing: $MISS_PKG" >&2
exit 127
fi
main
EOF'''.format(
EX_THIN_PYTHON_OLD=salt.exitcodes.EX_THIN_PYTHON_OLD,
)
# MD5 check for systems with a BSD userland (includes OSX).
if [ "$SUMCHECK" = "md5" ]
then
SUMCHECK="md5 -q"
# MD5 check for AIX systems.
elif [ "$SUMCHECK" = "csum" ]
then
SUMCHECK="csum -h MD5"
# MD5 check for Solaris systems.
elif [ "$SUMCHECK" = "digest" ]
then
SUMCHECK="digest -a md5"
fi
if [ -f "$SALT" ]
then
if [ "`cat /tmp/.salt/version`" != "{0}" ]
then
{{0}} rm -rf /tmp/.salt && mkdir -m 0700 -p /tmp/.salt
if [ $? -ne 0 ]; then
exit 1
fi
echo "{1}"
echo "deploy"
exit 1
fi
else
PY_TOO_OLD=`$PYTHON -c "import sys; print sys.hexversion < 0x02060000"`
if [ "$PY_TOO_OLD" = "True" ];
then
echo "Python too old" >&2
exit 1
fi
if [ -f /tmp/.salt/salt-thin.tgz ]
then
if [ "`$SUMCHECK /tmp/.salt/salt-thin.tgz | cut -f1 -d\ `" = "{{3}}" ]
then
cd /tmp/.salt/ && gunzip -c salt-thin.tgz | {{0}} tar opxvf -
else
echo "Mismatched checksum for /tmp/.salt/salt-thin.tgz" >&2
exit 1
fi
else
mkdir -m 0700 -p /tmp/.salt
echo "{1}"
echo "deploy"
exit 1
fi
fi
echo "{{4}}" > /tmp/.salt/minion
echo "{1}"
eval {{0}} $PYTHON $SALT --local --out json -l quiet {{1}} -c /tmp/.salt
EOF'''.format(salt.__version__, RSTR)
with open(os.path.join(os.path.dirname(__file__), 'ssh_py_shim.py')) as ssh_py_shim:
SSH_PY_SHIM = ''.join(ssh_py_shim.readlines()).encode('base64')
log = logging.getLogger(__name__)
@ -337,9 +311,6 @@ class SSH(object):
**target)
ret = {'id': single.id}
stdout, stderr, retcode = single.run()
if stdout.startswith('deploy'):
single.deploy()
stdout, stderr, retcode = single.run()
# This job is done, yield
try:
data = salt.utils.find_json(stdout)
@ -522,7 +493,7 @@ class Single(object):
self.shell = salt.client.ssh.shell.Shell(opts, **args)
self.minion_config = yaml.dump(
{
'root_dir': '/tmp/.salt/running_data',
'root_dir': os.path.join(DEFAULT_THIN_DIR, 'running_data'),
'id': self.id,
}).strip()
self.target = kwargs
@ -570,8 +541,9 @@ class Single(object):
'''
thin = salt.utils.thin.gen_thin(self.opts['cachedir'])
self.shell.send(
thin,
'/tmp/.salt/salt-thin.tgz')
thin,
os.path.join(DEFAULT_THIN_DIR, 'salt-thin.tgz'),
)
return True
def run(self, deploy_attempted=False):
@ -598,10 +570,6 @@ class Single(object):
else:
stdout, stderr, retcode = self.cmd_block()
if stdout.startswith('deploy') and not deploy_attempted:
self.deploy()
return self.run(deploy_attempted=True)
return stdout, stderr, retcode
def run_wfunc(self):
@ -668,14 +636,39 @@ class Single(object):
ret = json.dumps(self.wfuncs[self.fun](*self.args, **self.kwargs))
return ret, '', None
def _cmd_str(self):
'''
Prepare the command string
'''
sudo = 'sudo' if self.target['sudo'] else ''
thin_sum = salt.utils.thin.thin_sum(self.opts['cachedir'], 'sha1')
debug = ''
if salt.log.LOG_LEVELS['debug'] >= salt.log.LOG_LEVELS[self.opts['log_level']]:
debug = '1'
ssh_py_shim_args = [
'--config', self.minion_config,
'--delimeter', RSTR,
'--saltdir', DEFAULT_THIN_DIR,
'--checksum', thin_sum,
'--hashfunc', 'sha1',
'--version', salt.__version__,
'--',
] + self.argv
cmd = SSH_SH_SHIM.format(
DEBUG=debug,
SUDO=sudo,
SSH_PY_CODE=SSH_PY_SHIM,
SSH_PY_ARGS=' '.join([self._escape_arg(arg) for arg in ssh_py_shim_args]),
)
return cmd
def cmd(self):
'''
Prepare the pre-check command to send to the subsystem
'''
# 1. check if python is on the target
# 2. check is salt-call is on the target
# 3. deploy salt-thin
# 4. execute command
if self.fun.startswith('state.highstate'):
self.highstate_seed()
elif self.fun.startswith('state.sls'):
@ -684,88 +677,132 @@ class Single(object):
salt.utils.args.parse_input(self.args)
)
self.sls_seed(*args, **kwargs)
cmd_str = ' '.join([self._escape_arg(arg) for arg in self.argv])
sudo = 'sudo' if self.target['sudo'] else ''
thin_sum = salt.utils.thin.thin_sum(
self.opts['cachedir'],
self.opts['hash_type'])
cmd = SSH_SHIM.format(
sudo,
cmd_str,
self.opts['hash_type'],
thin_sum,
self.minion_config)
for stdout, stderr, retcode in self.shell.exec_nb_cmd(cmd):
cmd_str = self._cmd_str()
for stdout, stderr, retcode in self.shell.exec_nb_cmd(cmd_str):
yield stdout, stderr, retcode
def cmd_block(self, is_retry=False):
'''
Prepare the pre-check command to send to the subsystem
'''
# 1. check if python is on the target
# 2. check is salt-call is on the target
# 3. deploy salt-thin
# 4. execute command
cmd_str = ' '.join([self._escape_arg(arg) for arg in self.argv])
sudo = 'sudo' if self.target['sudo'] else ''
thin_sum = salt.utils.thin.thin_sum(
self.opts['cachedir'],
self.opts['hash_type'])
cmd = SSH_SHIM.format(
sudo,
cmd_str,
self.opts['hash_type'],
thin_sum,
self.minion_config)
log.debug('Performing shimmed command as follows:\n{0}'.format(cmd))
stdout, stderr, retcode = self.shell.exec_cmd(cmd)
# 1. execute SHIM + command
# 2. check if SHIM returns a master request or if it completed
# 3. handle any master request
# 4. re-execute SHIM + command
# 5. split SHIM results from command results
# 6. return command results
log.debug('Performing shimmed, blocking command as follows:\n{0}'.format(' '.join(self.argv)))
cmd_str = self._cmd_str()
stdout, stderr, retcode = self.shell.exec_cmd(cmd_str)
log.debug('STDOUT {1}\n{0}'.format(stdout, self.target['host']))
log.debug('STDERR {1}\n{0}'.format(stderr, self.target['host']))
log.debug('RETCODE {1}\n{0}'.format(retcode, self.target['host']))
log.debug('RETCODE {1}: {0}'.format(retcode, self.target['host']))
error = self.categorize_shim_errors(stdout, stderr, retcode)
if error:
return 'ERROR: {0}'.format(error), stderr, retcode
if RSTR in stdout:
stdout = stdout.split(RSTR)[1].strip()
if stdout.startswith('deploy'):
self.deploy()
stdout, stderr, retcode = self.shell.exec_cmd(cmd)
if RSTR in stdout:
stdout = stdout.split(RSTR)[1].strip()
# FIXME: this discards output from ssh_shim if the shim succeeds. It should
# always save the shim output regardless of shim success or failure.
if re.search(RSTR_RE, stdout):
stdout = re.split(RSTR_RE, stdout, 1)[1].strip()
else:
# This is actually an error state prior to the shim but let it fall through
pass
if re.search(RSTR_RE, stderr):
# Found RSTR in stderr which means SHIM completed and only
# and remaining output is only from salt.
stderr = re.split(RSTR_RE, stderr, 1)[1].strip()
else:
# RSTR was found in stdout but not stderr - which means there
# is a SHIM command for the master.
shim_command = re.split(r'\r?\n', stdout, 1)[0].strip()
if 'deploy' == shim_command and retcode == salt.exitcodes.EX_THIN_DEPLOY:
self.deploy()
stdout, stderr, retcode = self.shell.exec_cmd(cmd_str)
if not re.search(RSTR_RE, stdout) or not re.search(RSTR_RE, stderr):
# If RSTR is not seen in both stdout and stderr then there
# was a thin deployment problem.
return 'ERROR: Failure deploying thin: {0}'.format(stdout), stderr, retcode
stdout = re.split(RSTR_RE, stdout, 1)[1].strip()
stderr = re.split(RSTR_RE, stderr, 1)[1].strip()
return stdout, stderr, retcode
def categorize_shim_errors(self, stdout, stderr, retcode):
# Unused stdout and retcode for now but these may be used to
# categorize errors
_ = stdout
_ = retcode
if re.search(RSTR_RE, stdout):
# RSTR was found in stdout which means that the shim
# functioned without *errors* . . . but there may be shim
# commands
return None
if re.search(RSTR_RE, stderr):
# Undefined state
return 'Undefined SHIM state'
if stderr.startswith('Permission denied'):
# SHIM was not even reached
return None
perm_error_fmt = 'Permissions problem, target user may need '\
'to be root or use sudo:\n {0}'
if stderr.startswith('Permission denied'):
return None
errors = [
('sudo: no tty present and no askpass program specified',
'sudo expected a password, NOPASSWD required'),
('Python too old',
'salt requires python 2.6 or better on target hosts'),
('sudo: sorry, you must have a tty to run sudo',
'sudo is configured with requiretty'),
('Failed to open log file',
perm_error_fmt.format(stderr)),
('Permission denied:.*/salt',
perm_error_fmt.format(stderr)),
('Failed to create directory path.*/salt',
perm_error_fmt.format(stderr)),
]
(
(),
'sudo: no tty present and no askpass program specified',
'sudo expected a password, NOPASSWD required'
),
(
(salt.exitcodes.EX_THIN_PYTHON_OLD,),
'Python interpreter is too old',
'salt requires python 2.6 or newer on target hosts'
),
(
(salt.exitcodes.EX_THIN_CHECKSUM,),
'checksum mismatched',
'The salt thin transfer was corrupted'
),
(
(os.EX_CANTCREAT,),
'salt path .* exists but is not a directory',
'A necessary path for salt thin unexpectedly exists:\n ' + stderr,
),
(
(),
'sudo: sorry, you must have a tty to run sudo',
'sudo is configured with requiretty'
),
(
(),
'Failed to open log file',
perm_error_fmt.format(stderr)
),
(
(),
'Permission denied:.*/salt',
perm_error_fmt.format(stderr)
),
(
(),
'Failed to create directory path.*/salt',
perm_error_fmt.format(stderr)
),
(
(os.EX_SOFTWARE,),
'exists but is not',
'An internal error occurred with the shim, please investigate:\n ' + stderr,
),
]
for error in errors:
if re.search(error[0], stderr):
return error[1]
if retcode in error[0] or re.search(error[1], stderr):
return error[2]
return None
def sls_seed(self,
@ -822,8 +859,9 @@ class Single(object):
file_refs = lowstate_file_refs(chunks)
trans_tar = prep_trans_tar(self.opts, chunks, file_refs)
self.shell.send(
trans_tar,
'/tmp/salt_state.tgz')
trans_tar,
os.path.join(DEFAULT_THIN_DIR, 'salt_state.tgz'),
)
self.argv = ['state.pkg', '/tmp/salt_state.tgz', 'test={0}'.format(test)]

View File

@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
'''
This is a shim that handles checking and updating salt thin and
then invoking thin.
This is not intended to be instantiated as a module, rather it is a
helper script used by salt.client.ssh.Single. It is here, in a
seperate file, for convenience of development.
'''
import optparse
import hashlib
import tarfile
import shutil
import sys
import os
THIN_ARCHIVE = 'salt-thin.tgz'
# FIXME - it would be ideal if these could be obtained directly from
# salt.exitcodes rather than duplicated.
EX_THIN_DEPLOY = 11
EX_THIN_CHECKSUM = 12
OPTIONS = None
ARGS = None
def parse_argv(argv):
global OPTIONS
global ARGS
oparser = optparse.OptionParser(usage="%prog -- [SHIM_OPTIONS] -- [SALT_OPTIONS]")
oparser.add_option(
"-c", "--config",
default='',
help="YAML configuration for salt thin",
)
oparser.add_option(
"-d", "--delimeter",
help="Delimeter string (viz. magic string) to indicate beginning of salt output",
)
oparser.add_option(
"-s", "--saltdir",
help="Directory where salt thin is or will be installed.",
)
oparser.add_option(
"--sum", "--checksum",
dest="checksum",
help="Salt thin checksum",
)
oparser.add_option(
"--hashfunc",
default='sha1',
help="Hash function for computing checksum",
)
oparser.add_option(
"-v", "--version",
help="Salt thin version to be deployed/verified",
)
if argv and '--' not in argv:
oparser.error('A "--" argument must be the initial argument indicating the start of options to this script')
(OPTIONS, ARGS) = oparser.parse_args(argv[argv.index('--')+1:])
for option in (
'delimeter',
'saltdir',
'checksum',
'version',
):
if getattr(OPTIONS, option, None):
continue
oparser.error('Option "--{0}" is required.'.format(option))
def need_deployment():
if os.path.exists(OPTIONS.saltdir):
shutil.rmtree(OPTIONS.saltdir)
old_umask = os.umask(0077)
os.makedirs(OPTIONS.saltdir)
os.umask(old_umask)
# Delimeter emitted on stdout *only* to indicate shim message to master.
sys.stdout.write("%s\ndeploy\n".format(OPTIONS.delimeter))
sys.exit(EX_THIN_DEPLOY)
# Adapted from salt.utils.get_hash()
def get_hash(path, form='md5', chunk_size=4096):
try:
hash_type = getattr(hashlib, form)
except AttributeError:
raise ValueError('Invalid hash type: {0}'.format(form))
with open(path, 'rb') as ifile:
hash_obj = hash_type()
# read the file in in chunks, not the entire file
for chunk in iter(lambda: ifile.read(chunk_size), b''):
hash_obj.update(chunk)
return hash_obj.hexdigest()
def unpack_thin(thin_path):
tfile = tarfile.TarFile.gzopen(thin_path)
tfile.extractall(path=OPTIONS.saltdir)
tfile.close()
os.unlink(thin_path)
def main(argv):
parse_argv(argv)
thin_path = os.path.join(OPTIONS.saltdir, THIN_ARCHIVE)
if os.path.exists(thin_path):
if OPTIONS.checksum != get_hash(thin_path, OPTIONS.hashfunc):
os.unlink(thin_path)
sys.stderr.write('WARNING: checksum mismatch for "{0}"\n'.format(thin_path))
sys.exit(EX_THIN_CHECKSUM)
unpack_thin(thin_path)
# Salt thin now is available to use
else:
if not os.path.exists(OPTIONS.saltdir):
need_deployment()
if not os.path.isdir(OPTIONS.saltdir):
sys.stderr.write('ERROR: salt path "{0}" exists but is not a directory\n'.format(OPTIONS.saltdir))
sys.exit(os.EX_CANTCREAT)
version_path = os.path.join(OPTIONS.saltdir, 'version')
if not os.path.exists(version_path) or not os.path.isfile(version_path):
sys.stderr.write('WARNING: Unable to locate current thin version.\n')
need_deployment()
with open(version_path, 'r') as vpo:
cur_version = vpo.readline().strip()
if cur_version != OPTIONS.version:
sys.stderr.write('WARNING: current thin version is not up-to-date.\n')
need_deployment()
# Salt thin exists and is up-to-date - fall through and use it
salt_call_path = os.path.join(OPTIONS.saltdir, 'salt-call')
if not os.path.isfile(salt_call_path):
sys.stderr.write('ERROR: thin is missing "{0}"\n'.format(salt_call_path))
sys.exit(os.EX_SOFTWARE)
with open(os.path.join(OPTIONS.saltdir, 'minion'), 'w') as config:
config.write(OPTIONS.config + '\n')
salt_argv = [
sys.executable,
salt_call_path,
'--local',
'--out', 'json',
'-l', 'quiet',
'-c', OPTIONS.saltdir,
'--',
] + ARGS
sys.stderr.write('SALT_ARGV: {0}\n'.format(salt_argv))
# Only emit the delimiter on *both* stdout and stderr when completely successful.
# Yes, the flush() is necessary.
sys.stdout.write(OPTIONS.delimeter + '\n')
sys.stdout.flush()
sys.stderr.write(OPTIONS.delimeter + '\n')
sys.stderr.flush()
os.execv(sys.executable, salt_argv)
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@ -8,3 +8,8 @@ prefix or in `sysexits.h`).
# Too many situations use "exit 1" - try not to use it when something
# else is more appropriate.
EX_GENERIC = 1
# Salt SSH "Thin" deployment failures
EX_THIN_PYTHON_OLD = 10
EX_THIN_DEPLOY = 11
EX_THIN_CHECKSUM = 12