diff --git a/doc/configuration.rst b/doc/configuration.rst index eabb44155..8eea1452c 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -3123,12 +3123,23 @@ Implements: DockerDriver: image_uri: 'rastasheep/ubuntu-sshd:16.04' + pull: 'always' container_name: 'ubuntu-lg-example' host_config: {'network_mode': 'bridge'} network_services: [{'port': 22, 'username': 'root', 'password': 'root'}] Arguments: - image_uri (str): identifier of the docker image to use (may have a tag suffix) + - pull (str): pull policy, supports "always", "missing", "never". Default is + "always" + + - always: Always pull the image and throw an error if the pull fails. + - missing: Pull the image only when the image is not in the local + containers storage. Throw an error if no image is found and the pull + fails. + - never: Never pull the image but use the one from the local containers + storage. Throw a `docker.errors.ImageNotFound` if no image is found. + - command (str): optional, command to run in the container (depends on image) - volumes (list): optional, list to configure volumes mounted inside the container - container_name (str): name of the container diff --git a/labgrid/driver/dockerdriver.py b/labgrid/driver/dockerdriver.py index 49e45f021..033099a7a 100644 --- a/labgrid/driver/dockerdriver.py +++ b/labgrid/driver/dockerdriver.py @@ -1,6 +1,8 @@ """ Class for connecting to a docker daemon running on the host machine. """ +from enum import Enum + import attr from labgrid.factory import target_factory @@ -9,6 +11,33 @@ from labgrid.protocol.powerprotocol import PowerProtocol +class PullPolicy(Enum): + """Pull policy for the `DockerDriver`. + + Modelled after `podman run --pull` / `docker run --pull`. + + * always: Always pull the image and throw an error if the pull fails. + * missing: Pull the image only when the image is not in the local + containers storage. Throw an error if no image is found and the pull + fails. + * never: Never pull the image but use the one from the local containers + storage. Throw an error if no image is found. + * newer: **Note** not supported by the driver, and therefore not + implemented. + """ + Always = 'always' + Missing = 'missing' + Never = 'never' + +def pull_policy_converter(value): + if isinstance(value, PullPolicy): + return value + try: + return PullPolicy(value) + except ValueError: + raise ValueError(f"Invalid pull policy: {value}") + + @target_factory.reg_driver @attr.s(eq=False) class DockerDriver(PowerProtocol, Driver): @@ -31,6 +60,8 @@ class DockerDriver(PowerProtocol, Driver): bindings (dict): The labgrid bindings Args passed to docker.create_container: image_uri (str): The uri of the image to fetch + pull (str): Pull policy. Default policy is `always` for backward + compatibility concerns command (str): The command to execute once container has been created volumes (list): The volumes to declare environment (list): Docker environment variables to set @@ -42,6 +73,8 @@ class DockerDriver(PowerProtocol, Driver): bindings = {"docker_daemon": {"DockerDaemon"}} image_uri = attr.ib(default=None, validator=attr.validators.optional( attr.validators.instance_of(str))) + pull = attr.ib(default=PullPolicy.Always, + converter=pull_policy_converter) command = attr.ib(default=None, validator=attr.validators.optional( attr.validators.instance_of(str))) volumes = attr.ib(default=None, validator=attr.validators.optional( @@ -73,7 +106,17 @@ def on_activate(self): import docker self._client = docker.DockerClient( base_url=self.docker_daemon.docker_daemon_url) - self._client.images.pull(self.image_uri) + + if self.pull == PullPolicy.Always: + self._client.images.pull(self.image_uri) + elif self.pull == PullPolicy.Missing: + try: + self._client.images.get(self.image_uri) + except docker.errors.ImageNotFound: + self._client.images.pull(self.image_uri) + elif self.pull == PullPolicy.Never: + self._client.images.get(self.image_uri) + self._container = self._client.api.create_container( self.image_uri, command=self.command, diff --git a/tests/test_docker.py b/tests/test_docker.py index 80c82d84e..bfdaf3534 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -4,6 +4,8 @@ """ import pytest +import docker +import io from labgrid import Environment from labgrid.driver import DockerDriver @@ -44,6 +46,34 @@ def docker_env(tmp_path_factory): drivers: - DockerDriver: image_uri: "rastasheep/ubuntu-sshd:16.04" + pull: 'missing' + container_name: "ubuntu-lg-example" + host_config: {"network_mode": "bridge"} + network_services: [ + {"port": 22, "username": "root", "password": "root"}] + - DockerStrategy: {} + - SSHDriver: + keyfile: "" + """ + ) + return Environment(str(p)) + + +@pytest.fixture +def docker_env_for_local_container(tmp_path_factory): + """Create Environment instance from the given inline YAML file.""" + p = tmp_path_factory.mktemp("docker") / "config.yaml" + p.write_text( + """ + targets: + main: + resources: + - DockerDaemon: + docker_daemon_url: "unix:///var/run/docker.sock" + drivers: + - DockerDriver: + image_uri: "local_rastasheep" + pull: "never" container_name: "ubuntu-lg-example" host_config: {"network_mode": "bridge"} network_services: [ @@ -91,6 +121,25 @@ def command(docker_target): strategy.transition("gone") +@pytest.fixture +def docker_target_for_local_image(docker_env_for_local_container): + """Same as `docker_target` but uses a different image uri""" + t = docker_env_for_local_container.get_target() + yield t + + from labgrid.resource import ResourceManager + ResourceManager.instances = {} + + +@pytest.fixture +def local_command(docker_target_for_local_image): + """Same as `command` but uses a different image uri""" + strategy = docker_target_for_local_image.get_driver('DockerStrategy') + strategy.transition("accessible") + shell = docker_target_for_local_image.get_driver('CommandProtocol') + yield shell + strategy.transition("gone") + @pytest.mark.skipif(not check_external_progs_present(), reason="No access to a docker daemon") def test_docker_with_daemon(command): @@ -110,6 +159,32 @@ def test_docker_with_daemon(command): assert len(stderr) == 0 +@pytest.fixture +def build_image(): + client = docker.from_env() + dockerfile_content = """ + FROM rastasheep/ubuntu-sshd:16.04 + """ + dockerfile_stream = io.BytesIO(dockerfile_content.encode("utf-8")) + image, logs = client.images.build(fileobj=dockerfile_stream, tag="local_rastasheep", rm=True) + + +@pytest.mark.skipif(not check_external_progs_present(), + reason="No access to a docker daemon") +def test_docker_with_daemon_and_local_image(build_image, local_command): + """Build a container locally and connect to it""" + stdout, stderr, return_code = local_command.run('cat /proc/version') + assert return_code == 0 + assert len(stdout) > 0 + assert len(stderr) == 0 + assert 'Linux' in stdout[0] + + stdout, stderr, return_code = local_command.run('false') + assert return_code != 0 + assert len(stdout) == 0 + assert len(stderr) == 0 + + def test_create_driver_fail_missing_docker_daemon(target): """The test target does not contain any DockerDaemon instance - and so creation must fail. @@ -159,6 +234,8 @@ def test_docker_without_daemon(docker_env, mocker): 'Id': '1' }] ] + docker_client.images.get.side_effect = docker.errors.ImageNotFound( + "Image not found", response=None, explanation="") # Mock actions on the imported "socket" python module socket_create_connection = mocker.patch('socket.create_connection') @@ -199,7 +276,10 @@ def test_docker_without_daemon(docker_env, mocker): # Assert what mock calls transitioning to "shell" must have caused # # DockerDriver::on_activate(): - assert docker_client.images.pull.call_count == 1 + image_uri = t.get_driver('DockerDriver').image_uri + docker_client.images.get.assert_called_once_with(image_uri) + docker_client.images.pull.assert_called_once_with(image_uri) + assert api_client.create_host_config.call_count == 1 assert api_client.create_container.call_count == 1 #