diff --git a/docker/api/container.py b/docker/api/container.py index 6a764fbf..9fa6d763 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -911,9 +911,6 @@ class ContainerApiMixin(object): Raises: :py:class:`docker.errors.APIError` If the server returns an error. - - Raises: - :py:class:`~docker.errors.APIError` If an error occurs. """ params = {'path': path} url = self._url('/containers/{0}/archive', container) @@ -921,6 +918,28 @@ class ContainerApiMixin(object): self._raise_for_status(res) return res.status_code == 200 + @utils.minimum_version('1.25') + def prune_containers(self, filters=None): + """ + Delete stopped containers + + Args: + filters (dict): Filters to process on the prune list. + + Returns: + (dict): A dict containing a list of deleted container IDs and + the amount of disk space reclaimed in bytes. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + params = {} + if filters: + params['filters'] = utils.convert_filters(filters) + url = self._url('/containers/prune') + return self._result(self._post(url, params=params), True) + @utils.check_resource def remove_container(self, container, v=False, link=False, force=False): """ diff --git a/docker/api/image.py b/docker/api/image.py index c1ebc69c..09eb086d 100644 --- a/docker/api/image.py +++ b/docker/api/image.py @@ -274,6 +274,31 @@ class ImageApiMixin(object): res = self._post(self._url("/images/load"), data=data) self._raise_for_status(res) + @utils.minimum_version('1.25') + def prune_images(self, filters=None): + """ + Delete unused images + + Args: + filters (dict): Filters to process on the prune list. + Available filters: + - dangling (bool): When set to true (or 1), prune only + unused and untagged images. + + Returns: + (dict): A dict containing a list of deleted image IDs and + the amount of disk space reclaimed in bytes. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + url = self._url("/images/prune") + params = {} + if filters is not None: + params['filters'] = utils.convert_filters(filters) + return self._result(self._post(url, params=params), True) + def pull(self, repository, tag=None, stream=False, insecure_registry=False, auth_config=None, decode=False): """ diff --git a/docker/api/network.py b/docker/api/network.py index 9f6d98fe..9652228d 100644 --- a/docker/api/network.py +++ b/docker/api/network.py @@ -133,6 +133,28 @@ class NetworkApiMixin(object): res = self._post_json(url, data=data) return self._result(res, json=True) + @minimum_version('1.25') + def prune_networks(self, filters=None): + """ + Delete unused networks + + Args: + filters (dict): Filters to process on the prune list. + + Returns: + (dict): A dict containing a list of deleted network names and + the amount of disk space reclaimed in bytes. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + params = {} + if filters: + params['filters'] = utils.convert_filters(filters) + url = self._url('/networks/prune') + return self._result(self._post(url, params=params), True) + @minimum_version('1.21') def remove_network(self, net_id): """ diff --git a/docker/api/volume.py b/docker/api/volume.py index 7557e2c8..ce911c8f 100644 --- a/docker/api/volume.py +++ b/docker/api/volume.py @@ -116,6 +116,28 @@ class VolumeApiMixin(object): url = self._url('/volumes/{0}', name) return self._result(self._get(url), True) + @utils.minimum_version('1.25') + def prune_volumes(self, filters=None): + """ + Delete unused volumes + + Args: + filters (dict): Filters to process on the prune list. + + Returns: + (dict): A dict containing a list of deleted volume names and + the amount of disk space reclaimed in bytes. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + params = {} + if filters: + params['filters'] = utils.convert_filters(filters) + url = self._url('/volumes/prune') + return self._result(self._post(url, params=params), True) + @utils.minimum_version('1.21') def remove_volume(self, name, force=False): """ diff --git a/docker/models/containers.py b/docker/models/containers.py index c4a4add4..78463fd8 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -1,5 +1,6 @@ import copy +from ..api import APIClient from ..errors import (ContainerError, ImageNotFound, create_unexpected_kwargs_error) from ..types import HostConfig @@ -763,6 +764,10 @@ class ContainerCollection(Collection): since=since) return [self.get(r['Id']) for r in resp] + def prune(self, filters=None): + return self.client.api.prune_containers(filters=filters) + prune.__doc__ = APIClient.prune_containers.__doc__ + # kwargs to copy straight from run to create RUN_CREATE_KWARGS = [ diff --git a/docker/models/images.py b/docker/models/images.py index 968e4e32..a749f63b 100644 --- a/docker/models/images.py +++ b/docker/models/images.py @@ -269,3 +269,7 @@ class ImageCollection(Collection): def search(self, *args, **kwargs): return self.client.api.search(*args, **kwargs) search.__doc__ = APIClient.search.__doc__ + + def prune(self, filters=None): + return self.client.api.prune_images(filters=filters) + prune.__doc__ = APIClient.prune_images.__doc__ diff --git a/docker/models/networks.py b/docker/models/networks.py index a80c9f5f..a712e9bc 100644 --- a/docker/models/networks.py +++ b/docker/models/networks.py @@ -1,3 +1,4 @@ +from ..api import APIClient from .containers import Container from .resource import Model, Collection @@ -180,3 +181,7 @@ class NetworkCollection(Collection): """ resp = self.client.api.networks(*args, **kwargs) return [self.prepare_model(item) for item in resp] + + def prune(self, filters=None): + self.client.api.prune_networks(filters=filters) + prune.__doc__ = APIClient.prune_networks.__doc__ diff --git a/docker/models/volumes.py b/docker/models/volumes.py index 3111f674..3c2e8378 100644 --- a/docker/models/volumes.py +++ b/docker/models/volumes.py @@ -1,3 +1,4 @@ +from ..api import APIClient from .resource import Model, Collection @@ -92,3 +93,7 @@ class VolumeCollection(Collection): if not resp.get('Volumes'): return [] return [self.prepare_model(obj) for obj in resp['Volumes']] + + def prune(self, filters=None): + return self.client.api.prune_volumes(filters=filters) + prune.__doc__ = APIClient.prune_volumes.__doc__ diff --git a/docs/containers.rst b/docs/containers.rst index eb51ae4c..9b27a306 100644 --- a/docs/containers.rst +++ b/docs/containers.rst @@ -14,6 +14,7 @@ Methods available on ``client.containers``: .. automethod:: create(image, command=None, **kwargs) .. automethod:: get(id_or_name) .. automethod:: list(**kwargs) + .. automethod:: prune Container objects ----------------- diff --git a/docs/images.rst b/docs/images.rst index 7572c2d6..866786de 100644 --- a/docs/images.rst +++ b/docs/images.rst @@ -18,6 +18,7 @@ Methods available on ``client.images``: .. automethod:: push .. automethod:: remove .. automethod:: search + .. automethod:: prune Image objects diff --git a/docs/networks.rst b/docs/networks.rst index f6de38bd..b585f0bd 100644 --- a/docs/networks.rst +++ b/docs/networks.rst @@ -13,6 +13,7 @@ Methods available on ``client.networks``: .. automethod:: create .. automethod:: get .. automethod:: list + .. automethod:: prune Network objects ----------------- diff --git a/docs/volumes.rst b/docs/volumes.rst index 8c0574b5..fcd022a5 100644 --- a/docs/volumes.rst +++ b/docs/volumes.rst @@ -13,6 +13,7 @@ Methods available on ``client.volumes``: .. automethod:: create .. automethod:: get .. automethod:: list + .. automethod:: prune Volume objects -------------- diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index 3cede45d..c0e5b932 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -1094,6 +1094,20 @@ class PauseTest(BaseAPIIntegrationTest): self.assertEqual(state['Paused'], False) +class PruneTest(BaseAPIIntegrationTest): + @requires_api_version('1.25') + def test_prune_containers(self): + container1 = self.client.create_container(BUSYBOX, ['echo', 'hello']) + container2 = self.client.create_container(BUSYBOX, ['sleep', '9999']) + self.client.start(container1) + self.client.start(container2) + self.client.wait(container1) + result = self.client.prune_containers() + assert container1['Id'] in result['ContainersDeleted'] + assert result['SpaceReclaimed'] > 0 + assert container2['Id'] not in result['ContainersDeleted'] + + class GetContainerStatsTest(BaseAPIIntegrationTest): @requires_api_version('1.19') def test_get_container_stats_no_stream(self): diff --git a/tests/integration/api_image_test.py b/tests/integration/api_image_test.py index 135f115b..11146a8a 100644 --- a/tests/integration/api_image_test.py +++ b/tests/integration/api_image_test.py @@ -14,6 +14,7 @@ from six.moves import socketserver import docker +from ..helpers import requires_api_version from .base import BaseAPIIntegrationTest, BUSYBOX @@ -285,3 +286,32 @@ class ImportImageTest(BaseAPIIntegrationTest): self.assertIn('status', result) img_id = result['status'] self.tmp_imgs.append(img_id) + + +@requires_api_version('1.25') +class PruneImagesTest(BaseAPIIntegrationTest): + def test_prune_images(self): + try: + self.client.remove_image('hello-world') + except docker.errors.APIError: + pass + + # Ensure busybox does not get pruned + ctnr = self.client.create_container(BUSYBOX, ['sleep', '9999']) + self.tmp_containers.append(ctnr) + + self.client.pull('hello-world') + self.tmp_imgs.append('hello-world') + img_id = self.client.inspect_image('hello-world')['Id'] + result = self.client.prune_images() + assert img_id not in [ + img.get('Deleted') for img in result['ImagesDeleted'] + ] + result = self.client.prune_images({'dangling': False}) + assert result['SpaceReclaimed'] > 0 + assert 'hello-world:latest' in [ + img.get('Untagged') for img in result['ImagesDeleted'] + ] + assert img_id in [ + img.get('Deleted') for img in result['ImagesDeleted'] + ] diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py index 982a5182..b3ae5120 100644 --- a/tests/integration/api_network_test.py +++ b/tests/integration/api_network_test.py @@ -3,7 +3,7 @@ from docker.types import IPAMConfig, IPAMPool import pytest from ..helpers import random_name, requires_api_version -from .base import BaseAPIIntegrationTest +from .base import BaseAPIIntegrationTest, BUSYBOX class TestNetworks(BaseAPIIntegrationTest): @@ -98,7 +98,7 @@ class TestNetworks(BaseAPIIntegrationTest): def test_connect_and_disconnect_container(self): net_name, net_id = self.create_network() - container = self.client.create_container('busybox', 'top') + container = self.client.create_container(BUSYBOX, 'top') self.tmp_containers.append(container) self.client.start(container) @@ -126,7 +126,7 @@ class TestNetworks(BaseAPIIntegrationTest): def test_connect_and_force_disconnect_container(self): net_name, net_id = self.create_network() - container = self.client.create_container('busybox', 'top') + container = self.client.create_container(BUSYBOX, 'top') self.tmp_containers.append(container) self.client.start(container) @@ -153,7 +153,7 @@ class TestNetworks(BaseAPIIntegrationTest): def test_connect_with_aliases(self): net_name, net_id = self.create_network() - container = self.client.create_container('busybox', 'top') + container = self.client.create_container(BUSYBOX, 'top') self.tmp_containers.append(container) self.client.start(container) @@ -171,7 +171,7 @@ class TestNetworks(BaseAPIIntegrationTest): net_name, net_id = self.create_network() container = self.client.create_container( - image='busybox', + image=BUSYBOX, command='top', host_config=self.client.create_host_config(network_mode=net_name), ) @@ -192,7 +192,7 @@ class TestNetworks(BaseAPIIntegrationTest): net_name, net_id = self.create_network() container = self.client.create_container( - image='busybox', + image=BUSYBOX, command='top', host_config=self.client.create_host_config( network_mode=net_name, @@ -222,7 +222,7 @@ class TestNetworks(BaseAPIIntegrationTest): ), ) container = self.client.create_container( - image='busybox', command='top', + image=BUSYBOX, command='top', host_config=self.client.create_host_config(network_mode=net_name), networking_config=self.client.create_networking_config({ net_name: self.client.create_endpoint_config( @@ -251,7 +251,7 @@ class TestNetworks(BaseAPIIntegrationTest): ), ) container = self.client.create_container( - image='busybox', command='top', + image=BUSYBOX, command='top', host_config=self.client.create_host_config(network_mode=net_name), networking_config=self.client.create_networking_config({ net_name: self.client.create_endpoint_config( @@ -274,7 +274,7 @@ class TestNetworks(BaseAPIIntegrationTest): @requires_api_version('1.24') def test_create_with_linklocal_ips(self): container = self.client.create_container( - 'busybox', 'top', + BUSYBOX, 'top', networking_config=self.client.create_networking_config( { 'bridge': self.client.create_endpoint_config( @@ -451,3 +451,9 @@ class TestNetworks(BaseAPIIntegrationTest): _, net_id = self.create_network(driver='overlay', attachable=True) net = self.client.inspect_network(net_id) assert net['Attachable'] is True + + @requires_api_version('1.25') + def test_prune_networks(self): + net_name, _ = self.create_network() + result = self.client.prune_networks() + assert net_name in result['NetworksDeleted'] diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index 46b0a79e..fe964596 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -3,7 +3,7 @@ import random import docker from ..helpers import force_leave_swarm, requires_api_version -from .base import BaseAPIIntegrationTest +from .base import BaseAPIIntegrationTest, BUSYBOX class ServiceTest(BaseAPIIntegrationTest): @@ -31,7 +31,7 @@ class ServiceTest(BaseAPIIntegrationTest): name = self.get_service_name() container_spec = docker.types.ContainerSpec( - 'busybox', ['echo', 'hello'] + BUSYBOX, ['echo', 'hello'] ) task_tmpl = docker.types.TaskTemplate(container_spec) return name, self.client.create_service(task_tmpl, name=name) @@ -81,7 +81,7 @@ class ServiceTest(BaseAPIIntegrationTest): def test_create_service_custom_log_driver(self): container_spec = docker.types.ContainerSpec( - 'busybox', ['echo', 'hello'] + BUSYBOX, ['echo', 'hello'] ) log_cfg = docker.types.DriverConfig('none') task_tmpl = docker.types.TaskTemplate( @@ -99,7 +99,7 @@ class ServiceTest(BaseAPIIntegrationTest): def test_create_service_with_volume_mount(self): vol_name = self.get_service_name() container_spec = docker.types.ContainerSpec( - 'busybox', ['ls'], + BUSYBOX, ['ls'], mounts=[ docker.types.Mount(target='/test', source=vol_name) ] @@ -119,7 +119,7 @@ class ServiceTest(BaseAPIIntegrationTest): assert mount['Type'] == 'volume' def test_create_service_with_resources_constraints(self): - container_spec = docker.types.ContainerSpec('busybox', ['true']) + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) resources = docker.types.Resources( cpu_limit=4000000, mem_limit=3 * 1024 * 1024 * 1024, cpu_reservation=3500000, mem_reservation=2 * 1024 * 1024 * 1024 @@ -139,7 +139,7 @@ class ServiceTest(BaseAPIIntegrationTest): ] def test_create_service_with_update_config(self): - container_spec = docker.types.ContainerSpec('busybox', ['true']) + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) task_tmpl = docker.types.TaskTemplate(container_spec) update_config = docker.types.UpdateConfig( parallelism=10, delay=5, failure_action='pause' @@ -173,7 +173,7 @@ class ServiceTest(BaseAPIIntegrationTest): assert update_config['MaxFailureRatio'] == uc['MaxFailureRatio'] def test_create_service_with_restart_policy(self): - container_spec = docker.types.ContainerSpec('busybox', ['true']) + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) policy = docker.types.RestartPolicy( docker.types.RestartPolicy.condition_types.ANY, delay=5, max_attempts=5 @@ -196,7 +196,7 @@ class ServiceTest(BaseAPIIntegrationTest): 'dockerpytest_2', driver='overlay', ipam={'Driver': 'default'} ) self.tmp_networks.append(net2['Id']) - container_spec = docker.types.ContainerSpec('busybox', ['true']) + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) task_tmpl = docker.types.TaskTemplate(container_spec) name = self.get_service_name() svc_id = self.client.create_service( @@ -212,7 +212,7 @@ class ServiceTest(BaseAPIIntegrationTest): def test_create_service_with_placement(self): node_id = self.client.nodes()[0]['ID'] - container_spec = docker.types.ContainerSpec('busybox', ['true']) + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) task_tmpl = docker.types.TaskTemplate( container_spec, placement=['node.id=={}'.format(node_id)] ) @@ -224,7 +224,7 @@ class ServiceTest(BaseAPIIntegrationTest): {'Constraints': ['node.id=={}'.format(node_id)]}) def test_create_service_with_endpoint_spec(self): - container_spec = docker.types.ContainerSpec('busybox', ['true']) + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) task_tmpl = docker.types.TaskTemplate(container_spec) name = self.get_service_name() endpoint_spec = docker.types.EndpointSpec(ports={ @@ -255,7 +255,7 @@ class ServiceTest(BaseAPIIntegrationTest): def test_create_service_with_env(self): container_spec = docker.types.ContainerSpec( - 'busybox', ['true'], env={'DOCKER_PY_TEST': 1} + BUSYBOX, ['true'], env={'DOCKER_PY_TEST': 1} ) task_tmpl = docker.types.TaskTemplate( container_spec, @@ -271,7 +271,7 @@ class ServiceTest(BaseAPIIntegrationTest): def test_create_service_global_mode(self): container_spec = docker.types.ContainerSpec( - 'busybox', ['echo', 'hello'] + BUSYBOX, ['echo', 'hello'] ) task_tmpl = docker.types.TaskTemplate(container_spec) name = self.get_service_name() @@ -284,7 +284,7 @@ class ServiceTest(BaseAPIIntegrationTest): def test_create_service_replicated_mode(self): container_spec = docker.types.ContainerSpec( - 'busybox', ['echo', 'hello'] + BUSYBOX, ['echo', 'hello'] ) task_tmpl = docker.types.TaskTemplate(container_spec) name = self.get_service_name() diff --git a/tests/integration/api_volume_test.py b/tests/integration/api_volume_test.py index 4bfc672b..5a4bb1e0 100644 --- a/tests/integration/api_volume_test.py +++ b/tests/integration/api_volume_test.py @@ -56,6 +56,14 @@ class TestVolumes(BaseAPIIntegrationTest): self.client.create_volume(name) self.client.remove_volume(name, force=True) + @requires_api_version('1.25') + def test_prune_volumes(self): + name = 'hopelessmasquerade' + self.client.create_volume(name) + self.tmp_volumes.append(name) + result = self.client.prune_volumes() + assert name in result['VolumesDeleted'] + def test_remove_nonexistent_volume(self): name = 'shootthebullet' with pytest.raises(docker.errors.NotFound): diff --git a/tests/integration/base.py b/tests/integration/base.py index f0f5a910..7da3aa75 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -81,7 +81,7 @@ class BaseAPIIntegrationTest(BaseIntegrationTest): return container - def create_and_start(self, image='busybox', command='top', **kwargs): + def create_and_start(self, image=BUSYBOX, command='top', **kwargs): container = self.client.create_container( image=image, command=command, **kwargs) self.tmp_containers.append(container)