1# Copyright (c) 2019-2020, Felix Fontein <felix@fontein.de> 2# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 3 4from __future__ import (absolute_import, division, print_function) 5__metaclass__ = type 6 7DOCUMENTATION = ''' 8author: 9 - Felix Fontein (@felixfontein) 10name: docker_api 11short_description: Run tasks in docker containers 12version_added: 1.1.0 13description: 14 - Run commands or put/fetch files to an existing docker container. 15 - Uses Docker SDK for Python to interact directly with the Docker daemon instead of 16 using the Docker CLI. Use the 17 R(community.docker.docker,ansible_collections.community.docker.docker_connection) 18 connection plugin if you want to use the Docker CLI. 19options: 20 remote_user: 21 type: str 22 description: 23 - The user to execute as inside the container. 24 vars: 25 - name: ansible_user 26 - name: ansible_docker_user 27 remote_addr: 28 type: str 29 description: 30 - The name of the container you want to access. 31 default: inventory_hostname 32 vars: 33 - name: ansible_host 34 - name: ansible_docker_host 35 36extends_documentation_fragment: 37 - community.docker.docker 38 - community.docker.docker.var_names 39 - community.docker.docker.docker_py_1_documentation 40''' 41 42import io 43import os 44import os.path 45import shutil 46import tarfile 47 48from ansible.errors import AnsibleFileNotFound, AnsibleConnectionFailure 49from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text 50from ansible.plugins.connection import ConnectionBase 51from ansible.utils.display import Display 52 53from ansible_collections.community.docker.plugins.module_utils.common import ( 54 RequestException, 55) 56from ansible_collections.community.docker.plugins.plugin_utils.socket_handler import ( 57 DockerSocketHandler, 58) 59from ansible_collections.community.docker.plugins.plugin_utils.common import ( 60 AnsibleDockerClient, 61) 62 63try: 64 from docker.errors import DockerException, APIError, NotFound 65except Exception: 66 # missing Docker SDK for Python handled in ansible_collections.community.docker.plugins.module_utils.common 67 pass 68 69MIN_DOCKER_PY = '1.7.0' 70MIN_DOCKER_API = None 71 72 73display = Display() 74 75 76class Connection(ConnectionBase): 77 ''' Local docker based connections ''' 78 79 transport = 'community.docker.docker_api' 80 has_pipelining = True 81 82 def _call_client(self, play_context, callable, not_found_can_be_resource=False): 83 try: 84 return callable() 85 except NotFound as e: 86 if not_found_can_be_resource: 87 raise AnsibleConnectionFailure('Could not find container "{1}" or resource in it ({0})'.format(e, play_context.remote_addr)) 88 else: 89 raise AnsibleConnectionFailure('Could not find container "{1}" ({0})'.format(e, play_context.remote_addr)) 90 except APIError as e: 91 if e.response and e.response.status_code == 409: 92 raise AnsibleConnectionFailure('The container "{1}" has been paused ({0})'.format(e, play_context.remote_addr)) 93 self.client.fail( 94 'An unexpected docker error occurred for container "{1}": {0}'.format(e, play_context.remote_addr) 95 ) 96 except DockerException as e: 97 self.client.fail( 98 'An unexpected docker error occurred for container "{1}": {0}'.format(e, play_context.remote_addr) 99 ) 100 except RequestException as e: 101 self.client.fail( 102 'An unexpected requests error occurred for container "{1}" when docker-py tried to talk to the docker daemon: {0}' 103 .format(e, play_context.remote_addr) 104 ) 105 106 def __init__(self, play_context, new_stdin, *args, **kwargs): 107 super(Connection, self).__init__(play_context, new_stdin, *args, **kwargs) 108 109 self.client = None 110 self.ids = dict() 111 112 # Windows uses Powershell modules 113 if getattr(self._shell, "_IS_WINDOWS", False): 114 self.module_implementation_preferences = ('.ps1', '.exe', '') 115 116 self.actual_user = play_context.remote_user 117 118 def _connect(self, port=None): 119 """ Connect to the container. Nothing to do """ 120 super(Connection, self)._connect() 121 if not self._connected: 122 display.vvv(u"ESTABLISH DOCKER CONNECTION FOR USER: {0}".format( 123 self.actual_user or u'?'), host=self._play_context.remote_addr 124 ) 125 if self.client is None: 126 self.client = AnsibleDockerClient(self, min_docker_version=MIN_DOCKER_PY, min_docker_api_version=MIN_DOCKER_API) 127 self._connected = True 128 129 if self.actual_user is None and display.verbosity > 2: 130 # Since we're not setting the actual_user, look it up so we have it for logging later 131 # Only do this if display verbosity is high enough that we'll need the value 132 # This saves overhead from calling into docker when we don't need to 133 display.vvv(u"Trying to determine actual user") 134 result = self._call_client(self._play_context, lambda: self.client.inspect_container(self._play_context.remote_addr)) 135 if result.get('Config'): 136 self.actual_user = result['Config'].get('User') 137 if self.actual_user is not None: 138 display.vvv(u"Actual user is '{0}'".format(self.actual_user)) 139 140 def exec_command(self, cmd, in_data=None, sudoable=False): 141 """ Run a command on the docker host """ 142 143 super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable) 144 145 command = [self._play_context.executable, '-c', to_text(cmd)] 146 147 do_become = self.become and self.become.expect_prompt() and sudoable 148 149 display.vvv( 150 u"EXEC {0}{1}{2}".format( 151 to_text(command), 152 ', with stdin ({0} bytes)'.format(len(in_data)) if in_data is not None else '', 153 ', with become prompt' if do_become else '', 154 ), 155 host=self._play_context.remote_addr 156 ) 157 158 need_stdin = True if (in_data is not None) or do_become else False 159 160 exec_data = self._call_client(self._play_context, lambda: self.client.exec_create( 161 self._play_context.remote_addr, 162 command, 163 stdout=True, 164 stderr=True, 165 stdin=need_stdin, 166 user=self._play_context.remote_user or '', 167 workdir=None, 168 )) 169 exec_id = exec_data['Id'] 170 171 if need_stdin: 172 exec_socket = self._call_client(self._play_context, lambda: self.client.exec_start( 173 exec_id, 174 detach=False, 175 socket=True, 176 )) 177 try: 178 with DockerSocketHandler(display, exec_socket, container=self._play_context.remote_addr) as exec_socket_handler: 179 if do_become: 180 become_output = [b''] 181 182 def append_become_output(stream_id, data): 183 become_output[0] += data 184 185 exec_socket_handler.set_block_done_callback(append_become_output) 186 187 while not self.become.check_success(become_output[0]) and not self.become.check_password_prompt(become_output[0]): 188 if not exec_socket_handler.select(self._play_context.timeout): 189 stdout, stderr = exec_socket_handler.consume() 190 raise AnsibleConnectionFailure('timeout waiting for privilege escalation password prompt:\n' + to_native(become_output[0])) 191 192 if exec_socket_handler.is_eof(): 193 raise AnsibleConnectionFailure('privilege output closed while waiting for password prompt:\n' + to_native(become_output[0])) 194 195 if not self.become.check_success(become_output[0]): 196 become_pass = self.become.get_option('become_pass', playcontext=self._play_context) 197 exec_socket_handler.write(to_bytes(become_pass, errors='surrogate_or_strict') + b'\n') 198 199 if in_data is not None: 200 exec_socket_handler.write(in_data) 201 202 stdout, stderr = exec_socket_handler.consume() 203 finally: 204 exec_socket.close() 205 else: 206 stdout, stderr = self._call_client(self._play_context, lambda: self.client.exec_start( 207 exec_id, 208 detach=False, 209 stream=False, 210 socket=False, 211 demux=True, 212 )) 213 214 result = self._call_client(self._play_context, lambda: self.client.exec_inspect(exec_id)) 215 216 return result.get('ExitCode') or 0, stdout or b'', stderr or b'' 217 218 def _prefix_login_path(self, remote_path): 219 ''' Make sure that we put files into a standard path 220 221 If a path is relative, then we need to choose where to put it. 222 ssh chooses $HOME but we aren't guaranteed that a home dir will 223 exist in any given chroot. So for now we're choosing "/" instead. 224 This also happens to be the former default. 225 226 Can revisit using $HOME instead if it's a problem 227 ''' 228 if getattr(self._shell, "_IS_WINDOWS", False): 229 import ntpath 230 return ntpath.normpath(remote_path) 231 else: 232 if not remote_path.startswith(os.path.sep): 233 remote_path = os.path.join(os.path.sep, remote_path) 234 return os.path.normpath(remote_path) 235 236 def put_file(self, in_path, out_path): 237 """ Transfer a file from local to docker container """ 238 super(Connection, self).put_file(in_path, out_path) 239 display.vvv("PUT %s TO %s" % (in_path, out_path), host=self._play_context.remote_addr) 240 241 out_path = self._prefix_login_path(out_path) 242 if not os.path.exists(to_bytes(in_path, errors='surrogate_or_strict')): 243 raise AnsibleFileNotFound( 244 "file or module does not exist: %s" % to_native(in_path)) 245 246 if self.actual_user not in self.ids: 247 dummy, ids, dummy = self.exec_command(b'id -u && id -g') 248 try: 249 user_id, group_id = ids.splitlines() 250 self.ids[self.actual_user] = int(user_id), int(group_id) 251 display.vvvv( 252 'PUT: Determined uid={0} and gid={1} for user "{2}"'.format(user_id, group_id, self.actual_user), 253 host=self._play_context.remote_addr 254 ) 255 except Exception as e: 256 raise AnsibleConnectionFailure( 257 'Error while determining user and group ID of current user in container "{1}": {0}\nGot value: {2!r}' 258 .format(e, self._play_context.remote_addr, ids) 259 ) 260 261 b_in_path = to_bytes(in_path, errors='surrogate_or_strict') 262 263 out_dir, out_file = os.path.split(out_path) 264 265 # TODO: stream tar file, instead of creating it in-memory into a BytesIO 266 267 bio = io.BytesIO() 268 with tarfile.open(fileobj=bio, mode='w|', dereference=True, encoding='utf-8') as tar: 269 # Note that without both name (bytes) and arcname (unicode), this either fails for 270 # Python 2.6/2.7, Python 3.5/3.6, or Python 3.7+. Only when passing both (in this 271 # form) it works with Python 2.6, 2.7, 3.5, 3.6, and 3.7 up to 3.9. 272 tarinfo = tar.gettarinfo(b_in_path, arcname=to_text(out_file)) 273 user_id, group_id = self.ids[self.actual_user] 274 tarinfo.uid = user_id 275 tarinfo.uname = '' 276 if self.actual_user: 277 tarinfo.uname = self.actual_user 278 tarinfo.gid = group_id 279 tarinfo.gname = '' 280 tarinfo.mode &= 0o700 281 with open(b_in_path, 'rb') as f: 282 tar.addfile(tarinfo, fileobj=f) 283 data = bio.getvalue() 284 285 ok = self._call_client(self._play_context, lambda: self.client.put_archive( 286 self._play_context.remote_addr, 287 out_dir, 288 data, # can also be file object for streaming; this is only clear from the 289 # implementation of put_archive(), which uses requests's put(). 290 # See https://2.python-requests.org/en/master/user/advanced/#streaming-uploads 291 # WARNING: might not work with all transports! 292 ), not_found_can_be_resource=True) 293 if not ok: 294 raise AnsibleConnectionFailure( 295 'Unknown error while creating file "{0}" in container "{1}".' 296 .format(out_path, self._play_context.remote_addr) 297 ) 298 299 def fetch_file(self, in_path, out_path): 300 """ Fetch a file from container to local. """ 301 super(Connection, self).fetch_file(in_path, out_path) 302 display.vvv("FETCH %s TO %s" % (in_path, out_path), host=self._play_context.remote_addr) 303 304 in_path = self._prefix_login_path(in_path) 305 b_out_path = to_bytes(out_path, errors='surrogate_or_strict') 306 307 considered_in_paths = set() 308 309 while True: 310 if in_path in considered_in_paths: 311 raise AnsibleConnectionFailure('Found infinite symbolic link loop when trying to fetch "{0}"'.format(in_path)) 312 considered_in_paths.add(in_path) 313 314 display.vvvv('FETCH: Fetching "%s"' % in_path, host=self._play_context.remote_addr) 315 stream, stats = self._call_client(self._play_context, lambda: self.client.get_archive( 316 self._play_context.remote_addr, 317 in_path, 318 ), not_found_can_be_resource=True) 319 320 # TODO: stream tar file instead of downloading it into a BytesIO 321 322 bio = io.BytesIO() 323 for chunk in stream: 324 bio.write(chunk) 325 bio.seek(0) 326 327 with tarfile.open(fileobj=bio, mode='r|') as tar: 328 symlink_member = None 329 first = True 330 for member in tar: 331 if not first: 332 raise AnsibleConnectionFailure('Received tarfile contains more than one file!') 333 first = False 334 if member.issym(): 335 symlink_member = member 336 continue 337 if not member.isfile(): 338 raise AnsibleConnectionFailure('Remote file "%s" is not a regular file or a symbolic link' % in_path) 339 in_f = tar.extractfile(member) # in Python 2, this *cannot* be used in `with`... 340 with open(b_out_path, 'wb') as out_f: 341 shutil.copyfileobj(in_f, out_f, member.size) 342 if first: 343 raise AnsibleConnectionFailure('Received tarfile is empty!') 344 # If the only member was a file, it's already extracted. If it is a symlink, process it now. 345 if symlink_member is not None: 346 in_path = os.path.join(os.path.split(in_path)[0], symlink_member.linkname) 347 display.vvvv('FETCH: Following symbolic link to "%s"' % in_path, host=self._play_context.remote_addr) 348 continue 349 return 350 351 def close(self): 352 """ Terminate the connection. Nothing to do for Docker""" 353 super(Connection, self).close() 354 self._connected = False 355