From ee5a45de572ddb9b0300ff072993583a55fce012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Thu, 4 Apr 2024 11:32:44 +0200 Subject: [PATCH] Add equivalent of bind-recursive option to the Mount type class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the recursive mount behavior change in Docker 25, it is not possible to make recursive mounts writable with the current API. Add the `recursive` option which is equivalent of bind-recursive in CLI. This also allows for setting the mount to be non-recursive (added earlier in API v1.41). Signed-off-by: Jan Čermák --- docker/types/services.py | 28 ++++++++-- tests/integration/api_container_test.py | 69 ++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docker/types/services.py b/docker/types/services.py index 821115411..87ca26c7d 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -235,6 +235,9 @@ class Mount(dict): ``default```, ``consistent``, ``cached``, ``delegated``. propagation (string): A propagation mode with the value ``[r]private``, ``[r]shared``, or ``[r]slave``. Only valid for the ``bind`` type. + recursive (string): Bind mount recursive mode, one of ``enabled``, + ``disabled``, ``writable``, or ``readonly``. Only valid for the + ``bind`` type. no_copy (bool): False if the volume should be populated with the data from the target. Default: ``False``. Only valid for the ``volume`` type. @@ -247,9 +250,9 @@ class Mount(dict): """ def __init__(self, target, source, type='volume', read_only=False, - consistency=None, propagation=None, no_copy=False, - labels=None, driver_config=None, tmpfs_size=None, - tmpfs_mode=None): + consistency=None, propagation=None, recursive=None, + no_copy=False, labels=None, driver_config=None, + tmpfs_size=None, tmpfs_mode=None): self['Target'] = target self['Source'] = source if type not in ('bind', 'volume', 'tmpfs', 'npipe'): @@ -267,6 +270,21 @@ def __init__(self, target, source, type='volume', read_only=False, self['BindOptions'] = { 'Propagation': propagation } + if recursive is not None: + bind_options = self.setdefault('BindOptions', {}) + if recursive == "enabled": + pass # noop - default + elif recursive == "disabled": + bind_options['NonRecursive'] = True + elif recursive == "writable": + bind_options['ReadOnlyNonRecursive'] = True + elif recursive == "readonly": + bind_options['ReadOnlyForceRecursive'] = True + else: + raise errors.InvalidArgument( + 'Invalid recursive bind option, must be one of ' + '"enabled", "disabled", "writable", or "readonly".' + ) if any([labels, driver_config, no_copy, tmpfs_size, tmpfs_mode]): raise errors.InvalidArgument( 'Incompatible options have been provided for the bind ' @@ -282,7 +300,7 @@ def __init__(self, target, source, type='volume', read_only=False, volume_opts['DriverConfig'] = driver_config if volume_opts: self['VolumeOptions'] = volume_opts - if any([propagation, tmpfs_size, tmpfs_mode]): + if any([propagation, recursive, tmpfs_size, tmpfs_mode]): raise errors.InvalidArgument( 'Incompatible options have been provided for the volume ' 'type mount.' @@ -299,7 +317,7 @@ def __init__(self, target, source, type='volume', read_only=False, tmpfs_opts['SizeBytes'] = parse_bytes(tmpfs_size) if tmpfs_opts: self['TmpfsOptions'] = tmpfs_opts - if any([propagation, labels, driver_config, no_copy]): + if any([propagation, recursive, labels, driver_config, no_copy]): raise errors.InvalidArgument( 'Incompatible options have been provided for the tmpfs ' 'type mount.' diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index 0215e14c2..1dfca13b9 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -598,6 +598,60 @@ def test_create_with_mounts_ro(self): inspect_data = self.client.inspect_container(container) self.check_container_data(inspect_data, False) + @requires_api_version('1.41') + def test_create_with_mounts_recursive_disabled(self): + mount = docker.types.Mount( + type="bind", source=self.mount_origin, target=self.mount_dest, + read_only=True, recursive="disabled" + ) + host_config = self.client.create_host_config(mounts=[mount]) + container = self.run_container( + TEST_IMG, ['ls', self.mount_dest], + host_config=host_config + ) + assert container + logs = self.client.logs(container).decode('utf-8') + assert self.filename in logs + inspect_data = self.client.inspect_container(container) + self.check_container_data(inspect_data, False, + bind_options_field="NonRecursive") + + @requires_api_version('1.44') + def test_create_with_mounts_recursive_writable(self): + mount = docker.types.Mount( + type="bind", source=self.mount_origin, target=self.mount_dest, + read_only=True, recursive="writable" + ) + host_config = self.client.create_host_config(mounts=[mount]) + container = self.run_container( + TEST_IMG, ['ls', self.mount_dest], + host_config=host_config + ) + assert container + logs = self.client.logs(container).decode('utf-8') + assert self.filename in logs + inspect_data = self.client.inspect_container(container) + self.check_container_data(inspect_data, False, + bind_options_field="ReadOnlyNonRecursive") + + @requires_api_version('1.44') + def test_create_with_mounts_recursive_ro(self): + mount = docker.types.Mount( + type="bind", source=self.mount_origin, target=self.mount_dest, + read_only=True, recursive="readonly" + ) + host_config = self.client.create_host_config(mounts=[mount]) + container = self.run_container( + TEST_IMG, ['ls', self.mount_dest], + host_config=host_config + ) + assert container + logs = self.client.logs(container).decode('utf-8') + assert self.filename in logs + inspect_data = self.client.inspect_container(container) + self.check_container_data(inspect_data, False, + bind_options_field="ReadOnlyForceRecursive") + @requires_api_version('1.30') def test_create_with_volume_mount(self): mount = docker.types.Mount( @@ -620,7 +674,8 @@ def test_create_with_volume_mount(self): assert mount['Source'] == mount_data['Name'] assert mount_data['RW'] is True - def check_container_data(self, inspect_data, rw, propagation='rprivate'): + def check_container_data(self, inspect_data, rw, propagation='rprivate', + bind_options_field=None): assert 'Mounts' in inspect_data filtered = list(filter( lambda x: x['Destination'] == self.mount_dest, @@ -631,6 +686,18 @@ def check_container_data(self, inspect_data, rw, propagation='rprivate'): assert mount_data['Source'] == self.mount_origin assert mount_data['RW'] == rw assert mount_data['Propagation'] == propagation + if bind_options_field: + assert 'Mounts' in inspect_data['HostConfig'] + mounts = [ + x for x in inspect_data['HostConfig']['Mounts'] + if x['Target'] == self.mount_dest + ] + assert len(mounts) == 1 + mount = mounts[0] + assert 'BindOptions' in mount + bind_options = mount['BindOptions'] + assert bind_options_field in bind_options + assert bind_options[bind_options_field] is True def run_with_volume(self, ro, *args, **kwargs): return self.run_container(