• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

localstack / localstack / 2009fc4e-e31d-490f-88f3-dd98faad493a

14 Mar 2025 12:28PM UTC coverage: 86.958% (+0.03%) from 86.933%
2009fc4e-e31d-490f-88f3-dd98faad493a

push

circleci

web-flow
Docker Utils: Expose the build logs (#12376)

6 of 7 new or added lines in 2 files covered. (85.71%)

8 existing lines in 2 files now uncovered.

62320 of 71667 relevant lines covered (86.96%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

38.74
/localstack-core/localstack/utils/container_utils/docker_cmd_client.py
1
import functools
1✔
2
import itertools
1✔
3
import json
1✔
4
import logging
1✔
5
import os
1✔
6
import re
1✔
7
import shlex
1✔
8
import subprocess
1✔
9
from typing import Dict, List, Optional, Tuple, Union
1✔
10

11
from localstack import config
1✔
12
from localstack.utils.collections import ensure_list
1✔
13
from localstack.utils.container_utils.container_client import (
1✔
14
    AccessDenied,
15
    CancellableStream,
16
    ContainerClient,
17
    ContainerException,
18
    DockerContainerStats,
19
    DockerContainerStatus,
20
    DockerNotAvailable,
21
    DockerPlatform,
22
    LogConfig,
23
    NoSuchContainer,
24
    NoSuchImage,
25
    NoSuchNetwork,
26
    NoSuchObject,
27
    PortMappings,
28
    RegistryConnectionError,
29
    SimpleVolumeBind,
30
    Ulimit,
31
    Util,
32
    VolumeBind,
33
)
34
from localstack.utils.run import run
1✔
35
from localstack.utils.strings import first_char_to_upper, to_str
1✔
36

37
LOG = logging.getLogger(__name__)
1✔
38

39

40
class CancellableProcessStream(CancellableStream):
1✔
41
    process: subprocess.Popen
1✔
42

43
    def __init__(self, process: subprocess.Popen) -> None:
1✔
44
        super().__init__()
×
45
        self.process = process
×
46

47
    def __iter__(self):
1✔
48
        return self
×
49

50
    def __next__(self):
1✔
51
        line = self.process.stdout.readline()
×
52
        if not line:
×
53
            raise StopIteration
×
54
        return line
×
55

56
    def close(self):
1✔
57
        return self.process.terminate()
×
58

59

60
def parse_size_string(size_str: str) -> int:
1✔
61
    """Parse human-readable size strings from Docker CLI into bytes"""
62
    size_str = size_str.strip().replace(" ", "").upper()
×
63
    if size_str == "0B":
×
64
        return 0
×
65

66
    # Match value and unit using regex
67
    match = re.match(r"^([\d.]+)([A-Za-z]+)$", size_str)
×
68
    if not match:
×
69
        return 0
×
70

71
    value = float(match.group(1))
×
72
    unit = match.group(2)
×
73

74
    unit_factors = {
×
75
        "B": 1,
76
        "KB": 10**3,
77
        "MB": 10**6,
78
        "GB": 10**9,
79
        "TB": 10**12,
80
        "KIB": 2**10,
81
        "MIB": 2**20,
82
        "GIB": 2**30,
83
        "TIB": 2**40,
84
    }
85

86
    return int(value * unit_factors.get(unit, 1))
×
87

88

89
class CmdDockerClient(ContainerClient):
1✔
90
    """
91
    Class for managing Docker (or Podman) containers using the command line executable.
92

93
    The client also supports targeting Podman engines, as Podman is almost a drop-in replacement
94
    for Docker these days. The majority of compatibility switches in this class is to handle slightly
95
    different response payloads or error messages returned by the `docker` vs `podman` commands.
96
    """
97

98
    default_run_outfile: Optional[str] = None
1✔
99

100
    def _docker_cmd(self) -> List[str]:
1✔
101
        """
102
        Get the configured, tested Docker CMD.
103
        :return: string to be used for running Docker commands
104
        :raises: DockerNotAvailable exception if the Docker command or the socker is not available
105
        """
106
        if not self.has_docker():
1✔
107
            raise DockerNotAvailable()
1✔
108
        return shlex.split(config.DOCKER_CMD)
1✔
109

110
    def get_system_info(self) -> dict:
1✔
111
        cmd = [
1✔
112
            *self._docker_cmd(),
113
            "info",
114
            "--format",
115
            "{{json .}}",
116
        ]
117
        cmd_result = run(cmd)
1✔
118

119
        return json.loads(cmd_result)
1✔
120

121
    def get_container_status(self, container_name: str) -> DockerContainerStatus:
1✔
122
        cmd = self._docker_cmd()
1✔
123
        cmd += [
1✔
124
            "ps",
125
            "-a",
126
            "--filter",
127
            f"name={container_name}",
128
            "--format",
129
            "{{ .Status }} - {{ .Names }}",
130
        ]
131
        cmd_result = run(cmd)
1✔
132

133
        # filter empty / invalid lines from docker ps output
134
        cmd_result = next((line for line in cmd_result.splitlines() if container_name in line), "")
1✔
135
        container_status = cmd_result.strip().lower()
1✔
136
        if len(container_status) == 0:
1✔
137
            return DockerContainerStatus.NON_EXISTENT
1✔
138
        elif "(paused)" in container_status:
1✔
139
            return DockerContainerStatus.PAUSED
×
140
        elif container_status.startswith("up "):
1✔
141
            return DockerContainerStatus.UP
1✔
142
        else:
143
            return DockerContainerStatus.DOWN
1✔
144

145
    def get_container_stats(self, container_name: str) -> DockerContainerStats:
1✔
146
        cmd = self._docker_cmd()
×
147
        cmd += ["stats", "--no-stream", "--format", "{{json .}}", container_name]
×
148
        cmd_result = run(cmd)
×
149
        raw_stats = json.loads(cmd_result)
×
150

151
        # BlockIO (read, write)
152
        block_io_parts = raw_stats["BlockIO"].split("/")
×
153
        block_read = parse_size_string(block_io_parts[0])
×
154
        block_write = parse_size_string(block_io_parts[1])
×
155

156
        # CPU percentage
157
        cpu_percentage = float(raw_stats["CPUPerc"].strip("%"))
×
158

159
        # Memory (usage, limit)
160
        mem_parts = raw_stats["MemUsage"].split("/")
×
161
        mem_used = parse_size_string(mem_parts[0])
×
162
        mem_limit = parse_size_string(mem_parts[1])
×
163
        mem_percentage = float(raw_stats["MemPerc"].strip("%"))
×
164

165
        # Network (rx, tx)
166
        net_parts = raw_stats["NetIO"].split("/")
×
167
        net_rx = parse_size_string(net_parts[0])
×
168
        net_tx = parse_size_string(net_parts[1])
×
169

170
        return DockerContainerStats(
×
171
            Container=raw_stats["ID"],
172
            ID=raw_stats["ID"],
173
            Name=raw_stats["Name"],
174
            BlockIO=(block_read, block_write),
175
            CPUPerc=round(cpu_percentage, 2),
176
            MemPerc=round(mem_percentage, 2),
177
            MemUsage=(mem_used, mem_limit),
178
            NetIO=(net_rx, net_tx),
179
            PIDs=int(raw_stats["PIDs"]),
180
            SDKStats=None,
181
        )
182

183
    def stop_container(self, container_name: str, timeout: int = 10) -> None:
1✔
184
        cmd = self._docker_cmd()
1✔
185
        cmd += ["stop", "--time", str(timeout), container_name]
1✔
186
        LOG.debug("Stopping container with cmd %s", cmd)
1✔
187
        try:
1✔
188
            run(cmd)
1✔
189
        except subprocess.CalledProcessError as e:
1✔
190
            self._check_and_raise_no_such_container_error(container_name, error=e)
1✔
191
            raise ContainerException(
×
192
                f"Docker process returned with errorcode {e.returncode}", e.stdout, e.stderr
193
            ) from e
194

195
    def restart_container(self, container_name: str, timeout: int = 10) -> None:
1✔
196
        cmd = self._docker_cmd()
×
197
        cmd += ["restart", "--time", str(timeout), container_name]
×
198
        LOG.debug("Restarting container with cmd %s", cmd)
×
199
        try:
×
200
            run(cmd)
×
201
        except subprocess.CalledProcessError as e:
×
202
            self._check_and_raise_no_such_container_error(container_name, error=e)
×
203
            raise ContainerException(
×
204
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
205
            ) from e
206

207
    def pause_container(self, container_name: str) -> None:
1✔
208
        cmd = self._docker_cmd()
×
209
        cmd += ["pause", container_name]
×
210
        LOG.debug("Pausing container with cmd %s", cmd)
×
211
        try:
×
212
            run(cmd)
×
213
        except subprocess.CalledProcessError as e:
×
214
            self._check_and_raise_no_such_container_error(container_name, error=e)
×
215
            raise ContainerException(
×
216
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
217
            ) from e
218

219
    def unpause_container(self, container_name: str) -> None:
1✔
220
        cmd = self._docker_cmd()
×
221
        cmd += ["unpause", container_name]
×
222
        LOG.debug("Unpausing container with cmd %s", cmd)
×
223
        try:
×
224
            run(cmd)
×
225
        except subprocess.CalledProcessError as e:
×
226
            self._check_and_raise_no_such_container_error(container_name, error=e)
×
227
            raise ContainerException(
×
228
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
229
            ) from e
230

231
    def remove_image(self, image: str, force: bool = True) -> None:
1✔
232
        cmd = self._docker_cmd()
×
233
        cmd += ["rmi", image]
×
234
        if force:
×
235
            cmd += ["--force"]
×
236
        LOG.debug("Removing image %s %s", image, "(forced)" if force else "")
×
237
        try:
×
238
            run(cmd)
×
239
        except subprocess.CalledProcessError as e:
×
240
            # handle different error messages for Docker and podman
241
            error_messages = ["No such image", "image not known"]
×
242
            if any(msg in to_str(e.stdout) for msg in error_messages):
×
243
                raise NoSuchImage(image, stdout=e.stdout, stderr=e.stderr)
×
244
            raise ContainerException(
×
245
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
246
            ) from e
247

248
    def commit(
1✔
249
        self,
250
        container_name_or_id: str,
251
        image_name: str,
252
        image_tag: str,
253
    ):
254
        cmd = self._docker_cmd()
×
255
        cmd += ["commit", container_name_or_id, f"{image_name}:{image_tag}"]
×
256
        LOG.debug(
×
257
            "Creating image from container %s as %s:%s", container_name_or_id, image_name, image_tag
258
        )
259
        try:
×
260
            run(cmd)
×
261
        except subprocess.CalledProcessError as e:
×
262
            self._check_and_raise_no_such_container_error(container_name_or_id, error=e)
×
263
            raise ContainerException(
×
264
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
265
            ) from e
266

267
    def remove_container(self, container_name: str, force=True, check_existence=False) -> None:
1✔
268
        if check_existence and container_name not in self.get_running_container_names():
1✔
269
            return
×
270
        cmd = self._docker_cmd() + ["rm"]
1✔
271
        if force:
1✔
272
            cmd.append("-f")
1✔
273
        cmd.append(container_name)
1✔
274
        LOG.debug("Removing container with cmd %s", cmd)
1✔
275
        try:
1✔
276
            run(cmd)
1✔
277
        except subprocess.CalledProcessError as e:
×
278
            self._check_and_raise_no_such_container_error(container_name, error=e)
×
279
            raise ContainerException(
×
280
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
281
            ) from e
282

283
    def list_containers(self, filter: Union[List[str], str, None] = None, all=True) -> List[dict]:
1✔
284
        filter = [filter] if isinstance(filter, str) else filter
1✔
285
        cmd = self._docker_cmd()
1✔
286
        cmd.append("ps")
1✔
287
        if all:
1✔
288
            cmd.append("-a")
1✔
289
        options = []
1✔
290
        if filter:
1✔
291
            options += [y for filter_item in filter for y in ["--filter", filter_item]]
×
292
        cmd += options
1✔
293
        cmd.append("--format")
1✔
294
        cmd.append("{{json . }}")
1✔
295
        try:
1✔
296
            cmd_result = run(cmd).strip()
1✔
297
        except subprocess.CalledProcessError as e:
×
298
            raise ContainerException(
×
299
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
300
            ) from e
301
        container_list = []
1✔
302
        if cmd_result:
1✔
303
            if cmd_result[0] == "[":
1✔
304
                container_list = json.loads(cmd_result)
×
305
            else:
306
                container_list = [json.loads(line) for line in cmd_result.splitlines()]
1✔
307
        result = []
1✔
308
        for container in container_list:
1✔
309
            labels = self._transform_container_labels(container["Labels"])
1✔
310
            result.append(
1✔
311
                {
312
                    # support both, Docker and podman API response formats (`ID` vs `Id`)
313
                    "id": container.get("ID") or container["Id"],
314
                    "image": container["Image"],
315
                    # Docker returns a single string for `Names`, whereas podman returns a list of names
316
                    "name": ensure_list(container["Names"])[0],
317
                    "status": container["State"],
318
                    "labels": labels,
319
                }
320
            )
321
        return result
1✔
322

323
    def copy_into_container(
1✔
324
        self, container_name: str, local_path: str, container_path: str
325
    ) -> None:
326
        cmd = self._docker_cmd()
1✔
327
        cmd += ["cp", local_path, f"{container_name}:{container_path}"]
1✔
328
        LOG.debug("Copying into container with cmd: %s", cmd)
1✔
329
        try:
1✔
330
            run(cmd)
1✔
331
        except subprocess.CalledProcessError as e:
×
332
            self._check_and_raise_no_such_container_error(container_name, error=e)
×
333
            if "does not exist" in to_str(e.stdout):
×
334
                raise NoSuchContainer(container_name, stdout=e.stdout, stderr=e.stderr)
×
335
            raise ContainerException(
×
336
                f"Docker process returned with errorcode {e.returncode}", e.stdout, e.stderr
337
            ) from e
338

339
    def copy_from_container(
1✔
340
        self, container_name: str, local_path: str, container_path: str
341
    ) -> None:
342
        cmd = self._docker_cmd()
×
343
        cmd += ["cp", f"{container_name}:{container_path}", local_path]
×
344
        LOG.debug("Copying from container with cmd: %s", cmd)
×
345
        try:
×
346
            run(cmd)
×
347
        except subprocess.CalledProcessError as e:
×
348
            self._check_and_raise_no_such_container_error(container_name, error=e)
×
349
            # additional check to support Podman CLI output
350
            if re.match(".*container .+ does not exist", to_str(e.stdout)):
×
351
                raise NoSuchContainer(container_name, stdout=e.stdout, stderr=e.stderr)
×
352
            raise ContainerException(
×
353
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
354
            ) from e
355

356
    def pull_image(self, docker_image: str, platform: Optional[DockerPlatform] = None) -> None:
1✔
357
        cmd = self._docker_cmd()
1✔
358
        cmd += ["pull", docker_image]
1✔
359
        if platform:
1✔
360
            cmd += ["--platform", platform]
1✔
361
        LOG.debug("Pulling image with cmd: %s", cmd)
1✔
362
        try:
1✔
363
            run(cmd)
1✔
364
        except subprocess.CalledProcessError as e:
×
365
            stdout_str = to_str(e.stdout)
×
366
            if "pull access denied" in stdout_str:
×
367
                raise NoSuchImage(docker_image, stdout=e.stdout, stderr=e.stderr)
×
368
            # note: error message 'access to the resource is denied' raised by Podman client
369
            if "Trying to pull" in stdout_str and "access to the resource is denied" in stdout_str:
×
370
                raise NoSuchImage(docker_image, stdout=e.stdout, stderr=e.stderr)
×
371
            raise ContainerException(
×
372
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
373
            ) from e
374

375
    def push_image(self, docker_image: str) -> None:
1✔
376
        cmd = self._docker_cmd()
×
377
        cmd += ["push", docker_image]
×
378
        LOG.debug("Pushing image with cmd: %s", cmd)
×
379
        try:
×
380
            run(cmd)
×
381
        except subprocess.CalledProcessError as e:
×
382
            if "is denied" in to_str(e.stdout):
×
383
                raise AccessDenied(docker_image)
×
384
            if "requesting higher privileges than access token allows" in to_str(e.stdout):
×
385
                raise AccessDenied(docker_image)
×
386
            if "access token has insufficient scopes" in to_str(e.stdout):
×
387
                raise AccessDenied(docker_image)
×
388
            if "does not exist" in to_str(e.stdout):
×
389
                raise NoSuchImage(docker_image)
×
390
            if "connection refused" in to_str(e.stdout):
×
391
                raise RegistryConnectionError(e.stdout)
×
392
            # note: error message 'image not known' raised by Podman client
393
            if "image not known" in to_str(e.stdout):
×
394
                raise NoSuchImage(docker_image)
×
395
            raise ContainerException(
×
396
                f"Docker process returned with errorcode {e.returncode}", e.stdout, e.stderr
397
            ) from e
398

399
    def build_image(
1✔
400
        self,
401
        dockerfile_path: str,
402
        image_name: str,
403
        context_path: str = None,
404
        platform: Optional[DockerPlatform] = None,
405
    ):
406
        cmd = self._docker_cmd()
×
407
        dockerfile_path = Util.resolve_dockerfile_path(dockerfile_path)
×
408
        context_path = context_path or os.path.dirname(dockerfile_path)
×
409
        cmd += ["build", "-t", image_name, "-f", dockerfile_path]
×
410
        if platform:
×
411
            cmd += ["--platform", platform]
×
412
        cmd += [context_path]
×
413
        LOG.debug("Building Docker image: %s", cmd)
×
414
        try:
×
NEW
415
            return run(cmd)
×
416
        except subprocess.CalledProcessError as e:
×
417
            raise ContainerException(
×
418
                f"Docker build process returned with error code {e.returncode}", e.stdout, e.stderr
419
            ) from e
420

421
    def tag_image(self, source_ref: str, target_name: str) -> None:
1✔
422
        cmd = self._docker_cmd()
×
423
        cmd += ["tag", source_ref, target_name]
×
424
        LOG.debug("Tagging Docker image %s as %s", source_ref, target_name)
×
425
        try:
×
426
            run(cmd)
×
427
        except subprocess.CalledProcessError as e:
×
428
            # handle different error messages for Docker and podman
429
            error_messages = ["No such image", "image not known"]
×
430
            if any(msg in to_str(e.stdout) for msg in error_messages):
×
431
                raise NoSuchImage(source_ref)
×
432
            raise ContainerException(
×
433
                f"Docker process returned with error code {e.returncode}", e.stdout, e.stderr
434
            ) from e
435

436
    def get_docker_image_names(
1✔
437
        self, strip_latest=True, include_tags=True, strip_wellknown_repo_prefixes: bool = True
438
    ):
439
        format_string = "{{.Repository}}:{{.Tag}}" if include_tags else "{{.Repository}}"
×
440
        cmd = self._docker_cmd()
×
441
        cmd += ["images", "--format", format_string]
×
442
        try:
×
443
            output = run(cmd)
×
444

445
            image_names = output.splitlines()
×
446
            if strip_wellknown_repo_prefixes:
×
447
                image_names = Util.strip_wellknown_repo_prefixes(image_names)
×
448
            if strip_latest:
×
449
                Util.append_without_latest(image_names)
×
450

451
            return image_names
×
452
        except Exception as e:
×
453
            LOG.info('Unable to list Docker images via "%s": %s', cmd, e)
×
454
            return []
×
455

456
    def get_container_logs(self, container_name_or_id: str, safe=False) -> str:
1✔
457
        cmd = self._docker_cmd()
×
458
        cmd += ["logs", container_name_or_id]
×
459
        try:
×
460
            return run(cmd)
×
461
        except subprocess.CalledProcessError as e:
×
462
            if safe:
×
463
                return ""
×
464
            self._check_and_raise_no_such_container_error(container_name_or_id, error=e)
×
465
            raise ContainerException(
×
466
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
467
            ) from e
468

469
    def stream_container_logs(self, container_name_or_id: str) -> CancellableStream:
1✔
470
        self.inspect_container(container_name_or_id)  # guard to check whether container is there
×
471

472
        cmd = self._docker_cmd()
×
473
        cmd += ["logs", "--follow", container_name_or_id]
×
474

475
        process: subprocess.Popen = run(
×
476
            cmd, asynchronous=True, outfile=subprocess.PIPE, stderr=subprocess.STDOUT
477
        )
478

479
        return CancellableProcessStream(process)
×
480

481
    def _inspect_object(self, object_name_or_id: str) -> Dict[str, Union[dict, list, str]]:
1✔
482
        cmd = self._docker_cmd()
1✔
483
        cmd += ["inspect", "--format", "{{json .}}", object_name_or_id]
1✔
484
        try:
1✔
485
            cmd_result = run(cmd, print_error=False)
1✔
486
        except subprocess.CalledProcessError as e:
×
487
            # note: case-insensitive comparison, to support Docker and Podman output formats
488
            if "no such object" in to_str(e.stdout).lower():
×
489
                raise NoSuchObject(object_name_or_id, stdout=e.stdout, stderr=e.stderr)
×
490
            raise ContainerException(
×
491
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
492
            ) from e
493
        object_data = json.loads(cmd_result.strip())
1✔
494
        if isinstance(object_data, list):
1✔
495
            # return first list item, for compatibility with Podman API
496
            if len(object_data) == 1:
×
497
                result = object_data[0]
×
498
                # convert first character to uppercase (e.g., `name` -> `Name`), for Podman/Docker compatibility
499
                result = {first_char_to_upper(k): v for k, v in result.items()}
×
500
                return result
×
501
            LOG.info(
×
502
                "Expected a single object for `inspect` on ID %s, got %s",
503
                object_name_or_id,
504
                len(object_data),
505
            )
506
        return object_data
1✔
507

508
    def inspect_container(self, container_name_or_id: str) -> Dict[str, Union[Dict, str]]:
1✔
509
        try:
1✔
510
            return self._inspect_object(container_name_or_id)
1✔
511
        except NoSuchObject as e:
×
512
            raise NoSuchContainer(container_name_or_id=e.object_id)
×
513

514
    def inspect_image(
1✔
515
        self,
516
        image_name: str,
517
        pull: bool = True,
518
        strip_wellknown_repo_prefixes: bool = True,
519
    ) -> Dict[str, Union[dict, list, str]]:
520
        try:
×
521
            result = self._inspect_object(image_name)
×
522
            if strip_wellknown_repo_prefixes:
×
523
                if result.get("RepoDigests"):
×
524
                    result["RepoDigests"] = Util.strip_wellknown_repo_prefixes(
×
525
                        result["RepoDigests"]
526
                    )
527
                if result.get("RepoTags"):
×
528
                    result["RepoTags"] = Util.strip_wellknown_repo_prefixes(result["RepoTags"])
×
529
            return result
×
530
        except NoSuchObject as e:
×
531
            if pull:
×
532
                self.pull_image(image_name)
×
533
                return self.inspect_image(image_name, pull=False)
×
534
            raise NoSuchImage(image_name=e.object_id)
×
535

536
    def create_network(self, network_name: str) -> str:
1✔
537
        cmd = self._docker_cmd()
×
538
        cmd += ["network", "create", network_name]
×
539
        try:
×
540
            return run(cmd).strip()
×
541
        except subprocess.CalledProcessError as e:
×
542
            raise ContainerException(
×
543
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
544
            ) from e
545

546
    def delete_network(self, network_name: str) -> None:
1✔
547
        cmd = self._docker_cmd()
×
548
        cmd += ["network", "rm", network_name]
×
549
        try:
×
550
            run(cmd)
×
551
        except subprocess.CalledProcessError as e:
×
552
            stdout_str = to_str(e.stdout)
×
553
            if re.match(r".*network (.*) not found.*", stdout_str):
×
554
                raise NoSuchNetwork(network_name=network_name)
×
555
            else:
556
                raise ContainerException(
×
557
                    "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
558
                ) from e
559

560
    def inspect_network(self, network_name: str) -> Dict[str, Union[Dict, str]]:
1✔
561
        try:
1✔
562
            return self._inspect_object(network_name)
1✔
563
        except NoSuchObject as e:
×
564
            raise NoSuchNetwork(network_name=e.object_id)
×
565

566
    def connect_container_to_network(
1✔
567
        self,
568
        network_name: str,
569
        container_name_or_id: str,
570
        aliases: Optional[List] = None,
571
        link_local_ips: List[str] = None,
572
    ) -> None:
573
        LOG.debug(
×
574
            "Connecting container '%s' to network '%s' with aliases '%s'",
575
            container_name_or_id,
576
            network_name,
577
            aliases,
578
        )
579
        cmd = self._docker_cmd()
×
580
        cmd += ["network", "connect"]
×
581
        if aliases:
×
582
            cmd += ["--alias", ",".join(aliases)]
×
583
        if link_local_ips:
×
584
            cmd += ["--link-local-ip", ",".join(link_local_ips)]
×
585
        cmd += [network_name, container_name_or_id]
×
586
        try:
×
587
            run(cmd)
×
588
        except subprocess.CalledProcessError as e:
×
589
            stdout_str = to_str(e.stdout)
×
590
            if re.match(r".*network (.*) not found.*", stdout_str):
×
591
                raise NoSuchNetwork(network_name=network_name)
×
592
            self._check_and_raise_no_such_container_error(container_name_or_id, error=e)
×
593
            raise ContainerException(
×
594
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
595
            ) from e
596

597
    def disconnect_container_from_network(
1✔
598
        self, network_name: str, container_name_or_id: str
599
    ) -> None:
600
        LOG.debug(
×
601
            "Disconnecting container '%s' from network '%s'", container_name_or_id, network_name
602
        )
603
        cmd = self._docker_cmd() + ["network", "disconnect", network_name, container_name_or_id]
×
604
        try:
×
605
            run(cmd)
×
606
        except subprocess.CalledProcessError as e:
×
607
            stdout_str = to_str(e.stdout)
×
608
            if re.match(r".*network (.*) not found.*", stdout_str):
×
609
                raise NoSuchNetwork(network_name=network_name)
×
610
            self._check_and_raise_no_such_container_error(container_name_or_id, error=e)
×
611
            raise ContainerException(
×
612
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
613
            ) from e
614

615
    def get_container_ip(self, container_name_or_id: str) -> str:
1✔
616
        cmd = self._docker_cmd()
×
617
        cmd += [
×
618
            "inspect",
619
            "--format",
620
            "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}",
621
            container_name_or_id,
622
        ]
623
        try:
×
624
            result = run(cmd).strip()
×
625
            return result.split(" ")[0] if result else ""
×
626
        except subprocess.CalledProcessError as e:
×
627
            self._check_and_raise_no_such_container_error(container_name_or_id, error=e)
×
628
            # consider different error messages for Podman
629
            if "no such object" in to_str(e.stdout).lower():
×
630
                raise NoSuchContainer(container_name_or_id, stdout=e.stdout, stderr=e.stderr)
×
631
            raise ContainerException(
×
632
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
633
            ) from e
634

635
    def login(self, username: str, password: str, registry: Optional[str] = None) -> None:
1✔
636
        cmd = self._docker_cmd()
×
637
        # TODO specify password via stdin
638
        cmd += ["login", "-u", username, "-p", password]
×
639
        if registry:
×
640
            cmd.append(registry)
×
641
        try:
×
642
            run(cmd)
×
643
        except subprocess.CalledProcessError as e:
×
644
            raise ContainerException(
×
645
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
646
            ) from e
647

648
    @functools.lru_cache(maxsize=None)
1✔
649
    def has_docker(self) -> bool:
1✔
650
        try:
1✔
651
            # do not use self._docker_cmd here (would result in a loop)
652
            run(shlex.split(config.DOCKER_CMD) + ["ps"])
1✔
653
            return True
1✔
654
        except (subprocess.CalledProcessError, FileNotFoundError):
1✔
655
            return False
1✔
656

657
    def create_container(self, image_name: str, **kwargs) -> str:
1✔
658
        cmd, env_file = self._build_run_create_cmd("create", image_name, **kwargs)
1✔
659
        LOG.debug("Create container with cmd: %s", cmd)
1✔
660
        try:
1✔
661
            container_id = run(cmd)
1✔
662
            # Note: strip off Docker warning messages like "DNS setting (--dns=127.0.0.1) may fail in containers"
663
            container_id = container_id.strip().split("\n")[-1]
1✔
664
            return container_id.strip()
1✔
665
        except subprocess.CalledProcessError as e:
×
666
            error_messages = ["Unable to find image", "Trying to pull"]
×
667
            if any(msg in to_str(e.stdout) for msg in error_messages):
×
668
                raise NoSuchImage(image_name, stdout=e.stdout, stderr=e.stderr)
×
669
            raise ContainerException(
×
670
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
671
            ) from e
672
        finally:
673
            Util.rm_env_vars_file(env_file)
1✔
674

675
    def run_container(self, image_name: str, stdin=None, **kwargs) -> Tuple[bytes, bytes]:
1✔
676
        cmd, env_file = self._build_run_create_cmd("run", image_name, **kwargs)
×
677
        LOG.debug("Run container with cmd: %s", cmd)
×
678
        try:
×
679
            return self._run_async_cmd(cmd, stdin, kwargs.get("name") or "", image_name)
×
680
        except ContainerException as e:
×
681
            if "Trying to pull" in str(e) and "access to the resource is denied" in str(e):
×
682
                raise NoSuchImage(image_name, stdout=e.stdout, stderr=e.stderr) from e
×
683
            raise
×
684
        finally:
685
            Util.rm_env_vars_file(env_file)
×
686

687
    def exec_in_container(
1✔
688
        self,
689
        container_name_or_id: str,
690
        command: Union[List[str], str],
691
        interactive=False,
692
        detach=False,
693
        env_vars: Optional[Dict[str, Optional[str]]] = None,
694
        stdin: Optional[bytes] = None,
695
        user: Optional[str] = None,
696
        workdir: Optional[str] = None,
697
    ) -> Tuple[bytes, bytes]:
698
        env_file = None
×
699
        cmd = self._docker_cmd()
×
700
        cmd.append("exec")
×
701
        if interactive:
×
702
            cmd.append("--interactive")
×
703
        if detach:
×
704
            cmd.append("--detach")
×
705
        if user:
×
706
            cmd += ["--user", user]
×
707
        if workdir:
×
708
            cmd += ["--workdir", workdir]
×
709
        if env_vars:
×
710
            env_flag, env_file = Util.create_env_vars_file_flag(env_vars)
×
711
            cmd += env_flag
×
712
        cmd.append(container_name_or_id)
×
713
        cmd += command if isinstance(command, List) else [command]
×
714
        LOG.debug("Execute command in container: %s", cmd)
×
715
        try:
×
716
            return self._run_async_cmd(cmd, stdin, container_name_or_id)
×
717
        finally:
718
            Util.rm_env_vars_file(env_file)
×
719

720
    def start_container(
1✔
721
        self,
722
        container_name_or_id: str,
723
        stdin=None,
724
        interactive: bool = False,
725
        attach: bool = False,
726
        flags: Optional[str] = None,
727
    ) -> Tuple[bytes, bytes]:
728
        cmd = self._docker_cmd() + ["start"]
1✔
729
        if flags:
1✔
730
            cmd.append(flags)
×
731
        if interactive:
1✔
732
            cmd.append("--interactive")
×
733
        if attach:
1✔
734
            cmd.append("--attach")
×
735
        cmd.append(container_name_or_id)
1✔
736
        LOG.debug("Start container with cmd: %s", cmd)
1✔
737
        return self._run_async_cmd(cmd, stdin, container_name_or_id)
1✔
738

739
    def attach_to_container(self, container_name_or_id: str):
1✔
740
        cmd = self._docker_cmd() + ["attach", container_name_or_id]
×
741
        LOG.debug("Attaching to container %s", container_name_or_id)
×
742
        return self._run_async_cmd(cmd, stdin=None, container_name=container_name_or_id)
×
743

744
    def _run_async_cmd(
1✔
745
        self, cmd: List[str], stdin: bytes, container_name: str, image_name=None
746
    ) -> Tuple[bytes, bytes]:
747
        kwargs = {
1✔
748
            "inherit_env": True,
749
            "asynchronous": True,
750
            "stderr": subprocess.PIPE,
751
            "outfile": self.default_run_outfile or subprocess.PIPE,
752
        }
753
        if stdin:
1✔
754
            kwargs["stdin"] = True
×
755
        try:
1✔
756
            process = run(cmd, **kwargs)
1✔
757
            stdout, stderr = process.communicate(input=stdin)
1✔
758
            if process.returncode != 0:
1✔
759
                raise subprocess.CalledProcessError(
×
760
                    process.returncode,
761
                    cmd,
762
                    stdout,
763
                    stderr,
764
                )
765
            else:
766
                return stdout, stderr
1✔
767
        except subprocess.CalledProcessError as e:
×
768
            stderr_str = to_str(e.stderr)
×
769
            if "Unable to find image" in stderr_str:
×
770
                raise NoSuchImage(image_name or "", stdout=e.stdout, stderr=e.stderr)
×
771
            # consider different error messages for Docker/Podman
772
            error_messages = ("No such container", "no container with name or ID")
×
773
            if any(msg.lower() in to_str(e.stderr).lower() for msg in error_messages):
×
774
                raise NoSuchContainer(container_name, stdout=e.stdout, stderr=e.stderr)
×
775
            raise ContainerException(
×
776
                "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr
777
            ) from e
778

779
    def _build_run_create_cmd(
1✔
780
        self,
781
        action: str,
782
        image_name: str,
783
        *,
784
        name: Optional[str] = None,
785
        entrypoint: Optional[Union[List[str], str]] = None,
786
        remove: bool = False,
787
        interactive: bool = False,
788
        tty: bool = False,
789
        detach: bool = False,
790
        command: Optional[Union[List[str], str]] = None,
791
        volumes: Optional[List[SimpleVolumeBind]] = None,
792
        ports: Optional[PortMappings] = None,
793
        exposed_ports: Optional[List[str]] = None,
794
        env_vars: Optional[Dict[str, str]] = None,
795
        user: Optional[str] = None,
796
        cap_add: Optional[List[str]] = None,
797
        cap_drop: Optional[List[str]] = None,
798
        security_opt: Optional[List[str]] = None,
799
        network: Optional[str] = None,
800
        dns: Optional[Union[str, List[str]]] = None,
801
        additional_flags: Optional[str] = None,
802
        workdir: Optional[str] = None,
803
        privileged: Optional[bool] = None,
804
        labels: Optional[Dict[str, str]] = None,
805
        platform: Optional[DockerPlatform] = None,
806
        ulimits: Optional[List[Ulimit]] = None,
807
        init: Optional[bool] = None,
808
        log_config: Optional[LogConfig] = None,
809
    ) -> Tuple[List[str], str]:
810
        env_file = None
1✔
811
        cmd = self._docker_cmd() + [action]
1✔
812
        if remove:
1✔
813
            cmd.append("--rm")
×
814
        if name:
1✔
815
            cmd += ["--name", name]
1✔
816
        if entrypoint is not None:  # empty string entrypoint can be intentional
1✔
817
            if isinstance(entrypoint, str):
1✔
818
                cmd += ["--entrypoint", entrypoint]
1✔
819
            else:
820
                cmd += ["--entrypoint", shlex.join(entrypoint)]
×
821
        if privileged:
1✔
822
            cmd += ["--privileged"]
×
823
        if volumes:
1✔
824
            cmd += [
1✔
825
                param for volume in volumes for param in ["-v", self._map_to_volume_param(volume)]
826
            ]
827
        if interactive:
1✔
828
            cmd.append("--interactive")
×
829
        if tty:
1✔
830
            cmd.append("--tty")
×
831
        if detach:
1✔
832
            cmd.append("--detach")
×
833
        if ports:
1✔
834
            cmd += ports.to_list()
1✔
835
        if exposed_ports:
1✔
836
            cmd += list(itertools.chain.from_iterable(["--expose", port] for port in exposed_ports))
×
837
        if env_vars:
1✔
838
            env_flags, env_file = Util.create_env_vars_file_flag(env_vars)
1✔
839
            cmd += env_flags
1✔
840
        if user:
1✔
841
            cmd += ["--user", user]
×
842
        if cap_add:
1✔
843
            cmd += list(itertools.chain.from_iterable(["--cap-add", cap] for cap in cap_add))
×
844
        if cap_drop:
1✔
845
            cmd += list(itertools.chain.from_iterable(["--cap-drop", cap] for cap in cap_drop))
×
846
        if security_opt:
1✔
847
            cmd += list(
×
848
                itertools.chain.from_iterable(["--security-opt", opt] for opt in security_opt)
849
            )
850
        if network:
1✔
851
            cmd += ["--network", network]
1✔
852
        if dns:
1✔
853
            for dns_server in ensure_list(dns):
×
854
                cmd += ["--dns", dns_server]
×
855
        if workdir:
1✔
856
            cmd += ["--workdir", workdir]
×
857
        if labels:
1✔
858
            for key, value in labels.items():
×
859
                cmd += ["--label", f"{key}={value}"]
×
860
        if platform:
1✔
861
            cmd += ["--platform", platform]
1✔
862
        if ulimits:
1✔
863
            cmd += list(
×
864
                itertools.chain.from_iterable(["--ulimit", str(ulimit)] for ulimit in ulimits)
865
            )
866
        if init:
1✔
867
            cmd += ["--init"]
×
868
        if log_config:
1✔
869
            cmd += ["--log-driver", log_config.type]
×
870
            for key, value in log_config.config.items():
×
871
                cmd += ["--log-opt", f"{key}={value}"]
×
872

873
        if additional_flags:
1✔
874
            cmd += shlex.split(additional_flags)
×
875
        cmd.append(image_name)
1✔
876
        if command:
1✔
877
            cmd += command if isinstance(command, List) else [command]
×
878
        return cmd, env_file
1✔
879

880
    @staticmethod
1✔
881
    def _map_to_volume_param(volume: Union[SimpleVolumeBind, VolumeBind]) -> str:
1✔
882
        """
883
        Maps the mount volume, to a parameter for the -v docker cli argument.
884

885
        Examples:
886
        (host_path, container_path) -> host_path:container_path
887
        VolumeBind(host_dir=host_path, container_dir=container_path, read_only=True) -> host_path:container_path:ro
888

889
        :param volume: Either a SimpleVolumeBind, in essence a tuple (host_dir, container_dir), or a VolumeBind object
890
        :return: String which is passable as parameter to the docker cli -v option
891
        """
892
        if isinstance(volume, VolumeBind):
×
893
            return volume.to_str()
×
894
        else:
895
            return f"{volume[0]}:{volume[1]}"
×
896

897
    def _check_and_raise_no_such_container_error(
1✔
898
        self, container_name_or_id: str, error: subprocess.CalledProcessError
899
    ):
900
        """
901
        Check the given client invocation error and raise a `NoSuchContainer` exception if it
902
        represents a `no such container` exception from Docker or Podman.
903
        """
904

905
        # consider different error messages for Docker/Podman
906
        error_messages = ("No such container", "no container with name or ID")
1✔
907
        process_stdout_lower = to_str(error.stdout).lower()
1✔
908
        if any(msg.lower() in process_stdout_lower for msg in error_messages):
1✔
909
            raise NoSuchContainer(container_name_or_id, stdout=error.stdout, stderr=error.stderr)
1✔
910

911
    def _transform_container_labels(self, labels: str) -> Dict[str, str]:
1✔
912
        """
913
        Transforms the container labels returned by the docker command from the key-value pair format to a dict
914
        :param labels: Input string, comma separated key value pairs. Example: key1=value1,key2=value2
915
        :return: Dict representation of the passed values, example: {"key1": "value1", "key2": "value2"}
916
        """
917
        labels = labels.split(",")
1✔
918
        labels = [label.partition("=") for label in labels]
1✔
919
        return {label[0]: label[2] for label in labels}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc