From d285e6103de43e41298bae38a35ab610283ce199 Mon Sep 17 00:00:00 2001 From: Stanislav Dimov Date: Fri, 3 Feb 2023 15:23:43 +0000 Subject: [PATCH 1/4] Added support for starting container from a checkpoint --- docker/api/container.py | 18 +++++++++++++++--- tests/unit/api_container_test.py | 13 +++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docker/api/container.py b/docker/api/container.py index ce483710c..2829a109e 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -1088,7 +1088,8 @@ def restart(self, container, timeout=10): self._raise_for_status(res) @utils.check_resource('container') - def start(self, container, *args, **kwargs): + def start(self, container, checkpoint=None, checkpoint_dir=None, + *args, **kwargs): """ Start a container. Similar to the ``docker start`` command, but doesn't support attach options. @@ -1101,12 +1102,17 @@ def start(self, container, *args, **kwargs): Args: container (str): The container to start + checkpoint (str): (Experimental) The checkpoint ID from which + to start + checkpoint_dir (str): (Experimental) Custom directory in which to + search for checkpoints Raises: :py:class:`docker.errors.APIError` If the server returns an error. :py:class:`docker.errors.DeprecatedMethod` - If any argument besides ``container`` are provided. + If any argument besides ``container``, ``checkpoint`` + or ``checkpoint_dir`` are provided. Example: @@ -1115,6 +1121,12 @@ def start(self, container, *args, **kwargs): ... command='/bin/sleep 30') >>> client.api.start(container=container.get('Id')) """ + params = {} + if checkpoint: + params["checkpoint"] = checkpoint + if checkpoint_dir: + params['checkpoint-dir'] = checkpoint_dir + if args or kwargs: raise errors.DeprecatedMethod( 'Providing configuration in the start() method is no longer ' @@ -1122,7 +1134,7 @@ def start(self, container, *args, **kwargs): 'instead.' ) url = self._url("/containers/{0}/start", container) - res = self._post(url) + res = self._post(url, params=params) self._raise_for_status(res) @utils.check_resource('container') diff --git a/tests/unit/api_container_test.py b/tests/unit/api_container_test.py index d7b356c44..9f4317758 100644 --- a/tests/unit/api_container_test.py +++ b/tests/unit/api_container_test.py @@ -29,6 +29,19 @@ def test_start_container(self): assert 'data' not in args[1] assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + def test_start_container_from_checkpoint(self): + self.client.start(fake_api.FAKE_CONTAINER_ID, + checkpoint="my-checkpoint", + checkpoint_dir="/path/to/checkpoint/dir") + + args = fake_request.call_args + assert args[0][1] == (url_prefix + 'containers/' + + fake_api.FAKE_CONTAINER_ID + '/start') + assert 'data' not in args[1] + assert args[1]["params"]["checkpoint"] == "my-checkpoint" + assert args[1]["params"]["checkpoint-dir"] == "/path/to/checkpoint/dir" + assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + def test_start_container_none(self): with pytest.raises(ValueError) as excinfo: self.client.start(container=None) From 1b6890603fdc0821eab3dd2dd6c8be3348c1e137 Mon Sep 17 00:00:00 2001 From: Stanislav Dimov Date: Fri, 3 Feb 2023 17:22:32 +0000 Subject: [PATCH 2/4] Added api support for creating container checkpoints --- docker/api/container.py | 92 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/docker/api/container.py b/docker/api/container.py index 2829a109e..14af5cc5f 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -669,6 +669,93 @@ def create_endpoint_config(self, *args, **kwargs): """ return EndpointConfig(self._version, *args, **kwargs) + @utils.check_resource('container') + def container_checkpoints(self, container, checkpoint_dir=None): + """ + (Experimental) List all container checkpoints. + + Args: + container (str): The container to find checkpoints for + checkpoint_dir (str): Custom directory in which to search for + checkpoints. Default: None (use default checkpoint dir) + Returns: + List of dicts, one for each checkpoint. In the form of: + [{"Name": ""}] + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + params = {} + if checkpoint_dir: + params["dir"] = checkpoint_dir + + return self._result( + self._get(self._url("/containers/{0}/checkpoints", container), + params=params), + True + ) + + @utils.check_resource('container') + def container_remove_checkpoint(self, container, checkpoint, + checkpoint_dir=None): + """ + (Experimental) Remove container checkpoint. + + Args: + container (str): The container the checkpoint belongs to + checkpoint (str): The checkpoint ID to remove + checkpoint_dir (str): Custom directory in which to search for + checkpoints. Default: None (use default checkpoint dir) + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + params = {} + if checkpoint_dir: + params["dir"] = checkpoint_dir + + res = self._delete( + self._url("/containers/{0}/checkpoints/{1}", + container, + checkpoint), + params=params + ) + self._raise_for_status(res) + + @utils.check_resource('container') + def container_create_checkpoint(self, container, checkpoint, + checkpoint_dir=None, + leave_running=False): + """ + (Experimental) Create new container checkpoint. + + Args: + container (str): The container to checkpoint + checkpoint (str): The checkpoint ID + checkpoint_dir (str): Custom directory in which to place the + checkpoint. Default: None (use default checkpoint dir) + leave_running (bool): Determines if the container should be left + running after the checkpoint is created + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + data = { + "CheckpointID": checkpoint, + "Exit": not leave_running, + } + if checkpoint_dir: + data["CheckpointDir"] = checkpoint_dir + + res = self._post_json( + self._url("/containers/{0}/checkpoints", container), + data=data + ) + self._raise_for_status(res) + @utils.check_resource('container') def diff(self, container): """ @@ -1103,9 +1190,10 @@ def start(self, container, checkpoint=None, checkpoint_dir=None, Args: container (str): The container to start checkpoint (str): (Experimental) The checkpoint ID from which - to start + to start. Default: None (do not start from a checkpoint) checkpoint_dir (str): (Experimental) Custom directory in which to - search for checkpoints + search for checkpoints. Default: None (use default + checkpoint dir) Raises: :py:class:`docker.errors.APIError` From 91d4484684099eeb44a23f9db90f28246c520dc1 Mon Sep 17 00:00:00 2001 From: Stanislav Dimov Date: Fri, 3 Feb 2023 20:53:34 +0000 Subject: [PATCH 3/4] Added checkpoint support to high level client; added unit tests --- docker/api/container.py | 9 ++- docker/errors.py | 4 + docker/models/checkpoints.py | 130 +++++++++++++++++++++++++++++++ docker/models/containers.py | 22 ++++++ tests/unit/api_container_test.py | 118 ++++++++++++++++++++++++++-- tests/unit/fake_api.py | 26 +++++++ 6 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 docker/models/checkpoints.py diff --git a/docker/api/container.py b/docker/api/container.py index 14af5cc5f..5fe9dceea 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -3,6 +3,7 @@ from .. import errors from .. import utils from ..constants import DEFAULT_DATA_CHUNK_SIZE +from ..models.checkpoints import Checkpoint from ..types import CancellableStream from ..types import ContainerConfig from ..types import EndpointConfig @@ -1189,8 +1190,10 @@ def start(self, container, checkpoint=None, checkpoint_dir=None, Args: container (str): The container to start - checkpoint (str): (Experimental) The checkpoint ID from which - to start. Default: None (do not start from a checkpoint) + checkpoint (:py:class:`docker.models.checkpoints.Checkpoint` or + str): + (Experimental) The checkpoint ID from which to start. + Default: None (do not start from a checkpoint) checkpoint_dir (str): (Experimental) Custom directory in which to search for checkpoints. Default: None (use default checkpoint dir) @@ -1211,6 +1214,8 @@ def start(self, container, checkpoint=None, checkpoint_dir=None, """ params = {} if checkpoint: + if isinstance(checkpoint, Checkpoint): + checkpoint = checkpoint.id params["checkpoint"] = checkpoint if checkpoint_dir: params['checkpoint-dir'] = checkpoint_dir diff --git a/docker/errors.py b/docker/errors.py index 8cf8670ba..dfb81388c 100644 --- a/docker/errors.py +++ b/docker/errors.py @@ -95,6 +95,10 @@ class ImageNotFound(NotFound): pass +class CheckpointNotFound(NotFound): + pass + + class InvalidVersion(DockerException): pass diff --git a/docker/models/checkpoints.py b/docker/models/checkpoints.py new file mode 100644 index 000000000..5136bd4aa --- /dev/null +++ b/docker/models/checkpoints.py @@ -0,0 +1,130 @@ +from ..errors import CheckpointNotFound +from .resource import Collection +from .resource import Model + + +class Checkpoint(Model): + """ (Experimental) Local representation of a checkpoint object. Detailed + configuration may be accessed through the :py:attr:`attrs` attribute. + Note that local attributes are cached; users may call :py:meth:`reload` + to query the Docker daemon for the current properties, causing + :py:attr:`attrs` to be refreshed. + """ + id_attribute = 'Name' + + @property + def short_id(self): + """ + The ID of the object. + """ + return self.id + + def remove(self): + """ + Remove this checkpoint. Similar to the + ``docker checkpoint rm`` command. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + return self.client.api.container_remove_checkpoint( + self.collection.container_id, + checkpoint=self.id, + checkpoint_dir=self.collection.checkpoint_dir, + ) + + +class CheckpointCollection(Collection): + """(Experimental).""" + model = Checkpoint + + def __init__(self, container_id, checkpoint_dir=None, **kwargs): + #: The client pointing at the server that this collection of objects + #: is on. + super().__init__(**kwargs) + self.container_id = container_id + self.checkpoint_dir = checkpoint_dir + + def create(self, checkpoint_id, **kwargs): + """ + Create a new container checkpoint. Similar to + ``docker checkpoint create``. + + Args: + checkpoint_id (str): The id (name) of the checkpoint + leave_running (bool): Determines if the container should be left + running after the checkpoint is created + + Returns: + A :py:class:`Checkpoint` object. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + self.client.api.container_create_checkpoint( + self.container_id, + checkpoint=checkpoint_id, + checkpoint_dir=self.checkpoint_dir, + **kwargs, + ) + return Checkpoint( + attrs={"Name": checkpoint_id}, + client=self.client, + collection=self + ) + + def get(self, id): + """ + Get a container checkpoint by id (name). + + Args: + id (str): The checkpoint id (name) + + Returns: + A :py:class:`Checkpoint` object. + + Raises: + :py:class:`docker.errors.NotFound` + If the checkpoint does not exist. + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + checkpoints = self.list() + + for checkpoint in checkpoints: + if checkpoint.id == id: + return checkpoint + + raise CheckpointNotFound( + f"Checkpoint with id={id} does not exist" + f" in checkpoint_dir={self.checkpoint_dir}" + ) + + def list(self): + """ + List checkpoints. Similar to the ``docker checkpoint ls`` command. + + Returns: + (list of :py:class:`Checkpoint`) + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + resp = self.client.api.container_checkpoints( + self.container_id, checkpoint_dir=self.checkpoint_dir + ) + return [self.prepare_model(checkpoint) for checkpoint in resp or []] + + def prune(self): + """ + Remove all checkpoints in this collection. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + for checkpoint in self.list(): + checkpoint.remove() diff --git a/docker/models/containers.py b/docker/models/containers.py index c718bbeac..a2676825d 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -10,6 +10,7 @@ ) from ..types import HostConfig from ..utils import version_gte +from .checkpoints import CheckpointCollection from .images import Image from .resource import Collection, Model @@ -261,6 +262,27 @@ def get_archive(self, path, chunk_size=DEFAULT_DATA_CHUNK_SIZE, return self.client.api.get_archive(self.id, path, chunk_size, encode_stream) + def get_checkpoints(self, checkpoint_dir=None): + """ + Get a collection of all container checkpoints in a given directory. + Similar to the ``docker checkpoint ls`` command. + + Args: + checkpoint_dir (str): Custom directory in which to search for + checkpoints. Default: None (use default checkpoint dir) + Returns: + :py:class:`CheckpointCollection` + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + return CheckpointCollection( + container_id=self.id, + checkpoint_dir=checkpoint_dir, + client=self.client, + ) + def kill(self, signal=None): """ Kill or send a signal to the container. diff --git a/tests/unit/api_container_test.py b/tests/unit/api_container_test.py index 9f4317758..435cef994 100644 --- a/tests/unit/api_container_test.py +++ b/tests/unit/api_container_test.py @@ -9,10 +9,12 @@ from . import fake_api from ..helpers import requires_api_version -from .api_test import ( - BaseAPIClientTest, url_prefix, fake_request, DEFAULT_TIMEOUT_SECONDS, - fake_inspect_container, url_base -) +from .api_test import BaseAPIClientTest +from .api_test import url_prefix +from .api_test import fake_request +from .api_test import DEFAULT_TIMEOUT_SECONDS +from .api_test import fake_inspect_container +from .api_test import url_base def fake_inspect_container_tty(self, container): @@ -31,14 +33,14 @@ def test_start_container(self): def test_start_container_from_checkpoint(self): self.client.start(fake_api.FAKE_CONTAINER_ID, - checkpoint="my-checkpoint", + checkpoint=fake_api.FAKE_CHECKPOINT_ID, checkpoint_dir="/path/to/checkpoint/dir") args = fake_request.call_args assert args[0][1] == (url_prefix + 'containers/' + fake_api.FAKE_CONTAINER_ID + '/start') assert 'data' not in args[1] - assert args[1]["params"]["checkpoint"] == "my-checkpoint" + assert args[1]["params"]["checkpoint"] == fake_api.FAKE_CHECKPOINT_ID assert args[1]["params"]["checkpoint-dir"] == "/path/to/checkpoint/dir" assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS @@ -137,6 +139,110 @@ def test_start_container_with_dict_instead_of_id(self): assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS +class CheckpointContainerTest(BaseAPIClientTest): + def test_create_container_checkpoint(self): + self.client.container_create_checkpoint( + fake_api.FAKE_CONTAINER_ID, + fake_api.FAKE_CHECKPOINT_ID, + ) + + args = fake_request.call_args + assert args[0][1] == (url_prefix + 'containers/' + + fake_api.FAKE_CONTAINER_ID + '/checkpoints') + + data = json.loads(args[1]["data"]) + assert data["CheckpointID"] == fake_api.FAKE_CHECKPOINT_ID + assert data["Exit"] is True + assert "CheckpointDir" not in data + + assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + + def test_create_container_checkpoint_custom_opts(self): + self.client.container_create_checkpoint( + fake_api.FAKE_CONTAINER_ID, + fake_api.FAKE_CHECKPOINT_ID, + fake_api.FAKE_CHECKPOINT_DIR, + leave_running=True + ) + + args = fake_request.call_args + assert args[0][1] == (url_prefix + 'containers/' + + fake_api.FAKE_CONTAINER_ID + '/checkpoints') + + data = json.loads(args[1]["data"]) + assert data["CheckpointID"] == fake_api.FAKE_CHECKPOINT_ID + assert data["Exit"] is False + assert data["CheckpointDir"] == fake_api.FAKE_CHECKPOINT_DIR + + assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + + def test_remove_container_checkpoint(self): + self.client.container_remove_checkpoint( + fake_api.FAKE_CONTAINER_ID, + fake_api.FAKE_CHECKPOINT_ID, + ) + + args = fake_request.call_args + assert args[0][1] == (url_prefix + 'containers/' + + fake_api.FAKE_CONTAINER_ID + + '/checkpoints/' + + fake_api.FAKE_CHECKPOINT_ID) + + assert "data" not in args[1] + assert "dir" not in args[1]["params"] + + assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + + def test_remove_container_checkpoint_custom_opts(self): + self.client.container_remove_checkpoint( + fake_api.FAKE_CONTAINER_ID, + fake_api.FAKE_CHECKPOINT_ID, + fake_api.FAKE_CHECKPOINT_DIR, + ) + + args = fake_request.call_args + assert args[0][1] == (url_prefix + 'containers/' + + fake_api.FAKE_CONTAINER_ID + + '/checkpoints/' + + fake_api.FAKE_CHECKPOINT_ID) + + assert "data" not in args[1] + assert args[1]["params"]["dir"] == fake_api.FAKE_CHECKPOINT_DIR + + assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + + def test_container_checkpoints(self): + self.client.container_checkpoints( + fake_api.FAKE_CONTAINER_ID, + ) + + args = fake_request.call_args + assert args[0][1] == (url_prefix + 'containers/' + + fake_api.FAKE_CONTAINER_ID + + '/checkpoints') + + assert "data" not in args[1] + assert "dir" not in args[1]["params"] + + assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + + def test_container_checkpoints_custom_opts(self): + self.client.container_checkpoints( + fake_api.FAKE_CONTAINER_ID, + fake_api.FAKE_CHECKPOINT_DIR, + ) + + args = fake_request.call_args + assert args[0][1] == (url_prefix + 'containers/' + + fake_api.FAKE_CONTAINER_ID + + '/checkpoints') + + assert "data" not in args[1] + assert args[1]["params"]["dir"] == fake_api.FAKE_CHECKPOINT_DIR + + assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + + class CreateContainerTest(BaseAPIClientTest): def test_create_container(self): self.client.create_container('busybox', 'true') diff --git a/tests/unit/fake_api.py b/tests/unit/fake_api.py index 6acfb64b8..a49f242b0 100644 --- a/tests/unit/fake_api.py +++ b/tests/unit/fake_api.py @@ -19,6 +19,8 @@ FAKE_NODE_ID = '24ifsmvkjbyhk' FAKE_SECRET_ID = 'epdyrw4tsi03xy3deu8g8ly6o' FAKE_SECRET_NAME = 'super_secret' +FAKE_CHECKPOINT_ID = "my-checkpoint" +FAKE_CHECKPOINT_DIR = "/my-dir" # Each method is prefixed with HTTP method (get, post...) # for clarity and readability @@ -148,6 +150,24 @@ def post_fake_create_container(): return status_code, response +def post_fake_container_create_checkpoint(): + status_code = 201 + response = "" + return status_code, response + + +def get_fake_container_checkpoints(): + status_code = 200 + response = [{"Name": FAKE_CHECKPOINT_ID}] + return status_code, response + + +def delete_fake_container_remove_checkpoint(): + status_code = 204 + response = "" + return status_code, response + + def get_fake_inspect_container(tty=False): status_code = 200 response = { @@ -568,6 +588,12 @@ def post_fake_secret(): post_fake_update_container, f'{prefix}/{CURRENT_VERSION}/containers/{FAKE_CONTAINER_ID}/exec': post_fake_exec_create, + (f'{prefix}/{CURRENT_VERSION}/containers/{FAKE_CONTAINER_ID}/checkpoints', "POST"): # noqa: E501 + post_fake_container_create_checkpoint, + (f'{prefix}/{CURRENT_VERSION}/containers/{FAKE_CONTAINER_ID}/checkpoints', "GET"): # noqa: E501 + get_fake_container_checkpoints, + f'{prefix}/{CURRENT_VERSION}/containers/{FAKE_CONTAINER_ID}/checkpoints/{FAKE_CHECKPOINT_ID}': # noqa: E501 + delete_fake_container_remove_checkpoint, f'{prefix}/{CURRENT_VERSION}/exec/{FAKE_EXEC_ID}/start': post_fake_exec_start, f'{prefix}/{CURRENT_VERSION}/exec/{FAKE_EXEC_ID}/json': From c7bf137e5d24972267a9470b95d9d908d0e4d3bf Mon Sep 17 00:00:00 2001 From: sdimovv <36302090+sdimovv@users.noreply.github.com> Date: Tue, 7 Feb 2023 15:21:56 +0000 Subject: [PATCH 4/4] Added __eq__ method to Checkpoint class Signed-off-by: sdimovv <36302090+sdimovv@users.noreply.github.com> --- docker/models/checkpoints.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker/models/checkpoints.py b/docker/models/checkpoints.py index 5136bd4aa..65f0a89fe 100644 --- a/docker/models/checkpoints.py +++ b/docker/models/checkpoints.py @@ -33,7 +33,11 @@ def remove(self): checkpoint=self.id, checkpoint_dir=self.collection.checkpoint_dir, ) - + + def __eq__(self, other): + if isinstance(other, Checkpoint): + return self.id == other.id + return self.id == other class CheckpointCollection(Collection): """(Experimental).""" @@ -94,7 +98,7 @@ def get(self, id): checkpoints = self.list() for checkpoint in checkpoints: - if checkpoint.id == id: + if checkpoint == id: return checkpoint raise CheckpointNotFound(