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

localstack / localstack / cad8b38b-ee90-4409-9668-00c732d1bf6a

13 Jan 2025 10:13PM UTC coverage: 86.852% (+0.01%) from 86.84%
cad8b38b-ee90-4409-9668-00c732d1bf6a

push

circleci

web-flow
Update ASF APIs (#12125)

Co-authored-by: LocalStack Bot <localstack-bot@users.noreply.github.com>

61064 of 70308 relevant lines covered (86.85%)

0.87 hits per line

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

90.49
/localstack-core/localstack/utils/container_utils/container_client.py
1
import dataclasses
1✔
2
import io
1✔
3
import ipaddress
1✔
4
import logging
1✔
5
import os
1✔
6
import re
1✔
7
import shlex
1✔
8
import sys
1✔
9
import tarfile
1✔
10
import tempfile
1✔
11
from abc import ABCMeta, abstractmethod
1✔
12
from enum import Enum, unique
1✔
13
from pathlib import Path
1✔
14
from typing import Dict, List, Literal, NamedTuple, Optional, Protocol, Tuple, Union, get_args
1✔
15

16
import dotenv
1✔
17

18
from localstack import config
1✔
19
from localstack.utils.collections import HashableList, ensure_list
1✔
20
from localstack.utils.files import TMP_FILES, chmod_r, rm_rf, save_file
1✔
21
from localstack.utils.no_exit_argument_parser import NoExitArgumentParser
1✔
22
from localstack.utils.strings import short_uid
1✔
23

24
LOG = logging.getLogger(__name__)
1✔
25

26
# list of well-known image repo prefixes that should be stripped off to canonicalize image names
27
WELL_KNOWN_IMAGE_REPO_PREFIXES = ("localhost/", "docker.io/library/")
1✔
28

29

30
@unique
1✔
31
class DockerContainerStatus(Enum):
1✔
32
    DOWN = -1
1✔
33
    NON_EXISTENT = 0
1✔
34
    UP = 1
1✔
35
    PAUSED = 2
1✔
36

37

38
class ContainerException(Exception):
1✔
39
    def __init__(self, message=None, stdout=None, stderr=None) -> None:
1✔
40
        self.message = message or "Error during the communication with the docker daemon"
1✔
41
        self.stdout = stdout
1✔
42
        self.stderr = stderr
1✔
43

44

45
class NoSuchObject(ContainerException):
1✔
46
    def __init__(self, object_id: str, message=None, stdout=None, stderr=None) -> None:
1✔
47
        message = message or f"Docker object {object_id} not found"
×
48
        super().__init__(message, stdout, stderr)
×
49
        self.object_id = object_id
×
50

51

52
class NoSuchContainer(ContainerException):
1✔
53
    def __init__(self, container_name_or_id: str, message=None, stdout=None, stderr=None) -> None:
1✔
54
        message = message or f"Docker container {container_name_or_id} not found"
1✔
55
        super().__init__(message, stdout, stderr)
1✔
56
        self.container_name_or_id = container_name_or_id
1✔
57

58

59
class NoSuchImage(ContainerException):
1✔
60
    def __init__(self, image_name: str, message=None, stdout=None, stderr=None) -> None:
1✔
61
        message = message or f"Docker image {image_name} not found"
1✔
62
        super().__init__(message, stdout, stderr)
1✔
63
        self.image_name = image_name
1✔
64

65

66
class NoSuchNetwork(ContainerException):
1✔
67
    def __init__(self, network_name: str, message=None, stdout=None, stderr=None) -> None:
1✔
68
        message = message or f"Docker network {network_name} not found"
1✔
69
        super().__init__(message, stdout, stderr)
1✔
70
        self.network_name = network_name
1✔
71

72

73
class RegistryConnectionError(ContainerException):
1✔
74
    def __init__(self, details: str, message=None, stdout=None, stderr=None) -> None:
1✔
75
        message = message or f"Connection error: {details}"
1✔
76
        super().__init__(message, stdout, stderr)
1✔
77
        self.details = details
1✔
78

79

80
class DockerNotAvailable(ContainerException):
1✔
81
    def __init__(self, message=None, stdout=None, stderr=None) -> None:
1✔
82
        message = message or "Docker not available"
1✔
83
        super().__init__(message, stdout, stderr)
1✔
84

85

86
class AccessDenied(ContainerException):
1✔
87
    def __init__(self, object_name: str, message=None, stdout=None, stderr=None) -> None:
1✔
88
        message = message or f"Access denied to {object_name}"
1✔
89
        super().__init__(message, stdout, stderr)
1✔
90
        self.object_name = object_name
1✔
91

92

93
class CancellableStream(Protocol):
1✔
94
    """Describes a generator that can be closed. Borrowed from ``docker.types.daemon``."""
95

96
    def __iter__(self):
1✔
97
        raise NotImplementedError
98

99
    def __next__(self):
1✔
100
        raise NotImplementedError
101

102
    def close(self):
1✔
103
        raise NotImplementedError
104

105

106
class DockerPlatform(str):
1✔
107
    """Platform in the format ``os[/arch[/variant]]``"""
108

109
    linux_amd64 = "linux/amd64"
1✔
110
    linux_arm64 = "linux/arm64"
1✔
111

112

113
@dataclasses.dataclass
1✔
114
class Ulimit:
1✔
115
    """The ``ulimit`` settings for the container.
116
    See https://www.tutorialspoint.com/setting-ulimit-values-on-docker-containers
117
    """
118

119
    name: str
1✔
120
    soft_limit: int
1✔
121
    hard_limit: Optional[int] = None
1✔
122

123
    def __repr__(self):
124
        """Format: <type>=<soft limit>[:<hard limit>]"""
125
        ulimit_string = f"{self.name}={self.soft_limit}"
126
        if self.hard_limit:
127
            ulimit_string += f":{self.hard_limit}"
128
        return ulimit_string
129

130

131
# defines the type for port mappings (source->target port range)
132
PortRange = Union[List, HashableList]
1✔
133
# defines the protocol for a port range ("tcp" or "udp")
134
PortProtocol = str
1✔
135

136

137
def isinstance_union(obj, class_or_tuple):
1✔
138
    # that's some dirty hack
139
    if sys.version_info < (3, 10):
1✔
140
        return isinstance(obj, get_args(PortRange))
×
141
    else:
142
        return isinstance(obj, class_or_tuple)
1✔
143

144

145
class PortMappings:
1✔
146
    """Maps source to target port ranges for Docker port mappings."""
147

148
    # bind host to be used for defining port mappings
149
    bind_host: str
1✔
150
    # maps `from` port range to `to` port range for port mappings
151
    mappings: Dict[Tuple[PortRange, PortProtocol], List]
1✔
152

153
    def __init__(self, bind_host: str = None):
1✔
154
        self.bind_host = bind_host if bind_host else ""
1✔
155
        self.mappings = {}
1✔
156

157
    def add(
1✔
158
        self,
159
        port: Union[int, PortRange],
160
        mapped: Union[int, PortRange] = None,
161
        protocol: PortProtocol = "tcp",
162
    ):
163
        mapped = mapped or port
1✔
164
        if isinstance_union(port, PortRange):
1✔
165
            for i in range(port[1] - port[0] + 1):
1✔
166
                if isinstance_union(mapped, PortRange):
1✔
167
                    self.add(port[0] + i, mapped[0] + i, protocol)
1✔
168
                else:
169
                    self.add(port[0] + i, mapped, protocol)
1✔
170
            return
1✔
171
        if port is None or int(port) < 0:
1✔
172
            raise Exception(f"Unable to add mapping for invalid port: {port}")
×
173
        if self.contains(port, protocol):
1✔
174
            return
1✔
175
        bisected_host_port = None
1✔
176
        for (from_range, from_protocol), to_range in self.mappings.items():
1✔
177
            if not from_protocol == protocol:
1✔
178
                continue
1✔
179
            if not self.in_expanded_range(port, from_range):
1✔
180
                continue
1✔
181
            if not self.in_expanded_range(mapped, to_range):
1✔
182
                continue
1✔
183
            from_range_len = from_range[1] - from_range[0]
1✔
184
            to_range_len = to_range[1] - to_range[0]
1✔
185
            is_uniform = from_range_len == to_range_len
1✔
186
            if is_uniform:
1✔
187
                self.expand_range(port, from_range, protocol=protocol, remap=True)
1✔
188
                self.expand_range(mapped, to_range, protocol=protocol)
1✔
189
            else:
190
                if not self.in_range(mapped, to_range):
1✔
191
                    continue
1✔
192
                # extending a 1 to 1 mapping to be many to 1
193
                elif from_range_len == 1:
1✔
194
                    self.expand_range(port, from_range, protocol=protocol, remap=True)
1✔
195
                # splitting a uniform mapping
196
                else:
197
                    bisected_port_index = mapped - to_range[0]
1✔
198
                    bisected_host_port = from_range[0] + bisected_port_index
1✔
199
                    self.bisect_range(mapped, to_range, protocol=protocol)
1✔
200
                    self.bisect_range(bisected_host_port, from_range, protocol=protocol, remap=True)
1✔
201
                    break
1✔
202
            return
1✔
203
        if bisected_host_port is None:
1✔
204
            port_range = [port, port]
1✔
205
        elif bisected_host_port < port:
1✔
206
            port_range = [bisected_host_port, port]
1✔
207
        else:
208
            port_range = [port, bisected_host_port]
×
209
        protocol = str(protocol or "tcp").lower()
1✔
210
        self.mappings[(HashableList(port_range), protocol)] = [mapped, mapped]
1✔
211

212
    def to_str(self) -> str:
1✔
213
        bind_address = f"{self.bind_host}:" if self.bind_host else ""
1✔
214

215
        def entry(k, v):
1✔
216
            from_range, protocol = k
1✔
217
            to_range = v
1✔
218
            # use /<protocol> suffix if the protocol is not"tcp"
219
            protocol_suffix = f"/{protocol}" if protocol != "tcp" else ""
1✔
220
            if from_range[0] == from_range[1] and to_range[0] == to_range[1]:
1✔
221
                return f"-p {bind_address}{from_range[0]}:{to_range[0]}{protocol_suffix}"
1✔
222
            if from_range[0] != from_range[1] and to_range[0] == to_range[1]:
1✔
223
                return f"-p {bind_address}{from_range[0]}-{from_range[1]}:{to_range[0]}{protocol_suffix}"
1✔
224
            return f"-p {bind_address}{from_range[0]}-{from_range[1]}:{to_range[0]}-{to_range[1]}{protocol_suffix}"
1✔
225

226
        return " ".join([entry(k, v) for k, v in self.mappings.items()])
1✔
227

228
    def to_list(self) -> List[str]:  # TODO test
1✔
229
        bind_address = f"{self.bind_host}:" if self.bind_host else ""
1✔
230

231
        def entry(k, v):
1✔
232
            from_range, protocol = k
1✔
233
            to_range = v
1✔
234
            protocol_suffix = f"/{protocol}" if protocol != "tcp" else ""
1✔
235
            if from_range[0] == from_range[1] and to_range[0] == to_range[1]:
1✔
236
                return ["-p", f"{bind_address}{from_range[0]}:{to_range[0]}{protocol_suffix}"]
1✔
237
            return [
1✔
238
                "-p",
239
                f"{bind_address}{from_range[0]}-{from_range[1]}:{to_range[0]}-{to_range[1]}{protocol_suffix}",
240
            ]
241

242
        return [item for k, v in self.mappings.items() for item in entry(k, v)]
1✔
243

244
    def to_dict(self) -> Dict[str, Union[Tuple[str, Union[int, List[int]]], int]]:
1✔
245
        bind_address = self.bind_host or ""
1✔
246

247
        def bind_port(bind_address, host_port):
1✔
248
            if host_port == 0:
1✔
249
                return None
1✔
250
            elif bind_address:
1✔
251
                return (bind_address, host_port)
1✔
252
            else:
253
                return host_port
1✔
254

255
        def entry(k, v):
1✔
256
            from_range, protocol = k
1✔
257
            to_range = v
1✔
258
            protocol_suffix = f"/{protocol}"
1✔
259
            if from_range[0] != from_range[1] and to_range[0] == to_range[1]:
1✔
260
                container_port = to_range[0]
1✔
261
                host_ports = list(range(from_range[0], from_range[1] + 1))
1✔
262
                return [
1✔
263
                    (
264
                        f"{container_port}{protocol_suffix}",
265
                        (bind_address, host_ports) if bind_address else host_ports,
266
                    )
267
                ]
268
            return [
1✔
269
                (
270
                    f"{container_port}{protocol_suffix}",
271
                    bind_port(bind_address, host_port),
272
                )
273
                for container_port, host_port in zip(
274
                    range(to_range[0], to_range[1] + 1), range(from_range[0], from_range[1] + 1)
275
                )
276
            ]
277

278
        items = [item for k, v in self.mappings.items() for item in entry(k, v)]
1✔
279
        return dict(items)
1✔
280

281
    def contains(self, port: int, protocol: PortProtocol = "tcp") -> bool:
1✔
282
        for from_range_w_protocol, to_range in self.mappings.items():
1✔
283
            from_protocol = from_range_w_protocol[1]
1✔
284
            if from_protocol == protocol:
1✔
285
                from_range = from_range_w_protocol[0]
1✔
286
                if self.in_range(port, from_range):
1✔
287
                    return True
1✔
288

289
    def in_range(self, port: int, range: PortRange) -> bool:
1✔
290
        return port >= range[0] and port <= range[1]
1✔
291

292
    def in_expanded_range(self, port: int, range: PortRange):
1✔
293
        return port >= range[0] - 1 and port <= range[1] + 1
1✔
294

295
    def expand_range(
1✔
296
        self, port: int, range: PortRange, protocol: PortProtocol = "tcp", remap: bool = False
297
    ):
298
        """
299
        Expand the given port range by the given port. If remap==True, put the updated range into self.mappings
300
        """
301
        if self.in_range(port, range):
1✔
302
            return
1✔
303
        new_range = list(range) if remap else range
1✔
304
        if port == range[0] - 1:
1✔
305
            new_range[0] = port
×
306
        elif port == range[1] + 1:
1✔
307
            new_range[1] = port
1✔
308
        else:
309
            raise Exception(f"Unable to add port {port} to existing range {range}")
×
310
        if remap:
1✔
311
            self._remap_range(range, new_range, protocol=protocol)
1✔
312

313
    def bisect_range(
1✔
314
        self, port: int, range: PortRange, protocol: PortProtocol = "tcp", remap: bool = False
315
    ):
316
        """
317
        Bisect a port range, at the provided port. This is needed in some cases when adding a
318
        non-uniform host to port mapping adjacent to an existing port range.
319
        If remap==True, put the updated range into self.mappings
320
        """
321
        if not self.in_range(port, range):
1✔
322
            return
×
323
        new_range = list(range) if remap else range
1✔
324
        if port == range[0]:
1✔
325
            new_range[0] = port + 1
×
326
        else:
327
            new_range[1] = port - 1
1✔
328
        if remap:
1✔
329
            self._remap_range(range, new_range, protocol)
1✔
330

331
    def _remap_range(self, old_key: PortRange, new_key: PortRange, protocol: PortProtocol):
1✔
332
        self.mappings[(HashableList(new_key), protocol)] = self.mappings.pop(
1✔
333
            (HashableList(old_key), protocol)
334
        )
335

336
    def __repr__(self):
337
        return f"<PortMappings: {self.to_dict()}>"
338

339

340
SimpleVolumeBind = Tuple[str, str]
1✔
341
"""Type alias for a simple version of VolumeBind"""
1✔
342

343

344
@dataclasses.dataclass
1✔
345
class VolumeBind:
1✔
346
    """Represents a --volume argument run/create command. When using VolumeBind to bind-mount a file or directory
347
    that does not yet exist on the Docker host, -v creates the endpoint for you. It is always created as a directory.
348
    """
349

350
    host_dir: str
1✔
351
    container_dir: str
1✔
352
    read_only: bool = False
1✔
353

354
    def to_str(self) -> str:
1✔
355
        args = []
×
356

357
        if self.host_dir:
×
358
            args.append(self.host_dir)
×
359

360
        if not self.container_dir:
×
361
            raise ValueError("no container dir specified")
×
362

363
        args.append(self.container_dir)
×
364

365
        if self.read_only:
×
366
            args.append("ro")
×
367

368
        return ":".join(args)
×
369

370
    @classmethod
1✔
371
    def parse(cls, param: str) -> "VolumeBind":
1✔
372
        parts = param.split(":")
1✔
373
        if 1 > len(parts) > 3:
1✔
374
            raise ValueError(f"Cannot parse volume bind {param}")
×
375

376
        volume = cls(parts[0], parts[1])
1✔
377
        if len(parts) == 3:
1✔
378
            if "ro" in parts[2].split(","):
1✔
379
                volume.read_only = True
1✔
380
        return volume
1✔
381

382

383
class VolumeMappings:
1✔
384
    mappings: List[Union[SimpleVolumeBind, VolumeBind]]
1✔
385

386
    def __init__(self, mappings: List[Union[SimpleVolumeBind, VolumeBind]] = None):
1✔
387
        self.mappings = mappings if mappings is not None else []
1✔
388

389
    def add(self, mapping: Union[SimpleVolumeBind, VolumeBind]):
1✔
390
        self.append(mapping)
1✔
391

392
    def append(
1✔
393
        self,
394
        mapping: Union[
395
            SimpleVolumeBind,
396
            VolumeBind,
397
        ],
398
    ):
399
        self.mappings.append(mapping)
1✔
400

401
    def find_target_mapping(
1✔
402
        self, container_dir: str
403
    ) -> Optional[Union[SimpleVolumeBind, VolumeBind]]:
404
        """
405
        Looks through the volumes and returns the one where the container dir matches ``container_dir``.
406
        Returns None if there is no volume mapping to the given container directory.
407

408
        :param container_dir: the target of the volume mapping, i.e., the path in the container
409
        :return: the volume mapping or None
410
        """
411
        for volume in self.mappings:
×
412
            target_dir = volume[1] if isinstance(volume, tuple) else volume.container_dir
×
413
            if container_dir == target_dir:
×
414
                return volume
×
415
        return None
×
416

417
    def __iter__(self):
1✔
418
        return self.mappings.__iter__()
1✔
419

420
    def __repr__(self):
421
        return self.mappings.__repr__()
422

423

424
VolumeType = Literal["bind", "volume"]
1✔
425

426

427
class VolumeInfo(NamedTuple):
1✔
428
    """Container volume information."""
429

430
    type: VolumeType
1✔
431
    source: str
1✔
432
    destination: str
1✔
433
    mode: str
1✔
434
    rw: bool
1✔
435
    propagation: str
1✔
436
    name: Optional[str] = None
1✔
437
    driver: Optional[str] = None
1✔
438

439

440
@dataclasses.dataclass
1✔
441
class LogConfig:
1✔
442
    type: Literal["json-file", "syslog", "journald", "gelf", "fluentd", "none", "awslogs", "splunk"]
1✔
443
    config: Dict[str, str] = dataclasses.field(default_factory=dict)
1✔
444

445

446
@dataclasses.dataclass
1✔
447
class ContainerConfiguration:
1✔
448
    image_name: str
1✔
449
    name: Optional[str] = None
1✔
450
    volumes: VolumeMappings = dataclasses.field(default_factory=VolumeMappings)
1✔
451
    ports: PortMappings = dataclasses.field(default_factory=PortMappings)
1✔
452
    exposed_ports: List[str] = dataclasses.field(default_factory=list)
1✔
453
    entrypoint: Optional[Union[List[str], str]] = None
1✔
454
    additional_flags: Optional[str] = None
1✔
455
    command: Optional[List[str]] = None
1✔
456
    env_vars: Dict[str, str] = dataclasses.field(default_factory=dict)
1✔
457

458
    privileged: bool = False
1✔
459
    remove: bool = False
1✔
460
    interactive: bool = False
1✔
461
    tty: bool = False
1✔
462
    detach: bool = False
1✔
463

464
    stdin: Optional[str] = None
1✔
465
    user: Optional[str] = None
1✔
466
    cap_add: Optional[List[str]] = None
1✔
467
    cap_drop: Optional[List[str]] = None
1✔
468
    security_opt: Optional[List[str]] = None
1✔
469
    network: Optional[str] = None
1✔
470
    dns: Optional[str] = None
1✔
471
    workdir: Optional[str] = None
1✔
472
    platform: Optional[str] = None
1✔
473
    ulimits: Optional[List[Ulimit]] = None
1✔
474
    labels: Optional[Dict[str, str]] = None
1✔
475
    init: Optional[bool] = None
1✔
476
    log_config: Optional[LogConfig] = None
1✔
477

478

479
class ContainerConfigurator(Protocol):
1✔
480
    """Protocol for functional configurators. A ContainerConfigurator modifies, when called,
481
    a ContainerConfiguration in place."""
482

483
    def __call__(self, configuration: ContainerConfiguration):
1✔
484
        """
485
        Modify the given container configuration.
486

487
        :param configuration: the configuration to modify
488
        """
489
        ...
×
490

491

492
@dataclasses.dataclass
1✔
493
class DockerRunFlags:
1✔
494
    """Class to capture Docker run/create flags for a container.
495
    run: https://docs.docker.com/engine/reference/commandline/run/
496
    create: https://docs.docker.com/engine/reference/commandline/create/
497
    """
498

499
    env_vars: Optional[Dict[str, str]]
1✔
500
    extra_hosts: Optional[Dict[str, str]]
1✔
501
    labels: Optional[Dict[str, str]]
1✔
502
    volumes: Optional[List[SimpleVolumeBind]]
1✔
503
    network: Optional[str]
1✔
504
    platform: Optional[DockerPlatform]
1✔
505
    privileged: Optional[bool]
1✔
506
    ports: Optional[PortMappings]
1✔
507
    ulimits: Optional[List[Ulimit]]
1✔
508
    user: Optional[str]
1✔
509
    dns: Optional[List[str]]
1✔
510

511

512
# TODO: remove Docker/Podman compatibility switches (in particular strip_wellknown_repo_prefixes=...)
513
#  from the container client base interface and introduce derived Podman client implementations instead!
514
class ContainerClient(metaclass=ABCMeta):
1✔
515
    @abstractmethod
1✔
516
    def get_system_info(self) -> dict:
1✔
517
        """Returns the docker system-wide information as dictionary (``docker info``)."""
518

519
    def get_system_id(self) -> str:
1✔
520
        """Returns the unique and stable ID of the docker daemon."""
521
        return self.get_system_info()["ID"]
1✔
522

523
    @abstractmethod
1✔
524
    def get_container_status(self, container_name: str) -> DockerContainerStatus:
1✔
525
        """Returns the status of the container with the given name"""
526
        pass
×
527

528
    def get_networks(self, container_name: str) -> List[str]:
1✔
529
        LOG.debug("Getting networks for container: %s", container_name)
1✔
530
        container_attrs = self.inspect_container(container_name_or_id=container_name)
1✔
531
        return list(container_attrs["NetworkSettings"].get("Networks", {}).keys())
1✔
532

533
    def get_container_ipv4_for_network(
1✔
534
        self, container_name_or_id: str, container_network: str
535
    ) -> str:
536
        """
537
        Returns the IPv4 address for the container on the interface connected to the given network
538
        :param container_name_or_id: Container to inspect
539
        :param container_network: Network the IP address will belong to
540
        :return: IP address of the given container on the interface connected to the given network
541
        """
542
        LOG.debug(
1✔
543
            "Getting ipv4 address for container %s in network %s.",
544
            container_name_or_id,
545
            container_network,
546
        )
547
        # we always need the ID for this
548
        container_id = self.get_container_id(container_name=container_name_or_id)
1✔
549
        network_attrs = self.inspect_network(container_network)
1✔
550
        containers = network_attrs.get("Containers") or {}
1✔
551
        if container_id not in containers:
1✔
552
            LOG.debug("Network attributes: %s", network_attrs)
1✔
553
            try:
1✔
554
                inspection = self.inspect_container(container_name_or_id=container_name_or_id)
1✔
555
                LOG.debug("Container %s Attributes: %s", container_name_or_id, inspection)
1✔
556
                logs = self.get_container_logs(container_name_or_id=container_name_or_id)
1✔
557
                LOG.debug("Container %s Logs: %s", container_name_or_id, logs)
1✔
558
            except ContainerException as e:
×
559
                LOG.debug("Cannot inspect container %s: %s", container_name_or_id, e)
×
560
            raise ContainerException(
1✔
561
                "Container %s is not connected to target network %s",
562
                container_name_or_id,
563
                container_network,
564
            )
565
        try:
1✔
566
            ip = str(ipaddress.IPv4Interface(containers[container_id]["IPv4Address"]).ip)
1✔
567
        except Exception as e:
×
568
            raise ContainerException(
×
569
                f"Unable to detect IP address for container {container_name_or_id} in network {container_network}: {e}"
570
            )
571
        return ip
1✔
572

573
    @abstractmethod
1✔
574
    def stop_container(self, container_name: str, timeout: int = 10):
1✔
575
        """Stops container with given name
576
        :param container_name: Container identifier (name or id) of the container to be stopped
577
        :param timeout: Timeout after which SIGKILL is sent to the container.
578
        """
579

580
    @abstractmethod
1✔
581
    def restart_container(self, container_name: str, timeout: int = 10):
1✔
582
        """Restarts a container with the given name.
583
        :param container_name: Container identifier
584
        :param timeout: Seconds to wait for stop before killing the container
585
        """
586

587
    @abstractmethod
1✔
588
    def pause_container(self, container_name: str):
1✔
589
        """Pauses a container with the given name."""
590

591
    @abstractmethod
1✔
592
    def unpause_container(self, container_name: str):
1✔
593
        """Unpauses a container with the given name."""
594

595
    @abstractmethod
1✔
596
    def remove_container(self, container_name: str, force=True, check_existence=False) -> None:
1✔
597
        """Removes container with given name"""
598

599
    @abstractmethod
1✔
600
    def remove_image(self, image: str, force: bool = True) -> None:
1✔
601
        """Removes an image with given name
602

603
        :param image: Image name and tag
604
        :param force: Force removal
605
        """
606

607
    @abstractmethod
1✔
608
    def list_containers(self, filter: Union[List[str], str, None] = None, all=True) -> List[dict]:
1✔
609
        """List all containers matching the given filters
610

611
        :return: A list of dicts with keys id, image, name, labels, status
612
        """
613

614
    def get_running_container_names(self) -> List[str]:
1✔
615
        """Returns a list of the names of all running containers"""
616
        result = self.list_containers(all=False)
1✔
617
        result = [container["name"] for container in result]
1✔
618
        return result
1✔
619

620
    def is_container_running(self, container_name: str) -> bool:
1✔
621
        """Checks whether a container with a given name is currently running"""
622
        return container_name in self.get_running_container_names()
1✔
623

624
    def create_file_in_container(
1✔
625
        self,
626
        container_name,
627
        file_contents: bytes,
628
        container_path: str,
629
        chmod_mode: Optional[int] = None,
630
    ) -> None:
631
        """
632
        Create a file in container with the provided content. Provide the 'chmod_mode' argument if you want the file to have specific permissions.
633
        """
634
        with tempfile.NamedTemporaryFile() as tmp:
1✔
635
            tmp.write(file_contents)
1✔
636
            tmp.flush()
1✔
637
            if chmod_mode is not None:
1✔
638
                chmod_r(tmp.name, chmod_mode)
×
639
            self.copy_into_container(
1✔
640
                container_name=container_name,
641
                local_path=tmp.name,
642
                container_path=container_path,
643
            )
644

645
    @abstractmethod
1✔
646
    def copy_into_container(
1✔
647
        self, container_name: str, local_path: str, container_path: str
648
    ) -> None:
649
        """Copy contents of the given local path into the container"""
650

651
    @abstractmethod
1✔
652
    def copy_from_container(
1✔
653
        self, container_name: str, local_path: str, container_path: str
654
    ) -> None:
655
        """Copy contents of the given container to the host"""
656

657
    @abstractmethod
1✔
658
    def pull_image(self, docker_image: str, platform: Optional[DockerPlatform] = None) -> None:
1✔
659
        """Pulls an image with a given name from a Docker registry"""
660

661
    @abstractmethod
1✔
662
    def push_image(self, docker_image: str) -> None:
1✔
663
        """Pushes an image with a given name to a Docker registry"""
664

665
    @abstractmethod
1✔
666
    def build_image(
1✔
667
        self,
668
        dockerfile_path: str,
669
        image_name: str,
670
        context_path: str = None,
671
        platform: Optional[DockerPlatform] = None,
672
    ) -> None:
673
        """Builds an image from the given Dockerfile
674

675
        :param dockerfile_path: Path to Dockerfile, or a directory that contains a Dockerfile
676
        :param image_name: Name of the image to be built
677
        :param context_path: Path for build context (defaults to dirname of Dockerfile)
678
        :param platform: Target platform for build (defaults to platform of Docker host)
679
        """
680

681
    @abstractmethod
1✔
682
    def tag_image(self, source_ref: str, target_name: str) -> None:
1✔
683
        """Tags an image with a new name
684

685
        :param source_ref: Name or ID of the image to be tagged
686
        :param target_name: New name (tag) of the tagged image
687
        """
688

689
    @abstractmethod
1✔
690
    def get_docker_image_names(
1✔
691
        self,
692
        strip_latest: bool = True,
693
        include_tags: bool = True,
694
        strip_wellknown_repo_prefixes: bool = True,
695
    ) -> List[str]:
696
        """
697
        Get all names of docker images available to the container engine
698
        :param strip_latest: return images both with and without :latest tag
699
        :param include_tags: include tags of the images in the names
700
        :param strip_wellknown_repo_prefixes: whether to strip off well-known repo prefixes like
701
               "localhost/" or "docker.io/library/" which are added by the Podman API, but not by Docker
702
        :return: List of image names
703
        """
704

705
    @abstractmethod
1✔
706
    def get_container_logs(self, container_name_or_id: str, safe: bool = False) -> str:
1✔
707
        """Get all logs of a given container"""
708

709
    @abstractmethod
1✔
710
    def stream_container_logs(self, container_name_or_id: str) -> CancellableStream:
1✔
711
        """Returns a blocking generator you can iterate over to retrieve log output as it happens."""
712

713
    @abstractmethod
1✔
714
    def inspect_container(self, container_name_or_id: str) -> Dict[str, Union[Dict, str]]:
1✔
715
        """Get detailed attributes of a container.
716

717
        :return: Dict containing docker attributes as returned by the daemon
718
        """
719

720
    def inspect_container_volumes(self, container_name_or_id) -> List[VolumeInfo]:
1✔
721
        """Return information about the volumes mounted into the given container.
722

723
        :param container_name_or_id: the container name or id
724
        :return: a list of volumes
725
        """
726
        volumes = []
1✔
727
        for doc in self.inspect_container(container_name_or_id)["Mounts"]:
1✔
728
            volumes.append(VolumeInfo(**{k.lower(): v for k, v in doc.items()}))
1✔
729

730
        return volumes
1✔
731

732
    @abstractmethod
1✔
733
    def inspect_image(
1✔
734
        self, image_name: str, pull: bool = True, strip_wellknown_repo_prefixes: bool = True
735
    ) -> Dict[str, Union[dict, list, str]]:
736
        """Get detailed attributes of an image.
737

738
        :param image_name: Image name to inspect
739
        :param pull: Whether to pull image if not existent
740
        :param strip_wellknown_repo_prefixes: whether to strip off well-known repo prefixes like
741
               "localhost/" or "docker.io/library/" which are added by the Podman API, but not by Docker
742
        :return: Dict containing docker attributes as returned by the daemon
743
        """
744

745
    @abstractmethod
1✔
746
    def create_network(self, network_name: str) -> str:
1✔
747
        """
748
        Creates a network with the given name
749
        :param network_name: Name of the network
750
        :return Network ID
751
        """
752

753
    @abstractmethod
1✔
754
    def delete_network(self, network_name: str) -> None:
1✔
755
        """
756
        Delete a network with the given name
757
        :param network_name: Name of the network
758
        """
759

760
    @abstractmethod
1✔
761
    def inspect_network(self, network_name: str) -> Dict[str, Union[Dict, str]]:
1✔
762
        """Get detailed attributes of an network.
763

764
        :return: Dict containing docker attributes as returned by the daemon
765
        """
766

767
    @abstractmethod
1✔
768
    def connect_container_to_network(
1✔
769
        self,
770
        network_name: str,
771
        container_name_or_id: str,
772
        aliases: Optional[List] = None,
773
        link_local_ips: List[str] = None,
774
    ) -> None:
775
        """
776
        Connects a container to a given network
777
        :param network_name: Network to connect the container to
778
        :param container_name_or_id: Container to connect to the network
779
        :param aliases: List of dns names the container should be available under in the network
780
        :param link_local_ips: List of link-local (IPv4 or IPv6) addresses
781
        """
782

783
    @abstractmethod
1✔
784
    def disconnect_container_from_network(
1✔
785
        self, network_name: str, container_name_or_id: str
786
    ) -> None:
787
        """
788
        Disconnects a container from a given network
789
        :param network_name: Network to disconnect the container from
790
        :param container_name_or_id: Container to disconnect from the network
791
        """
792

793
    def get_container_name(self, container_id: str) -> str:
1✔
794
        """Get the name of a container by a given identifier"""
795
        return self.inspect_container(container_id)["Name"].lstrip("/")
1✔
796

797
    def get_container_id(self, container_name: str) -> str:
1✔
798
        """Get the id of a container by a given name"""
799
        return self.inspect_container(container_name)["Id"]
1✔
800

801
    @abstractmethod
1✔
802
    def get_container_ip(self, container_name_or_id: str) -> str:
1✔
803
        """Get the IP address of a given container
804

805
        If container has multiple networks, it will return the IP of the first
806
        """
807

808
    def get_image_cmd(self, docker_image: str, pull: bool = True) -> List[str]:
1✔
809
        """Get the command for the given image
810
        :param docker_image: Docker image to inspect
811
        :param pull: Whether to pull if image is not present
812
        :return: Image command in its array form
813
        """
814
        cmd_list = self.inspect_image(docker_image, pull)["Config"]["Cmd"] or []
1✔
815
        return cmd_list
1✔
816

817
    def get_image_entrypoint(self, docker_image: str, pull: bool = True) -> str:
1✔
818
        """Get the entry point for the given image
819
        :param docker_image: Docker image to inspect
820
        :param pull: Whether to pull if image is not present
821
        :return: Image entrypoint
822
        """
823
        LOG.debug("Getting the entrypoint for image: %s", docker_image)
1✔
824
        entrypoint_list = self.inspect_image(docker_image, pull)["Config"].get("Entrypoint") or []
1✔
825
        return shlex.join(entrypoint_list)
1✔
826

827
    @abstractmethod
1✔
828
    def has_docker(self) -> bool:
1✔
829
        """Check if system has docker available"""
830

831
    @abstractmethod
1✔
832
    def commit(
1✔
833
        self,
834
        container_name_or_id: str,
835
        image_name: str,
836
        image_tag: str,
837
    ):
838
        """Create an image from a running container.
839

840
        :param container_name_or_id: Source container
841
        :param image_name: Destination image name
842
        :param image_tag: Destination image tag
843
        """
844

845
    def create_container_from_config(self, container_config: ContainerConfiguration) -> str:
1✔
846
        """
847
        Similar to create_container, but allows passing the whole ContainerConfiguration
848
        :param container_config: ContainerConfiguration how to start the container
849
        :return: Container ID
850
        """
851
        return self.create_container(
1✔
852
            image_name=container_config.image_name,
853
            name=container_config.name,
854
            entrypoint=container_config.entrypoint,
855
            remove=container_config.remove,
856
            interactive=container_config.interactive,
857
            tty=container_config.tty,
858
            command=container_config.command,
859
            volumes=container_config.volumes,
860
            ports=container_config.ports,
861
            exposed_ports=container_config.exposed_ports,
862
            env_vars=container_config.env_vars,
863
            user=container_config.user,
864
            cap_add=container_config.cap_add,
865
            cap_drop=container_config.cap_drop,
866
            security_opt=container_config.security_opt,
867
            network=container_config.network,
868
            dns=container_config.dns,
869
            additional_flags=container_config.additional_flags,
870
            workdir=container_config.workdir,
871
            privileged=container_config.privileged,
872
            platform=container_config.platform,
873
            labels=container_config.labels,
874
            ulimits=container_config.ulimits,
875
            init=container_config.init,
876
            log_config=container_config.log_config,
877
        )
878

879
    @abstractmethod
1✔
880
    def create_container(
1✔
881
        self,
882
        image_name: str,
883
        *,
884
        name: Optional[str] = None,
885
        entrypoint: Optional[Union[List[str], str]] = None,
886
        remove: bool = False,
887
        interactive: bool = False,
888
        tty: bool = False,
889
        detach: bool = False,
890
        command: Optional[Union[List[str], str]] = None,
891
        volumes: Optional[Union[VolumeMappings, List[SimpleVolumeBind]]] = None,
892
        ports: Optional[PortMappings] = None,
893
        exposed_ports: Optional[List[str]] = None,
894
        env_vars: Optional[Dict[str, str]] = None,
895
        user: Optional[str] = None,
896
        cap_add: Optional[List[str]] = None,
897
        cap_drop: Optional[List[str]] = None,
898
        security_opt: Optional[List[str]] = None,
899
        network: Optional[str] = None,
900
        dns: Optional[Union[str, List[str]]] = None,
901
        additional_flags: Optional[str] = None,
902
        workdir: Optional[str] = None,
903
        privileged: Optional[bool] = None,
904
        labels: Optional[Dict[str, str]] = None,
905
        platform: Optional[DockerPlatform] = None,
906
        ulimits: Optional[List[Ulimit]] = None,
907
        init: Optional[bool] = None,
908
        log_config: Optional[LogConfig] = None,
909
    ) -> str:
910
        """Creates a container with the given image
911

912
        :return: Container ID
913
        """
914

915
    @abstractmethod
1✔
916
    def run_container(
1✔
917
        self,
918
        image_name: str,
919
        stdin: bytes = None,
920
        *,
921
        name: Optional[str] = None,
922
        entrypoint: Optional[str] = None,
923
        remove: bool = False,
924
        interactive: bool = False,
925
        tty: bool = False,
926
        detach: bool = False,
927
        command: Optional[Union[List[str], str]] = None,
928
        volumes: Optional[Union[VolumeMappings, List[SimpleVolumeBind]]] = None,
929
        ports: Optional[PortMappings] = None,
930
        exposed_ports: Optional[List[str]] = None,
931
        env_vars: Optional[Dict[str, str]] = None,
932
        user: Optional[str] = None,
933
        cap_add: Optional[List[str]] = None,
934
        cap_drop: Optional[List[str]] = None,
935
        security_opt: Optional[List[str]] = None,
936
        network: Optional[str] = None,
937
        dns: Optional[str] = None,
938
        additional_flags: Optional[str] = None,
939
        workdir: Optional[str] = None,
940
        labels: Optional[Dict[str, str]] = None,
941
        platform: Optional[DockerPlatform] = None,
942
        privileged: Optional[bool] = None,
943
        ulimits: Optional[List[Ulimit]] = None,
944
        init: Optional[bool] = None,
945
        log_config: Optional[LogConfig] = None,
946
    ) -> Tuple[bytes, bytes]:
947
        """Creates and runs a given docker container
948

949
        :return: A tuple (stdout, stderr)
950
        """
951

952
    def run_container_from_config(
1✔
953
        self, container_config: ContainerConfiguration
954
    ) -> Tuple[bytes, bytes]:
955
        """Like ``run_container`` but uses the parameters from the configuration."""
956

957
        return self.run_container(
×
958
            image_name=container_config.image_name,
959
            stdin=container_config.stdin,
960
            name=container_config.name,
961
            entrypoint=container_config.entrypoint,
962
            remove=container_config.remove,
963
            interactive=container_config.interactive,
964
            tty=container_config.tty,
965
            detach=container_config.detach,
966
            command=container_config.command,
967
            volumes=container_config.volumes,
968
            ports=container_config.ports,
969
            exposed_ports=container_config.exposed_ports,
970
            env_vars=container_config.env_vars,
971
            user=container_config.user,
972
            cap_add=container_config.cap_add,
973
            cap_drop=container_config.cap_drop,
974
            security_opt=container_config.security_opt,
975
            network=container_config.network,
976
            dns=container_config.dns,
977
            additional_flags=container_config.additional_flags,
978
            workdir=container_config.workdir,
979
            platform=container_config.platform,
980
            privileged=container_config.privileged,
981
            ulimits=container_config.ulimits,
982
            init=container_config.init,
983
            log_config=container_config.log_config,
984
        )
985

986
    @abstractmethod
1✔
987
    def exec_in_container(
1✔
988
        self,
989
        container_name_or_id: str,
990
        command: Union[List[str], str],
991
        interactive: bool = False,
992
        detach: bool = False,
993
        env_vars: Optional[Dict[str, Optional[str]]] = None,
994
        stdin: Optional[bytes] = None,
995
        user: Optional[str] = None,
996
        workdir: Optional[str] = None,
997
    ) -> Tuple[bytes, bytes]:
998
        """Execute a given command in a container
999

1000
        :return: A tuple (stdout, stderr)
1001
        """
1002

1003
    @abstractmethod
1✔
1004
    def start_container(
1✔
1005
        self,
1006
        container_name_or_id: str,
1007
        stdin: bytes = None,
1008
        interactive: bool = False,
1009
        attach: bool = False,
1010
        flags: Optional[str] = None,
1011
    ) -> Tuple[bytes, bytes]:
1012
        """Start a given, already created container
1013

1014
        :return: A tuple (stdout, stderr) if attach or interactive is set, otherwise a tuple (b"container_name_or_id", b"")
1015
        """
1016

1017
    @abstractmethod
1✔
1018
    def attach_to_container(self, container_name_or_id: str):
1✔
1019
        """
1020
        Attach local standard input, output, and error streams to a running container
1021
        """
1022

1023
    @abstractmethod
1✔
1024
    def login(self, username: str, password: str, registry: Optional[str] = None) -> None:
1✔
1025
        """
1026
        Login into an OCI registry
1027

1028
        :param username: Username for the registry
1029
        :param password: Password / token for the registry
1030
        :param registry: Registry url
1031
        """
1032

1033

1034
class Util:
1✔
1035
    MAX_ENV_ARGS_LENGTH = 20000
1✔
1036

1037
    @staticmethod
1✔
1038
    def format_env_vars(key: str, value: Optional[str]):
1✔
1039
        if value is None:
1✔
1040
            return key
×
1041
        return f"{key}={value}"
1✔
1042

1043
    @classmethod
1✔
1044
    def create_env_vars_file_flag(cls, env_vars: Dict) -> Tuple[List[str], Optional[str]]:
1✔
1045
        if not env_vars:
1✔
1046
            return [], None
×
1047
        result = []
1✔
1048
        env_vars = dict(env_vars)
1✔
1049
        env_file = None
1✔
1050
        if len(str(env_vars)) > cls.MAX_ENV_ARGS_LENGTH:
1✔
1051
            # default ARG_MAX=131072 in Docker - let's create an env var file if the string becomes too long...
1052
            env_file = cls.mountable_tmp_file()
×
1053
            env_content = ""
×
1054
            for name, value in dict(env_vars).items():
×
1055
                if len(value) > cls.MAX_ENV_ARGS_LENGTH:
×
1056
                    # each line in the env file has a max size as well (error "bufio.Scanner: token too long")
1057
                    continue
×
1058
                env_vars.pop(name)
×
1059
                value = value.replace("\n", "\\")
×
1060
                env_content += f"{cls.format_env_vars(name, value)}\n"
×
1061
            save_file(env_file, env_content)
×
1062
            result += ["--env-file", env_file]
×
1063

1064
        env_vars_res = [
1✔
1065
            item for k, v in env_vars.items() for item in ["-e", cls.format_env_vars(k, v)]
1066
        ]
1067
        result += env_vars_res
1✔
1068
        return result, env_file
1✔
1069

1070
    @staticmethod
1✔
1071
    def rm_env_vars_file(env_vars_file) -> None:
1✔
1072
        if env_vars_file:
1✔
1073
            return rm_rf(env_vars_file)
×
1074

1075
    @staticmethod
1✔
1076
    def mountable_tmp_file():
1✔
1077
        f = os.path.join(config.dirs.mounted_tmp, short_uid())
×
1078
        TMP_FILES.append(f)
×
1079
        return f
×
1080

1081
    @staticmethod
1✔
1082
    def append_without_latest(image_names: List[str]):
1✔
1083
        suffix = ":latest"
1✔
1084
        for image in list(image_names):
1✔
1085
            if image.endswith(suffix):
1✔
1086
                image_names.append(image[: -len(suffix)])
1✔
1087

1088
    @staticmethod
1✔
1089
    def strip_wellknown_repo_prefixes(image_names: List[str]) -> List[str]:
1✔
1090
        """
1091
        Remove well-known repo prefixes like `localhost/` or `docker.io/library/` from the list of given
1092
        image names. This is mostly to ensure compatibility of our Docker client with Podman API responses.
1093
        :return: a copy of the list of image names, with well-known repo prefixes removed
1094
        """
1095
        result = []
1✔
1096
        for image in image_names:
1✔
1097
            for prefix in WELL_KNOWN_IMAGE_REPO_PREFIXES:
1✔
1098
                if image.startswith(prefix):
1✔
1099
                    image = image.removeprefix(prefix)
×
1100
                    # strip only one of the matching prefixes (avoid multi-stripping)
1101
                    break
×
1102
            result.append(image)
1✔
1103
        return result
1✔
1104

1105
    @staticmethod
1✔
1106
    def tar_path(path: str, target_path: str, is_dir: bool):
1✔
1107
        f = tempfile.NamedTemporaryFile()
1✔
1108
        with tarfile.open(mode="w", fileobj=f) as t:
1✔
1109
            abs_path = os.path.abspath(path)
1✔
1110
            arcname = (
1✔
1111
                os.path.basename(path)
1112
                if is_dir
1113
                else (os.path.basename(target_path) or os.path.basename(path))
1114
            )
1115
            t.add(abs_path, arcname=arcname)
1✔
1116

1117
        f.seek(0)
1✔
1118
        return f
1✔
1119

1120
    @staticmethod
1✔
1121
    def untar_to_path(tardata, target_path):
1✔
1122
        target_path = Path(target_path)
1✔
1123
        with tarfile.open(mode="r", fileobj=io.BytesIO(b"".join(b for b in tardata))) as t:
1✔
1124
            if target_path.is_dir():
1✔
1125
                t.extractall(path=target_path)
1✔
1126
            else:
1127
                member = t.next()
1✔
1128
                if member:
1✔
1129
                    member.name = target_path.name
1✔
1130
                    t.extract(member, target_path.parent)
1✔
1131
                else:
1132
                    LOG.debug("File to copy empty, ignoring...")
×
1133

1134
    @staticmethod
1✔
1135
    def _read_docker_cli_env_file(env_file: str) -> Dict[str, str]:
1✔
1136
        """
1137
        Read an environment file in docker CLI format, specified here:
1138
        https://docs.docker.com/reference/cli/docker/container/run/#env
1139
        :param env_file: Path to the environment file
1140
        :return: Read environment variables
1141
        """
1142
        env_vars = {}
1✔
1143
        try:
1✔
1144
            with open(env_file, mode="rt") as f:
1✔
1145
                env_file_lines = f.readlines()
1✔
1146
        except FileNotFoundError as e:
×
1147
            LOG.error(
×
1148
                "Specified env file '%s' not found. Please make sure the file is properly mounted into the LocalStack container. Error: %s",
1149
                env_file,
1150
                e,
1151
            )
1152
            raise
×
1153
        except OSError as e:
×
1154
            LOG.error(
×
1155
                "Could not read env file '%s'. Please make sure the LocalStack container has the permissions to read it. Error: %s",
1156
                env_file,
1157
                e,
1158
            )
1159
            raise
×
1160
        for idx, line in enumerate(env_file_lines):
1✔
1161
            line = line.strip()
1✔
1162
            if not line or line.startswith("#"):
1✔
1163
                # skip comments or empty lines
1164
                continue
1✔
1165
            lhs, separator, rhs = line.partition("=")
1✔
1166
            if rhs or separator:
1✔
1167
                env_vars[lhs] = rhs
1✔
1168
            else:
1169
                # No "=" in the line, only the name => lookup in local env
1170
                if env_value := os.environ.get(lhs):
1✔
1171
                    env_vars[lhs] = env_value
1✔
1172
        return env_vars
1✔
1173

1174
    @staticmethod
1✔
1175
    def parse_additional_flags(
1✔
1176
        additional_flags: str,
1177
        env_vars: Optional[Dict[str, str]] = None,
1178
        labels: Optional[Dict[str, str]] = None,
1179
        volumes: Optional[List[SimpleVolumeBind]] = None,
1180
        network: Optional[str] = None,
1181
        platform: Optional[DockerPlatform] = None,
1182
        ports: Optional[PortMappings] = None,
1183
        privileged: Optional[bool] = None,
1184
        user: Optional[str] = None,
1185
        ulimits: Optional[List[Ulimit]] = None,
1186
        dns: Optional[Union[str, List[str]]] = None,
1187
    ) -> DockerRunFlags:
1188
        """Parses additional CLI-formatted Docker flags, which could overwrite provided defaults.
1189
        :param additional_flags: String which contains the flag definitions inspired by the Docker CLI reference:
1190
                                 https://docs.docker.com/engine/reference/commandline/run/
1191
        :param env_vars: Dict with env vars. Will be modified in place.
1192
        :param labels: Dict with labels. Will be modified in place.
1193
        :param volumes: List of mount tuples (host_path, container_path). Will be modified in place.
1194
        :param network: Existing network name (optional). Warning will be printed if network is overwritten in flags.
1195
        :param platform: Platform to execute container. Warning will be printed if platform is overwritten in flags.
1196
        :param ports: PortMapping object. Will be modified in place.
1197
        :param privileged: Run the container in privileged mode. Warning will be printed if overwritten in flags.
1198
        :param ulimits: ulimit options in the format <type>=<soft limit>[:<hard limit>]
1199
        :param user: User to run first process. Warning will be printed if user is overwritten in flags.
1200
        :param dns: List of DNS servers to configure the container with.
1201
        :return: A DockerRunFlags object that will return new objects if respective parameters were None and
1202
                additional flags contained a flag for that object or the same which are passed otherwise.
1203
        """
1204
        # Argparse refactoring opportunity: custom argparse actions can be used to modularize parsing (e.g., key=value)
1205
        # https://docs.python.org/3/library/argparse.html#action
1206

1207
        # Configure parser
1208
        parser = NoExitArgumentParser(description="Docker run flags parser")
1✔
1209
        parser.add_argument(
1✔
1210
            "--add-host",
1211
            help="Add a custom host-to-IP mapping (host:ip)",
1212
            dest="add_hosts",
1213
            action="append",
1214
        )
1215
        parser.add_argument(
1✔
1216
            "--env", "-e", help="Set environment variables", dest="envs", action="append"
1217
        )
1218
        parser.add_argument(
1✔
1219
            "--env-file",
1220
            help="Set environment variables via a file",
1221
            dest="env_files",
1222
            action="append",
1223
        )
1224
        parser.add_argument(
1✔
1225
            "--compose-env-file",
1226
            help="Set environment variables via a file, with a docker-compose supported feature set.",
1227
            dest="compose_env_files",
1228
            action="append",
1229
        )
1230
        parser.add_argument(
1✔
1231
            "--label", "-l", help="Add container meta data", dest="labels", action="append"
1232
        )
1233
        parser.add_argument("--network", help="Connect a container to a network")
1✔
1234
        parser.add_argument(
1✔
1235
            "--platform",
1236
            type=DockerPlatform,
1237
            help="Docker platform (e.g., linux/amd64 or linux/arm64)",
1238
        )
1239
        parser.add_argument(
1✔
1240
            "--privileged",
1241
            help="Give extended privileges to this container",
1242
            action="store_true",
1243
        )
1244
        parser.add_argument(
1✔
1245
            "--publish",
1246
            "-p",
1247
            help="Publish container port(s) to the host",
1248
            dest="publish_ports",
1249
            action="append",
1250
        )
1251
        parser.add_argument(
1✔
1252
            "--ulimit", help="Container ulimit settings", dest="ulimits", action="append"
1253
        )
1254
        parser.add_argument("--user", "-u", help="Username or UID to execute first process")
1✔
1255
        parser.add_argument(
1✔
1256
            "--volume", "-v", help="Bind mount a volume", dest="volumes", action="append"
1257
        )
1258
        parser.add_argument("--dns", help="Set custom DNS servers", dest="dns", action="append")
1✔
1259

1260
        # Parse
1261
        flags = shlex.split(additional_flags)
1✔
1262
        args = parser.parse_args(flags)
1✔
1263

1264
        # Post-process parsed flags
1265
        extra_hosts = None
1✔
1266
        if args.add_hosts:
1✔
1267
            for add_host in args.add_hosts:
1✔
1268
                extra_hosts = extra_hosts if extra_hosts is not None else {}
1✔
1269
                hosts_split = add_host.split(":")
1✔
1270
                extra_hosts[hosts_split[0]] = hosts_split[1]
1✔
1271

1272
        # set env file values before env values, as the latter override the earlier
1273
        if args.env_files:
1✔
1274
            env_vars = env_vars if env_vars is not None else {}
1✔
1275
            for env_file in args.env_files:
1✔
1276
                env_vars.update(Util._read_docker_cli_env_file(env_file))
1✔
1277

1278
        if args.compose_env_files:
1✔
1279
            env_vars = env_vars if env_vars is not None else {}
1✔
1280
            for env_file in args.compose_env_files:
1✔
1281
                env_vars.update(dotenv.dotenv_values(env_file))
1✔
1282

1283
        if args.envs:
1✔
1284
            env_vars = env_vars if env_vars is not None else {}
1✔
1285
            for env in args.envs:
1✔
1286
                lhs, _, rhs = env.partition("=")
1✔
1287
                env_vars[lhs] = rhs
1✔
1288

1289
        if args.labels:
1✔
1290
            labels = labels if labels is not None else {}
1✔
1291
            for label in args.labels:
1✔
1292
                key, _, value = label.partition("=")
1✔
1293
                # Only consider non-empty labels
1294
                if key:
1✔
1295
                    labels[key] = value
1✔
1296

1297
        if args.network:
1✔
1298
            LOG.warning(
1✔
1299
                "Overwriting Docker container network '%s' with new value '%s'",
1300
                network,
1301
                args.network,
1302
            )
1303
            network = args.network
1✔
1304

1305
        if args.platform:
1✔
1306
            LOG.warning(
1✔
1307
                "Overwriting Docker platform '%s' with new value '%s'",
1308
                platform,
1309
                args.platform,
1310
            )
1311
            platform = args.platform
1✔
1312

1313
        if args.privileged:
1✔
1314
            LOG.warning(
1✔
1315
                "Overwriting Docker container privileged flag %s with new value %s",
1316
                privileged,
1317
                args.privileged,
1318
            )
1319
            privileged = args.privileged
1✔
1320

1321
        if args.publish_ports:
1✔
1322
            for port_mapping in args.publish_ports:
1✔
1323
                port_split = port_mapping.split(":")
1✔
1324
                protocol = "tcp"
1✔
1325
                if len(port_split) == 2:
1✔
1326
                    host_port, container_port = port_split
1✔
1327
                elif len(port_split) == 3:
1✔
1328
                    LOG.warning(
1✔
1329
                        "Host part of port mappings are ignored currently in additional flags"
1330
                    )
1331
                    _, host_port, container_port = port_split
1✔
1332
                else:
1333
                    raise ValueError(f"Invalid port string provided: {port_mapping}")
1✔
1334
                host_port_split = host_port.split("-")
1✔
1335
                if len(host_port_split) == 2:
1✔
1336
                    host_port = [int(host_port_split[0]), int(host_port_split[1])]
1✔
1337
                elif len(host_port_split) == 1:
1✔
1338
                    host_port = int(host_port)
1✔
1339
                else:
1340
                    raise ValueError(f"Invalid port string provided: {port_mapping}")
×
1341
                if "/" in container_port:
1✔
1342
                    container_port, protocol = container_port.split("/")
1✔
1343
                ports = ports if ports is not None else PortMappings()
1✔
1344
                ports.add(host_port, int(container_port), protocol)
1✔
1345

1346
        if args.ulimits:
1✔
1347
            ulimits = ulimits if ulimits is not None else []
1✔
1348
            ulimits_dict = {ul.name: ul for ul in ulimits}
1✔
1349
            for ulimit in args.ulimits:
1✔
1350
                name, _, rhs = ulimit.partition("=")
1✔
1351
                soft, _, hard = rhs.partition(":")
1✔
1352
                hard_limit = int(hard) if hard else int(soft)
1✔
1353
                new_ulimit = Ulimit(name=name, soft_limit=int(soft), hard_limit=hard_limit)
1✔
1354
                if ulimits_dict.get(name):
1✔
1355
                    LOG.warning("Overwriting Docker ulimit %s", new_ulimit)
1✔
1356
                ulimits_dict[name] = new_ulimit
1✔
1357
            ulimits = list(ulimits_dict.values())
1✔
1358

1359
        if args.user:
1✔
1360
            LOG.warning(
1✔
1361
                "Overwriting Docker user '%s' with new value '%s'",
1362
                user,
1363
                args.user,
1364
            )
1365
            user = args.user
1✔
1366

1367
        if args.volumes:
1✔
1368
            volumes = volumes if volumes is not None else []
1✔
1369
            for volume in args.volumes:
1✔
1370
                match = re.match(
1✔
1371
                    r"(?P<host>[\w\s\\\/:\-.]+?):(?P<container>[\w\s\/\-.]+)(?::(?P<arg>ro|rw|z|Z))?",
1372
                    volume,
1373
                )
1374
                if not match:
1✔
1375
                    LOG.warning("Unable to parse volume mount Docker flags: %s", volume)
×
1376
                    continue
×
1377
                host_path = match.group("host")
1✔
1378
                container_path = match.group("container")
1✔
1379
                rw_args = match.group("arg")
1✔
1380
                if rw_args:
1✔
1381
                    LOG.info("Volume options like :ro or :rw are currently ignored.")
1✔
1382
                volumes.append((host_path, container_path))
1✔
1383

1384
        dns = ensure_list(dns or [])
1✔
1385
        if args.dns:
1✔
1386
            LOG.info(
1✔
1387
                "Extending Docker container DNS servers %s with additional values %s", dns, args.dns
1388
            )
1389
            dns.extend(args.dns)
1✔
1390

1391
        return DockerRunFlags(
1✔
1392
            env_vars=env_vars,
1393
            extra_hosts=extra_hosts,
1394
            labels=labels,
1395
            volumes=volumes,
1396
            ports=ports,
1397
            network=network,
1398
            platform=platform,
1399
            privileged=privileged,
1400
            ulimits=ulimits,
1401
            user=user,
1402
            dns=dns,
1403
        )
1404

1405
    @staticmethod
1✔
1406
    def convert_mount_list_to_dict(
1✔
1407
        volumes: Union[List[SimpleVolumeBind], VolumeMappings],
1408
    ) -> Dict[str, Dict[str, str]]:
1409
        """Converts a List of (host_path, container_path) tuples to a Dict suitable as volume argument for docker sdk"""
1410

1411
        def _map_to_dict(paths: SimpleVolumeBind | VolumeBind):
1✔
1412
            if isinstance(paths, VolumeBind):
1✔
1413
                return str(paths.host_dir), {
1✔
1414
                    "bind": paths.container_dir,
1415
                    "mode": "ro" if paths.read_only else "rw",
1416
                }
1417
            else:
1418
                return str(paths[0]), {"bind": paths[1], "mode": "rw"}
×
1419

1420
        return dict(
1✔
1421
            map(
1422
                _map_to_dict,
1423
                volumes,
1424
            )
1425
        )
1426

1427
    @staticmethod
1✔
1428
    def resolve_dockerfile_path(dockerfile_path: str) -> str:
1✔
1429
        """If the given path is a directory that contains a Dockerfile, then return the file path to it."""
1430
        rel_path = os.path.join(dockerfile_path, "Dockerfile")
1✔
1431
        if os.path.isdir(dockerfile_path) and os.path.exists(rel_path):
1✔
1432
            return rel_path
1✔
1433
        return dockerfile_path
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc