From c7a06f6029ea1c8c7e61916d5bd09992d25702e3 Mon Sep 17 00:00:00 2001
From: Bo Maryniuk <bo@suse.de>
Date: Fri, 25 Nov 2016 15:34:33 +0100
Subject: [PATCH 31/38] Port rsync state from 2016.3

* Port rsync state from 2016.3
---
 salt/modules/rsync.py | 115 +++++++++++++++++++++-----------------
 salt/states/rsync.py  | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 214 insertions(+), 50 deletions(-)
 create mode 100644 salt/states/rsync.py

diff --git a/salt/modules/rsync.py b/salt/modules/rsync.py
index 36763e2..35e518f 100644
--- a/salt/modules/rsync.py
+++ b/salt/modules/rsync.py
@@ -10,16 +10,17 @@ Options passed into opts will overwrite options passed into pillar.
 from __future__ import absolute_import
 
 # Import python libs
+import errno
 import logging
-import os
 
 # Import salt libs
-from salt.exceptions import CommandExecutionError
+import salt.utils
+from salt.exceptions import CommandExecutionError, SaltInvocationError
 
 log = logging.getLogger(__name__)
 
 
-def _check(delete, force, update, passwordfile, exclude, excludefrom):
+def _check(delete, force, update, passwordfile, exclude, excludefrom, dryrun):
     '''
     Generate rsync options
     '''
@@ -32,15 +33,16 @@ def _check(delete, force, update, passwordfile, exclude, excludefrom):
     if update:
         options.append('--update')
     if passwordfile:
-        options.append('--password-file={0}'.format(passwordfile))
+        options.extend(['--password-file', passwordfile])
     if excludefrom:
-        options.append('--exclude-from={0}'.format(excludefrom))
+        options.extend(['--exclude-from', excludefrom])
         if exclude:
-            exclude = None
+            exclude = False
     if exclude:
-        options.append('--exclude={0}'.format(exclude))
-
-    return ' '.join(options)
+        options.extend(['--exclude', exclude])
+    if dryrun:
+        options.append('--dry-run')
+    return options
 
 
 def rsync(src,
@@ -51,8 +53,12 @@ def rsync(src,
           passwordfile=None,
           exclude=None,
           excludefrom=None,
-          ):
+          dryrun=False):
     '''
+        Return data now contains just the output of the rsync command, instead
+        of a dictionary as returned from :py:func:`cmd.run_all
+        <salt.modules.cmdmod.run_all>`.
+
     Rsync files from src to dst
 
     CLI Example:
@@ -78,30 +84,27 @@ def rsync(src,
         exclude = __salt__['config.option']('rsync.exclude')
     if not excludefrom:
         excludefrom = __salt__['config.option']('rsync.excludefrom')
+    if not dryrun:
+        dryrun = __salt__['config.option']('rsync.dryrun')
     if not src or not dst:
-        raise CommandExecutionError('ERROR: src and dst cannot be empty.')
-
-    option = _check(delete, force, update, passwordfile, exclude, excludefrom)
-    cmd = (
-        r'''rsync {option} {src} {dst}'''
-        .format(
-            option=option,
-            src=src,
-            dst=dst,
-        )
-    )
+        raise SaltInvocationError('src and dst cannot be empty')
 
+    option = _check(delete, force, update, passwordfile, exclude, excludefrom, dryrun)
+    cmd = ['rsync'] + option + [src, dst]
+    log.debug('Running rsync command: {0}'.format(cmd))
     try:
-        ret = __salt__['cmd.run_all'](cmd, python_shell=False)
+        return __salt__['cmd.run_all'](cmd, python_shell=False)
     except (IOError, OSError) as exc:
         raise CommandExecutionError(exc.strerror)
 
-    return ret
-
 
 def version():
     '''
-    Return rsync version
+        Return data now contains just the version number as a string, instead
+        of a dictionary as returned from :py:func:`cmd.run_all
+        <salt.modules.cmdmod.run_all>`.
+
+    Returns rsync version
 
     CLI Example:
 
@@ -109,21 +112,28 @@ def version():
 
         salt '*' rsync.version
     '''
-
-    cmd = (r'''rsync --version''')
-
     try:
-        ret = __salt__['cmd.run_all'](cmd)
+        out = __salt__['cmd.run_stdout'](
+            ['rsync', '--version'],
+            python_shell=False)
     except (IOError, OSError) as exc:
         raise CommandExecutionError(exc.strerror)
-
-    ret['stdout'] = ret['stdout'].split('\n')[0].split()[2]
-    return ret
+    try:
+        return out.split('\n')[0].split()[2]
+    except IndexError:
+        raise CommandExecutionError('Unable to determine rsync version')
 
 
-def config(confile='/etc/rsyncd.conf'):
+def config(conf_path='/etc/rsyncd.conf'):
     '''
-    Return rsync config
+        Return data now contains just the contents of the rsyncd.conf as a
+        string, instead of a dictionary as returned from :py:func:`cmd.run_all
+        <salt.modules.cmdmod.run_all>`.
+
+    Returns the contents of the rsync config file
+
+    conf_path : /etc/rsyncd.conf
+        Path to the config file
 
     CLI Example:
 
@@ -131,20 +141,25 @@ def config(confile='/etc/rsyncd.conf'):
 
         salt '*' rsync.config
     '''
-
-    if not os.path.isfile(confile):
-        raise CommandExecutionError('{0!r} does not exit'.format(confile))
-
-    cmd = (
-          r'''cat {confile}'''
-              .format(
-                   confile=confile
-               )
-          )
-
+    ret = ''
     try:
-        ret = __salt__['cmd.run_all'](cmd, python_shell=False)
-    except (IOError, OSError) as exc:
-        raise CommandExecutionError(exc.strerror)
-
-    return ret
+        with salt.utils.fopen(conf_path, 'r') as fp_:
+            for line in fp_:
+                ret += line
+    except IOError as exc:
+        if exc.errno == errno.ENOENT:
+            raise CommandExecutionError('{0} does not exist'.format(conf_path))
+        elif exc.errno == errno.EACCES:
+            raise CommandExecutionError(
+                'Unable to read {0}, access denied'.format(conf_path)
+            )
+        elif exc.errno == errno.EISDIR:
+            raise CommandExecutionError(
+                'Unable to read {0}, path is a directory'.format(conf_path)
+            )
+        else:
+            raise CommandExecutionError(
+                'Error {0}: {1}'.format(exc.errno, exc.strerror)
+            )
+    else:
+        return ret
diff --git a/salt/states/rsync.py b/salt/states/rsync.py
new file mode 100644
index 0000000..7485da8
--- /dev/null
+++ b/salt/states/rsync.py
@@ -0,0 +1,149 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright 2015 SUSE LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+'''
+State to synchronize files and directories with rsync.
+
+.. versionadded:: 2016.3.0
+
+.. code-block:: yaml
+
+    /opt/user-backups:
+      rsync.synchronized:
+        - source: /home
+        - force: True
+
+'''
+
+from __future__ import absolute_import
+import salt.utils
+import os
+
+
+def __virtual__():
+    '''
+    Only if Rsync is available.
+
+    :return:
+    '''
+    return salt.utils.which('rsync') and 'rsync' or False
+
+
+def _get_summary(rsync_out):
+    '''
+    Get summary from the rsync successfull output.
+    '''
+
+    return "- " + "\n- ".join([elm for elm in rsync_out.split("\n\n")[-1].replace("  ", "\n").split("\n") if elm])
+
+
+def _get_changes(rsync_out):
+    '''
+    Get changes from the rsync successfull output.
+    '''
+    copied = list()
+    deleted = list()
+
+    for line in rsync_out.split("\n\n")[0].split("\n")[1:]:
+        if line.startswith("deleting "):
+            deleted.append(line.split(" ", 1)[-1])
+        else:
+            copied.append(line)
+
+    return {
+        'copied': os.linesep.join(sorted(copied)) or "N/A",
+        'deleted': os.linesep.join(sorted(deleted)) or "N/A",
+    }
+
+
+def synchronized(name, source,
+                 delete=False,
+                 force=False,
+                 update=False,
+                 passwordfile=None,
+                 exclude=None,
+                 excludefrom=None,
+                 prepare=False,
+                 dryrun=False):
+    '''
+    Guarantees that the source directory is always copied to the target.
+
+    name
+        Name of the target directory.
+
+    source
+        Source directory.
+
+    prepare
+        Create destination directory if it does not exists.
+
+    delete
+        Delete extraneous files from the destination dirs (True or False)
+
+    force
+        Force deletion of dirs even if not empty
+
+    update
+        Skip files that are newer on the receiver (True or False)
+
+    passwordfile
+        Read daemon-access password from the file (path)
+
+    exclude
+        Exclude files, that matches pattern.
+
+    excludefrom
+        Read exclude patterns from the file (path)
+
+    dryrun
+        Perform a trial run with no changes made. Is the same as
+        doing test=True
+
+        .. versionadded:: 2016.3.1
+    '''
+
+    ret = {'name': name, 'changes': {}, 'result': True, 'comment': ''}
+
+    if not os.path.exists(source):
+        ret['result'] = False
+        ret['comment'] = "Source directory {src} was not found.".format(src=source)
+    elif not os.path.exists(name) and not force and not prepare:
+        ret['result'] = False
+        ret['comment'] = "Destination directory {dest} was not found.".format(dest=name)
+    else:
+        if not os.path.exists(name) and prepare:
+            os.makedirs(name)
+
+        if __opts__['test']:
+            dryrun = True
+
+        result = __salt__['rsync.rsync'](source, name, delete=delete, force=force, update=update,
+                                         passwordfile=passwordfile, exclude=exclude, excludefrom=excludefrom,
+                                         dryrun=dryrun)
+
+        if __opts__['test'] or dryrun:
+            ret['result'] = None
+            ret['comment'] = _get_summary(result['stdout'])
+            return ret
+
+        if result.get('retcode'):
+            ret['result'] = False
+            ret['comment'] = result['stderr']
+            ret['changes'] = result['stdout']
+        else:
+            ret['comment'] = _get_summary(result['stdout'])
+            ret['changes'] = _get_changes(result['stdout'])
+
+    return ret
-- 
2.10.2

