Merge pull request #41398 from rallytime/merge-2016.11

[2016.11] Merge forward from 2016.3 to 2016.11
This commit is contained in:
Nicole Thomas 2017-05-26 09:17:48 -06:00 committed by GitHub
commit 824f2d3b69
11 changed files with 381 additions and 107 deletions

View File

@ -2,32 +2,42 @@
``salt-cp`` ``salt-cp``
=========== ===========
Copy a file to a set of systems Copy a file or files to one or more minions
Synopsis Synopsis
======== ========
.. code-block:: bash .. code-block:: bash
salt-cp '*' [ options ] SOURCE DEST salt-cp '*' [ options ] SOURCE [SOURCE2 SOURCE3 ...] DEST
salt-cp -E '.*' [ options ] SOURCE DEST salt-cp -E '.*' [ options ] SOURCE [SOURCE2 SOURCE3 ...] DEST
salt-cp -G 'os:Arch.*' [ options ] SOURCE DEST salt-cp -G 'os:Arch.*' [ options ] SOURCE [SOURCE2 SOURCE3 ...] DEST
Description Description
=========== ===========
Salt copy copies a local file out to all of the Salt minions matched by the salt-cp copies files from the master to all of the Salt minions matched by the
given target. specified target expression.
Salt copy is only intended for use with small files (< 100KB). If you need .. note::
to copy large files out to minions please use the cp.get_file function. salt-cp uses Salt's publishing mechanism. This means the privacy of the
contents of the file on the wire is completely dependent upon the transport
in use. In addition, if the master or minion is running with debug logging,
the contents of the file will be logged to disk.
Note: salt-cp uses salt's publishing mechanism. This means the privacy of the In addition, this tool is less efficient than the Salt fileserver when
contents of the file on the wire is completely dependent upon the transport copying larger files. It is recommended to instead use
in use. In addition, if the salt-master is running with debug logging it is :py:func:`cp.get_file <salt.modules.cp.get_file>` to copy larger files to
possible that the contents of the file will be logged to disk. minions. However, this requires the file to be located within one of the
fileserver directories.
.. versionchanged:: 2016.3.7,2016.11.6,Nitrogen
Compression support added, disable with ``-n``. Also, if the destination
path ends in a path separator (i.e. ``/``, or ``\`` on Windows, the
desitination will be assumed to be a directory. Finally, recursion is now
supported, allowing for entire directories to be copied.
Options Options
======= =======
@ -46,6 +56,12 @@ Options
.. include:: _includes/target-selection.rst .. include:: _includes/target-selection.rst
.. option:: -n, --no-compression
Disable gzip compression.
.. versionadded:: 2016.3.7,2016.11.6,Nitrogen
See also See also
======== ========

View File

@ -9,15 +9,26 @@ Salt-cp can be used to distribute configuration files
# Import python libs # Import python libs
from __future__ import print_function from __future__ import print_function
from __future__ import absolute_import from __future__ import absolute_import
import base64
import errno
import logging
import os import os
import re
import sys import sys
# Import salt libs # Import salt libs
import salt.client import salt.client
from salt.utils import parsers, print_cli import salt.utils.gzip_util
import salt.utils.minions
from salt.utils import parsers, to_bytes
from salt.utils.verify import verify_log from salt.utils.verify import verify_log
import salt.output import salt.output
# Import 3rd party libs
from salt.ext import six
log = logging.getLogger(__name__)
class SaltCPCli(parsers.SaltCPOptionParser): class SaltCPCli(parsers.SaltCPOptionParser):
''' '''
@ -44,65 +55,168 @@ class SaltCP(object):
''' '''
def __init__(self, opts): def __init__(self, opts):
self.opts = opts self.opts = opts
self.is_windows = salt.utils.is_windows()
def _file_dict(self, fn_): def _mode(self, path):
''' if self.is_windows:
Take a path and return the contents of the file as a string return None
''' try:
if not os.path.isfile(fn_): return int(oct(os.stat(path).st_mode)[-4:], 8)
err = 'The referenced file, {0} is not available.'.format(fn_) except (TypeError, IndexError, ValueError):
sys.stderr.write(err + '\n') return None
sys.exit(42)
with salt.utils.fopen(fn_, 'r') as fp_:
data = fp_.read()
return {fn_: data}
def _recurse_dir(self, fn_, files=None): def _recurse(self, path):
''' '''
Recursively pull files from a directory Get a list of all specified files
'''
if files is None:
files = {}
for base in os.listdir(fn_):
path = os.path.join(fn_, base)
if os.path.isdir(path):
files.update(self._recurse_dir(path))
else:
files.update(self._file_dict(path))
return files
def _load_files(self):
'''
Parse the files indicated in opts['src'] and load them into a python
object for transport
''' '''
files = {} files = {}
empty_dirs = []
try:
sub_paths = os.listdir(path)
except OSError as exc:
if exc.errno == errno.ENOENT:
# Path does not exist
sys.stderr.write('{0} does not exist\n'.format(path))
sys.exit(42)
elif exc.errno in (errno.EINVAL, errno.ENOTDIR):
# Path is a file (EINVAL on Windows, ENOTDIR otherwise)
files[path] = self._mode(path)
else:
if not sub_paths:
empty_dirs.append(path)
for fn_ in sub_paths:
files_, empty_dirs_ = self._recurse(os.path.join(path, fn_))
files.update(files_)
empty_dirs.extend(empty_dirs_)
return files, empty_dirs
def _list_files(self):
files = {}
empty_dirs = set()
for fn_ in self.opts['src']: for fn_ in self.opts['src']:
if os.path.isfile(fn_): files_, empty_dirs_ = self._recurse(fn_)
files.update(self._file_dict(fn_)) files.update(files_)
elif os.path.isdir(fn_): empty_dirs.update(empty_dirs_)
print_cli(fn_ + ' is a directory, only files are supported.') return files, sorted(empty_dirs)
#files.update(self._recurse_dir(fn_))
return files
def run(self): def run(self):
''' '''
Make the salt client call Make the salt client call
''' '''
arg = [self._load_files(), self.opts['dest']] files, empty_dirs = self._list_files()
dest = self.opts['dest']
gzip = self.opts['gzip']
tgt = self.opts['tgt']
timeout = self.opts['timeout']
selected_target_option = self.opts.get('selected_target_option')
dest_is_dir = bool(empty_dirs) \
or len(files) > 1 \
or bool(re.search(r'[\\/]$', dest))
reader = salt.utils.gzip_util.compress_file \
if gzip \
else salt.utils.itertools.read_file
minions = salt.utils.minions.CkMinions(self.opts).check_minions(
tgt,
expr_form=selected_target_option or 'glob')
local = salt.client.get_local_client(self.opts['conf_file']) local = salt.client.get_local_client(self.opts['conf_file'])
args = [self.opts['tgt'],
'cp.recv', def _get_remote_path(fn_):
arg, if fn_ in self.opts['src']:
self.opts['timeout'], # This was a filename explicitly passed on the CLI
return os.path.join(dest, os.path.basename(fn_)) \
if dest_is_dir \
else dest
else:
for path in self.opts['src']:
relpath = os.path.relpath(fn_, path + os.sep)
if relpath.startswith(parent):
# File is not within this dir
continue
return os.path.join(dest, os.path.basename(path), relpath)
else: # pylint: disable=useless-else-on-loop
# Should not happen
log.error('Failed to find remote path for %s', fn_)
return None
ret = {}
parent = '..' + os.sep
for fn_, mode in six.iteritems(files):
remote_path = _get_remote_path(fn_)
index = 1
failed = {}
for chunk in reader(fn_, chunk_size=self.opts['salt_cp_chunk_size']):
chunk = base64.b64encode(to_bytes(chunk))
append = index > 1
log.debug(
'Copying %s to %starget \'%s\' as %s%s',
fn_,
'{0} '.format(selected_target_option)
if selected_target_option
else '',
tgt,
remote_path,
' (chunk #{0})'.format(index) if append else ''
)
args = [
tgt,
'cp.recv',
[remote_path, chunk, append, gzip, mode],
timeout,
] ]
if selected_target_option is not None:
args.append(selected_target_option)
selected_target_option = self.opts.get('selected_target_option', None) result = local.cmd(*args)
if selected_target_option is not None:
args.append(selected_target_option)
ret = local.cmd(*args) if not result:
# Publish failed
msg = (
'Publish failed.{0} It may be necessary to '
'decrease salt_cp_chunk_size (current value: '
'{1})'.format(
' File partially transferred.' if index > 1 else '',
self.opts['salt_cp_chunk_size'],
)
)
for minion in minions:
ret.setdefault(minion, {})[remote_path] = msg
break
for minion_id, minion_ret in six.iteritems(result):
ret.setdefault(minion_id, {})[remote_path] = minion_ret
# Catch first error message for a given minion, we will
# rewrite the results after we're done iterating through
# the chunks.
if minion_ret is not True and minion_id not in failed:
failed[minion_id] = minion_ret
index += 1
for minion_id, msg in six.iteritems(failed):
ret[minion_id][remote_path] = msg
for dirname in empty_dirs:
remote_path = _get_remote_path(dirname)
log.debug(
'Creating empty dir %s on %starget \'%s\'',
dirname,
'{0} '.format(selected_target_option)
if selected_target_option
else '',
tgt,
)
args = [tgt, 'cp.recv', [remote_path, None], timeout]
if selected_target_option is not None:
args.append(selected_target_option)
for minion_id, minion_ret in six.iteritems(local.cmd(*args)):
ret.setdefault(minion_id, {})[remote_path] = minion_ret
salt.output.display_output( salt.output.display_output(
ret, ret,

View File

@ -1122,8 +1122,17 @@ class LocalClient(object):
minions.remove(raw['data']['id']) minions.remove(raw['data']['id'])
break break
except KeyError as exc: except KeyError as exc:
# This is a safe pass. We're just using the try/except to avoid having to deep-check for keys # This is a safe pass. We're just using the try/except to
log.debug('Passing on saltutil error. This may be an error in saltclient. {0}'.format(exc)) # avoid having to deep-check for keys.
missing_key = exc.__str__().strip('\'"')
if missing_key == 'retcode':
log.debug('retcode missing from client return')
else:
log.debug(
'Passing on saltutil error. Key \'%s\' missing '
'from client return. This may be an error in '
'the client.', missing_key
)
# Keep track of the jid events to unsubscribe from later # Keep track of the jid events to unsubscribe from later
open_jids.add(jinfo['jid']) open_jids.add(jinfo['jid'])

View File

@ -958,6 +958,9 @@ VALID_OPTS = {
# Permit or deny allowing minions to request revoke of its own key # Permit or deny allowing minions to request revoke of its own key
'allow_minion_key_revoke': bool, 'allow_minion_key_revoke': bool,
# File chunk size for salt-cp
'salt_cp_chunk_size': int,
} }
# default configurations # default configurations
@ -1201,6 +1204,7 @@ DEFAULT_MINION_OPTS = {
'minion_jid_queue_hwm': 100, 'minion_jid_queue_hwm': 100,
'ssl': None, 'ssl': None,
'cache': 'localfs', 'cache': 'localfs',
'salt_cp_chunk_size': 65536,
} }
DEFAULT_MASTER_OPTS = { DEFAULT_MASTER_OPTS = {
@ -1478,6 +1482,7 @@ DEFAULT_MASTER_OPTS = {
'django_auth_path': '', 'django_auth_path': '',
'django_auth_settings': '', 'django_auth_settings': '',
'allow_minion_key_revoke': True, 'allow_minion_key_revoke': True,
'salt_cp_chunk_size': 98304,
} }

View File

@ -5,6 +5,8 @@ Minion side functions for salt-cp
# Import python libs # Import python libs
from __future__ import absolute_import from __future__ import absolute_import
import base64
import errno
import os import os
import logging import logging
import fnmatch import fnmatch
@ -13,6 +15,7 @@ import fnmatch
import salt.minion import salt.minion
import salt.fileclient import salt.fileclient
import salt.utils import salt.utils
import salt.utils.gzip_util
import salt.utils.url import salt.utils.url
import salt.crypt import salt.crypt
import salt.transport import salt.transport
@ -54,33 +57,69 @@ def _gather_pillar(pillarenv, pillar_override):
return ret return ret
def recv(files, dest): def recv(dest, chunk, append=False, compressed=True, mode=None):
''' '''
Used with salt-cp, pass the files dict, and the destination. This function receives files copied to the minion using ``salt-cp`` and is
not intended to be used directly on the CLI.
This function receives small fast copy files from the master via salt-cp.
It does not work via the CLI.
''' '''
ret = {} if 'retcode' not in __context__:
for path, data in six.iteritems(files): __context__['retcode'] = 0
if os.path.basename(path) == os.path.basename(dest) \
and not os.path.isdir(dest):
final = dest
elif os.path.isdir(dest):
final = os.path.join(dest, os.path.basename(path))
elif os.path.isdir(os.path.dirname(dest)):
final = dest
else:
return 'Destination unavailable'
def _error(msg):
__context__['retcode'] = 1
return msg
if chunk is None:
# dest is an empty dir and needs to be created
try: try:
with salt.utils.fopen(final, 'w+') as fp_: os.makedirs(dest)
fp_.write(data) except OSError as exc:
ret[final] = True if exc.errno == errno.EEXIST:
except IOError: if os.path.isfile(dest):
ret[final] = False return 'Path exists and is a file'
else:
return _error(exc.__str__())
return True
return ret chunk = base64.b64decode(chunk)
open_mode = 'ab' if append else 'wb'
try:
fh_ = salt.utils.fopen(dest, open_mode)
except (IOError, OSError) as exc:
if exc.errno != errno.ENOENT:
# Parent dir does not exist, we need to create it
return _error(exc.__str__())
try:
os.makedirs(os.path.dirname(dest))
except (IOError, OSError) as makedirs_exc:
# Failed to make directory
return _error(makedirs_exc.__str__())
fh_ = salt.utils.fopen(dest, open_mode)
try:
# Write the chunk to disk
fh_.write(salt.utils.gzip_util.uncompress(chunk) if compressed
else chunk)
except (IOError, OSError) as exc:
# Write failed
return _error(exc.__str__())
else:
# Write successful
if not append and mode is not None:
# If this is the first chunk we're writing, set the mode
#log.debug('Setting mode for %s to %s', dest, oct(mode))
log.debug('Setting mode for %s to %s', dest, mode)
try:
os.chmod(dest, mode)
except OSError:
return _error(exc.__str__())
return True
finally:
try:
fh_.close()
except AttributeError:
pass
def _mk_client(): def _mk_client():

View File

@ -471,10 +471,11 @@ def latest_version(*names, **kwargs):
def _check_cur(pkg): def _check_cur(pkg):
if pkg.name in cur_pkgs: if pkg.name in cur_pkgs:
for installed_version in cur_pkgs[pkg.name]: for installed_version in cur_pkgs[pkg.name]:
# If any installed version is greater than the one found by # If any installed version is greater than (or equal to) the
# yum/dnf list available, then it is not an upgrade. # one found by yum/dnf list available, then it is not an
# upgrade.
if salt.utils.compare_versions(ver1=installed_version, if salt.utils.compare_versions(ver1=installed_version,
oper='>', oper='>=',
ver2=pkg.version, ver2=pkg.version,
cmp_func=version_cmp): cmp_func=version_cmp):
return False return False

View File

@ -10,8 +10,11 @@ from __future__ import absolute_import
# Import python libs # Import python libs
import gzip import gzip
# Import Salt libs
import salt.utils
# Import 3rd-party libs # Import 3rd-party libs
from salt.ext.six import BytesIO from salt.ext.six import BytesIO, StringIO
class GzipFile(gzip.GzipFile): class GzipFile(gzip.GzipFile):
@ -63,3 +66,38 @@ def uncompress(data):
with open_fileobj(buf, 'rb') as igz: with open_fileobj(buf, 'rb') as igz:
unc = igz.read() unc = igz.read()
return unc return unc
def compress_file(fh_, compresslevel=9, chunk_size=1048576):
'''
Generator that reads chunk_size bytes at a time from a file/filehandle and
yields the compressed result of each read.
.. note::
Each chunk is compressed separately. They cannot be stitched together
to form a compressed file. This function is designed to break up a file
into compressed chunks for transport and decompression/reassembly on a
remote host.
'''
try:
bytes_read = int(chunk_size)
if bytes_read != chunk_size:
raise ValueError
except ValueError:
raise ValueError('chunk_size must be an integer')
try:
while bytes_read == chunk_size:
buf = StringIO()
with open_fileobj(buf, 'wb', compresslevel) as ogz:
try:
bytes_read = ogz.write(fh_.read(chunk_size))
except AttributeError:
# Open the file and re-attempt the read
fh_ = salt.utils.fopen(fh_, 'rb')
bytes_read = ogz.write(fh_.read(chunk_size))
yield buf.getvalue()
finally:
try:
fh_.close()
except AttributeError:
pass

View File

@ -7,6 +7,9 @@ Helpful generators and other tools
from __future__ import absolute_import from __future__ import absolute_import
import re import re
# Import Salt libs
import salt.utils
def split(orig, sep=None): def split(orig, sep=None):
''' '''
@ -32,3 +35,31 @@ def split(orig, sep=None):
if pos < match.start() or sep is not None: if pos < match.start() or sep is not None:
yield orig[pos:match.start()] yield orig[pos:match.start()]
pos = match.end() pos = match.end()
def read_file(fh_, chunk_size=1048576):
'''
Generator that reads chunk_size bytes at a time from a file/filehandle and
yields it.
'''
try:
if chunk_size != int(chunk_size):
raise ValueError
except ValueError:
raise ValueError('chunk_size must be an integer')
try:
while True:
try:
chunk = fh_.read(chunk_size)
except AttributeError:
# Open the file and re-attempt the read
fh_ = salt.utils.fopen(fh_, 'rb')
chunk = fh_.read(chunk_size)
if not chunk:
break
yield chunk
finally:
try:
fh_.close()
except AttributeError:
pass

View File

@ -2108,6 +2108,17 @@ class SaltCPOptionParser(six.with_metaclass(OptionParserMeta,
_default_logging_level_ = config.DEFAULT_MASTER_OPTS['log_level'] _default_logging_level_ = config.DEFAULT_MASTER_OPTS['log_level']
_default_logging_logfile_ = config.DEFAULT_MASTER_OPTS['log_file'] _default_logging_logfile_ = config.DEFAULT_MASTER_OPTS['log_file']
def _mixin_setup(self):
file_opts_group = optparse.OptionGroup(self, 'File Options')
file_opts_group.add_option(
'-n', '--no-compression',
default=True,
dest='compression',
action='store_false',
help='Disable gzip compression.'
)
self.add_option_group(file_opts_group)
def _mixin_after_parsed(self): def _mixin_after_parsed(self):
# salt-cp needs arguments # salt-cp needs arguments
if len(self.args) <= 1: if len(self.args) <= 1:
@ -2121,8 +2132,9 @@ class SaltCPOptionParser(six.with_metaclass(OptionParserMeta,
self.config['tgt'] = self.args[0].split() self.config['tgt'] = self.args[0].split()
else: else:
self.config['tgt'] = self.args[0] self.config['tgt'] = self.args[0]
self.config['src'] = self.args[1:-1] self.config['src'] = [os.path.realpath(x) for x in self.args[1:-1]]
self.config['dest'] = self.args[-1] self.config['dest'] = self.args[-1]
self.config['gzip'] = True
def setup_config(self): def setup_config(self):
return config.master_config(self.get_config_file_path()) return config.master_config(self.get_config_file_path())

View File

@ -9,10 +9,12 @@
# Import python libs # Import python libs
from __future__ import absolute_import from __future__ import absolute_import
import errno
import os import os
import yaml import yaml
import pipes import pipes
import shutil import shutil
import tempfile
# Import Salt Testing libs # Import Salt Testing libs
from salttesting.helpers import ensure_in_syspath from salttesting.helpers import ensure_in_syspath
@ -112,18 +114,13 @@ class CopyTest(integration.ShellCase, integration.ShellCaseCommonTestsMixIn):
self.assertTrue(data[minion]) self.assertTrue(data[minion])
def test_issue_7754(self): def test_issue_7754(self):
try:
old_cwd = os.getcwd()
except OSError:
# Jenkins throws an OSError from os.getcwd()??? Let's not worry
# about it
old_cwd = None
config_dir = os.path.join(integration.TMP, 'issue-7754') config_dir = os.path.join(integration.TMP, 'issue-7754')
if not os.path.isdir(config_dir):
os.makedirs(config_dir)
os.chdir(config_dir) try:
os.makedirs(config_dir)
except OSError as exc:
if exc.errno != errno.EEXIST:
raise
config_file_name = 'master' config_file_name = 'master'
with salt.utils.fopen(self.get_config_file_path(config_file_name), 'r') as fhr: with salt.utils.fopen(self.get_config_file_path(config_file_name), 'r') as fhr:
@ -134,15 +131,24 @@ class CopyTest(integration.ShellCase, integration.ShellCaseCommonTestsMixIn):
yaml.dump(config, default_flow_style=False) yaml.dump(config, default_flow_style=False)
) )
ret = self.run_script(
self._call_binary_,
'--out pprint --config-dir {0} \'*\' foo {0}/foo'.format(
config_dir
),
catch_stderr=True,
with_retcode=True
)
try: try:
fd_, fn_ = tempfile.mkstemp()
os.close(fd_)
with salt.utils.fopen(fn_, 'w') as fp_:
fp_.write('Hello world!\n')
ret = self.run_script(
self._call_binary_,
'--out pprint --config-dir {0} \'*\' {1} {0}/{2}'.format(
config_dir,
fn_,
os.path.basename(fn_),
),
catch_stderr=True,
with_retcode=True
)
self.assertIn('minion', '\n'.join(ret[0])) self.assertIn('minion', '\n'.join(ret[0]))
self.assertIn('sub_minion', '\n'.join(ret[0])) self.assertIn('sub_minion', '\n'.join(ret[0]))
self.assertFalse(os.path.isdir(os.path.join(config_dir, 'file:'))) self.assertFalse(os.path.isdir(os.path.join(config_dir, 'file:')))
@ -156,8 +162,11 @@ class CopyTest(integration.ShellCase, integration.ShellCaseCommonTestsMixIn):
) )
self.assertEqual(ret[2], 2) self.assertEqual(ret[2], 2)
finally: finally:
if old_cwd is not None: try:
self.chdir(old_cwd) os.remove(fn_)
except OSError as exc:
if exc.errno != errno.ENOENT:
raise
if os.path.isdir(config_dir): if os.path.isdir(config_dir):
shutil.rmtree(config_dir) shutil.rmtree(config_dir)

View File

@ -33,7 +33,7 @@ __testcontext__ = {}
_PKG_TARGETS = { _PKG_TARGETS = {
'Arch': ['sl', 'libpng'], 'Arch': ['sl', 'libpng'],
'Debian': ['python-plist', 'apg'], 'Debian': ['python-plist', 'apg'],
'RedHat': ['xz-devel', 'zsh-html'], 'RedHat': ['units', 'zsh-html'],
'FreeBSD': ['aalib', 'pth'], 'FreeBSD': ['aalib', 'pth'],
'Suse': ['aalib', 'python-pssh'], 'Suse': ['aalib', 'python-pssh'],
'MacOS': ['libpng', 'jpeg'], 'MacOS': ['libpng', 'jpeg'],