mirror of
https://github.com/ansible-collections/community.docker.git
synced 2025-12-16 03:52:05 +00:00
Add docker_container_copy_into module (#545)
* Move copying functionality to module_utils. * Add docker_container_copy_into module. * Use new module in other tests. * Fix copyright and attributes. * Improve idempotency, improve stat code. * Document and test when a stopped container works. * Improve owner/group detection error handling when container is stopped. * Fix formulation. Co-authored-by: Brian Scholer <1260690+briantist@users.noreply.github.com> * Improve file comparison. * Avoid reading whole file at once. * Stream when fetching files from daemon. * Fix comment. * Use read() instead of read1(). * Stream files when copying into container. * Linting. * Add force parameter. * Simplify library code. * Linting. * Add content and content_is_b64 options. * Make force=false work as for copy module: only copy if the destination does not exist. * Improve docs. * content should be no_log. * Implement diff mode. * Improve error handling. * Lint and improve. * Set owner/group ID to avoid ID lookup (which fails in paused containers). * Apply suggestions from code review Co-authored-by: Brian Scholer <1260690+briantist@users.noreply.github.com> Co-authored-by: Brian Scholer <1260690+briantist@users.noreply.github.com>
This commit is contained in:
parent
134d32cae6
commit
e198e4ab43
@ -57,6 +57,7 @@ If you use the Ansible package and do not update collections independently, use
|
|||||||
* Modules:
|
* Modules:
|
||||||
* Docker:
|
* Docker:
|
||||||
- community.docker.docker_container: manage Docker containers
|
- community.docker.docker_container: manage Docker containers
|
||||||
|
- community.docker.docker_container_copy_into: copy a file into a Docker container
|
||||||
- community.docker.docker_container_exec: run commands in Docker containers
|
- community.docker.docker_container_exec: run commands in Docker containers
|
||||||
- community.docker.docker_container_info: retrieve information on Docker containers
|
- community.docker.docker_container_info: retrieve information on Docker containers
|
||||||
- community.docker.docker_host_info: retrieve information on the Docker daemon
|
- community.docker.docker_host_info: retrieve information on the Docker daemon
|
||||||
|
|||||||
2
changelogs/fragments/545-docker_api.yml
Normal file
2
changelogs/fragments/545-docker_api.yml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
minor_changes:
|
||||||
|
- "docker_api connection plugin - when copying files to/from a container, stream the file contents instead of first reading them to memory (https://github.com/ansible-collections/community.docker/pull/545)."
|
||||||
@ -9,6 +9,7 @@ action_groups:
|
|||||||
- docker_compose
|
- docker_compose
|
||||||
- docker_config
|
- docker_config
|
||||||
- docker_container
|
- docker_container
|
||||||
|
- docker_container_copy_into
|
||||||
- docker_container_exec
|
- docker_container_exec
|
||||||
- docker_container_info
|
- docker_container_info
|
||||||
- docker_host_info
|
- docker_host_info
|
||||||
|
|||||||
41
plugins/action/docker_container_copy_into.py
Normal file
41
plugins/action/docker_container_copy_into.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Copyright (c) 2022, Felix Fontein <felix@fontein.de>
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from ansible import constants as C
|
||||||
|
from ansible.errors import AnsibleError
|
||||||
|
from ansible.plugins.action import ActionBase
|
||||||
|
from ansible.utils.vars import merge_hash
|
||||||
|
|
||||||
|
from ansible_collections.community.docker.plugins.module_utils._scramble import unscramble
|
||||||
|
|
||||||
|
|
||||||
|
class ActionModule(ActionBase):
|
||||||
|
# Set to True when transfering files to the remote
|
||||||
|
TRANSFERS_FILES = False
|
||||||
|
|
||||||
|
def run(self, tmp=None, task_vars=None):
|
||||||
|
self._supports_check_mode = True
|
||||||
|
self._supports_async = True
|
||||||
|
|
||||||
|
result = super(ActionModule, self).run(tmp, task_vars)
|
||||||
|
del tmp # tmp no longer has any effect
|
||||||
|
|
||||||
|
self._task.args['_max_file_size_for_diff'] = C.MAX_FILE_SIZE_FOR_DIFF
|
||||||
|
|
||||||
|
result = merge_hash(result, self._execute_module(task_vars=task_vars, wrap_async=self._task.async_val))
|
||||||
|
|
||||||
|
if u'diff' in result and result[u'diff'].get(u'scrambled_diff'):
|
||||||
|
# Scrambling is not done for security, but to avoid no_log screwing up the diff
|
||||||
|
diff = result[u'diff']
|
||||||
|
key = base64.b64decode(diff.pop(u'scrambled_diff'))
|
||||||
|
for k in (u'before', u'after'):
|
||||||
|
if k in diff:
|
||||||
|
diff[k] = unscramble(diff[k], key)
|
||||||
|
|
||||||
|
return result
|
||||||
@ -68,11 +68,8 @@ options:
|
|||||||
type: integer
|
type: integer
|
||||||
'''
|
'''
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
import shutil
|
|
||||||
import tarfile
|
|
||||||
|
|
||||||
from ansible.errors import AnsibleFileNotFound, AnsibleConnectionFailure
|
from ansible.errors import AnsibleFileNotFound, AnsibleConnectionFailure
|
||||||
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
|
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
|
||||||
@ -82,6 +79,13 @@ from ansible.utils.display import Display
|
|||||||
from ansible_collections.community.docker.plugins.module_utils.common_api import (
|
from ansible_collections.community.docker.plugins.module_utils.common_api import (
|
||||||
RequestException,
|
RequestException,
|
||||||
)
|
)
|
||||||
|
from ansible_collections.community.docker.plugins.module_utils.copy import (
|
||||||
|
DockerFileCopyError,
|
||||||
|
DockerFileNotFound,
|
||||||
|
fetch_file,
|
||||||
|
put_file,
|
||||||
|
)
|
||||||
|
|
||||||
from ansible_collections.community.docker.plugins.plugin_utils.socket_handler import (
|
from ansible_collections.community.docker.plugins.plugin_utils.socket_handler import (
|
||||||
DockerSocketHandler,
|
DockerSocketHandler,
|
||||||
)
|
)
|
||||||
@ -89,7 +93,6 @@ from ansible_collections.community.docker.plugins.plugin_utils.common_api import
|
|||||||
AnsibleDockerClient,
|
AnsibleDockerClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ansible_collections.community.docker.plugins.module_utils._api.constants import DEFAULT_DATA_CHUNK_SIZE
|
|
||||||
from ansible_collections.community.docker.plugins.module_utils._api.errors import APIError, DockerException, NotFound
|
from ansible_collections.community.docker.plugins.module_utils._api.errors import APIError, DockerException, NotFound
|
||||||
|
|
||||||
MIN_DOCKER_API = None
|
MIN_DOCKER_API = None
|
||||||
@ -260,24 +263,12 @@ class Connection(ConnectionBase):
|
|||||||
remote_path = os.path.join(os.path.sep, remote_path)
|
remote_path = os.path.join(os.path.sep, remote_path)
|
||||||
return os.path.normpath(remote_path)
|
return os.path.normpath(remote_path)
|
||||||
|
|
||||||
def _put_archive(self, container, path, data):
|
|
||||||
# data can also be file object for streaming. This is because _put uses requests's put().
|
|
||||||
# See https://2.python-requests.org/en/master/user/advanced/#streaming-uploads
|
|
||||||
# WARNING: might not work with all transports!
|
|
||||||
url = self.client._url('/containers/{0}/archive', container)
|
|
||||||
res = self.client._put(url, params={'path': path}, data=data)
|
|
||||||
self.client._raise_for_status(res)
|
|
||||||
return res.status_code == 200
|
|
||||||
|
|
||||||
def put_file(self, in_path, out_path):
|
def put_file(self, in_path, out_path):
|
||||||
""" Transfer a file from local to docker container """
|
""" Transfer a file from local to docker container """
|
||||||
super(Connection, self).put_file(in_path, out_path)
|
super(Connection, self).put_file(in_path, out_path)
|
||||||
display.vvv("PUT %s TO %s" % (in_path, out_path), host=self.get_option('remote_addr'))
|
display.vvv("PUT %s TO %s" % (in_path, out_path), host=self.get_option('remote_addr'))
|
||||||
|
|
||||||
out_path = self._prefix_login_path(out_path)
|
out_path = self._prefix_login_path(out_path)
|
||||||
if not os.path.exists(to_bytes(in_path, errors='surrogate_or_strict')):
|
|
||||||
raise AnsibleFileNotFound(
|
|
||||||
"file or module does not exist: %s" % to_native(in_path))
|
|
||||||
|
|
||||||
if self.actual_user not in self.ids:
|
if self.actual_user not in self.ids:
|
||||||
dummy, ids, dummy = self.exec_command(b'id -u && id -g')
|
dummy, ids, dummy = self.exec_command(b'id -u && id -g')
|
||||||
@ -294,43 +285,25 @@ class Connection(ConnectionBase):
|
|||||||
.format(e, self.get_option('remote_addr'), ids)
|
.format(e, self.get_option('remote_addr'), ids)
|
||||||
)
|
)
|
||||||
|
|
||||||
b_in_path = to_bytes(in_path, errors='surrogate_or_strict')
|
user_id, group_id = self.ids[self.actual_user]
|
||||||
|
try:
|
||||||
out_dir, out_file = os.path.split(out_path)
|
self._call_client(
|
||||||
|
lambda: put_file(
|
||||||
# TODO: stream tar file, instead of creating it in-memory into a BytesIO
|
self.client,
|
||||||
|
container=self.get_option('remote_addr'),
|
||||||
bio = io.BytesIO()
|
in_path=in_path,
|
||||||
with tarfile.open(fileobj=bio, mode='w|', dereference=True, encoding='utf-8') as tar:
|
out_path=out_path,
|
||||||
# Note that without both name (bytes) and arcname (unicode), this either fails for
|
user_id=user_id,
|
||||||
# Python 2.7, Python 3.5/3.6, or Python 3.7+. Only when passing both (in this
|
group_id=group_id,
|
||||||
# form) it works with Python 2.7, 3.5, 3.6, and 3.7 up to 3.11
|
user_name=self.actual_user,
|
||||||
tarinfo = tar.gettarinfo(b_in_path, arcname=to_text(out_file))
|
follow_links=True,
|
||||||
user_id, group_id = self.ids[self.actual_user]
|
),
|
||||||
tarinfo.uid = user_id
|
not_found_can_be_resource=True,
|
||||||
tarinfo.uname = ''
|
|
||||||
if self.actual_user:
|
|
||||||
tarinfo.uname = self.actual_user
|
|
||||||
tarinfo.gid = group_id
|
|
||||||
tarinfo.gname = ''
|
|
||||||
tarinfo.mode &= 0o700
|
|
||||||
with open(b_in_path, 'rb') as f:
|
|
||||||
tar.addfile(tarinfo, fileobj=f)
|
|
||||||
data = bio.getvalue()
|
|
||||||
|
|
||||||
ok = self._call_client(
|
|
||||||
lambda: self._put_archive(
|
|
||||||
self.get_option('remote_addr'),
|
|
||||||
out_dir,
|
|
||||||
data,
|
|
||||||
),
|
|
||||||
not_found_can_be_resource=True,
|
|
||||||
)
|
|
||||||
if not ok:
|
|
||||||
raise AnsibleConnectionFailure(
|
|
||||||
'Unknown error while creating file "{0}" in container "{1}".'
|
|
||||||
.format(out_path, self.get_option('remote_addr'))
|
|
||||||
)
|
)
|
||||||
|
except DockerFileNotFound as exc:
|
||||||
|
raise AnsibleFileNotFound(to_native(exc))
|
||||||
|
except DockerFileCopyError as exc:
|
||||||
|
raise AnsibleConnectionFailure(to_native(exc))
|
||||||
|
|
||||||
def fetch_file(self, in_path, out_path):
|
def fetch_file(self, in_path, out_path):
|
||||||
""" Fetch a file from container to local. """
|
""" Fetch a file from container to local. """
|
||||||
@ -338,55 +311,23 @@ class Connection(ConnectionBase):
|
|||||||
display.vvv("FETCH %s TO %s" % (in_path, out_path), host=self.get_option('remote_addr'))
|
display.vvv("FETCH %s TO %s" % (in_path, out_path), host=self.get_option('remote_addr'))
|
||||||
|
|
||||||
in_path = self._prefix_login_path(in_path)
|
in_path = self._prefix_login_path(in_path)
|
||||||
b_out_path = to_bytes(out_path, errors='surrogate_or_strict')
|
|
||||||
|
|
||||||
considered_in_paths = set()
|
try:
|
||||||
|
self._call_client(
|
||||||
while True:
|
lambda: fetch_file(
|
||||||
if in_path in considered_in_paths:
|
self.client,
|
||||||
raise AnsibleConnectionFailure('Found infinite symbolic link loop when trying to fetch "{0}"'.format(in_path))
|
container=self.get_option('remote_addr'),
|
||||||
considered_in_paths.add(in_path)
|
in_path=in_path,
|
||||||
|
out_path=out_path,
|
||||||
display.vvvv('FETCH: Fetching "%s"' % in_path, host=self.get_option('remote_addr'))
|
follow_links=True,
|
||||||
stream = self._call_client(
|
log=lambda msg: display.vvvv(msg, host=self.get_option('remote_addr')),
|
||||||
lambda: self.client.get_raw_stream(
|
|
||||||
'/containers/{0}/archive', self.get_option('remote_addr'),
|
|
||||||
params={'path': in_path},
|
|
||||||
headers={'Accept-Encoding': 'identity'},
|
|
||||||
),
|
),
|
||||||
not_found_can_be_resource=True,
|
not_found_can_be_resource=True,
|
||||||
)
|
)
|
||||||
|
except DockerFileNotFound as exc:
|
||||||
# TODO: stream tar file instead of downloading it into a BytesIO
|
raise AnsibleFileNotFound(to_native(exc))
|
||||||
|
except DockerFileCopyError as exc:
|
||||||
bio = io.BytesIO()
|
raise AnsibleConnectionFailure(to_native(exc))
|
||||||
for chunk in stream:
|
|
||||||
bio.write(chunk)
|
|
||||||
bio.seek(0)
|
|
||||||
|
|
||||||
with tarfile.open(fileobj=bio, mode='r|') as tar:
|
|
||||||
symlink_member = None
|
|
||||||
first = True
|
|
||||||
for member in tar:
|
|
||||||
if not first:
|
|
||||||
raise AnsibleConnectionFailure('Received tarfile contains more than one file!')
|
|
||||||
first = False
|
|
||||||
if member.issym():
|
|
||||||
symlink_member = member
|
|
||||||
continue
|
|
||||||
if not member.isfile():
|
|
||||||
raise AnsibleConnectionFailure('Remote file "%s" is not a regular file or a symbolic link' % in_path)
|
|
||||||
in_f = tar.extractfile(member) # in Python 2, this *cannot* be used in `with`...
|
|
||||||
with open(b_out_path, 'wb') as out_f:
|
|
||||||
shutil.copyfileobj(in_f, out_f, member.size)
|
|
||||||
if first:
|
|
||||||
raise AnsibleConnectionFailure('Received tarfile is empty!')
|
|
||||||
# If the only member was a file, it's already extracted. If it is a symlink, process it now.
|
|
||||||
if symlink_member is not None:
|
|
||||||
in_path = os.path.join(os.path.split(in_path)[0], symlink_member.linkname)
|
|
||||||
display.vvvv('FETCH: Following symbolic link to "%s"' % in_path, host=self.get_option('remote_addr'))
|
|
||||||
continue
|
|
||||||
return
|
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
""" Terminate the connection. Nothing to do for Docker"""
|
""" Terminate the connection. Nothing to do for Docker"""
|
||||||
|
|||||||
@ -227,6 +227,10 @@ class APIClient(
|
|||||||
def _get(self, url, **kwargs):
|
def _get(self, url, **kwargs):
|
||||||
return self.get(url, **self._set_request_timeout(kwargs))
|
return self.get(url, **self._set_request_timeout(kwargs))
|
||||||
|
|
||||||
|
@update_headers
|
||||||
|
def _head(self, url, **kwargs):
|
||||||
|
return self.head(url, **self._set_request_timeout(kwargs))
|
||||||
|
|
||||||
@update_headers
|
@update_headers
|
||||||
def _put(self, url, **kwargs):
|
def _put(self, url, **kwargs):
|
||||||
return self.put(url, **self._set_request_timeout(kwargs))
|
return self.put(url, **self._set_request_timeout(kwargs))
|
||||||
|
|||||||
56
plugins/module_utils/_scramble.py
Normal file
56
plugins/module_utils/_scramble.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Copyright 2016 Red Hat | Ansible
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import random
|
||||||
|
|
||||||
|
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
|
||||||
|
from ansible.module_utils.six import PY2
|
||||||
|
|
||||||
|
|
||||||
|
def generate_insecure_key():
|
||||||
|
'''Do NOT use this for cryptographic purposes!'''
|
||||||
|
while True:
|
||||||
|
# Generate a one-byte key. Right now the functions below do not use more
|
||||||
|
# than one byte, so this is sufficient.
|
||||||
|
if PY2:
|
||||||
|
key = chr(random.randint(0, 255))
|
||||||
|
else:
|
||||||
|
key = bytes([random.randint(0, 255)])
|
||||||
|
# Return anything that is not zero
|
||||||
|
if key != b'\x00':
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def scramble(value, key):
|
||||||
|
'''Do NOT use this for cryptographic purposes!'''
|
||||||
|
if len(key) < 1:
|
||||||
|
raise ValueError('Key must be at least one byte')
|
||||||
|
value = to_bytes(value)
|
||||||
|
if PY2:
|
||||||
|
k = ord(key[0])
|
||||||
|
value = b''.join([chr(k ^ ord(b)) for b in value])
|
||||||
|
else:
|
||||||
|
k = key[0]
|
||||||
|
value = bytes([k ^ b for b in value])
|
||||||
|
return '=S=' + to_native(base64.b64encode(value))
|
||||||
|
|
||||||
|
|
||||||
|
def unscramble(value, key):
|
||||||
|
'''Do NOT use this for cryptographic purposes!'''
|
||||||
|
if len(key) < 1:
|
||||||
|
raise ValueError('Key must be at least one byte')
|
||||||
|
if not value.startswith(u'=S='):
|
||||||
|
raise ValueError('Value does not start with indicator')
|
||||||
|
value = base64.b64decode(value[3:])
|
||||||
|
if PY2:
|
||||||
|
k = ord(key[0])
|
||||||
|
value = b''.join([chr(k ^ ord(b)) for b in value])
|
||||||
|
else:
|
||||||
|
k = key[0]
|
||||||
|
value = bytes([k ^ b for b in value])
|
||||||
|
return to_text(value)
|
||||||
442
plugins/module_utils/copy.py
Normal file
442
plugins/module_utils/copy.py
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
# Copyright 2016 Red Hat | Ansible
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
|
||||||
|
from ansible.module_utils.six import raise_from
|
||||||
|
|
||||||
|
from ansible_collections.community.docker.plugins.module_utils._api.errors import APIError, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
class DockerFileCopyError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DockerUnexpectedError(DockerFileCopyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DockerFileNotFound(DockerFileCopyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _put_archive(client, container, path, data):
|
||||||
|
# data can also be file object for streaming. This is because _put uses requests's put().
|
||||||
|
# See https://requests.readthedocs.io/en/latest/user/advanced/#streaming-uploads
|
||||||
|
url = client._url('/containers/{0}/archive', container)
|
||||||
|
res = client._put(url, params={'path': path}, data=data)
|
||||||
|
client._raise_for_status(res)
|
||||||
|
return res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def _symlink_tar_creator(b_in_path, file_stat, out_file, user_id, group_id, mode=None, user_name=None):
|
||||||
|
if not stat.S_ISLNK(file_stat.st_mode):
|
||||||
|
raise DockerUnexpectedError('stat information is not for a symlink')
|
||||||
|
bio = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=bio, mode='w|', dereference=False, encoding='utf-8') as tar:
|
||||||
|
# Note that without both name (bytes) and arcname (unicode), this either fails for
|
||||||
|
# Python 2.7, Python 3.5/3.6, or Python 3.7+. Only when passing both (in this
|
||||||
|
# form) it works with Python 2.7, 3.5, 3.6, and 3.7 up to 3.11
|
||||||
|
tarinfo = tar.gettarinfo(b_in_path, arcname=to_text(out_file))
|
||||||
|
tarinfo.uid = user_id
|
||||||
|
tarinfo.uname = ''
|
||||||
|
if user_name:
|
||||||
|
tarinfo.uname = user_name
|
||||||
|
tarinfo.gid = group_id
|
||||||
|
tarinfo.gname = ''
|
||||||
|
tarinfo.mode &= 0o700
|
||||||
|
if mode is not None:
|
||||||
|
tarinfo.mode = mode
|
||||||
|
if not tarinfo.issym():
|
||||||
|
raise DockerUnexpectedError('stat information is not for a symlink')
|
||||||
|
tar.addfile(tarinfo)
|
||||||
|
return bio.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _symlink_tar_generator(b_in_path, file_stat, out_file, user_id, group_id, mode=None, user_name=None):
|
||||||
|
yield _symlink_tar_creator(b_in_path, file_stat, out_file, user_id, group_id, mode, user_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _regular_file_tar_generator(b_in_path, file_stat, out_file, user_id, group_id, mode=None, user_name=None):
|
||||||
|
if not stat.S_ISREG(file_stat.st_mode):
|
||||||
|
raise DockerUnexpectedError('stat information is not for a regular file')
|
||||||
|
tarinfo = tarfile.TarInfo()
|
||||||
|
tarinfo.name = os.path.splitdrive(to_text(out_file))[1].replace(os.sep, '/').lstrip('/')
|
||||||
|
tarinfo.mode = (file_stat.st_mode & 0o700) if mode is None else mode
|
||||||
|
tarinfo.uid = user_id
|
||||||
|
tarinfo.gid = group_id
|
||||||
|
tarinfo.size = file_stat.st_size
|
||||||
|
tarinfo.mtime = file_stat.st_mtime
|
||||||
|
tarinfo.type = tarfile.REGTYPE
|
||||||
|
tarinfo.linkname = ''
|
||||||
|
if user_name:
|
||||||
|
tarinfo.uname = user_name
|
||||||
|
|
||||||
|
tarinfo_buf = tarinfo.tobuf()
|
||||||
|
total_size = len(tarinfo_buf)
|
||||||
|
yield tarinfo_buf
|
||||||
|
|
||||||
|
size = tarinfo.size
|
||||||
|
total_size += size
|
||||||
|
with open(b_in_path, 'rb') as f:
|
||||||
|
while size > 0:
|
||||||
|
to_read = min(size, 65536)
|
||||||
|
buf = f.read(to_read)
|
||||||
|
if not buf:
|
||||||
|
break
|
||||||
|
size -= len(buf)
|
||||||
|
yield buf
|
||||||
|
if size:
|
||||||
|
# If for some reason the file shrunk, fill up to the announced size with zeros.
|
||||||
|
# (If it enlarged, ignore the remainder.)
|
||||||
|
yield tarfile.NUL * size
|
||||||
|
|
||||||
|
remainder = tarinfo.size % tarfile.BLOCKSIZE
|
||||||
|
if remainder:
|
||||||
|
# We need to write a multiple of 512 bytes. Fill up with zeros.
|
||||||
|
yield tarfile.NUL * (tarfile.BLOCKSIZE - remainder)
|
||||||
|
total_size += tarfile.BLOCKSIZE - remainder
|
||||||
|
|
||||||
|
# End with two zeroed blocks
|
||||||
|
yield tarfile.NUL * (2 * tarfile.BLOCKSIZE)
|
||||||
|
total_size += 2 * tarfile.BLOCKSIZE
|
||||||
|
|
||||||
|
remainder = total_size % tarfile.RECORDSIZE
|
||||||
|
if remainder > 0:
|
||||||
|
yield tarfile.NUL * (tarfile.RECORDSIZE - remainder)
|
||||||
|
|
||||||
|
|
||||||
|
def _regular_content_tar_generator(content, out_file, user_id, group_id, mode, user_name=None):
|
||||||
|
tarinfo = tarfile.TarInfo()
|
||||||
|
tarinfo.name = os.path.splitdrive(to_text(out_file))[1].replace(os.sep, '/').lstrip('/')
|
||||||
|
tarinfo.mode = mode
|
||||||
|
tarinfo.uid = user_id
|
||||||
|
tarinfo.gid = group_id
|
||||||
|
tarinfo.size = len(content)
|
||||||
|
try:
|
||||||
|
tarinfo.mtime = int(datetime.datetime.now().timestamp())
|
||||||
|
except AttributeError:
|
||||||
|
# Python 2 (or more precisely: Python < 3.3) has no timestamp(). Use the following
|
||||||
|
# expression for Python 2:
|
||||||
|
tarinfo.mtime = int((datetime.datetime.utcnow() - datetime.datetime(1970, 1, 1)).total_seconds())
|
||||||
|
tarinfo.type = tarfile.REGTYPE
|
||||||
|
tarinfo.linkname = ''
|
||||||
|
if user_name:
|
||||||
|
tarinfo.uname = user_name
|
||||||
|
|
||||||
|
tarinfo_buf = tarinfo.tobuf()
|
||||||
|
total_size = len(tarinfo_buf)
|
||||||
|
yield tarinfo_buf
|
||||||
|
|
||||||
|
total_size += len(content)
|
||||||
|
yield content
|
||||||
|
|
||||||
|
remainder = tarinfo.size % tarfile.BLOCKSIZE
|
||||||
|
if remainder:
|
||||||
|
# We need to write a multiple of 512 bytes. Fill up with zeros.
|
||||||
|
yield tarfile.NUL * (tarfile.BLOCKSIZE - remainder)
|
||||||
|
total_size += tarfile.BLOCKSIZE - remainder
|
||||||
|
|
||||||
|
# End with two zeroed blocks
|
||||||
|
yield tarfile.NUL * (2 * tarfile.BLOCKSIZE)
|
||||||
|
total_size += 2 * tarfile.BLOCKSIZE
|
||||||
|
|
||||||
|
remainder = total_size % tarfile.RECORDSIZE
|
||||||
|
if remainder > 0:
|
||||||
|
yield tarfile.NUL * (tarfile.RECORDSIZE - remainder)
|
||||||
|
|
||||||
|
|
||||||
|
def put_file(client, container, in_path, out_path, user_id, group_id, mode=None, user_name=None, follow_links=False):
|
||||||
|
"""Transfer a file from local to Docker container."""
|
||||||
|
if not os.path.exists(to_bytes(in_path, errors='surrogate_or_strict')):
|
||||||
|
raise DockerFileNotFound(
|
||||||
|
"file or module does not exist: %s" % to_native(in_path))
|
||||||
|
|
||||||
|
b_in_path = to_bytes(in_path, errors='surrogate_or_strict')
|
||||||
|
|
||||||
|
out_dir, out_file = os.path.split(out_path)
|
||||||
|
|
||||||
|
if follow_links:
|
||||||
|
file_stat = os.stat(b_in_path)
|
||||||
|
else:
|
||||||
|
file_stat = os.lstat(b_in_path)
|
||||||
|
|
||||||
|
if stat.S_ISREG(file_stat.st_mode):
|
||||||
|
stream = _regular_file_tar_generator(b_in_path, file_stat, out_file, user_id, group_id, mode=mode, user_name=user_name)
|
||||||
|
elif stat.S_ISLNK(file_stat.st_mode):
|
||||||
|
stream = _symlink_tar_generator(b_in_path, file_stat, out_file, user_id, group_id, mode=mode, user_name=user_name)
|
||||||
|
else:
|
||||||
|
raise DockerFileCopyError(
|
||||||
|
'File{0} {1} is neither a regular file nor a symlink (stat mode {2}).'.format(
|
||||||
|
' referenced by' if follow_links else '', in_path, oct(file_stat.st_mode)))
|
||||||
|
|
||||||
|
ok = _put_archive(client, container, out_dir, stream)
|
||||||
|
if not ok:
|
||||||
|
raise DockerUnexpectedError('Unknown error while creating file "{0}" in container "{1}".'.format(out_path, container))
|
||||||
|
|
||||||
|
|
||||||
|
def put_file_content(client, container, content, out_path, user_id, group_id, mode, user_name=None):
|
||||||
|
"""Transfer a file from local to Docker container."""
|
||||||
|
out_dir, out_file = os.path.split(out_path)
|
||||||
|
|
||||||
|
stream = _regular_content_tar_generator(content, out_file, user_id, group_id, mode, user_name=user_name)
|
||||||
|
|
||||||
|
ok = _put_archive(client, container, out_dir, stream)
|
||||||
|
if not ok:
|
||||||
|
raise DockerUnexpectedError('Unknown error while creating file "{0}" in container "{1}".'.format(out_path, container))
|
||||||
|
|
||||||
|
|
||||||
|
def stat_file(client, container, in_path, follow_links=False, log=None):
|
||||||
|
"""Fetch information on a file from a Docker container to local.
|
||||||
|
|
||||||
|
Return a tuple ``(path, stat_data, link_target)`` where:
|
||||||
|
|
||||||
|
:path: is the resolved path in case ``follow_links=True``;
|
||||||
|
:stat_data: is ``None`` if the file does not exist, or a dictionary with fields
|
||||||
|
``name`` (string), ``size`` (integer), ``mode`` (integer, see https://pkg.go.dev/io/fs#FileMode),
|
||||||
|
``mtime`` (string), and ``linkTarget`` (string);
|
||||||
|
:link_target: is ``None`` if the file is not a symlink or when ``follow_links=False``,
|
||||||
|
and a string with the symlink target otherwise.
|
||||||
|
"""
|
||||||
|
considered_in_paths = set()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if in_path in considered_in_paths:
|
||||||
|
raise DockerFileCopyError('Found infinite symbolic link loop when trying to stating "{0}"'.format(in_path))
|
||||||
|
considered_in_paths.add(in_path)
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log('FETCH: Stating "%s"' % in_path)
|
||||||
|
|
||||||
|
response = client._head(
|
||||||
|
client._url('/containers/{0}/archive', container),
|
||||||
|
params={'path': in_path},
|
||||||
|
)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return in_path, None, None
|
||||||
|
client._raise_for_status(response)
|
||||||
|
header = response.headers.get('x-docker-container-path-stat')
|
||||||
|
try:
|
||||||
|
stat_data = json.loads(base64.b64decode(header))
|
||||||
|
except Exception as exc:
|
||||||
|
raise DockerUnexpectedError(
|
||||||
|
'When retrieving information for {in_path} from {container}, obtained header {header!r} that cannot be loaded as JSON: {exc}'
|
||||||
|
.format(in_path=in_path, container=container, header=header, exc=exc)
|
||||||
|
)
|
||||||
|
|
||||||
|
# https://pkg.go.dev/io/fs#FileMode: bit 32 - 5 means ModeSymlink
|
||||||
|
if stat_data['mode'] & (1 << (32 - 5)) != 0:
|
||||||
|
link_target = stat_data['linkTarget']
|
||||||
|
if not follow_links:
|
||||||
|
return in_path, stat_data, link_target
|
||||||
|
in_path = os.path.join(os.path.split(in_path)[0], link_target)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return in_path, stat_data, None
|
||||||
|
|
||||||
|
|
||||||
|
class _RawGeneratorFileobj(io.RawIOBase):
|
||||||
|
def __init__(self, stream):
|
||||||
|
self._stream = stream
|
||||||
|
self._buf = b''
|
||||||
|
|
||||||
|
def readable(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _readinto_from_buf(self, b, index, length):
|
||||||
|
cpy = min(length - index, len(self._buf))
|
||||||
|
if cpy:
|
||||||
|
b[index:index + cpy] = self._buf[:cpy]
|
||||||
|
self._buf = self._buf[cpy:]
|
||||||
|
index += cpy
|
||||||
|
return index
|
||||||
|
|
||||||
|
def readinto(self, b):
|
||||||
|
index = 0
|
||||||
|
length = len(b)
|
||||||
|
|
||||||
|
index = self._readinto_from_buf(b, index, length)
|
||||||
|
if index == length:
|
||||||
|
return index
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._buf += next(self._stream)
|
||||||
|
except StopIteration:
|
||||||
|
return index
|
||||||
|
|
||||||
|
return self._readinto_from_buf(b, index, length)
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_generator_to_fileobj(stream):
|
||||||
|
'''Given a generator that generates chunks of bytes, create a readable buffered stream.'''
|
||||||
|
raw = _RawGeneratorFileobj(stream)
|
||||||
|
return io.BufferedReader(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_file_ex(client, container, in_path, process_none, process_regular, process_symlink, process_other, follow_links=False, log=None):
|
||||||
|
"""Fetch a file (as a tar file entry) from a Docker container to local."""
|
||||||
|
considered_in_paths = set()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if in_path in considered_in_paths:
|
||||||
|
raise DockerFileCopyError('Found infinite symbolic link loop when trying to fetch "{0}"'.format(in_path))
|
||||||
|
considered_in_paths.add(in_path)
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log('FETCH: Fetching "%s"' % in_path)
|
||||||
|
try:
|
||||||
|
stream = client.get_raw_stream(
|
||||||
|
'/containers/{0}/archive', container,
|
||||||
|
params={'path': in_path},
|
||||||
|
headers={'Accept-Encoding': 'identity'},
|
||||||
|
)
|
||||||
|
except NotFound:
|
||||||
|
return process_none(in_path)
|
||||||
|
|
||||||
|
with tarfile.open(fileobj=_stream_generator_to_fileobj(stream), mode='r|') as tar:
|
||||||
|
symlink_member = None
|
||||||
|
result = None
|
||||||
|
found = False
|
||||||
|
for member in tar:
|
||||||
|
if found:
|
||||||
|
raise DockerUnexpectedError('Received tarfile contains more than one file!')
|
||||||
|
found = True
|
||||||
|
if member.issym():
|
||||||
|
symlink_member = member
|
||||||
|
continue
|
||||||
|
if member.isfile():
|
||||||
|
result = process_regular(in_path, tar, member)
|
||||||
|
continue
|
||||||
|
result = process_other(in_path, member)
|
||||||
|
if symlink_member:
|
||||||
|
if not follow_links:
|
||||||
|
return process_symlink(in_path, symlink_member)
|
||||||
|
in_path = os.path.join(os.path.split(in_path)[0], symlink_member.linkname)
|
||||||
|
if log:
|
||||||
|
log('FETCH: Following symbolic link to "%s"' % in_path)
|
||||||
|
continue
|
||||||
|
if found:
|
||||||
|
return result
|
||||||
|
raise DockerUnexpectedError('Received tarfile is empty!')
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_file(client, container, in_path, out_path, follow_links=False, log=None):
|
||||||
|
b_out_path = to_bytes(out_path, errors='surrogate_or_strict')
|
||||||
|
|
||||||
|
def process_none(in_path):
|
||||||
|
raise DockerFileNotFound(
|
||||||
|
'File {in_path} does not exist in container {container}'
|
||||||
|
.format(in_path=in_path, container=container)
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_regular(in_path, tar, member):
|
||||||
|
if not follow_links and os.path.exists(b_out_path):
|
||||||
|
os.unlink(b_out_path)
|
||||||
|
|
||||||
|
in_f = tar.extractfile(member) # in Python 2, this *cannot* be used in `with`...
|
||||||
|
with open(b_out_path, 'wb') as out_f:
|
||||||
|
shutil.copyfileobj(in_f, out_f)
|
||||||
|
return in_path
|
||||||
|
|
||||||
|
def process_symlink(in_path, member):
|
||||||
|
if os.path.exists(b_out_path):
|
||||||
|
os.unlink(b_out_path)
|
||||||
|
|
||||||
|
os.symlink(member.linkname, b_out_path)
|
||||||
|
return in_path
|
||||||
|
|
||||||
|
def process_other(in_path, member):
|
||||||
|
raise DockerFileCopyError('Remote file "%s" is not a regular file or a symbolic link' % in_path)
|
||||||
|
|
||||||
|
return fetch_file_ex(client, container, in_path, process_none, process_regular, process_symlink, process_other, follow_links=follow_links, log=log)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_command(client, container, command, log=None, check_rc=False):
|
||||||
|
if log:
|
||||||
|
log('Executing {command} in {container}'.format(command=command, container=container))
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'Container': container,
|
||||||
|
'User': '',
|
||||||
|
'Privileged': False,
|
||||||
|
'Tty': False,
|
||||||
|
'AttachStdin': False,
|
||||||
|
'AttachStdout': True,
|
||||||
|
'AttachStderr': True,
|
||||||
|
'Cmd': command,
|
||||||
|
}
|
||||||
|
|
||||||
|
if 'detachKeys' in client._general_configs:
|
||||||
|
data['detachKeys'] = client._general_configs['detachKeys']
|
||||||
|
|
||||||
|
try:
|
||||||
|
exec_data = client.post_json_to_json('/containers/{0}/exec', container, data=data)
|
||||||
|
except NotFound as e:
|
||||||
|
raise_from(
|
||||||
|
DockerFileCopyError('Could not find container "{container}"'.format(container=container)),
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
except APIError as e:
|
||||||
|
if e.response is not None and e.response.status_code == 409:
|
||||||
|
raise_from(
|
||||||
|
DockerFileCopyError('Cannot execute command in paused container "{container}"'.format(container=container)),
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
exec_id = exec_data['Id']
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'Tty': False,
|
||||||
|
'Detach': False
|
||||||
|
}
|
||||||
|
stdout, stderr = client.post_json_to_stream('/exec/{0}/start', exec_id, stream=False, demux=True, tty=False)
|
||||||
|
|
||||||
|
result = client.get_json('/exec/{0}/json', exec_id)
|
||||||
|
|
||||||
|
rc = result.get('ExitCode') or 0
|
||||||
|
stdout = stdout or b''
|
||||||
|
stderr = stderr or b''
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log('Exit code {rc}, stdout {stdout!r}, stderr {stderr!r}'.format(rc=rc, stdout=stdout, stderr=stderr))
|
||||||
|
|
||||||
|
if check_rc and rc != 0:
|
||||||
|
raise DockerUnexpectedError(
|
||||||
|
'Obtained unexpected exit code {rc} when running "{command}" in {container}.\nSTDOUT: {stdout}\nSTDERR: {stderr}'
|
||||||
|
.format(command=' '.join(command), container=container, rc=rc, stdout=stdout, stderr=stderr)
|
||||||
|
)
|
||||||
|
|
||||||
|
return rc, stdout, stderr
|
||||||
|
|
||||||
|
|
||||||
|
def determine_user_group(client, container, log=None):
|
||||||
|
dummy, stdout, stderr = _execute_command(client, container, ['/bin/sh', '-c', 'id -u && id -g'], check_rc=True, log=log)
|
||||||
|
|
||||||
|
stdout_lines = stdout.splitlines()
|
||||||
|
if len(stdout_lines) != 2:
|
||||||
|
raise DockerUnexpectedError(
|
||||||
|
'Expected two-line output to obtain user and group ID for container {container}, but got {lc} lines:\n{stdout}'
|
||||||
|
.format(container=container, lc=len(stdout_lines), stdout=stdout)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id, group_id = stdout_lines
|
||||||
|
try:
|
||||||
|
return int(user_id), int(group_id)
|
||||||
|
except ValueError:
|
||||||
|
raise DockerUnexpectedError(
|
||||||
|
'Expected two-line output with numeric IDs to obtain user and group ID for container {container}, but got "{l1}" and "{l2}" instead'
|
||||||
|
.format(container=container, l1=user_id, l2=group_id)
|
||||||
|
)
|
||||||
869
plugins/modules/docker_container_copy_into.py
Normal file
869
plugins/modules/docker_container_copy_into.py
Normal file
@ -0,0 +1,869 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
#
|
||||||
|
# Copyright (c) 2022, Felix Fontein <felix@fontein.de>
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
|
||||||
|
DOCUMENTATION = '''
|
||||||
|
---
|
||||||
|
module: docker_container_copy_into
|
||||||
|
|
||||||
|
short_description: Copy a file into a Docker container
|
||||||
|
|
||||||
|
description:
|
||||||
|
- Copy a file into a Docker container.
|
||||||
|
- Similar to C(docker cp).
|
||||||
|
- To copy files in a non-running container, you must provide the I(owner_id) and I(group_id) options.
|
||||||
|
This is also necessary if the container does not contain a C(/bin/sh) shell with an C(id) tool.
|
||||||
|
|
||||||
|
attributes:
|
||||||
|
check_mode:
|
||||||
|
support: full
|
||||||
|
diff_mode:
|
||||||
|
support: full
|
||||||
|
details:
|
||||||
|
- Additional data will need to be transferred to compute diffs.
|
||||||
|
- The module uses R(the MAX_FILE_SIZE_FOR_DIFF ansible-core configuration,MAX_FILE_SIZE_FOR_DIFF)
|
||||||
|
to determine for how large files diffs should be computed.
|
||||||
|
|
||||||
|
options:
|
||||||
|
container:
|
||||||
|
description:
|
||||||
|
- The name of the container to copy files to.
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
path:
|
||||||
|
description:
|
||||||
|
- Path to a file on the managed node.
|
||||||
|
- Mutually exclusive with I(content). One of I(content) and I(path) is required.
|
||||||
|
type: path
|
||||||
|
content:
|
||||||
|
description:
|
||||||
|
- The file's content.
|
||||||
|
- If you plan to provide binary data, provide it pre-encoded to base64, and set I(content_is_b64=true).
|
||||||
|
- Mutually exclusive with I(path). One of I(content) and I(path) is required.
|
||||||
|
type: str
|
||||||
|
content_is_b64:
|
||||||
|
description:
|
||||||
|
- If set to C(true), the content in I(content) is assumed to be Base64 encoded and
|
||||||
|
will be decoded before being used.
|
||||||
|
- To use binary I(content), it is better to keep it Base64 encoded and let it
|
||||||
|
be decoded by this option. Otherwise you risk the data to be interpreted as
|
||||||
|
UTF-8 and corrupted.
|
||||||
|
type: bool
|
||||||
|
default: false
|
||||||
|
container_path:
|
||||||
|
description:
|
||||||
|
- Path to a file inside the Docker container.
|
||||||
|
- Must be an absolute path.
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
follow:
|
||||||
|
description:
|
||||||
|
- This flag indicates that filesystem links in the Docker container, if they exist, should be followed.
|
||||||
|
type: bool
|
||||||
|
default: false
|
||||||
|
local_follow:
|
||||||
|
description:
|
||||||
|
- This flag indicates that filesystem links in the source tree (where the module is executed), if they exist, should be followed.
|
||||||
|
type: bool
|
||||||
|
default: true
|
||||||
|
owner_id:
|
||||||
|
description:
|
||||||
|
- The owner ID to use when writing the file to disk.
|
||||||
|
- If provided, I(group_id) must also be provided.
|
||||||
|
- If not provided, the module will try to determine the user and group ID for the current user in the container.
|
||||||
|
This will only work if C(/bin/sh) is present in the container and the C(id) binary or shell builtin is available.
|
||||||
|
Also the container must be running.
|
||||||
|
type: int
|
||||||
|
group_id:
|
||||||
|
description:
|
||||||
|
- The group ID to use when writing the file to disk.
|
||||||
|
- If provided, I(owner_id) must also be provided.
|
||||||
|
- If not provided, the module will try to determine the user and group ID for the current user in the container.
|
||||||
|
This will only work if C(/bin/sh) is present in the container and the C(id) binary or shell builtin is available.
|
||||||
|
Also the container must be running.
|
||||||
|
type: int
|
||||||
|
mode:
|
||||||
|
description:
|
||||||
|
- The file mode to use when writing the file to disk.
|
||||||
|
- Will use the file's mode from the source system if this option is not provided.
|
||||||
|
type: int
|
||||||
|
force:
|
||||||
|
description:
|
||||||
|
- If set to C(true), force writing the file (without performing any idempotency checks).
|
||||||
|
- If set to C(false), only write the file if it does not exist on the target. If a filesystem object exists at
|
||||||
|
the destination, the module will not do any change.
|
||||||
|
- If this option is not specified, the module will be idempotent. To verify idempotency, it will try to get information
|
||||||
|
on the filesystem object in the container, and if everything seems to match will download the file from the container
|
||||||
|
to compare it to the file to upload.
|
||||||
|
type: bool
|
||||||
|
|
||||||
|
extends_documentation_fragment:
|
||||||
|
- community.docker.docker.api_documentation
|
||||||
|
- community.docker.attributes
|
||||||
|
- community.docker.attributes.actiongroup_docker
|
||||||
|
|
||||||
|
author:
|
||||||
|
- "Felix Fontein (@felixfontein)"
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- "Docker API >= 1.25"
|
||||||
|
'''
|
||||||
|
|
||||||
|
EXAMPLES = '''
|
||||||
|
- name: Copy a file into the container
|
||||||
|
community.docker.docker_container_copy_into:
|
||||||
|
container: mydata
|
||||||
|
path: /home/user/data.txt
|
||||||
|
container_path: /data/input.txt
|
||||||
|
|
||||||
|
- name: Copy a file into the container with owner, group, and mode set
|
||||||
|
community.docker.docker_container_copy_into:
|
||||||
|
container: mydata
|
||||||
|
path: /home/user/bin/runme.o
|
||||||
|
container_path: /bin/runme
|
||||||
|
owner: 0 # root
|
||||||
|
group: 0 # root
|
||||||
|
mode: 0o755 # readable and executable by all users, writable by root
|
||||||
|
'''
|
||||||
|
|
||||||
|
RETURN = '''
|
||||||
|
container_path:
|
||||||
|
description:
|
||||||
|
- The actual path in the container.
|
||||||
|
- Can only be different from I(container_path) when I(follow=true).
|
||||||
|
type: str
|
||||||
|
returned: success
|
||||||
|
'''
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import stat
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from ansible.module_utils._text import to_bytes, to_native, to_text
|
||||||
|
|
||||||
|
from ansible_collections.community.docker.plugins.module_utils._api.errors import APIError, DockerException, NotFound
|
||||||
|
|
||||||
|
from ansible_collections.community.docker.plugins.module_utils.common_api import (
|
||||||
|
AnsibleDockerClient,
|
||||||
|
RequestException,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ansible_collections.community.docker.plugins.module_utils.copy import (
|
||||||
|
DockerFileCopyError,
|
||||||
|
DockerFileNotFound,
|
||||||
|
DockerUnexpectedError,
|
||||||
|
determine_user_group,
|
||||||
|
fetch_file_ex,
|
||||||
|
put_file,
|
||||||
|
put_file_content,
|
||||||
|
stat_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ansible_collections.community.docker.plugins.module_utils._scramble import generate_insecure_key, scramble
|
||||||
|
|
||||||
|
|
||||||
|
def are_fileobjs_equal(f1, f2):
|
||||||
|
'''Given two (buffered) file objects, compare their contents.'''
|
||||||
|
blocksize = 65536
|
||||||
|
b1buf = b''
|
||||||
|
b2buf = b''
|
||||||
|
while True:
|
||||||
|
if f1 and len(b1buf) < blocksize:
|
||||||
|
f1b = f1.read(blocksize)
|
||||||
|
if not f1b:
|
||||||
|
# f1 is EOF, so stop reading from it
|
||||||
|
f1 = None
|
||||||
|
b1buf += f1b
|
||||||
|
if f2 and len(b2buf) < blocksize:
|
||||||
|
f2b = f2.read(blocksize)
|
||||||
|
if not f2b:
|
||||||
|
# f2 is EOF, so stop reading from it
|
||||||
|
f2 = None
|
||||||
|
b2buf += f2b
|
||||||
|
if not b1buf or not b2buf:
|
||||||
|
# At least one of f1 and f2 is EOF and all its data has
|
||||||
|
# been processed. If both are EOF and their data has been
|
||||||
|
# processed, the files are equal, otherwise not.
|
||||||
|
return not b1buf and not b2buf
|
||||||
|
# Compare the next chunk of data, and remove it from the buffers
|
||||||
|
buflen = min(len(b1buf), len(b2buf))
|
||||||
|
if b1buf[:buflen] != b2buf[:buflen]:
|
||||||
|
return False
|
||||||
|
b1buf = b1buf[buflen:]
|
||||||
|
b2buf = b2buf[buflen:]
|
||||||
|
|
||||||
|
|
||||||
|
def are_fileobjs_equal_read_first(f1, f2):
|
||||||
|
'''Given two (buffered) file objects, compare their contents.
|
||||||
|
|
||||||
|
Returns a tuple (is_equal, content_of_f1), where the first element indicates
|
||||||
|
whether the two file objects have the same content, and the second element is
|
||||||
|
the content of the first file object.'''
|
||||||
|
blocksize = 65536
|
||||||
|
b1buf = b''
|
||||||
|
b2buf = b''
|
||||||
|
is_equal = True
|
||||||
|
content = []
|
||||||
|
while True:
|
||||||
|
if f1 and len(b1buf) < blocksize:
|
||||||
|
f1b = f1.read(blocksize)
|
||||||
|
if not f1b:
|
||||||
|
# f1 is EOF, so stop reading from it
|
||||||
|
f1 = None
|
||||||
|
b1buf += f1b
|
||||||
|
if f2 and len(b2buf) < blocksize:
|
||||||
|
f2b = f2.read(blocksize)
|
||||||
|
if not f2b:
|
||||||
|
# f2 is EOF, so stop reading from it
|
||||||
|
f2 = None
|
||||||
|
b2buf += f2b
|
||||||
|
if not b1buf or not b2buf:
|
||||||
|
# At least one of f1 and f2 is EOF and all its data has
|
||||||
|
# been processed. If both are EOF and their data has been
|
||||||
|
# processed, the files are equal, otherwise not.
|
||||||
|
is_equal = not b1buf and not b2buf
|
||||||
|
break
|
||||||
|
# Compare the next chunk of data, and remove it from the buffers
|
||||||
|
buflen = min(len(b1buf), len(b2buf))
|
||||||
|
if b1buf[:buflen] != b2buf[:buflen]:
|
||||||
|
is_equal = False
|
||||||
|
break
|
||||||
|
content.append(b1buf[:buflen])
|
||||||
|
b1buf = b1buf[buflen:]
|
||||||
|
b2buf = b2buf[buflen:]
|
||||||
|
|
||||||
|
content.append(b1buf)
|
||||||
|
if f1:
|
||||||
|
content.append(f1.read())
|
||||||
|
|
||||||
|
return is_equal, b''.join(content)
|
||||||
|
|
||||||
|
|
||||||
|
def is_container_file_not_regular_file(container_stat):
|
||||||
|
for bit in (
|
||||||
|
# https://pkg.go.dev/io/fs#FileMode
|
||||||
|
32 - 1, # ModeDir
|
||||||
|
32 - 4, # ModeTemporary
|
||||||
|
32 - 5, # ModeSymlink
|
||||||
|
32 - 6, # ModeDevice
|
||||||
|
32 - 7, # ModeNamedPipe
|
||||||
|
32 - 8, # ModeSocket
|
||||||
|
32 - 11, # ModeCharDevice
|
||||||
|
32 - 13, # ModeIrregular
|
||||||
|
):
|
||||||
|
if container_stat['mode'] & (1 << bit) != 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_file_mode(container_stat):
|
||||||
|
mode = container_stat['mode'] & 0xFFF
|
||||||
|
if container_stat['mode'] & (1 << (32 - 9)) != 0: # ModeSetuid
|
||||||
|
mode |= stat.S_ISUID # set UID bit
|
||||||
|
if container_stat['mode'] & (1 << (32 - 10)) != 0: # ModeSetgid
|
||||||
|
mode |= stat.S_ISGID # set GID bit
|
||||||
|
if container_stat['mode'] & (1 << (32 - 12)) != 0: # ModeSticky
|
||||||
|
mode |= stat.S_ISVTX # sticky bit
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
|
def add_other_diff(diff, in_path, member):
|
||||||
|
if diff is None:
|
||||||
|
return
|
||||||
|
diff['before_header'] = in_path
|
||||||
|
if member.isdir():
|
||||||
|
diff['before'] = '(directory)'
|
||||||
|
elif member.issym() or member.islnk():
|
||||||
|
diff['before'] = member.linkname
|
||||||
|
elif member.ischr():
|
||||||
|
diff['before'] = '(character device)'
|
||||||
|
elif member.isblk():
|
||||||
|
diff['before'] = '(block device)'
|
||||||
|
elif member.isfifo():
|
||||||
|
diff['before'] = '(fifo)'
|
||||||
|
elif member.isdev():
|
||||||
|
diff['before'] = '(device)'
|
||||||
|
elif member.isfile():
|
||||||
|
raise DockerUnexpectedError('should not be a regular file')
|
||||||
|
else:
|
||||||
|
diff['before'] = '(unknown filesystem object)'
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat=None, link_target=None):
|
||||||
|
if diff is None:
|
||||||
|
return
|
||||||
|
if regular_stat is not None:
|
||||||
|
# First handle all filesystem object types that are not regular files
|
||||||
|
if regular_stat['mode'] & (1 << (32 - 1)) != 0:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = '(directory)'
|
||||||
|
return
|
||||||
|
elif regular_stat['mode'] & (1 << (32 - 4)) != 0:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = '(temporary file)'
|
||||||
|
return
|
||||||
|
elif regular_stat['mode'] & (1 << (32 - 5)) != 0:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = link_target
|
||||||
|
return
|
||||||
|
elif regular_stat['mode'] & (1 << (32 - 6)) != 0:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = '(device)'
|
||||||
|
return
|
||||||
|
elif regular_stat['mode'] & (1 << (32 - 7)) != 0:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = '(named pipe)'
|
||||||
|
return
|
||||||
|
elif regular_stat['mode'] & (1 << (32 - 8)) != 0:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = '(socket)'
|
||||||
|
return
|
||||||
|
elif regular_stat['mode'] & (1 << (32 - 11)) != 0:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = '(character device)'
|
||||||
|
return
|
||||||
|
elif regular_stat['mode'] & (1 << (32 - 13)) != 0:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = '(unknown filesystem object)'
|
||||||
|
return
|
||||||
|
# Check whether file is too large
|
||||||
|
if regular_stat['size'] > max_file_size_for_diff > 0:
|
||||||
|
diff['dst_larger'] = max_file_size_for_diff
|
||||||
|
return
|
||||||
|
|
||||||
|
# We need to get hold of the content
|
||||||
|
def process_none(in_path):
|
||||||
|
diff['before'] = ''
|
||||||
|
|
||||||
|
def process_regular(in_path, tar, member):
|
||||||
|
add_diff_dst_from_regular_member(diff, max_file_size_for_diff, in_path, tar, member)
|
||||||
|
|
||||||
|
def process_symlink(in_path, member):
|
||||||
|
diff['before_header'] = in_path
|
||||||
|
diff['before'] = member.linkname
|
||||||
|
|
||||||
|
def process_other(in_path, member):
|
||||||
|
add_other_diff(diff, in_path, member)
|
||||||
|
|
||||||
|
fetch_file_ex(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
in_path=container_path,
|
||||||
|
process_none=process_none,
|
||||||
|
process_regular=process_regular,
|
||||||
|
process_symlink=process_symlink,
|
||||||
|
process_other=process_other,
|
||||||
|
follow_links=follow_links,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_binary(content):
|
||||||
|
if b'\x00' in content:
|
||||||
|
return True
|
||||||
|
# TODO: better detection
|
||||||
|
# (ansible-core also just checks for 0x00, and even just sticks to the first 8k, so this isn't too bad...)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def are_fileobjs_equal_with_diff_of_first(f1, f2, size, diff, max_file_size_for_diff, container_path):
|
||||||
|
if diff is None:
|
||||||
|
return are_fileobjs_equal(f1, f2)
|
||||||
|
if size > max_file_size_for_diff > 0:
|
||||||
|
diff['dst_larger'] = max_file_size_for_diff
|
||||||
|
return are_fileobjs_equal(f1, f2)
|
||||||
|
is_equal, content = are_fileobjs_equal_read_first(f1, f2)
|
||||||
|
if is_binary(content):
|
||||||
|
diff['dst_binary'] = 1
|
||||||
|
else:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = to_text(content)
|
||||||
|
return is_equal
|
||||||
|
|
||||||
|
|
||||||
|
def add_diff_dst_from_regular_member(diff, max_file_size_for_diff, container_path, tar, member):
|
||||||
|
if diff is None:
|
||||||
|
return
|
||||||
|
if member.size > max_file_size_for_diff > 0:
|
||||||
|
diff['dst_larger'] = max_file_size_for_diff
|
||||||
|
return
|
||||||
|
|
||||||
|
tar_f = tar.extractfile(member) # in Python 2, this *cannot* be used in `with`...
|
||||||
|
content = tar_f.read()
|
||||||
|
if is_binary(content):
|
||||||
|
diff['dst_binary'] = 1
|
||||||
|
else:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = to_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_dst_to_src(diff):
|
||||||
|
if diff is None:
|
||||||
|
return
|
||||||
|
for f, t in [
|
||||||
|
('dst_size', 'src_size'),
|
||||||
|
('dst_binary', 'src_binary'),
|
||||||
|
('before_header', 'after_header'),
|
||||||
|
('before', 'after'),
|
||||||
|
]:
|
||||||
|
if f in diff:
|
||||||
|
diff[t] = diff[f]
|
||||||
|
elif t in diff:
|
||||||
|
diff.pop(t)
|
||||||
|
|
||||||
|
|
||||||
|
def is_file_idempotent(client, container, managed_path, container_path, follow_links, local_follow_links, owner_id, group_id, mode,
|
||||||
|
force=False, diff=None, max_file_size_for_diff=1):
|
||||||
|
# Retrieve information of local file
|
||||||
|
try:
|
||||||
|
file_stat = os.stat(managed_path) if local_follow_links else os.lstat(managed_path)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno == 2:
|
||||||
|
raise DockerFileNotFound('Cannot find local file {managed_path}'.format(managed_path=managed_path))
|
||||||
|
raise
|
||||||
|
if mode is None:
|
||||||
|
mode = stat.S_IMODE(file_stat.st_mode)
|
||||||
|
if not stat.S_ISLNK(file_stat.st_mode) and not stat.S_ISREG(file_stat.st_mode):
|
||||||
|
raise DockerFileCopyError('Local path {managed_path} is not a symbolic link or file')
|
||||||
|
|
||||||
|
if diff is not None:
|
||||||
|
if file_stat.st_size > max_file_size_for_diff > 0:
|
||||||
|
diff['src_larger'] = max_file_size_for_diff
|
||||||
|
elif stat.S_ISLNK(file_stat.st_mode):
|
||||||
|
diff['after_header'] = managed_path
|
||||||
|
diff['after'] = os.readlink(managed_path)
|
||||||
|
else:
|
||||||
|
with open(managed_path, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
if is_binary(content):
|
||||||
|
diff['src_binary'] = 1
|
||||||
|
else:
|
||||||
|
diff['after_header'] = managed_path
|
||||||
|
diff['after'] = to_text(content)
|
||||||
|
|
||||||
|
# When forcing and we're not following links in the container, go!
|
||||||
|
if force and not follow_links:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
# Resolve symlinks in the container (if requested), and get information on container's file
|
||||||
|
real_container_path, regular_stat, link_target = stat_file(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
in_path=container_path,
|
||||||
|
follow_links=follow_links,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Follow links in the Docker container?
|
||||||
|
if follow_links:
|
||||||
|
container_path = real_container_path
|
||||||
|
|
||||||
|
# If the file wasn't found, continue
|
||||||
|
if regular_stat is None:
|
||||||
|
if diff is not None:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = ''
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
# When forcing, go!
|
||||||
|
if force:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
# If force is set to False, and the destination exists, assume there's nothing to do
|
||||||
|
if force is False:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
copy_dst_to_src(diff)
|
||||||
|
return container_path, mode, True
|
||||||
|
|
||||||
|
# Basic idempotency checks
|
||||||
|
if stat.S_ISLNK(file_stat.st_mode):
|
||||||
|
if link_target is None:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
local_link_target = os.readlink(managed_path)
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, local_link_target == link_target
|
||||||
|
if link_target is not None:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
if is_container_file_not_regular_file(regular_stat):
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
if file_stat.st_size != regular_stat['size']:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
if mode != get_container_file_mode(regular_stat):
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
# Fetch file from container
|
||||||
|
def process_none(in_path):
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
def process_regular(in_path, tar, member):
|
||||||
|
# Check things like user/group ID and mode
|
||||||
|
if any([
|
||||||
|
member.mode & 0xFFF != mode,
|
||||||
|
member.uid != owner_id,
|
||||||
|
member.gid != group_id,
|
||||||
|
not stat.S_ISREG(file_stat.st_mode),
|
||||||
|
member.size != file_stat.st_size,
|
||||||
|
]):
|
||||||
|
add_diff_dst_from_regular_member(diff, max_file_size_for_diff, in_path, tar, member)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
tar_f = tar.extractfile(member) # in Python 2, this *cannot* be used in `with`...
|
||||||
|
with open(managed_path, 'rb') as local_f:
|
||||||
|
is_equal = are_fileobjs_equal_with_diff_of_first(tar_f, local_f, member.size, diff, max_file_size_for_diff, in_path)
|
||||||
|
return container_path, mode, is_equal
|
||||||
|
|
||||||
|
def process_symlink(in_path, member):
|
||||||
|
if diff is not None:
|
||||||
|
diff['before_header'] = in_path
|
||||||
|
diff['before'] = member.linkname
|
||||||
|
|
||||||
|
# Check things like user/group ID and mode
|
||||||
|
if member.mode & 0xFFF != mode:
|
||||||
|
return container_path, mode, False
|
||||||
|
if member.uid != owner_id:
|
||||||
|
return container_path, mode, False
|
||||||
|
if member.gid != group_id:
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
if not stat.S_ISLNK(file_stat.st_mode):
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
local_link_target = os.readlink(managed_path)
|
||||||
|
return container_path, mode, member.linkname == local_link_target
|
||||||
|
|
||||||
|
def process_other(in_path, member):
|
||||||
|
add_other_diff(diff, in_path, member)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
return fetch_file_ex(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
in_path=container_path,
|
||||||
|
process_none=process_none,
|
||||||
|
process_regular=process_regular,
|
||||||
|
process_symlink=process_symlink,
|
||||||
|
process_other=process_other,
|
||||||
|
follow_links=follow_links,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_file_into_container(client, container, managed_path, container_path, follow_links, local_follow_links,
|
||||||
|
owner_id, group_id, mode, force=False, diff=False, max_file_size_for_diff=1):
|
||||||
|
if diff:
|
||||||
|
diff = {}
|
||||||
|
else:
|
||||||
|
diff = None
|
||||||
|
|
||||||
|
container_path, mode, idempotent = is_file_idempotent(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
managed_path,
|
||||||
|
container_path,
|
||||||
|
follow_links,
|
||||||
|
local_follow_links,
|
||||||
|
owner_id,
|
||||||
|
group_id,
|
||||||
|
mode,
|
||||||
|
force=force,
|
||||||
|
diff=diff,
|
||||||
|
max_file_size_for_diff=max_file_size_for_diff,
|
||||||
|
)
|
||||||
|
changed = not idempotent
|
||||||
|
|
||||||
|
if changed and not client.module.check_mode:
|
||||||
|
put_file(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
in_path=managed_path,
|
||||||
|
out_path=container_path,
|
||||||
|
user_id=owner_id,
|
||||||
|
group_id=group_id,
|
||||||
|
mode=mode,
|
||||||
|
follow_links=local_follow_links,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = dict(
|
||||||
|
container_path=container_path,
|
||||||
|
changed=changed,
|
||||||
|
)
|
||||||
|
if diff:
|
||||||
|
result['diff'] = diff
|
||||||
|
client.module.exit_json(**result)
|
||||||
|
|
||||||
|
|
||||||
|
def is_content_idempotent(client, container, content, container_path, follow_links, owner_id, group_id, mode,
|
||||||
|
force=False, diff=None, max_file_size_for_diff=1):
|
||||||
|
if diff is not None:
|
||||||
|
if len(content) > max_file_size_for_diff > 0:
|
||||||
|
diff['src_larger'] = max_file_size_for_diff
|
||||||
|
elif is_binary(content):
|
||||||
|
diff['src_binary'] = 1
|
||||||
|
else:
|
||||||
|
diff['after_header'] = 'dynamically generated'
|
||||||
|
diff['after'] = to_text(content)
|
||||||
|
|
||||||
|
# When forcing and we're not following links in the container, go!
|
||||||
|
if force and not follow_links:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
# Resolve symlinks in the container (if requested), and get information on container's file
|
||||||
|
real_container_path, regular_stat, link_target = stat_file(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
in_path=container_path,
|
||||||
|
follow_links=follow_links,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Follow links in the Docker container?
|
||||||
|
if follow_links:
|
||||||
|
container_path = real_container_path
|
||||||
|
|
||||||
|
# If the file wasn't found, continue
|
||||||
|
if regular_stat is None:
|
||||||
|
if diff is not None:
|
||||||
|
diff['before_header'] = container_path
|
||||||
|
diff['before'] = ''
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
# When forcing, go!
|
||||||
|
if force:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
# If force is set to False, and the destination exists, assume there's nothing to do
|
||||||
|
if force is False:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
copy_dst_to_src(diff)
|
||||||
|
return container_path, mode, True
|
||||||
|
|
||||||
|
# Basic idempotency checks
|
||||||
|
if link_target is not None:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
if is_container_file_not_regular_file(regular_stat):
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
if len(content) != regular_stat['size']:
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
if mode != get_container_file_mode(regular_stat):
|
||||||
|
retrieve_diff(client, container, container_path, follow_links, diff, max_file_size_for_diff, regular_stat, link_target)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
# Fetch file from container
|
||||||
|
def process_none(in_path):
|
||||||
|
if diff is not None:
|
||||||
|
diff['before'] = ''
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
def process_regular(in_path, tar, member):
|
||||||
|
# Check things like user/group ID and mode
|
||||||
|
if any([
|
||||||
|
member.mode & 0xFFF != mode,
|
||||||
|
member.uid != owner_id,
|
||||||
|
member.gid != group_id,
|
||||||
|
member.size != len(content),
|
||||||
|
]):
|
||||||
|
add_diff_dst_from_regular_member(diff, max_file_size_for_diff, in_path, tar, member)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
tar_f = tar.extractfile(member) # in Python 2, this *cannot* be used in `with`...
|
||||||
|
is_equal = are_fileobjs_equal_with_diff_of_first(tar_f, io.BytesIO(content), member.size, diff, max_file_size_for_diff, in_path)
|
||||||
|
return container_path, mode, is_equal
|
||||||
|
|
||||||
|
def process_symlink(in_path, member):
|
||||||
|
if diff is not None:
|
||||||
|
diff['before_header'] = in_path
|
||||||
|
diff['before'] = member.linkname
|
||||||
|
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
def process_other(in_path, member):
|
||||||
|
add_other_diff(diff, in_path, member)
|
||||||
|
return container_path, mode, False
|
||||||
|
|
||||||
|
return fetch_file_ex(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
in_path=container_path,
|
||||||
|
process_none=process_none,
|
||||||
|
process_regular=process_regular,
|
||||||
|
process_symlink=process_symlink,
|
||||||
|
process_other=process_other,
|
||||||
|
follow_links=follow_links,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_content_into_container(client, container, content, container_path, follow_links,
|
||||||
|
owner_id, group_id, mode, force=False, diff=False, max_file_size_for_diff=1):
|
||||||
|
if diff:
|
||||||
|
diff = {}
|
||||||
|
else:
|
||||||
|
diff = None
|
||||||
|
|
||||||
|
container_path, mode, idempotent = is_content_idempotent(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
content,
|
||||||
|
container_path,
|
||||||
|
follow_links,
|
||||||
|
owner_id,
|
||||||
|
group_id,
|
||||||
|
mode,
|
||||||
|
force=force,
|
||||||
|
diff=diff,
|
||||||
|
max_file_size_for_diff=max_file_size_for_diff,
|
||||||
|
)
|
||||||
|
changed = not idempotent
|
||||||
|
|
||||||
|
if changed and not client.module.check_mode:
|
||||||
|
put_file_content(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
content=content,
|
||||||
|
out_path=container_path,
|
||||||
|
user_id=owner_id,
|
||||||
|
group_id=group_id,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = dict(
|
||||||
|
container_path=container_path,
|
||||||
|
changed=changed,
|
||||||
|
)
|
||||||
|
if diff:
|
||||||
|
# Since the content is no_log, make sure that the before/after strings look sufficiently different
|
||||||
|
key = generate_insecure_key()
|
||||||
|
diff['scrambled_diff'] = base64.b64encode(key)
|
||||||
|
for k in ('before', 'after'):
|
||||||
|
if k in diff:
|
||||||
|
diff[k] = scramble(diff[k], key)
|
||||||
|
result['diff'] = diff
|
||||||
|
client.module.exit_json(**result)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argument_spec = dict(
|
||||||
|
container=dict(type='str', required=True),
|
||||||
|
path=dict(type='path'),
|
||||||
|
container_path=dict(type='str', required=True),
|
||||||
|
follow=dict(type='bool', default=False),
|
||||||
|
local_follow=dict(type='bool', default=True),
|
||||||
|
owner_id=dict(type='int'),
|
||||||
|
group_id=dict(type='int'),
|
||||||
|
mode=dict(type='int'),
|
||||||
|
force=dict(type='bool'),
|
||||||
|
content=dict(type='str', no_log=True),
|
||||||
|
content_is_b64=dict(type='bool', default=False),
|
||||||
|
|
||||||
|
# Undocumented parameters for use by the action plugin
|
||||||
|
_max_file_size_for_diff=dict(type='int'),
|
||||||
|
)
|
||||||
|
|
||||||
|
client = AnsibleDockerClient(
|
||||||
|
argument_spec=argument_spec,
|
||||||
|
min_docker_api_version='1.20',
|
||||||
|
supports_check_mode=True,
|
||||||
|
mutually_exclusive=[('path', 'content')],
|
||||||
|
required_together=[('owner_id', 'group_id')],
|
||||||
|
required_by={
|
||||||
|
'content': ['mode'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
container = client.module.params['container']
|
||||||
|
managed_path = client.module.params['path']
|
||||||
|
container_path = client.module.params['container_path']
|
||||||
|
follow = client.module.params['follow']
|
||||||
|
local_follow = client.module.params['local_follow']
|
||||||
|
owner_id = client.module.params['owner_id']
|
||||||
|
group_id = client.module.params['group_id']
|
||||||
|
mode = client.module.params['mode']
|
||||||
|
force = client.module.params['force']
|
||||||
|
content = client.module.params['content']
|
||||||
|
max_file_size_for_diff = client.module.params['_max_file_size_for_diff'] or 1
|
||||||
|
|
||||||
|
if content is not None:
|
||||||
|
if client.module.params['content_is_b64']:
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(content)
|
||||||
|
except Exception as e: # depending on Python version and error, multiple different exceptions can be raised
|
||||||
|
client.fail('Cannot Base64 decode the content option: {0}'.format(e))
|
||||||
|
else:
|
||||||
|
content = to_bytes(content)
|
||||||
|
|
||||||
|
if not container_path.startswith(os.path.sep):
|
||||||
|
container_path = os.path.join(os.path.sep, container_path)
|
||||||
|
container_path = os.path.normpath(container_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if owner_id is None or group_id is None:
|
||||||
|
owner_id, group_id = determine_user_group(client, container)
|
||||||
|
|
||||||
|
if content is not None:
|
||||||
|
copy_content_into_container(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
content,
|
||||||
|
container_path,
|
||||||
|
follow_links=follow,
|
||||||
|
owner_id=owner_id,
|
||||||
|
group_id=group_id,
|
||||||
|
mode=mode,
|
||||||
|
force=force,
|
||||||
|
diff=client.module._diff,
|
||||||
|
max_file_size_for_diff=max_file_size_for_diff,
|
||||||
|
)
|
||||||
|
elif managed_path is not None:
|
||||||
|
copy_file_into_container(
|
||||||
|
client,
|
||||||
|
container,
|
||||||
|
managed_path,
|
||||||
|
container_path,
|
||||||
|
follow_links=follow,
|
||||||
|
local_follow_links=local_follow,
|
||||||
|
owner_id=owner_id,
|
||||||
|
group_id=group_id,
|
||||||
|
mode=mode,
|
||||||
|
force=force,
|
||||||
|
diff=client.module._diff,
|
||||||
|
max_file_size_for_diff=max_file_size_for_diff,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Can happen if a user explicitly passes `content: null` or `path: null`...
|
||||||
|
client.fail('One of path and content must be supplied')
|
||||||
|
except NotFound as exc:
|
||||||
|
client.fail('Could not find container "{1}" or resource in it ({0})'.format(exc, container))
|
||||||
|
except APIError as exc:
|
||||||
|
client.fail('An unexpected Docker error occurred for container "{1}": {0}'.format(exc, container), exception=traceback.format_exc())
|
||||||
|
except DockerException as exc:
|
||||||
|
client.fail('An unexpected Docker error occurred for container "{1}": {0}'.format(exc, container), exception=traceback.format_exc())
|
||||||
|
except RequestException as exc:
|
||||||
|
client.fail(
|
||||||
|
'An unexpected requests error occurred for container "{1}" when trying to talk to the Docker daemon: {0}'.format(exc, container),
|
||||||
|
exception=traceback.format_exc())
|
||||||
|
except DockerUnexpectedError as exc:
|
||||||
|
client.fail('Unexpected error: {exc}'.format(exc=to_native(exc)), exception=traceback.format_exc())
|
||||||
|
except DockerFileCopyError as exc:
|
||||||
|
client.fail(to_native(exc))
|
||||||
|
except OSError as exc:
|
||||||
|
client.fail('Unexpected error: {exc}'.format(exc=to_native(exc)), exception=traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
# Copyright (c) Ansible Project
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
azp/4
|
||||||
|
destructive
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
# Copyright (c) Ansible Project
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- setup_docker
|
||||||
|
- setup_remote_tmp_dir
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
# Copyright (c) Ansible Project
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
####################################################################
|
||||||
|
# WARNING: These are designed specifically for Ansible tests #
|
||||||
|
# and should not be used as examples of how to write Ansible roles #
|
||||||
|
####################################################################
|
||||||
|
|
||||||
|
- name: Gather facts on controller
|
||||||
|
setup:
|
||||||
|
gather_subset: '!all'
|
||||||
|
delegate_to: localhost
|
||||||
|
delegate_facts: true
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
# Create random name prefix (for containers)
|
||||||
|
- name: Create random container name prefix
|
||||||
|
set_fact:
|
||||||
|
cname_prefix: "{{ 'ansible-docker-test-%0x' % ((2**32) | random) }}"
|
||||||
|
cnames: []
|
||||||
|
|
||||||
|
- debug:
|
||||||
|
msg: "Using container name prefix {{ cname_prefix }}"
|
||||||
|
|
||||||
|
# Run the tests
|
||||||
|
- block:
|
||||||
|
- include_tasks: run-test.yml
|
||||||
|
with_fileglob:
|
||||||
|
- "tests/*.yml"
|
||||||
|
|
||||||
|
always:
|
||||||
|
- name: "Make sure all containers are removed"
|
||||||
|
docker_container:
|
||||||
|
name: "{{ item }}"
|
||||||
|
state: absent
|
||||||
|
force_kill: true
|
||||||
|
with_items: "{{ cnames }}"
|
||||||
|
diff: false
|
||||||
|
|
||||||
|
when: docker_api_version is version('1.25', '>=')
|
||||||
|
|
||||||
|
- fail: msg="Too old Docker API version to run all docker_container_copy_into tests!"
|
||||||
|
when: not(docker_api_version is version('1.25', '>=')) and (ansible_distribution != 'CentOS' or ansible_distribution_major_version|int > 6)
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
# Copyright (c) Ansible Project
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
- name: "Loading tasks from {{ item }}"
|
||||||
|
include_tasks: "{{ item }}"
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -43,7 +43,12 @@
|
|||||||
- nginx.conf
|
- nginx.conf
|
||||||
|
|
||||||
- name: Copy static files into volume
|
- name: Copy static files into volume
|
||||||
command: docker cp {{ remote_tmp_dir }}/{{ item }} {{ daemon_nginx_frontend }}:/etc/nginx/{{ item }}
|
docker_container_copy_into:
|
||||||
|
container: '{{ daemon_nginx_frontend }}'
|
||||||
|
path: '{{ remote_tmp_dir }}/{{ item }}'
|
||||||
|
container_path: '/etc/nginx/{{ item }}'
|
||||||
|
owner_id: 0
|
||||||
|
group_id: 0
|
||||||
loop:
|
loop:
|
||||||
- nginx.conf
|
- nginx.conf
|
||||||
register: can_copy_files
|
register: can_copy_files
|
||||||
@ -94,7 +99,12 @@
|
|||||||
provider: ownca
|
provider: ownca
|
||||||
|
|
||||||
- name: Copy dynamic files into volume
|
- name: Copy dynamic files into volume
|
||||||
command: docker cp {{ remote_tmp_dir }}/{{ item }} {{ daemon_nginx_frontend }}:/etc/nginx/{{ item }}
|
docker_container_copy_into:
|
||||||
|
container: '{{ daemon_nginx_frontend }}'
|
||||||
|
path: '{{ remote_tmp_dir }}/{{ item }}'
|
||||||
|
container_path: '/etc/nginx/{{ item }}'
|
||||||
|
owner_id: 0
|
||||||
|
group_id: 0
|
||||||
loop:
|
loop:
|
||||||
- ca.pem
|
- ca.pem
|
||||||
- cert.pem
|
- cert.pem
|
||||||
|
|||||||
@ -39,7 +39,12 @@
|
|||||||
- nginx.htpasswd
|
- nginx.htpasswd
|
||||||
|
|
||||||
- name: Copy static files into volume
|
- name: Copy static files into volume
|
||||||
command: docker cp {{ remote_tmp_dir }}/{{ item }} {{ docker_registry_container_name_frontend }}:/etc/nginx/{{ item }}
|
docker_container_copy_into:
|
||||||
|
container: '{{ docker_registry_container_name_frontend }}'
|
||||||
|
path: '{{ remote_tmp_dir }}/{{ item }}'
|
||||||
|
container_path: '/etc/nginx/{{ item }}'
|
||||||
|
owner_id: 0
|
||||||
|
group_id: 0
|
||||||
loop:
|
loop:
|
||||||
- nginx.conf
|
- nginx.conf
|
||||||
- nginx.htpasswd
|
- nginx.htpasswd
|
||||||
@ -71,7 +76,12 @@
|
|||||||
provider: selfsigned
|
provider: selfsigned
|
||||||
|
|
||||||
- name: Copy dynamic files into volume
|
- name: Copy dynamic files into volume
|
||||||
command: docker cp {{ remote_tmp_dir }}/{{ item }} {{ docker_registry_container_name_frontend }}:/etc/nginx/{{ item }}
|
docker_container_copy_into:
|
||||||
|
container: '{{ docker_registry_container_name_frontend }}'
|
||||||
|
path: '{{ remote_tmp_dir }}/{{ item }}'
|
||||||
|
container_path: '/etc/nginx/{{ item }}'
|
||||||
|
owner_id: 0
|
||||||
|
group_id: 0
|
||||||
loop:
|
loop:
|
||||||
- cert.pem
|
- cert.pem
|
||||||
- cert.key
|
- cert.key
|
||||||
|
|||||||
@ -8,3 +8,4 @@ plugins/modules/current_container_facts.py validate-modules:return-syntax-error
|
|||||||
plugins/module_utils/module_container/module.py compile-2.6!skip # Uses Python 2.7+ syntax
|
plugins/module_utils/module_container/module.py compile-2.6!skip # Uses Python 2.7+ syntax
|
||||||
plugins/module_utils/module_container/module.py import-2.6!skip # Uses Python 2.7+ syntax
|
plugins/module_utils/module_container/module.py import-2.6!skip # Uses Python 2.7+ syntax
|
||||||
plugins/modules/docker_container.py import-2.6!skip # Import uses Python 2.7+ syntax
|
plugins/modules/docker_container.py import-2.6!skip # Import uses Python 2.7+ syntax
|
||||||
|
plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin
|
||||||
|
|||||||
@ -8,3 +8,4 @@ plugins/modules/current_container_facts.py validate-modules:return-syntax-error
|
|||||||
plugins/module_utils/module_container/module.py compile-2.6!skip # Uses Python 2.7+ syntax
|
plugins/module_utils/module_container/module.py compile-2.6!skip # Uses Python 2.7+ syntax
|
||||||
plugins/module_utils/module_container/module.py import-2.6!skip # Uses Python 2.7+ syntax
|
plugins/module_utils/module_container/module.py import-2.6!skip # Uses Python 2.7+ syntax
|
||||||
plugins/modules/docker_container.py import-2.6!skip # Import uses Python 2.7+ syntax
|
plugins/modules/docker_container.py import-2.6!skip # Import uses Python 2.7+ syntax
|
||||||
|
plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
||||||
plugins/modules/current_container_facts.py validate-modules:return-syntax-error
|
plugins/modules/current_container_facts.py validate-modules:return-syntax-error
|
||||||
|
plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
||||||
|
plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
||||||
|
plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
||||||
|
plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin
|
||||||
|
|||||||
@ -7,3 +7,4 @@
|
|||||||
plugins/module_utils/module_container/module.py compile-2.6!skip # Uses Python 2.7+ syntax
|
plugins/module_utils/module_container/module.py compile-2.6!skip # Uses Python 2.7+ syntax
|
||||||
plugins/module_utils/module_container/module.py import-2.6!skip # Uses Python 2.7+ syntax
|
plugins/module_utils/module_container/module.py import-2.6!skip # Uses Python 2.7+ syntax
|
||||||
plugins/modules/docker_container.py import-2.6!skip # Import uses Python 2.7+ syntax
|
plugins/modules/docker_container.py import-2.6!skip # Import uses Python 2.7+ syntax
|
||||||
|
plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin
|
||||||
|
|||||||
29
tests/unit/plugins/module_utils/test__scramble.py
Normal file
29
tests/unit/plugins/module_utils/test__scramble.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Copyright 2022 Red Hat | Ansible
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
from ansible_collections.community.docker.plugins.module_utils._scramble import (
|
||||||
|
scramble,
|
||||||
|
unscramble,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('plaintext, key, scrambled', [
|
||||||
|
(u'', b'0', '=S='),
|
||||||
|
(u'hello', b'\x00', '=S=aGVsbG8='),
|
||||||
|
(u'hello', b'\x01', '=S=aWRtbW4='),
|
||||||
|
])
|
||||||
|
def test_scramble_unscramble(plaintext, key, scrambled):
|
||||||
|
scrambled_ = scramble(plaintext, key)
|
||||||
|
print('{0!r} == {1!r}'.format(scrambled_, scrambled))
|
||||||
|
assert scrambled_ == scrambled
|
||||||
|
|
||||||
|
plaintext_ = unscramble(scrambled, key)
|
||||||
|
print('{0!r} == {1!r}'.format(plaintext_, plaintext))
|
||||||
|
assert plaintext_ == plaintext
|
||||||
78
tests/unit/plugins/module_utils/test_copy.py
Normal file
78
tests/unit/plugins/module_utils/test_copy.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# Copyright 2022 Red Hat | Ansible
|
||||||
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
from ansible_collections.community.docker.plugins.module_utils.copy import (
|
||||||
|
_stream_generator_to_fileobj,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_generator(sequence):
|
||||||
|
for elt in sequence:
|
||||||
|
yield elt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('chunks, read_sizes', [
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(1, b'1'),
|
||||||
|
(1, b'2'),
|
||||||
|
(1, b'3'),
|
||||||
|
(1, b'4'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(1, b'123'),
|
||||||
|
(1, b'456'),
|
||||||
|
(1, b'789'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(10 * 1024 * 1024, b'0'),
|
||||||
|
(10 * 1024 * 1024, b'1'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1024 * 1024 - 5,
|
||||||
|
5 * 1024 * 1024 - 3,
|
||||||
|
10 * 1024 * 1024 - 2,
|
||||||
|
2 * 1024 * 1024 - 1,
|
||||||
|
2 * 1024 * 1024 + 5 + 3 + 2 + 1,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
])
|
||||||
|
def test__stream_generator_to_fileobj(chunks, read_sizes):
|
||||||
|
chunks = [count * data for count, data in chunks]
|
||||||
|
stream = _simple_generator(chunks)
|
||||||
|
expected = b''.join(chunks)
|
||||||
|
|
||||||
|
buffer = b''
|
||||||
|
totally_read = 0
|
||||||
|
f = _stream_generator_to_fileobj(stream)
|
||||||
|
for read_size in read_sizes:
|
||||||
|
chunk = f.read(read_size)
|
||||||
|
assert len(chunk) == min(read_size, len(expected) - len(buffer))
|
||||||
|
buffer += chunk
|
||||||
|
totally_read += read_size
|
||||||
|
|
||||||
|
assert buffer == expected[:len(buffer)]
|
||||||
|
assert min(totally_read, len(expected)) == len(buffer)
|
||||||
Loading…
Reference in New Issue
Block a user