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

localstack / localstack / 22209548116

19 Feb 2026 02:08PM UTC coverage: 86.964% (-0.04%) from 87.003%
22209548116

push

github

web-flow
Logs: fix snapshot region from tests (#13792)

69755 of 80211 relevant lines covered (86.96%)

0.87 hits per line

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

62.59
/localstack-core/localstack/utils/bootstrap.py
1
from __future__ import annotations
1✔
2

3
import copy
1✔
4
import functools
1✔
5
import logging
1✔
6
import os
1✔
7
import re
1✔
8
import shlex
1✔
9
import signal
1✔
10
import threading
1✔
11
import time
1✔
12
from collections.abc import Callable, Iterable
1✔
13
from functools import wraps
1✔
14
from typing import Any
1✔
15

16
from rich.console import Console
1✔
17

18
from localstack import config, constants
1✔
19
from localstack.config import (
1✔
20
    HostAndPort,
21
    default_ip,
22
    is_env_not_false,
23
    load_environment,
24
)
25
from localstack.constants import VERSION
1✔
26
from localstack.runtime import hooks
1✔
27
from localstack.utils.container_networking import get_main_container_name
1✔
28
from localstack.utils.container_utils.container_client import (
1✔
29
    BindMount,
30
    CancellableStream,
31
    ContainerClient,
32
    ContainerConfiguration,
33
    ContainerConfigurator,
34
    ContainerException,
35
    NoSuchContainer,
36
    NoSuchImage,
37
    NoSuchNetwork,
38
    PortMappings,
39
    VolumeMappings,
40
    VolumeMappingSpecification,
41
)
42
from localstack.utils.container_utils.docker_cmd_client import CmdDockerClient
1✔
43
from localstack.utils.docker_utils import DOCKER_CLIENT
1✔
44
from localstack.utils.files import cache_dir, mkdir
1✔
45
from localstack.utils.functions import call_safe
1✔
46
from localstack.utils.net import get_free_tcp_port, get_free_tcp_port_range
1✔
47
from localstack.utils.run import is_command_available, run, to_str
1✔
48
from localstack.utils.serving import Server
1✔
49
from localstack.utils.strings import short_uid
1✔
50
from localstack.utils.sync import poll_condition
1✔
51

52
LOG = logging.getLogger(__name__)
1✔
53
console = Console()
1✔
54

55
# Mandatory dependencies of services on other services
56
# - maps from API names to list of other API names that they _explicitly_ depend on: <service>:<dependent-services>
57
# - an explicit service dependency is a service without which another service's basic functionality breaks
58
# - this mapping is used when enabling strict service loading (use SERVICES env var to allow-list services)
59
# - do not add "optional" dependencies of services here, use API_DEPENDENCIES_OPTIONAL instead
60
API_DEPENDENCIES = {
1✔
61
    "dynamodb": ["dynamodbstreams"],
62
    # dynamodbstreams uses kinesis under the hood
63
    "dynamodbstreams": ["kinesis"],
64
    # es forwards all requests to opensearch (basically an API deprecation path in AWS)
65
    "es": ["opensearch"],
66
    "cloudformation": ["s3", "sts"],
67
    "lambda": ["s3", "sts"],
68
    # firehose currently only supports kinesis as source, this could become optional when more sources are supported
69
    "firehose": ["kinesis"],
70
    "transcribe": ["s3"],
71
    # secretsmanager uses lambda for rotation
72
    "secretsmanager": ["kms", "lambda"],
73
    # ssm uses secretsmanager for get_parameter
74
    "ssm": ["secretsmanager"],
75
}
76

77
# Optional dependencies of services on other services
78
# - maps from API names to list of other API names that they _optionally_ depend on: <service>:<dependent-services>
79
# - an optional service dependency is a service without which a service's basic functionality doesn't break,
80
#   but which is needed for certain features (f.e. for one of multiple integrations)
81
# - this mapping is used f.e. used for the selective test execution (localstack.testing.testselection)
82
# - only add optional dependencies of services here, use API_DEPENDENCIES for mandatory dependencies
83
API_DEPENDENCIES_OPTIONAL = {
1✔
84
    # firehose's optional dependencies are supported delivery stream destinations
85
    "firehose": ["es", "opensearch", "s3", "redshift"],
86
    "lambda": [
87
        "cloudwatch",  # Lambda metrics
88
        "dynamodbstreams",  # Event source mapping source
89
        "events",  # Lambda destination
90
        "logs",  # Function logging
91
        "kinesis",  # Event source mapping source
92
        "sqs",  # Event source mapping source + Lambda destination
93
        "sns",  # Lambda destination
94
        "sts",  # Credentials injection
95
        # Additional dependencies to Pro-only services are defined in ext
96
    ],
97
    "ses": ["sns"],
98
    "sns": ["sqs", "lambda", "firehose", "ses", "logs"],
99
    "sqs": ["cloudwatch"],
100
    "logs": ["lambda", "kinesis", "firehose"],
101
    "cloudformation": ["secretsmanager", "ssm", "lambda"],
102
    "events": ["lambda", "kinesis", "firehose", "sns", "sqs", "stepfunctions", "logs"],
103
    "stepfunctions": ["logs", "lambda", "dynamodb", "ecs", "sns", "sqs", "apigateway", "events"],
104
    "apigateway": [
105
        "s3",
106
        "sqs",
107
        "sns",
108
        "kinesis",
109
        "route53",
110
        "servicediscovery",
111
        "lambda",
112
        "dynamodb",
113
        "stepfunctions",
114
        "events",
115
    ],
116
    # This is for S3 notifications and S3 KMS key
117
    "s3": ["events", "sqs", "sns", "lambda", "kms"],
118
    # IAM and STS are tightly coupled
119
    "sts": ["iam"],
120
    "iam": ["sts"],
121
}
122

123
# composites define an abstract name like "serverless" that maps to a set of services
124
API_COMPOSITES = {
1✔
125
    "serverless": [
126
        "cloudformation",
127
        "cloudwatch",
128
        "iam",
129
        "sts",
130
        "lambda",
131
        "dynamodb",
132
        "apigateway",
133
        "s3",
134
    ],
135
    "cognito": ["cognito-idp", "cognito-identity"],
136
    "timestream": ["timestream-write", "timestream-query"],
137
}
138

139

140
def log_duration(name=None, min_ms=500):
1✔
141
    """Function decorator to log the duration of function invocations."""
142

143
    def wrapper(f):
1✔
144
        @wraps(f)
1✔
145
        def wrapped(*args, **kwargs):
1✔
146
            from time import perf_counter
1✔
147

148
            start_time = perf_counter()
1✔
149
            try:
1✔
150
                return f(*args, **kwargs)
1✔
151
            finally:
152
                end_time = perf_counter()
1✔
153
                func_name = name or f.__name__
1✔
154
                duration = (end_time - start_time) * 1000
1✔
155
                if duration > min_ms:
1✔
156
                    LOG.info('Execution of "%s" took %.2fms', func_name, duration)
1✔
157

158
        return wrapped
1✔
159

160
    return wrapper
1✔
161

162

163
def get_docker_image_details(image_name: str = None) -> dict[str, str]:
1✔
164
    image_name = image_name or get_docker_image_to_start()
1✔
165
    try:
1✔
166
        result = DOCKER_CLIENT.inspect_image(image_name)
1✔
167
    except ContainerException:
×
168
        return {}
×
169

170
    digests = result.get("RepoDigests")
1✔
171
    sha256 = digests[0].rpartition(":")[2] if digests else "Unavailable"
1✔
172
    result = {
1✔
173
        "id": result["Id"].replace("sha256:", "")[:12],
174
        "sha256": sha256,
175
        "tag": (result.get("RepoTags") or ["latest"])[0].split(":")[-1],
176
        "created": result["Created"].split(".")[0],
177
    }
178
    return result
1✔
179

180

181
def get_image_environment_variable(env_name: str) -> str | None:
1✔
182
    image_name = get_docker_image_to_start()
×
183
    image_info = DOCKER_CLIENT.inspect_image(image_name)
×
184
    image_envs = image_info["Config"]["Env"]
×
185

186
    try:
×
187
        found_env = next(env for env in image_envs if env.startswith(env_name))
×
188
    except StopIteration:
×
189
        return None
×
190
    return found_env.split("=")[1]
×
191

192

193
def get_container_default_logfile_location(container_name: str) -> str:
1✔
194
    return os.path.join(config.dirs.mounted_tmp, f"{container_name}_container.log")
1✔
195

196

197
def get_server_version_from_running_container() -> str:
1✔
198
    try:
×
199
        # try to extract from existing running container
200
        container_name = get_main_container_name()
×
201
        version, _ = DOCKER_CLIENT.exec_in_container(
×
202
            container_name, interactive=True, command=["bin/localstack", "--version"]
203
        )
204
        version = to_str(version).strip().splitlines()[-1]
×
205
        return version
×
206
    except ContainerException:
×
207
        try:
×
208
            # try to extract by starting a new container
209
            img_name = get_docker_image_to_start()
×
210
            version, _ = DOCKER_CLIENT.run_container(
×
211
                img_name,
212
                remove=True,
213
                interactive=True,
214
                entrypoint="",
215
                command=["bin/localstack", "--version"],
216
            )
217
            version = to_str(version).strip().splitlines()[-1]
×
218
            return version
×
219
        except ContainerException:
×
220
            # fall back to default constant
221
            return VERSION
×
222

223

224
def get_server_version() -> str:
1✔
225
    image_hash = get_docker_image_details()["id"]
×
226
    version_cache = cache_dir() / "image_metadata" / image_hash / "localstack_version"
×
227
    if version_cache.exists():
×
228
        cached_version = version_cache.read_text()
×
229
        return cached_version.strip()
×
230

231
    env_version = get_image_environment_variable("LOCALSTACK_BUILD_VERSION")
×
232
    if env_version is not None:
×
233
        version_cache.parent.mkdir(exist_ok=True, parents=True)
×
234
        version_cache.write_text(env_version)
×
235
        return env_version
×
236

237
    container_version = get_server_version_from_running_container()
×
238
    version_cache.parent.mkdir(exist_ok=True, parents=True)
×
239
    version_cache.write_text(container_version)
×
240

241
    return container_version
×
242

243

244
def setup_logging():
1✔
245
    """Determine and set log level. The singleton factory makes sure the logging is only set up once."""
246
    from localstack.logging.setup import setup_logging_from_config
1✔
247

248
    setup_logging_from_config()
1✔
249

250

251
# --------------
252
# INFRA STARTUP
253
# --------------
254

255

256
def resolve_apis(services: Iterable[str]) -> set[str]:
1✔
257
    """
258
    Resolves recursively for the given collection of services (e.g., ["serverless", "cognito"]) the list of actual
259
    API services that need to be included (e.g., {'dynamodb', 'cloudformation', 'logs', 'kinesis', 'sts',
260
    'cognito-identity', 's3', 'dynamodbstreams', 'apigateway', 'cloudwatch', 'lambda', 'cognito-idp', 'iam'}).
261

262
    More specifically, it does this by:
263
    (1) resolving and adding dependencies (e.g., "dynamodbstreams" requires "kinesis"),
264
    (2) resolving and adding composites (e.g., "serverless" describes an ensemble
265
            including "iam", "lambda", "dynamodb", "apigateway", "s3", "sns", and "logs"), and
266
    (3) removing duplicates from the list.
267

268
    :param services: a collection of services that can include composites (e.g., "serverless").
269
    :returns a set of canonical service names
270
    """
271
    stack = []
1✔
272
    result = set()
1✔
273

274
    # perform a graph search
275
    stack.extend(services)
1✔
276
    while stack:
1✔
277
        service = stack.pop()
1✔
278

279
        if service in result:
1✔
280
            continue
1✔
281

282
        # resolve composites (like "serverless"), but do not add it to the list of results
283
        if service in API_COMPOSITES:
1✔
284
            stack.extend(API_COMPOSITES[service])
1✔
285
            continue
1✔
286

287
        result.add(service)
1✔
288

289
        # add dependencies to stack
290
        if service in API_DEPENDENCIES:
1✔
291
            stack.extend(API_DEPENDENCIES[service])
1✔
292

293
    return result
1✔
294

295

296
@functools.lru_cache
1✔
297
def get_enabled_apis() -> set[str]:
1✔
298
    """
299
    Returns the list of APIs that are enabled through the combination of the SERVICES variable and
300
    STRICT_SERVICE_LOADING variable. If the SERVICES variable is empty, then it will return all available services.
301
    Meta-services like "serverless" or "cognito", and dependencies are resolved.
302

303
    The result is cached, so it's safe to call. Clear the cache with get_enabled_apis.cache_clear().
304
    """
305
    from localstack.services.plugins import SERVICE_PLUGINS
1✔
306

307
    services_env = os.environ.get("SERVICES", "").strip()
1✔
308
    services = SERVICE_PLUGINS.list_available()
1✔
309

310
    if services_env and is_env_not_false("STRICT_SERVICE_LOADING"):
1✔
311
        # SERVICES and STRICT_SERVICE_LOADING are set
312
        # we filter the result of SERVICE_PLUGINS.list_available() to cross the user-provided list with
313
        # the available ones
314
        enabled_services = []
1✔
315
        for service_port in re.split(r"\s*,\s*", services_env):
1✔
316
            # Only extract the service name, discard the port
317
            parts = re.split(r"[:=]", service_port)
1✔
318
            service = parts[0]
1✔
319
            enabled_services.append(service)
1✔
320

321
        services = [service for service in enabled_services if service in services]
1✔
322
        # TODO: log a message if a service was not supported? see with pro loading
323

324
    return resolve_apis(services)
1✔
325

326

327
def is_api_enabled(api: str) -> bool:
1✔
328
    return api in get_enabled_apis()
1✔
329

330

331
@functools.lru_cache
1✔
332
def get_preloaded_services() -> set[str]:
1✔
333
    """
334
    Returns the list of APIs that are marked to be eager loaded through the combination of SERVICES variable and
335
    EAGER_SERVICE_LOADING. If the SERVICES variable is empty, then it will return all available services.
336
    Meta-services like "serverless" or "cognito", and dependencies are resolved.
337

338
    The result is cached, so it's safe to call. Clear the cache with get_preloaded_services.cache_clear().
339
    """
340
    services_env = os.environ.get("SERVICES", "").strip()
1✔
341
    services = []
1✔
342

343
    if services_env:
1✔
344
        # SERVICES and EAGER_SERVICE_LOADING are set
345
        # SERVICES env var might contain ports, but we do not support these anymore
346
        for service_port in re.split(r"\s*,\s*", services_env):
1✔
347
            # Only extract the service name, discard the port
348
            parts = re.split(r"[:=]", service_port)
1✔
349
            service = parts[0]
1✔
350
            services.append(service)
1✔
351

352
    if not services:
1✔
353
        from localstack.services.plugins import SERVICE_PLUGINS
1✔
354

355
        services = SERVICE_PLUGINS.list_available()
1✔
356

357
    return resolve_apis(services)
1✔
358

359

360
def start_infra_locally():
1✔
361
    from localstack.runtime.main import main
×
362

363
    return main()
×
364

365

366
def validate_localstack_config(name: str):
1✔
367
    # TODO: separate functionality from CLI output
368
    #  (use exceptions to communicate errors, and return list of warnings)
369
    from subprocess import CalledProcessError
×
370

371
    dirname = os.getcwd()
×
372
    compose_file_name = name if os.path.isabs(name) else os.path.join(dirname, name)
×
373
    warns = []
×
374

375
    # some systems do not have "docker-compose" aliased to "docker compose", and older systems do not have
376
    # "docker compose" at all. By preferring the old way and falling back on the new, we should get docker compose in
377
    # any way, if installed
378
    if is_command_available("docker-compose"):
×
379
        compose_command = ["docker-compose"]
×
380
    else:
381
        compose_command = ["docker", "compose"]
×
382
    # validating docker-compose file
383
    cmd = [*compose_command, "-f", compose_file_name, "config"]
×
384
    try:
×
385
        run(cmd, shell=False, print_error=False)
×
386
    except CalledProcessError as e:
×
387
        msg = f"{e}\n{to_str(e.output)}".strip()
×
388
        raise ValueError(msg)
×
389

390
    import yaml  # keep import here to avoid issues in test Lambdas
×
391

392
    # validating docker-compose variable
393
    with open(compose_file_name) as file:
×
394
        compose_content = yaml.full_load(file)
×
395
    services_config = compose_content.get("services", {})
×
396
    ls_service_name = [
×
397
        name for name, svc in services_config.items() if "localstack" in svc.get("image", "")
398
    ]
399
    if not ls_service_name:
×
400
        raise Exception(
×
401
            'No LocalStack service found in config (looking for image names containing "localstack")'
402
        )
403
    if len(ls_service_name) > 1:
×
404
        warns.append(f"Multiple candidates found for LocalStack service: {ls_service_name}")
×
405
    ls_service_name = ls_service_name[0]
×
406
    ls_service_details = services_config[ls_service_name]
×
407
    image_name = ls_service_details.get("image", "")
×
408
    if image_name.split(":")[0] not in constants.OFFICIAL_IMAGES:
×
409
        warns.append(
×
410
            f'Using custom image "{image_name}", we recommend using an official image: {constants.OFFICIAL_IMAGES}'
411
        )
412

413
    # prepare config options
414
    container_name = ls_service_details.get("container_name") or ""
×
415
    docker_ports = (port.split(":")[-2] for port in ls_service_details.get("ports", []))
×
416
    docker_env = {
×
417
        env.split("=")[0]: env.split("=")[1] for env in ls_service_details.get("environment", {})
418
    }
419
    edge_port = config.GATEWAY_LISTEN[0].port
×
420
    main_container = config.MAIN_CONTAINER_NAME
×
421

422
    # docker-compose file validation cases
423

424
    if (main_container not in container_name) and not docker_env.get("MAIN_CONTAINER_NAME"):
×
425
        warns.append(
×
426
            f'Please use "container_name: {main_container}" or add "MAIN_CONTAINER_NAME" in "environment".'
427
        )
428

429
    def port_exposed(port):
×
430
        for exposed in docker_ports:
×
431
            if re.match(rf"^([0-9]+-)?{port}(-[0-9]+)?$", exposed):
×
432
                return True
×
433

434
    if not port_exposed(edge_port):
×
435
        warns.append(
×
436
            f"Edge port {edge_port} is not exposed. You may have to add the entry "
437
            'to the "ports" section of the docker-compose file.'
438
        )
439

440
    # print warning/info messages
441
    for warning in warns:
×
442
        console.print("[yellow]:warning:[/yellow]", warning)
×
443
    if not warns:
×
444
        return True
×
445
    return False
×
446

447

448
def get_docker_image_to_start():
1✔
449
    image_name = os.environ.get("IMAGE_NAME")
1✔
450
    if not image_name:
1✔
451
        image_name = constants.DOCKER_IMAGE_NAME
×
452
        if is_auth_token_configured():
×
453
            image_name = constants.DOCKER_IMAGE_NAME_PRO
×
454
    return image_name
1✔
455

456

457
def extract_port_flags(user_flags, port_mappings: PortMappings):
1✔
458
    regex = r"-p\s+([0-9]+)(\-([0-9]+))?:([0-9]+)(\-([0-9]+))?"
1✔
459
    matches = re.match(f".*{regex}", user_flags)
1✔
460
    if matches:
1✔
461
        for match in re.findall(regex, user_flags):
1✔
462
            start = int(match[0])
1✔
463
            end = int(match[2] or match[0])
1✔
464
            start_target = int(match[3] or start)
1✔
465
            end_target = int(match[5] or end)
1✔
466
            port_mappings.add([start, end], [start_target, end_target])
1✔
467
        user_flags = re.sub(regex, r"", user_flags)
1✔
468
    return user_flags
1✔
469

470

471
class ContainerConfigurators:
1✔
472
    """
473
    A set of useful container configurators that are typical for starting the localstack container.
474
    """
475

476
    @staticmethod
1✔
477
    def mount_docker_socket(cfg: ContainerConfiguration):
1✔
478
        source = config.DOCKER_SOCK
1✔
479
        target = "/var/run/docker.sock"
1✔
480
        if cfg.volumes.find_target_mapping(target):
1✔
481
            return
×
482
        cfg.volumes.add(BindMount(source, target))
1✔
483
        cfg.env_vars["DOCKER_HOST"] = f"unix://{target}"
1✔
484

485
    @staticmethod
1✔
486
    def mount_localstack_volume(host_path: str | os.PathLike = None):
1✔
487
        host_path = host_path or config.VOLUME_DIR
1✔
488

489
        def _cfg(cfg: ContainerConfiguration):
1✔
490
            if cfg.volumes.find_target_mapping(constants.DEFAULT_VOLUME_DIR):
1✔
491
                return
×
492
            cfg.volumes.add(BindMount(str(host_path), constants.DEFAULT_VOLUME_DIR))
1✔
493

494
        return _cfg
1✔
495

496
    @staticmethod
1✔
497
    def config_env_vars(cfg: ContainerConfiguration):
1✔
498
        """Sets all env vars from config.CONFIG_ENV_VARS."""
499

500
        profile_env = {}
1✔
501
        if config.LOADED_PROFILES:
1✔
502
            load_environment(profiles=",".join(config.LOADED_PROFILES), env=profile_env)
1✔
503

504
        non_prefixed_env_vars = []
1✔
505
        for env_var in config.CONFIG_ENV_VARS:
1✔
506
            value = os.environ.get(env_var, None)
1✔
507
            if value is not None:
1✔
508
                if (
1✔
509
                    env_var != "CI"
510
                    and not env_var.startswith("LOCALSTACK_")
511
                    and env_var not in profile_env
512
                ):
513
                    # Collect all env vars that are directly forwarded from the system env
514
                    # to the container which has not been prefixed with LOCALSTACK_ here.
515
                    # Suppress the "CI" env var.
516
                    # Suppress if the env var was set from the profile.
517
                    non_prefixed_env_vars.append(env_var)
1✔
518
                cfg.env_vars[env_var] = value
1✔
519

520
        # collectively log deprecation warnings for non-prefixed sys env vars
521
        if non_prefixed_env_vars:
1✔
522
            from localstack.utils.analytics import log
1✔
523

524
            for non_prefixed_env_var in non_prefixed_env_vars:
1✔
525
                # Show a deprecation warning for each individual env var collected above
526
                LOG.warning(
1✔
527
                    "Non-prefixed environment variable %(env_var)s is forwarded to the LocalStack container! "
528
                    "Please use `LOCALSTACK_%(env_var)s` instead of %(env_var)s to explicitly mark this environment variable to be forwarded from the CLI to the LocalStack Runtime.",
529
                    {"env_var": non_prefixed_env_var},
530
                )
531

532
            log.event(
1✔
533
                event="non_prefixed_cli_env_vars", payload={"env_vars": non_prefixed_env_vars}
534
            )
535

536
    @staticmethod
1✔
537
    def random_gateway_port(cfg: ContainerConfiguration):
1✔
538
        """Gets a random port on the host and maps it to the default edge port 4566."""
539
        return ContainerConfigurators.gateway_listen(get_free_tcp_port())(cfg)
1✔
540

541
    @staticmethod
1✔
542
    def default_gateway_port(cfg: ContainerConfiguration):
1✔
543
        """Adds 4566 to the list of port mappings"""
544
        return ContainerConfigurators.gateway_listen(constants.DEFAULT_PORT_EDGE)(cfg)
1✔
545

546
    @staticmethod
1✔
547
    def gateway_listen(
1✔
548
        port: int | Iterable[int] | HostAndPort | Iterable[HostAndPort],
549
    ):
550
        """
551
        Uses the given ports to configure GATEWAY_LISTEN. For instance, ``gateway_listen([4566, 443])`` would
552
        result in the port mappings 4566:4566, 443:443, as well as ``GATEWAY_LISTEN=:4566,:443``.
553

554
        :param port: a single or list of ports, can either be int ports or HostAndPort instances
555
        :return: a configurator
556
        """
557
        if isinstance(port, int):
1✔
558
            ports = [HostAndPort("", port)]
1✔
559
        elif isinstance(port, HostAndPort):
1✔
560
            ports = [port]
×
561
        else:
562
            ports = []
1✔
563
            for p in port:
1✔
564
                if isinstance(p, int):
1✔
565
                    ports.append(HostAndPort("", p))
×
566
                else:
567
                    ports.append(p)
1✔
568

569
        def _cfg(cfg: ContainerConfiguration):
1✔
570
            for _p in ports:
1✔
571
                cfg.ports.add(_p.port)
1✔
572

573
            # gateway listen should be compiled s.t. even if we set "127.0.0.1:4566" from the host,
574
            # it will be correctly exposed on "0.0.0.0:4566" in the container.
575
            cfg.env_vars["GATEWAY_LISTEN"] = ",".join(
1✔
576
                [f"{p.host if p.host != default_ip else ''}:{p.port}" for p in ports]
577
            )
578

579
        return _cfg
1✔
580

581
    @staticmethod
1✔
582
    def container_name(name: str):
1✔
583
        def _cfg(cfg: ContainerConfiguration):
×
584
            cfg.name = name
×
585
            cfg.env_vars["MAIN_CONTAINER_NAME"] = cfg.name
×
586

587
        return _cfg
×
588

589
    @staticmethod
1✔
590
    def random_container_name(cfg: ContainerConfiguration):
1✔
591
        cfg.name = f"localstack-{short_uid()}"
1✔
592
        cfg.env_vars["MAIN_CONTAINER_NAME"] = cfg.name
1✔
593

594
    @staticmethod
1✔
595
    def default_container_name(cfg: ContainerConfiguration):
1✔
596
        cfg.name = config.MAIN_CONTAINER_NAME
×
597
        cfg.env_vars["MAIN_CONTAINER_NAME"] = cfg.name
×
598

599
    @staticmethod
1✔
600
    def service_port_range(cfg: ContainerConfiguration):
1✔
601
        cfg.ports.add([config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END])
1✔
602
        cfg.env_vars["EXTERNAL_SERVICE_PORTS_START"] = config.EXTERNAL_SERVICE_PORTS_START
1✔
603
        cfg.env_vars["EXTERNAL_SERVICE_PORTS_END"] = config.EXTERNAL_SERVICE_PORTS_END
1✔
604

605
    @staticmethod
1✔
606
    def random_service_port_range(num: int = 50):
1✔
607
        """
608
        Tries to find a contiguous list of random ports on the host to map to the external service port
609
        range in the container.
610
        """
611

612
        def _cfg(cfg: ContainerConfiguration):
1✔
613
            port_range = get_free_tcp_port_range(num)
1✔
614
            cfg.ports.add([port_range.start, port_range.end])
1✔
615
            cfg.env_vars["EXTERNAL_SERVICE_PORTS_START"] = str(port_range.start)
1✔
616
            cfg.env_vars["EXTERNAL_SERVICE_PORTS_END"] = str(port_range.end)
1✔
617

618
        return _cfg
1✔
619

620
    @staticmethod
1✔
621
    def debug(cfg: ContainerConfiguration):
1✔
622
        cfg.env_vars["DEBUG"] = "1"
1✔
623

624
    @classmethod
1✔
625
    def develop(cls, cfg: ContainerConfiguration):
1✔
626
        cls.env_vars(
×
627
            {
628
                "DEVELOP": "1",
629
            }
630
        )(cfg)
631
        cls.port(5678)(cfg)
×
632

633
    @staticmethod
1✔
634
    def network(network: str):
1✔
635
        def _cfg(cfg: ContainerConfiguration):
1✔
636
            cfg.network = network
1✔
637

638
        return _cfg
1✔
639

640
    @staticmethod
1✔
641
    def custom_command(cmd: list[str]):
1✔
642
        """
643
        Overwrites the container command and unsets the default entrypoint.
644

645
        :param cmd: the command to run in the container
646
        :return: a configurator
647
        """
648

649
        def _cfg(cfg: ContainerConfiguration):
1✔
650
            cfg.command = cmd
1✔
651
            cfg.entrypoint = ""
1✔
652

653
        return _cfg
1✔
654

655
    @staticmethod
1✔
656
    def env_vars(env_vars: dict[str, str]):
1✔
657
        def _cfg(cfg: ContainerConfiguration):
1✔
658
            cfg.env_vars.update(env_vars)
1✔
659

660
        return _cfg
1✔
661

662
    @staticmethod
1✔
663
    def port(*args, **kwargs):
1✔
664
        def _cfg(cfg: ContainerConfiguration):
1✔
665
            cfg.ports.add(*args, **kwargs)
1✔
666

667
        return _cfg
1✔
668

669
    @staticmethod
1✔
670
    def volume(volume: VolumeMappingSpecification):
1✔
671
        def _cfg(cfg: ContainerConfiguration):
1✔
672
            cfg.volumes.add(volume)
1✔
673

674
        return _cfg
1✔
675

676
    @staticmethod
1✔
677
    def cli_params(params: dict[str, Any]):
1✔
678
        """
679
        Parse docker CLI parameters and add them to the config. The currently known CLI params are::
680

681
            --network=my-network       <- stored in "network"
682
            -e FOO=BAR -e BAR=ed       <- stored in "env"
683
            -p 4566:4566 -p 4510-4559  <- stored in "publish"
684
            -v ./bar:/foo/bar          <- stored in "volume"
685

686
        When parsed by click, the parameters would look like this::
687

688
            {
689
                "network": "my-network",
690
                "env": ("FOO=BAR", "BAR=ed"),
691
                "publish": ("4566:4566", "4510-4559"),
692
                "volume": ("./bar:/foo/bar",),
693
            }
694

695
        :param params: a dict of parsed parameters
696
        :return: a configurator
697
        """
698

699
        # TODO: consolidate with container_client.Util.parse_additional_flags
700
        def _cfg(cfg: ContainerConfiguration):
1✔
701
            if params.get("network"):
1✔
702
                cfg.network = params.get("network")
1✔
703

704
            if params.get("host_dns"):
1✔
705
                cfg.ports.add(config.DNS_PORT, config.DNS_PORT, "udp")
×
706
                cfg.ports.add(config.DNS_PORT, config.DNS_PORT, "tcp")
×
707

708
            # processed parsed -e, -p, and -v flags
709
            ContainerConfigurators.env_cli_params(params.get("env"))(cfg)
1✔
710
            ContainerConfigurators.port_cli_params(params.get("publish"))(cfg)
1✔
711
            ContainerConfigurators.volume_cli_params(params.get("volume"))(cfg)
1✔
712

713
        return _cfg
1✔
714

715
    @staticmethod
1✔
716
    def env_cli_params(params: Iterable[str] = None):
1✔
717
        """
718
        Configures environment variables from additional CLI input through the ``-e`` options.
719

720
        :param params: a list of environment variable declarations, e.g.,: ``("foo=bar", "baz=ed")``
721
        :return: a configurator
722
        """
723

724
        def _cfg(cfg: ContainerConfiguration):
1✔
725
            if not params:
1✔
726
                return
×
727

728
            for e in params:
1✔
729
                if "=" in e:
1✔
730
                    k, v = e.split("=", maxsplit=1)
1✔
731
                    cfg.env_vars[k] = v
1✔
732
                else:
733
                    # there's currently no way in our abstraction to only pass the variable name (as
734
                    # you can do in docker) so we resolve the value here.
735
                    cfg.env_vars[e] = os.getenv(e)
1✔
736

737
        return _cfg
1✔
738

739
    @staticmethod
1✔
740
    def port_cli_params(params: Iterable[str] = None):
1✔
741
        """
742
        Configures port variables from additional CLI input through the ``-p`` options.
743

744
        :param params: a list of port assignments, e.g.,: ``("4000-5000", "8080:80")``
745
        :return: a configurator
746
        """
747

748
        def _cfg(cfg: ContainerConfiguration):
1✔
749
            if not params:
1✔
750
                return
×
751

752
            for port_mapping in params:
1✔
753
                port_split = port_mapping.split(":")
1✔
754
                protocol = "tcp"
1✔
755
                if len(port_split) == 1:
1✔
756
                    host_port = container_port = port_split[0]
1✔
757
                elif len(port_split) == 2:
1✔
758
                    host_port, container_port = port_split
1✔
759
                elif len(port_split) == 3:
×
760
                    _, host_port, container_port = port_split
×
761
                else:
762
                    raise ValueError(f"Invalid port string provided: {port_mapping}")
×
763

764
                host_port_split = host_port.split("-")
1✔
765
                if len(host_port_split) == 2:
1✔
766
                    host_port = [int(host_port_split[0]), int(host_port_split[1])]
1✔
767
                elif len(host_port_split) == 1:
1✔
768
                    host_port = int(host_port)
1✔
769
                else:
770
                    raise ValueError(f"Invalid port string provided: {port_mapping}")
×
771

772
                if "/" in container_port:
1✔
773
                    container_port, protocol = container_port.split("/")
1✔
774

775
                container_port_split = container_port.split("-")
1✔
776
                if len(container_port_split) == 2:
1✔
777
                    container_port = [int(container_port_split[0]), int(container_port_split[1])]
1✔
778
                elif len(container_port_split) == 1:
1✔
779
                    container_port = int(container_port)
1✔
780
                else:
781
                    raise ValueError(f"Invalid port string provided: {port_mapping}")
×
782

783
                cfg.ports.add(host_port, container_port, protocol)
1✔
784

785
        return _cfg
1✔
786

787
    @staticmethod
1✔
788
    def volume_cli_params(params: Iterable[str] = None):
1✔
789
        """
790
        Configures volumes from additional CLI input through the ``-v`` options.
791

792
        :param params: a list of volume declarations, e.g.,: ``("./bar:/foo/bar",)``
793
        :return: a configurator
794
        """
795

796
        def _cfg(cfg: ContainerConfiguration):
1✔
797
            for param in params:
1✔
798
                cfg.volumes.append(BindMount.parse(param))
1✔
799

800
        return _cfg
1✔
801

802

803
def get_gateway_port(container: Container) -> int:
1✔
804
    """
805
    Heuristically determines for the given container the port the gateway will be reachable from the host.
806
    Parses the container's ``GATEWAY_LISTEN`` if necessary and finds the appropriate port mapping.
807

808
    :param container: the localstack container
809
    :return: the gateway port reachable from the host
810
    """
811
    candidates: list[int]
812

813
    gateway_listen = container.config.env_vars.get("GATEWAY_LISTEN")
1✔
814
    if gateway_listen:
1✔
815
        candidates = [
1✔
816
            HostAndPort.parse(
817
                value,
818
                default_host=constants.LOCALHOST_HOSTNAME,
819
                default_port=constants.DEFAULT_PORT_EDGE,
820
            ).port
821
            for value in gateway_listen.split(",")
822
        ]
823
    else:
824
        candidates = [constants.DEFAULT_PORT_EDGE]
1✔
825

826
    exposed = container.config.ports.to_dict()
1✔
827

828
    for candidate in candidates:
1✔
829
        port = exposed.get(f"{candidate}/tcp")
1✔
830
        if port:
1✔
831
            return port
1✔
832

833
    raise ValueError("no gateway port mapping found")
1✔
834

835

836
def get_gateway_url(
1✔
837
    container: Container,
838
    hostname: str = constants.LOCALHOST_HOSTNAME,
839
    protocol: str = "http",
840
) -> str:
841
    """
842
    Returns the localstack container's gateway URL reachable from the host. In most cases this will be
843
    ``http://localhost.localstack.cloud:4566``.
844

845
    :param container: the container
846
    :param hostname: the hostname to use (default localhost.localstack.cloud)
847
    :param protocol: the URI scheme (default http)
848
    :return: a URL
849
    `"""
850
    return f"{protocol}://{hostname}:{get_gateway_port(container)}"
1✔
851

852

853
class Container:
1✔
854
    def __init__(
1✔
855
        self, container_config: ContainerConfiguration, docker_client: ContainerClient | None = None
856
    ):
857
        self.config = container_config
1✔
858
        # marker to access the running container
859
        self.running_container: RunningContainer | None = None
1✔
860
        self.container_client = docker_client or DOCKER_CLIENT
1✔
861

862
    def configure(self, configurators: ContainerConfigurator | Iterable[ContainerConfigurator]):
1✔
863
        """
864
        Apply the given configurators to the config of this container.
865

866
        :param configurators:
867
        :return:
868
        """
869
        try:
1✔
870
            iterator = iter(configurators)
1✔
871
        except TypeError:
×
872
            configurators(self.config)
×
873
            return
×
874

875
        for configurator in iterator:
1✔
876
            configurator(self.config)
1✔
877

878
    def start(self, attach: bool = False) -> RunningContainer:
1✔
879
        # FIXME: this is pretty awkward, but additional_flags in the LocalstackContainer API was
880
        #  always a list of ["-e FOO=BAR", ...], whereas in the DockerClient it is expected to be
881
        #  a string. so we need to re-assemble it here. the better way would be to not use
882
        #  additional_flags here all together. it is still used in ext in
883
        #  `configure_pro_container` which could be refactored to use the additional port bindings.
884
        cfg = copy.deepcopy(self.config)
1✔
885
        if not cfg.additional_flags:
1✔
886
            cfg.additional_flags = ""
1✔
887

888
        # TODO: there could be a --network flag in `additional_flags`. we solve a similar problem
889
        #  for the ports using `extract_port_flags`. maybe it would be better to consolidate all
890
        #  this into the ContainerConfig object, like ContainerConfig.update_from_flags(str).
891
        self._ensure_container_network(cfg.network)
1✔
892

893
        try:
1✔
894
            id = self.container_client.create_container_from_config(cfg)
1✔
895
        except ContainerException as e:
×
896
            if LOG.isEnabledFor(logging.DEBUG):
×
897
                LOG.exception("Error while creating container")
×
898
            else:
899
                LOG.error(
×
900
                    "Error while creating container: %s\n%s", e.message, to_str(e.stderr or "?")
901
                )
902
            raise
×
903

904
        try:
1✔
905
            self.container_client.start_container(id, attach=attach)
1✔
906
        except ContainerException as e:
×
907
            LOG.error(
×
908
                "Error while starting LocalStack container: %s\n%s",
909
                e.message,
910
                to_str(e.stderr),
911
                exc_info=LOG.isEnabledFor(logging.DEBUG),
912
            )
913
            raise
×
914

915
        self.running_container = RunningContainer(id, container_config=self.config)
1✔
916
        return self.running_container
1✔
917

918
    def _ensure_container_network(self, network: str | None = None):
1✔
919
        """Makes sure the configured container network exists"""
920
        if network:
1✔
921
            if network in ["host", "bridge"]:
1✔
922
                return
×
923
            try:
1✔
924
                self.container_client.inspect_network(network)
1✔
925
            except NoSuchNetwork:
×
926
                LOG.debug("Container network %s not found, creating it", network)
×
927
                self.container_client.create_network(network)
×
928

929

930
class RunningContainer:
1✔
931
    """
932
    Represents a LocalStack container that is running.
933
    """
934

935
    def __init__(
1✔
936
        self,
937
        id: str,
938
        container_config: ContainerConfiguration,
939
        docker_client: ContainerClient | None = None,
940
    ):
941
        self.id = id
1✔
942
        self.config = container_config
1✔
943
        self.container_client = docker_client or DOCKER_CLIENT
1✔
944
        self.name = self.container_client.get_container_name(self.id)
1✔
945
        self._shutdown = False
1✔
946
        self._mutex = threading.Lock()
1✔
947

948
    def __enter__(self):
1✔
949
        return self
1✔
950

951
    def __exit__(self, exc_type, exc_value, traceback):
1✔
952
        self.shutdown()
1✔
953

954
    def ip_address(self, docker_network: str | None = None) -> str:
1✔
955
        """
956
        Get the IP address of the container
957

958
        Optionally specify the docker network
959
        """
960
        if docker_network is None:
1✔
961
            return self.container_client.get_container_ip(container_name_or_id=self.id)
1✔
962
        else:
963
            return self.container_client.get_container_ipv4_for_network(
1✔
964
                container_name_or_id=self.id, container_network=docker_network
965
            )
966

967
    def is_running(self) -> bool:
1✔
968
        try:
1✔
969
            self.container_client.inspect_container(self.id)
1✔
970
            return True
1✔
971
        except NoSuchContainer:
×
972
            return False
×
973

974
    def get_logs(self) -> str:
1✔
975
        return self.container_client.get_container_logs(self.id, safe=True)
1✔
976

977
    def stream_logs(self) -> CancellableStream:
1✔
978
        return self.container_client.stream_container_logs(self.id)
1✔
979

980
    def wait_until_ready(self, timeout: float = None) -> bool:
1✔
981
        return poll_condition(self.is_running, timeout)
1✔
982

983
    def shutdown(self, timeout: int = 10, remove: bool = True):
1✔
984
        with self._mutex:
1✔
985
            if self._shutdown:
1✔
986
                return
1✔
987
            self._shutdown = True
1✔
988

989
        try:
1✔
990
            self.container_client.stop_container(container_name=self.id, timeout=timeout)
1✔
991
        except NoSuchContainer:
×
992
            pass
×
993

994
        if remove:
1✔
995
            try:
1✔
996
                self.container_client.remove_container(
1✔
997
                    container_name=self.id, force=True, check_existence=False
998
                )
999
            except ContainerException as e:
×
1000
                if "is already in progress" in str(e):
×
1001
                    return
×
1002
                raise
×
1003

1004
    def inspect(self) -> dict[str, dict | str]:
1✔
1005
        return self.container_client.inspect_container(container_name_or_id=self.id)
1✔
1006

1007
    def attach(self):
1✔
1008
        self.container_client.attach_to_container(container_name_or_id=self.id)
1✔
1009

1010
    def exec_in_container(self, *args, **kwargs):
1✔
1011
        return self.container_client.exec_in_container(
1✔
1012
            *args, container_name_or_id=self.id, **kwargs
1013
        )
1014

1015
    def stopped(self) -> Container:
1✔
1016
        """
1017
        Convert this running instance to a stopped instance ready to be restarted
1018
        """
1019
        return Container(container_config=self.config, docker_client=self.container_client)
1✔
1020

1021

1022
class ContainerLogPrinter:
1✔
1023
    """
1024
    Waits on a container to start and then uses ``stream_logs`` to print each line of the logs.
1025
    """
1026

1027
    def __init__(self, container: Container, callback: Callable[[str], None] = print):
1✔
1028
        self.container = container
×
1029
        self.callback = callback
×
1030

1031
        self._closed = threading.Event()
×
1032
        self._stream: CancellableStream | None = None
×
1033

1034
    def _can_start_streaming(self):
1✔
1035
        if self._closed.is_set():
×
1036
            raise OSError("Already stopped")
×
1037
        if not self.container.running_container:
×
1038
            return False
×
1039
        return self.container.running_container.is_running()
×
1040

1041
    def run(self):
1✔
1042
        try:
×
1043
            poll_condition(self._can_start_streaming)
×
1044
        except OSError:
×
1045
            return
×
1046
        self._stream = self.container.running_container.stream_logs()
×
1047
        for line in self._stream:
×
1048
            self.callback(line.rstrip(b"\r\n").decode("utf-8"))
×
1049

1050
    def close(self):
1✔
1051
        self._closed.set()
×
1052
        if self._stream:
×
1053
            self._stream.close()
×
1054

1055

1056
class LocalstackContainerServer(Server):
1✔
1057
    container: Container | RunningContainer
1✔
1058

1059
    def __init__(
1✔
1060
        self, container_configuration: ContainerConfiguration | Container | None = None
1061
    ) -> None:
1062
        super().__init__(config.GATEWAY_LISTEN[0].port, config.GATEWAY_LISTEN[0].host)
1✔
1063

1064
        if container_configuration is None:
1✔
1065
            port_configuration = PortMappings(bind_host=config.GATEWAY_LISTEN[0].host)
1✔
1066
            for addr in config.GATEWAY_LISTEN:
1✔
1067
                port_configuration.add(addr.port)
1✔
1068

1069
            container_configuration = ContainerConfiguration(
1✔
1070
                image_name=get_docker_image_to_start(),
1071
                name=config.MAIN_CONTAINER_NAME,
1072
                volumes=VolumeMappings(),
1073
                remove=True,
1074
                ports=port_configuration,
1075
                entrypoint=os.environ.get("ENTRYPOINT"),
1076
                command=shlex.split(os.environ.get("CMD", "")) or None,
1077
                env_vars={},
1078
            )
1079

1080
        if isinstance(container_configuration, Container):
1✔
1081
            self.container = container_configuration
×
1082
        else:
1083
            self.container = Container(container_configuration)
1✔
1084

1085
    def is_up(self) -> bool:
1✔
1086
        """
1087
        Checks whether the container is running, and the Ready marker has been printed to the logs.
1088
        """
1089
        if not self.is_container_running():
1✔
1090
            return False
1✔
1091

1092
        logs = self.container.get_logs()
1✔
1093

1094
        if constants.READY_MARKER_OUTPUT not in logs.splitlines():
1✔
1095
            return False
1✔
1096

1097
        # also checks the edge port health status
1098
        return super().is_up()
1✔
1099

1100
    def is_container_running(self) -> bool:
1✔
1101
        # if we have not started the container then we are not up
1102
        if not isinstance(self.container, RunningContainer):
1✔
1103
            return False
1✔
1104

1105
        return self.container.is_running()
1✔
1106

1107
    def wait_is_container_running(self, timeout=None) -> bool:
1✔
1108
        return poll_condition(self.is_container_running, timeout)
×
1109

1110
    def start(self) -> bool:
1✔
1111
        if isinstance(self.container, RunningContainer):
1✔
1112
            raise RuntimeError("cannot start container as container reference has been started")
×
1113

1114
        return super().start()
1✔
1115

1116
    def do_run(self):
1✔
1117
        if self.is_container_running():
1✔
1118
            raise ContainerRunning(
×
1119
                f'LocalStack container named "{self.container.name}" is already running'
1120
            )
1121

1122
        config.dirs.mkdirs()
1✔
1123
        if not isinstance(self.container, Container):
1✔
1124
            raise ValueError(f"Invalid container type: {type(self.container)}")
×
1125

1126
        LOG.debug("starting LocalStack container")
1✔
1127
        self.container = self.container.start(attach=False)
1✔
1128
        if isinstance(DOCKER_CLIENT, CmdDockerClient):
1✔
1129
            DOCKER_CLIENT.default_run_outfile = get_container_default_logfile_location(
1✔
1130
                self.container.config.name
1131
            )
1132

1133
        # block the current thread
1134
        self.container.attach()
1✔
1135
        return self.container
1✔
1136

1137
    def shutdown(self):
1✔
1138
        if not isinstance(self.container, RunningContainer):
1✔
1139
            raise ValueError(f"Container {self.container} not started")
×
1140

1141
        return super().shutdown()
1✔
1142

1143
    def do_shutdown(self):
1✔
1144
        try:
1✔
1145
            self.container.shutdown(timeout=10)
1✔
1146
            self.container = self.container.stopped()
1✔
1147
        except Exception as e:
×
1148
            LOG.info("error cleaning up localstack container %s: %s", self.container.name, e)
×
1149

1150

1151
class ContainerExists(Exception):
1✔
1152
    pass
1✔
1153

1154

1155
class ContainerRunning(Exception):
1✔
1156
    pass
1✔
1157

1158

1159
def prepare_docker_start():
1✔
1160
    # prepare environment for docker start
1161
    container_name = config.MAIN_CONTAINER_NAME
×
1162

1163
    if DOCKER_CLIENT.is_container_running(container_name):
×
1164
        raise ContainerRunning(f'LocalStack container named "{container_name}" is already running')
×
1165

1166
    if container_name in DOCKER_CLIENT.get_all_container_names():
×
1167
        raise ContainerExists(f'LocalStack container named "{container_name}" already exists')
×
1168

1169
    config.dirs.mkdirs()
×
1170

1171

1172
def configure_container(container: Container):
1✔
1173
    """
1174
    Configuration routine for the LocalstackContainer.
1175
    """
1176
    port_configuration = PortMappings(bind_host=config.GATEWAY_LISTEN[0].host)
1✔
1177

1178
    # base configuration
1179
    container.config.image_name = get_docker_image_to_start()
1✔
1180
    container.config.name = config.MAIN_CONTAINER_NAME
1✔
1181
    container.config.volumes = VolumeMappings()
1✔
1182
    container.config.remove = True
1✔
1183
    container.config.ports = port_configuration
1✔
1184
    container.config.entrypoint = os.environ.get("ENTRYPOINT")
1✔
1185
    container.config.command = shlex.split(os.environ.get("CMD", "")) or None
1✔
1186
    container.config.env_vars = {}
1✔
1187

1188
    # parse `DOCKER_FLAGS` and add them appropriately
1189
    user_flags = config.DOCKER_FLAGS
1✔
1190
    user_flags = extract_port_flags(user_flags, container.config.ports)
1✔
1191
    if container.config.additional_flags is None:
1✔
1192
        container.config.additional_flags = user_flags
1✔
1193
    else:
1194
        container.config.additional_flags = f"{container.config.additional_flags} {user_flags}"
×
1195

1196
    # get additional parameters from plux
1197
    hooks.configure_localstack_container.run(container)
1✔
1198

1199
    if config.DEVELOP:
1✔
1200
        container.config.ports.add(config.DEVELOP_PORT)
×
1201

1202
    container.configure(
1✔
1203
        [
1204
            # external service port range
1205
            ContainerConfigurators.service_port_range,
1206
            ContainerConfigurators.mount_localstack_volume(config.VOLUME_DIR),
1207
            ContainerConfigurators.mount_docker_socket,
1208
            # overwrites any env vars set in the config that were previously set by configurators
1209
            ContainerConfigurators.config_env_vars,
1210
            # ensure that GATEWAY_LISTEN is taken from the config and not
1211
            # overridden by the `config_env_vars` configurator
1212
            # (when not specified in the environment).
1213
            ContainerConfigurators.gateway_listen(config.GATEWAY_LISTEN),
1214
        ]
1215
    )
1216

1217

1218
@log_duration()
1✔
1219
def prepare_host(console):
1✔
1220
    """
1221
    Prepare the host environment for running LocalStack, this should be called before start_infra_*.
1222
    """
1223
    if os.environ.get(constants.LOCALSTACK_INFRA_PROCESS) in constants.TRUE_STRINGS:
×
1224
        return
×
1225

1226
    try:
×
1227
        mkdir(config.VOLUME_DIR)
×
1228
    except Exception as e:
×
1229
        console.print(f"Error while creating volume dir {config.VOLUME_DIR}: {e}")
×
1230
        if config.DEBUG:
×
1231
            console.print_exception()
×
1232

1233
    setup_logging()
×
1234
    hooks.prepare_host.run()
×
1235

1236

1237
def start_infra_in_docker(console, cli_params: dict[str, Any] = None):
1✔
1238
    prepare_docker_start()
×
1239

1240
    # create and prepare container
1241
    container_config = ContainerConfiguration(get_docker_image_to_start())
×
1242
    container = Container(container_config)
×
1243
    ensure_container_image(console, container)
×
1244

1245
    configure_container(container)
×
1246
    container.configure(ContainerConfigurators.cli_params(cli_params or {}))
×
1247

1248
    status = console.status("Starting LocalStack container")
×
1249
    status.start()
×
1250

1251
    # printing the container log is the current way we're occupying the terminal
1252
    def _init_log_printer(line):
×
1253
        """Prints the console rule separator on the first line, then re-configures the callback
1254
        to print."""
1255
        status.stop()
×
1256
        console.rule("LocalStack Runtime Log (press [bold][yellow]CTRL-C[/yellow][/bold] to quit)")
×
1257
        print(line)
×
1258
        log_printer.callback = print
×
1259

1260
    log_printer = ContainerLogPrinter(container, callback=_init_log_printer)
×
1261

1262
    # Set up signal handler, to enable clean shutdown across different operating systems.
1263
    #  There are subtle differences across operating systems and terminal emulators when it
1264
    #  comes to handling of CTRL-C - in particular, Linux sends SIGINT to the parent process,
1265
    #  whereas macOS sends SIGINT to the process group, which can result in multiple SIGINT signals
1266
    #  being received (e.g., when running the localstack CLI as part of a "npm run .." script).
1267
    #  Hence, using a shutdown handler and synchronization event here, to avoid inconsistencies.
1268
    def shutdown_handler(*args):
×
1269
        with shutdown_event_lock:
×
1270
            if shutdown_event.is_set():
×
1271
                return
×
1272
            shutdown_event.set()
×
1273
        print("Shutting down...")
×
1274
        server.shutdown()
×
1275

1276
    shutdown_event = threading.Event()
×
1277
    shutdown_event_lock = threading.RLock()
×
1278
    signal.signal(signal.SIGINT, shutdown_handler)
×
1279

1280
    # start the Localstack container as a Server
1281
    server = LocalstackContainerServer(container)
×
1282
    log_printer_thread = threading.Thread(
×
1283
        target=log_printer.run, name="container-log-printer", daemon=True
1284
    )
1285
    try:
×
1286
        server.start()
×
1287
        log_printer_thread.start()
×
1288
        server.join()
×
1289
        error = server.get_error()
×
1290
        if error:
×
1291
            # if the server failed, raise the error
1292
            raise error
×
1293
    except KeyboardInterrupt:
×
1294
        print("ok, bye!")
×
1295
        shutdown_handler()
×
1296
    finally:
1297
        log_printer.close()
×
1298

1299

1300
def ensure_container_image(console, container: Container):
1✔
1301
    try:
×
1302
        DOCKER_CLIENT.inspect_image(container.config.image_name, pull=False)
×
1303
        return
×
1304
    except NoSuchImage:
×
1305
        console.log("container image not found on host")
×
1306

1307
    with console.status(f"Pulling container image {container.config.image_name}"):
×
1308
        DOCKER_CLIENT.pull_image(container.config.image_name)
×
1309
        console.log("download complete")
×
1310

1311

1312
def start_infra_in_docker_detached(console, cli_params: dict[str, Any] = None):
1✔
1313
    """
1314
    An alternative to start_infra_in_docker where the terminal is not blocked by the follow on the logfile.
1315
    """
1316
    console.log("preparing environment")
×
1317
    try:
×
1318
        prepare_docker_start()
×
1319
    except ContainerRunning as e:
×
1320
        # starting in detached mode is idempotent, return if container is already running
1321
        console.print(str(e))
×
1322
        return
×
1323

1324
    # create and prepare container
1325
    console.log("configuring container")
×
1326
    container_config = ContainerConfiguration(get_docker_image_to_start())
×
1327
    container = Container(container_config)
×
1328
    ensure_container_image(console, container)
×
1329
    configure_container(container)
×
1330
    container.configure(ContainerConfigurators.cli_params(cli_params or {}))
×
1331

1332
    container_config.detach = True
×
1333

1334
    # start the Localstack container as a Server
1335
    console.log("starting container")
×
1336
    server = LocalstackContainerServer(container_config)
×
1337
    server.start()
×
1338
    server.wait_is_container_running()
×
1339
    console.log("detaching")
×
1340

1341

1342
def wait_container_is_ready(timeout: float | None = None):
1✔
1343
    """Blocks until the localstack main container is running and the ready marker has been printed."""
1344
    container_name = config.MAIN_CONTAINER_NAME
×
1345
    started = time.time()
×
1346

1347
    def is_container_running():
×
1348
        return DOCKER_CLIENT.is_container_running(container_name)
×
1349

1350
    if not poll_condition(is_container_running, timeout=timeout):
×
1351
        return False
×
1352

1353
    stream = DOCKER_CLIENT.stream_container_logs(container_name)
×
1354

1355
    # create a timer that will terminate the log stream after the remaining timeout
1356
    timer = None
×
1357
    if timeout:
×
1358
        waited = time.time() - started
×
1359
        remaining = timeout - waited
×
1360
        # check the rare case that the timeout has already been reached
1361
        if remaining <= 0:
×
1362
            stream.close()
×
1363
            return False
×
1364
        timer = threading.Timer(remaining, stream.close)
×
1365
        timer.start()
×
1366

1367
    try:
×
1368
        for line in stream:
×
1369
            line = line.decode("utf-8").strip()
×
1370
            if line == constants.READY_MARKER_OUTPUT:
×
1371
                return True
×
1372

1373
        # EOF was reached or the stream was closed
1374
        return False
×
1375
    finally:
1376
        call_safe(stream.close)
×
1377
        if timer:
×
1378
            # make sure the timer is stopped (does nothing if it has already run)
1379
            timer.cancel()
×
1380

1381

1382
# ---------------
1383
# UTIL FUNCTIONS
1384
# ---------------
1385

1386

1387
def in_ci():
1✔
1388
    """Whether or not we are running in a CI environment"""
1389
    for key in ("CI", "TRAVIS"):
1✔
1390
        if os.environ.get(key, "") not in [False, "", "0", "false"]:
1✔
1391
            return True
1✔
1392
    return False
×
1393

1394

1395
def is_auth_token_configured() -> bool:
1✔
1396
    """Whether an API key is set in the environment."""
1397
    return (
×
1398
        True
1399
        if os.environ.get("LOCALSTACK_AUTH_TOKEN", "").strip()
1400
        or os.environ.get("LOCALSTACK_API_KEY", "").strip()
1401
        else False
1402
    )
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