Repository: ansible/ansible

Issue:
Ansible 2.0 - synchronize change in functionality - broke my deploys
I had been using a play for a few months now on the 1.9 releases.  It looks like the following: 

  {code}
    - name: Ensure All SSL Certs Are Copied to Server
      synchronize: src=ssl/ dest=/opt/backup/ssl/ archive=no recursive=yes
  {code}

With Ansible 2.0, its now trying to sudo on my local host, in order to be able to sudo copy them on the remote host.  

From the Docs:
Note
The user and permissions for the synchronize src are those of the user running the Ansible task on the local host, or the become_user if become: yes is active. synchronize will attempt to escalate privileges to the become_user on the local host.

This is counter to the previous functionality.  The user running the play has access to the remote server, the Local root account isn't available to use.  

I'm not understanding the purpose of the local sudo when "become:" is enabled.  When the module is showing a "set_remote_user" option as well.  the purpose of 'become'  is for the _remote_ system.  not the local one.  Make the 'set_remote_user' to be 'set_local_user'?   and keep the same functionality as was present in 1.9? 



Discussion:
This sounds like it's the cause of #13034 . I'm guessing that using "become" on the remote host isn't possible, since it's just running the rsync command locally (thus Ansible has no control over the remote side). So there's no harm in setting "become: false" on your synchronize tasks, since it will only affect the local side.

Yes, looks like the same issue.

On Wed, Jan 13, 2016 at 2:03 PM, Jacob Weber notifications@github.com
wrote:

> This sounds like it's the cause of #13034
> https://github.com/ansible/ansible/issues/13034, which is also causing
> problems for me.
> 
> —
> Reply to this email directly or view it on GitHub
> https://github.com/ansible/ansible/issues/13825#issuecomment-171431439.

Facing the same issue, the change in behaviour on the synchronize module broke my deployments as well :disappointed: 

Currently working around the problem with: `rsync_opts: --rsh "ssh -l {{ lookup('env', 'USER') }}"`, forcing ansible to use the real user and not root.

We're looking at what kind of fix we can apply to this for 2.0.1 or 2.0.2.  synchronize has always been somewhat hacky and unfortunately the hacks will interact badly with the become code that is in 2.0.  We're hoping that even if we can't make this code perfect for the next release that we can at least get something more like the 1.9.x behaviour.  However, there's a lot of ugliness in the code so we'll have to be careful not to break other things even worse.

Am I correct in thinking that what you all want is for no privilege escalation to be performed locally and privileges to be escalated on the remote host via passwordless sudo?  I think that's the limitations that were imposed on this usage in 1.9.x... just making sure that I'm not missing any wrinkles and additional features there.

Not sure about the original poster, but I didn't really need privilege escalation on the remote host, so setting become=false worked for me. Having "become" affect the local host was what confused me.

@abadger In my case I'm using privilege escalation already for both sides with `become: true` and `rsync_path: "sudo rsync"`.
What i want is for ansible to respect what was formerly the `ansible_ssh_user` fact and pass it along to ssh (with the -l flag) when rsycing, in order to take advantage of the user's ssh key i have setup (and not the root key). This was the observed behaviour in ansible 1.9.x.

@nocive From my brief testing, 1.9.x does not do privilege escalation on the controller.  Can you show how you have that working?

@abadger Apologies, maybe I didn't explain myself properly.
I'm using it in `pull` mode together with `delegate_to`, so none of it actually runs on the controller host.

```
synchronize:
    src:        "{{ some_src }}/"
    dest:       "{{ some_dest }}"
    mode:       pull
    archive:    yes
    compress:   yes
    rsync_path: "sudo rsync"
    #rsync_opts: --rsh "ssh -l {{ lookup('env', 'USER') }}" # for ansible 2.x
  sudo: true
  delegate_to: "{{ item }}"
  when: "play_hosts|length > 1 and item not in groups['foo'] and item not in groups['bar']"
  with_items: play_hosts
```

@nocive That's not working for me either with 1.9.4 (although I think the issue is bugs in 1.9's delegate_to handling rather than in it's sudo handling... but I'll have to figure out how to check that to be sure).  Could you check for me that 1.9 really is escalating privileges on the delegate_to host?  If you make some_src readable only by root does it succeed?

**Warning:**  This commit isn't tested with anything except the basic problem listed in this ticket so with further testing it may not prove suitable for inclusion in the next release.  I'm linking it here so you all can take a look at it but like I say, even though this change appears to fix this problem, there's no promise yet that it will go into the next release:

https://github.com/ansible/ansible/compare/synchronize-become-is-reversed?expand=1

That said, anyone who can test is encouraged to do so.  I finding important cornercases is mostly about more people using it in their real-world code.

On Thu, Jan 14, 2016 at 6:07 PM, Toshio Kuratomi notifications@github.com
wrote:

> Am I correct in thinking that what you all want is for no privilege
> escalation to be performed locally and privileges to be escalated on the
> remote host via passwordless sudo? I think that's the limitations that were
> imposed on this usage in 1.9.x... just making sure that I'm not missing any
> wrinkles and additional features there

​Yes, this is exactly how I used it.​


Resolving patch:
diff --git a/lib/ansible/module_utils/basic.py b/lib/ansible/module_utils/basic.py
--- a/lib/ansible/module_utils/basic.py
+++ b/lib/ansible/module_utils/basic.py
@@ -1788,7 +1788,9 @@ def run_command(self, args, check_rc=False, close_fds=True, executable=None, dat
         elif isinstance(args, basestring) and use_unsafe_shell:
             shell = True
         elif isinstance(args, basestring):
-            args = shlex.split(args.encode('utf-8'))
+            if isinstance(args, unicode):
+                args = args.encode('utf-8')
+            args = shlex.split(args)
         else:
             msg = "Argument 'args' to run_command must be list or string"
             self.fail_json(rc=257, cmd=args, msg=msg)
diff --git a/lib/ansible/plugins/action/synchronize.py b/lib/ansible/plugins/action/synchronize.py
--- a/lib/ansible/plugins/action/synchronize.py
+++ b/lib/ansible/plugins/action/synchronize.py
@@ -71,6 +71,8 @@ def _process_origin(self, host, path, user):
     def _process_remote(self, host, path, user):
         transport = self._play_context.connection
         if host not in C.LOCALHOST or transport != "local":
+            if host in C.LOCALHOST:
+                self._task.args['_substitute_controller'] = True
             return self._format_rsync_rsh_target(host, path, user)
 
         if ':' not in path and not path.startswith('/'):
@@ -103,14 +105,40 @@ def _override_module_replaced_vars(self, task_vars):
 
     def run(self, tmp=None, task_vars=None):
         ''' generates params and passes them on to the rsync module '''
+        # When modifying this function be aware of the tricky convolutions
+        # your thoughts have to go through:
+        #
+        # In normal ansible, we connect from controller to inventory_hostname
+        # (playbook's hosts: field) or controller to delegate_to host and run
+        # a module on one of those hosts.
+        #
+        # So things that are directly related to the core of ansible are in
+        # terms of that sort of connection that always originate on the
+        # controller.
+        #
+        # In synchronize we use ansible to connect to either the controller or
+        # to the delegate_to host and then run rsync which makes its own
+        # connection from controller to inventory_hostname or delegate_to to
+        # inventory_hostname.
+        #
+        # That means synchronize needs to have some knowledge of the
+        # controller to inventory_host/delegate host that ansible typically
+        # establishes and use those to construct a command line for rsync to
+        # connect from the inventory_host to the controller/delegate.  The
+        # challenge for coders is remembering which leg of the trip is
+        # associated with the conditions that you're checking at any one time.
         if task_vars is None:
             task_vars = dict()
 
         result = super(ActionModule, self).run(tmp, task_vars)
 
-        original_transport = task_vars.get('ansible_connection') or self._play_context.connection
+        # self._play_context.connection accounts for delegate_to so
+        # remote_transport is the transport ansible thought it would need
+        # between the controller and the delegate_to host or the controller
+        # and the remote_host if delegate_to isn't set.
+
         remote_transport = False
-        if original_transport != 'local':
+        if self._play_context.connection != 'local':
             remote_transport = True
 
         try:
@@ -136,7 +164,14 @@ def run(self, tmp=None, task_vars=None):
         except KeyError:
             dest_host = dest_host_inventory_vars.get('ansible_ssh_host', inventory_hostname)
 
-        dest_is_local = dest_host in C.LOCALHOST
+        # dest_is_local tells us if the host rsync runs on is the same as the
+        # host rsync puts the files on.  This is about *rsync's connection*,
+        # not about the ansible connection to run the module.
+        dest_is_local = False
+        if not delegate_to and remote_transport is False:
+            dest_is_local = True
+        elif delegate_to and delegate_to == dest_host:
+            dest_is_local = True
 
         # CHECK FOR NON-DEFAULT SSH PORT
         if self._task.args.get('dest_port', None) is None:
@@ -161,23 +196,13 @@ def run(self, tmp=None, task_vars=None):
         # Delegate to localhost as the source of the rsync unless we've been
         # told (via delegate_to) that a different host is the source of the
         # rsync
-        transport_overridden = False
         if not use_delegate and remote_transport:
             # Create a connection to localhost to run rsync on
             new_stdin = self._connection._new_stdin
             new_connection = connection_loader.get('local', self._play_context, new_stdin)
             self._connection = new_connection
-            transport_overridden = True
             self._override_module_replaced_vars(task_vars)
 
-        # COMPARE DELEGATE, HOST AND TRANSPORT
-        between_multiple_hosts = False
-        if dest_host != src_host and remote_transport:
-            # We're not copying two filesystem trees on the same host so we
-            # need to correctly format the paths for rsync (like
-            # user@host:path/to/tree
-            between_multiple_hosts = True
-
         # SWITCH SRC AND DEST HOST PER MODE
         if self._task.args.get('mode', 'push') == 'pull':
             (dest_host, src_host) = (src_host, dest_host)
@@ -185,7 +210,7 @@ def run(self, tmp=None, task_vars=None):
         # MUNGE SRC AND DEST PER REMOTE_HOST INFO
         src = self._task.args.get('src', None)
         dest = self._task.args.get('dest', None)
-        if between_multiple_hosts:
+        if not dest_is_local:
             # Private key handling
             if use_delegate:
                 private_key = task_vars.get('ansible_ssh_private_key_file') or self._play_context.private_key_file
@@ -231,9 +256,18 @@ def run(self, tmp=None, task_vars=None):
         # Allow custom rsync path argument
         rsync_path = self._task.args.get('rsync_path', None)
 
-        # If no rsync_path is set, sudo was originally set, and dest is remote then add 'sudo rsync' argument
-        if not rsync_path and transport_overridden and self._play_context.become and self._play_context.become_method == 'sudo' and not dest_is_local:
-            rsync_path = 'sudo rsync'
+        if not dest_is_local:
+            if self._play_context.become and not rsync_path:
+                # If no rsync_path is set, become was originally set, and dest is
+                # remote then add privilege escalation here.
+                if self._play_context.become_method == 'sudo':
+                    rsync_path = 'sudo rsync'
+                # TODO: have to add in the rest of the become methods here
+
+            # We cannot use privilege escalation on the machine running the
+            # module.  Instead we run it on the machine rsync is connecting
+            # to.
+            self._play_context.become = False
 
         # make sure rsync path is quoted.
         if rsync_path:
@@ -245,7 +279,7 @@ def run(self, tmp=None, task_vars=None):
         # run the module and store the result
         result.update(self._execute_module('synchronize', task_vars=task_vars))
 
-        if 'SyntaxError' in result['msg']:
+        if 'SyntaxError' in result.get('exception', result.get('msg', '')):
             # Emit a warning about using python3 because synchronize is
             # somewhat unique in running on localhost
             result['traceback'] = result['msg']


Test patch: