1# -*- coding: utf-8 -*- 2 3# (c) 2012-2013, Timothy Appnel <tim@appnel.com> 4# 5# Ansible is free software: you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# Ansible is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with Ansible. If not, see <http://www.gnu.org/licenses/>. 17from __future__ import (absolute_import, division, print_function) 18__metaclass__ = type 19 20import os.path 21 22from ansible import constants as C 23from ansible.module_utils.six import string_types 24from ansible.module_utils.six.moves import shlex_quote 25from ansible.module_utils._text import to_text 26from ansible.module_utils.common._collections_compat import MutableSequence 27from ansible.module_utils.parsing.convert_bool import boolean 28from ansible.plugins.action import ActionBase 29from ansible.plugins.loader import connection_loader 30 31 32DOCKER = ['docker', 'community.general.docker', 'community.docker.docker'] 33PODMAN = ['podman', 'ansible.builtin.podman', 'containers.podman.podman'] 34BUILDAH = ['buildah', 'containers.podman.buildah'] 35 36 37class ActionModule(ActionBase): 38 39 def _get_absolute_path(self, path): 40 original_path = path 41 42 # 43 # Check if we have a local relative path and do not process 44 # * remote paths (some.server.domain:/some/remote/path/...) 45 # * URLs (rsync://...) 46 # * local absolute paths (/some/local/path/...) 47 # 48 if ':' in path or path.startswith('/'): 49 return path 50 51 if self._task._role is not None: 52 path = self._loader.path_dwim_relative(self._task._role._role_path, 'files', path) 53 else: 54 path = self._loader.path_dwim_relative(self._loader.get_basedir(), 'files', path) 55 56 if original_path and original_path[-1] == '/' and path[-1] != '/': 57 # make sure the dwim'd path ends in a trailing "/" 58 # if the original path did 59 path += '/' 60 61 return path 62 63 def _host_is_ipv6_address(self, host): 64 return ':' in to_text(host, errors='surrogate_or_strict') 65 66 def _format_rsync_rsh_target(self, host, path, user): 67 ''' formats rsync rsh target, escaping ipv6 addresses if needed ''' 68 69 user_prefix = '' 70 71 if path.startswith('rsync://'): 72 return path 73 74 # If using docker or buildah, do not add user information 75 if self._remote_transport not in DOCKER + PODMAN + BUILDAH and user: 76 user_prefix = '%s@' % (user, ) 77 78 if self._host_is_ipv6_address(host): 79 return '[%s%s]:%s' % (user_prefix, host, path) 80 return '%s%s:%s' % (user_prefix, host, path) 81 82 def _process_origin(self, host, path, user): 83 84 if host not in C.LOCALHOST: 85 return self._format_rsync_rsh_target(host, path, user) 86 87 path = self._get_absolute_path(path=path) 88 return path 89 90 def _process_remote(self, task_args, host, path, user, port_matches_localhost_port): 91 """ 92 :arg host: hostname for the path 93 :arg path: file path 94 :arg user: username for the transfer 95 :arg port_matches_localhost_port: boolean whether the remote port 96 matches the port used by localhost's sshd. This is used in 97 conjunction with seeing whether the host is localhost to know 98 if we need to have the module substitute the pathname or if it 99 is a different host (for instance, an ssh tunnelled port or an 100 alternative ssh port to a vagrant host.) 101 """ 102 transport = self._connection.transport 103 # If we're connecting to a remote host or we're delegating to another 104 # host or we're connecting to a different ssh instance on the 105 # localhost then we have to format the path as a remote rsync path 106 if host not in C.LOCALHOST or transport != "local" or \ 107 (host in C.LOCALHOST and not port_matches_localhost_port): 108 # If we're delegating to non-localhost and but the 109 # inventory_hostname host is localhost then we need the module to 110 # fix up the rsync path to use the controller's public DNS/IP 111 # instead of "localhost" 112 if port_matches_localhost_port and host in C.LOCALHOST: 113 task_args['_substitute_controller'] = True 114 return self._format_rsync_rsh_target(host, path, user) 115 116 path = self._get_absolute_path(path=path) 117 return path 118 119 def _override_module_replaced_vars(self, task_vars): 120 """ Some vars are substituted into the modules. Have to make sure 121 that those are correct for localhost when synchronize creates its own 122 connection to localhost.""" 123 124 # Clear the current definition of these variables as they came from the 125 # connection to the remote host 126 if 'ansible_syslog_facility' in task_vars: 127 del task_vars['ansible_syslog_facility'] 128 for key in list(task_vars.keys()): 129 if key.startswith("ansible_") and key.endswith("_interpreter"): 130 del task_vars[key] 131 132 # Add the definitions from localhost 133 for host in C.LOCALHOST: 134 if host in task_vars['hostvars']: 135 localhost = task_vars['hostvars'][host] 136 break 137 if 'ansible_syslog_facility' in localhost: 138 task_vars['ansible_syslog_facility'] = localhost['ansible_syslog_facility'] 139 for key in localhost: 140 if key.startswith("ansible_") and key.endswith("_interpreter"): 141 task_vars[key] = localhost[key] 142 143 def run(self, tmp=None, task_vars=None): 144 ''' generates params and passes them on to the rsync module ''' 145 # When modifying this function be aware of the tricky convolutions 146 # your thoughts have to go through: 147 # 148 # In normal ansible, we connect from controller to inventory_hostname 149 # (playbook's hosts: field) or controller to delegate_to host and run 150 # a module on one of those hosts. 151 # 152 # So things that are directly related to the core of ansible are in 153 # terms of that sort of connection that always originate on the 154 # controller. 155 # 156 # In synchronize we use ansible to connect to either the controller or 157 # to the delegate_to host and then run rsync which makes its own 158 # connection from controller to inventory_hostname or delegate_to to 159 # inventory_hostname. 160 # 161 # That means synchronize needs to have some knowledge of the 162 # controller to inventory_host/delegate host that ansible typically 163 # establishes and use those to construct a command line for rsync to 164 # connect from the inventory_host to the controller/delegate. The 165 # challenge for coders is remembering which leg of the trip is 166 # associated with the conditions that you're checking at any one time. 167 if task_vars is None: 168 task_vars = dict() 169 170 # We make a copy of the args here because we may fail and be asked to 171 # retry. If that happens we don't want to pass the munged args through 172 # to our next invocation. Munged args are single use only. 173 _tmp_args = self._task.args.copy() 174 175 result = super(ActionModule, self).run(tmp, task_vars) 176 del tmp # tmp no longer has any effect 177 178 # Store remote connection type 179 self._remote_transport = self._connection.transport 180 use_ssh_args = _tmp_args.pop('use_ssh_args', None) 181 182 if use_ssh_args and self._connection.transport == 'ssh': 183 ssh_args = [ 184 self._connection.get_option('ssh_args'), 185 self._connection.get_option('ssh_common_args'), 186 self._connection.get_option('ssh_extra_args'), 187 ] 188 _tmp_args['ssh_args'] = ' '.join([a for a in ssh_args if a]) 189 190 # Handle docker connection options 191 if self._remote_transport in DOCKER: 192 self._docker_cmd = self._connection.docker_cmd 193 if self._play_context.docker_extra_args: 194 self._docker_cmd = "%s %s" % (self._docker_cmd, self._play_context.docker_extra_args) 195 elif self._remote_transport in PODMAN: 196 self._docker_cmd = self._connection._options['podman_executable'] 197 if self._connection._options.get('podman_extra_args'): 198 self._docker_cmd = "%s %s" % (self._docker_cmd, self._connection._options['podman_extra_args']) 199 200 # self._connection accounts for delegate_to so 201 # remote_transport is the transport ansible thought it would need 202 # between the controller and the delegate_to host or the controller 203 # and the remote_host if delegate_to isn't set. 204 205 remote_transport = False 206 if self._connection.transport != 'local': 207 remote_transport = True 208 209 try: 210 delegate_to = self._task.delegate_to 211 except (AttributeError, KeyError): 212 delegate_to = None 213 214 # ssh paramiko docker buildah and local are fully supported transports. Anything 215 # else only works with delegate_to 216 if delegate_to is None and self._connection.transport not in [ 217 'ssh', 'paramiko', 'local'] + DOCKER + PODMAN + BUILDAH: 218 result['failed'] = True 219 result['msg'] = ( 220 "synchronize uses rsync to function. rsync needs to connect to the remote " 221 "host via ssh, docker client or a direct filesystem " 222 "copy. This remote host is being accessed via %s instead " 223 "so it cannot work." % self._connection.transport) 224 return result 225 226 # Parameter name needed by the ansible module 227 _tmp_args['_local_rsync_path'] = task_vars.get('ansible_rsync_path') or 'rsync' 228 _tmp_args['_local_rsync_password'] = task_vars.get('ansible_ssh_pass') or task_vars.get('ansible_password') 229 230 # rsync thinks that one end of the connection is localhost and the 231 # other is the host we're running the task for (Note: We use 232 # ansible's delegate_to mechanism to determine which host rsync is 233 # running on so localhost could be a non-controller machine if 234 # delegate_to is used) 235 src_host = '127.0.0.1' 236 inventory_hostname = task_vars.get('inventory_hostname') 237 dest_host_inventory_vars = task_vars['hostvars'].get(inventory_hostname) 238 dest_host = dest_host_inventory_vars.get('ansible_host', inventory_hostname) 239 240 dest_host_ids = [hostid for hostid in (dest_host_inventory_vars.get('inventory_hostname'), 241 dest_host_inventory_vars.get('ansible_host')) 242 if hostid is not None] 243 244 localhost_ports = set() 245 for host in C.LOCALHOST: 246 localhost_vars = task_vars['hostvars'].get(host, {}) 247 for port_var in C.MAGIC_VARIABLE_MAPPING['port']: 248 port = localhost_vars.get(port_var, None) 249 if port: 250 break 251 else: 252 port = C.DEFAULT_REMOTE_PORT 253 localhost_ports.add(port) 254 255 # dest_is_local tells us if the host rsync runs on is the same as the 256 # host rsync puts the files on. This is about *rsync's connection*, 257 # not about the ansible connection to run the module. 258 dest_is_local = False 259 if delegate_to is None and remote_transport is False: 260 dest_is_local = True 261 elif delegate_to is not None and delegate_to in dest_host_ids: 262 dest_is_local = True 263 264 # CHECK FOR NON-DEFAULT SSH PORT 265 inv_port = task_vars.get('ansible_port', None) or C.DEFAULT_REMOTE_PORT 266 if _tmp_args.get('dest_port', None) is None: 267 if inv_port is not None: 268 _tmp_args['dest_port'] = inv_port 269 270 # Set use_delegate if we are going to run rsync on a delegated host 271 # instead of localhost 272 use_delegate = False 273 if delegate_to is not None and delegate_to in dest_host_ids: 274 # edge case: explicit delegate and dest_host are the same 275 # so we run rsync on the remote machine targeting its localhost 276 # (itself) 277 dest_host = '127.0.0.1' 278 use_delegate = True 279 elif delegate_to is not None and remote_transport: 280 # If we're delegating to a remote host then we need to use the 281 # delegate_to settings 282 use_delegate = True 283 284 # Delegate to localhost as the source of the rsync unless we've been 285 # told (via delegate_to) that a different host is the source of the 286 # rsync 287 if not use_delegate and remote_transport: 288 # Create a connection to localhost to run rsync on 289 new_stdin = self._connection._new_stdin 290 291 # Unlike port, there can be only one shell 292 localhost_shell = None 293 for host in C.LOCALHOST: 294 localhost_vars = task_vars['hostvars'].get(host, {}) 295 for shell_var in C.MAGIC_VARIABLE_MAPPING['shell']: 296 localhost_shell = localhost_vars.get(shell_var, None) 297 if localhost_shell: 298 break 299 if localhost_shell: 300 break 301 else: 302 localhost_shell = os.path.basename(C.DEFAULT_EXECUTABLE) 303 self._play_context.shell = localhost_shell 304 305 # Unlike port, there can be only one executable 306 localhost_executable = None 307 for host in C.LOCALHOST: 308 localhost_vars = task_vars['hostvars'].get(host, {}) 309 for executable_var in C.MAGIC_VARIABLE_MAPPING['executable']: 310 localhost_executable = localhost_vars.get(executable_var, None) 311 if localhost_executable: 312 break 313 if localhost_executable: 314 break 315 else: 316 localhost_executable = C.DEFAULT_EXECUTABLE 317 self._play_context.executable = localhost_executable 318 319 new_connection = connection_loader.get('local', self._play_context, new_stdin) 320 self._connection = new_connection 321 # Override _remote_is_local as an instance attribute specifically for the synchronize use case 322 # ensuring we set local tmpdir correctly 323 self._connection._remote_is_local = True 324 self._override_module_replaced_vars(task_vars) 325 326 # SWITCH SRC AND DEST HOST PER MODE 327 if _tmp_args.get('mode', 'push') == 'pull': 328 (dest_host, src_host) = (src_host, dest_host) 329 330 # MUNGE SRC AND DEST PER REMOTE_HOST INFO 331 src = _tmp_args.get('src', None) 332 dest = _tmp_args.get('dest', None) 333 if src is None or dest is None: 334 return dict(failed=True, msg="synchronize requires both src and dest parameters are set") 335 336 # Determine if we need a user@ 337 user = None 338 if not dest_is_local: 339 # Src and dest rsync "path" handling 340 if boolean(_tmp_args.get('set_remote_user', 'yes'), strict=False): 341 if use_delegate: 342 user = task_vars.get('ansible_delegated_vars', dict()).get('ansible_user', None) 343 if not user: 344 user = task_vars.get('ansible_user') or self._play_context.remote_user 345 if not user: 346 user = C.DEFAULT_REMOTE_USER 347 348 else: 349 user = task_vars.get('ansible_user') or self._play_context.remote_user 350 351 # Private key handling 352 # Use the private_key parameter if passed else use context private_key_file 353 _tmp_args['private_key'] = _tmp_args.get('private_key', self._play_context.private_key_file) 354 355 # use the mode to define src and dest's url 356 if _tmp_args.get('mode', 'push') == 'pull': 357 # src is a remote path: <user>@<host>, dest is a local path 358 src = self._process_remote(_tmp_args, src_host, src, user, inv_port in localhost_ports) 359 dest = self._process_origin(dest_host, dest, user) 360 else: 361 # src is a local path, dest is a remote path: <user>@<host> 362 src = self._process_origin(src_host, src, user) 363 dest = self._process_remote(_tmp_args, dest_host, dest, user, inv_port in localhost_ports) 364 else: 365 # Still need to munge paths (to account for roles) even if we aren't 366 # copying files between hosts 367 src = self._get_absolute_path(path=src) 368 dest = self._get_absolute_path(path=dest) 369 370 _tmp_args['src'] = src 371 _tmp_args['dest'] = dest 372 373 # Allow custom rsync path argument 374 rsync_path = _tmp_args.get('rsync_path', None) 375 376 # backup original become as we are probably about to unset it 377 become = self._play_context.become 378 379 if not dest_is_local: 380 # don't escalate for docker. doing --rsync-path with docker exec fails 381 # and we can switch directly to the user via docker arguments 382 if self._play_context.become and not rsync_path and self._remote_transport not in DOCKER + PODMAN: 383 # If no rsync_path is set, become was originally set, and dest is 384 # remote then add privilege escalation here. 385 if self._play_context.become_method == 'sudo': 386 if self._play_context.become_user: 387 rsync_path = 'sudo -u %s rsync' % self._play_context.become_user 388 else: 389 rsync_path = 'sudo rsync' 390 # TODO: have to add in the rest of the become methods here 391 392 # We cannot use privilege escalation on the machine running the 393 # module. Instead we run it on the machine rsync is connecting 394 # to. 395 self._play_context.become = False 396 397 _tmp_args['rsync_path'] = rsync_path 398 399 # If launching synchronize against docker container 400 # use rsync_opts to support container to override rsh options 401 if self._remote_transport in DOCKER + BUILDAH + PODMAN and not use_delegate: 402 # Replicate what we do in the module argumentspec handling for lists 403 if not isinstance(_tmp_args.get('rsync_opts'), MutableSequence): 404 tmp_rsync_opts = _tmp_args.get('rsync_opts', []) 405 if isinstance(tmp_rsync_opts, string_types): 406 tmp_rsync_opts = tmp_rsync_opts.split(',') 407 elif isinstance(tmp_rsync_opts, (int, float)): 408 tmp_rsync_opts = [to_text(tmp_rsync_opts)] 409 _tmp_args['rsync_opts'] = tmp_rsync_opts 410 411 if '--blocking-io' not in _tmp_args['rsync_opts']: 412 _tmp_args['rsync_opts'].append('--blocking-io') 413 414 if self._remote_transport in DOCKER + PODMAN: 415 if become and self._play_context.become_user: 416 _tmp_args['rsync_opts'].append('--rsh=' + shlex_quote('%s exec -u %s -i' % (self._docker_cmd, self._play_context.become_user))) 417 elif user is not None: 418 _tmp_args['rsync_opts'].append('--rsh=' + shlex_quote('%s exec -u %s -i' % (self._docker_cmd, user))) 419 else: 420 _tmp_args['rsync_opts'].append('--rsh=' + shlex_quote('%s exec -i' % self._docker_cmd)) 421 elif self._remote_transport in BUILDAH: 422 _tmp_args['rsync_opts'].append('--rsh=' + shlex_quote('buildah run --')) 423 424 # run the module and store the result 425 result.update(self._execute_module('ansible.posix.synchronize', module_args=_tmp_args, task_vars=task_vars)) 426 427 return result 428