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

localstack / localstack / 16712833638

01 Aug 2025 02:00PM UTC coverage: 86.885% (-0.01%) from 86.897%
16712833638

push

github

web-flow
merge CFn V1 and CFnV2 tests (#12944)

Co-authored-by: Simon Walker <simon.walker@localstack.cloud>

3 of 3 new or added lines in 1 file covered. (100.0%)

33 existing lines in 11 files now uncovered.

66476 of 76510 relevant lines covered (86.89%)

0.87 hits per line

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

63.12
/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 functools import wraps
1✔
13
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Union
1✔
14

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

49
LOG = logging.getLogger(__name__)
1✔
50

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

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

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

135

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

139
    def wrapper(f):
1✔
140
        @wraps(f)
1✔
141
        def wrapped(*args, **kwargs):
1✔
142
            from time import perf_counter
1✔
143

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

154
        return wrapped
1✔
155

156
    return wrapper
1✔
157

158

159
def get_docker_image_details(image_name: str = None) -> Dict[str, str]:
1✔
160
    image_name = image_name or get_docker_image_to_start()
1✔
161
    try:
1✔
162
        result = DOCKER_CLIENT.inspect_image(image_name)
1✔
163
    except ContainerException:
×
164
        return {}
×
165

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

176

177
def get_image_environment_variable(env_name: str) -> Optional[str]:
1✔
178
    image_name = get_docker_image_to_start()
×
179
    image_info = DOCKER_CLIENT.inspect_image(image_name)
×
180
    image_envs = image_info["Config"]["Env"]
×
181

182
    try:
×
183
        found_env = next(env for env in image_envs if env.startswith(env_name))
×
184
    except StopIteration:
×
185
        return None
×
186
    return found_env.split("=")[1]
×
187

188

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

192

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

219

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

227
    env_version = get_image_environment_variable("LOCALSTACK_BUILD_VERSION")
×
228
    if env_version is not None:
×
229
        version_cache.parent.mkdir(exist_ok=True, parents=True)
×
230
        version_cache.write_text(env_version)
×
231
        return env_version
×
232

233
    container_version = get_server_version_from_running_container()
×
234
    version_cache.parent.mkdir(exist_ok=True, parents=True)
×
235
    version_cache.write_text(container_version)
×
236

237
    return container_version
×
238

239

240
def setup_logging():
1✔
241
    """Determine and set log level. The singleton factory makes sure the logging is only set up once."""
242
    from localstack.logging.setup import setup_logging_from_config
1✔
243

244
    setup_logging_from_config()
1✔
245

246

247
# --------------
248
# INFRA STARTUP
249
# --------------
250

251

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

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

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

270
    # perform a graph search
271
    stack.extend(services)
1✔
272
    while stack:
1✔
273
        service = stack.pop()
1✔
274

275
        if service in result:
1✔
276
            continue
1✔
277

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

283
        result.add(service)
1✔
284

285
        # add dependencies to stack
286
        if service in API_DEPENDENCIES:
1✔
287
            stack.extend(API_DEPENDENCIES[service])
1✔
288

289
    return result
1✔
290

291

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

299
    The result is cached, so it's safe to call. Clear the cache with get_enabled_apis.cache_clear().
300
    """
301
    from localstack.services.plugins import SERVICE_PLUGINS
1✔
302

303
    services_env = os.environ.get("SERVICES", "").strip()
1✔
304
    services = SERVICE_PLUGINS.list_available()
1✔
305

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

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

320
    return resolve_apis(services)
1✔
321

322

323
def is_api_enabled(api: str) -> bool:
1✔
324
    return api in get_enabled_apis()
1✔
325

326

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

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

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

348
    if not services:
1✔
349
        from localstack.services.plugins import SERVICE_PLUGINS
1✔
350

351
        services = SERVICE_PLUGINS.list_available()
1✔
352

353
    return resolve_apis(services)
1✔
354

355

356
def start_infra_locally():
1✔
357
    from localstack.runtime.main import main
×
358

359
    return main()
×
360

361

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

367
    from localstack.cli import console
×
368

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

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

388
    import yaml  # keep import here to avoid issues in test Lambdas
×
389

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

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

420
    # docker-compose file validation cases
421

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

427
    def port_exposed(port):
×
428
        for exposed in docker_ports:
×
429
            if re.match(r"^([0-9]+-)?%s(-[0-9]+)?$" % port, exposed):
×
430
                return True
×
431

432
    if not port_exposed(edge_port):
×
433
        warns.append(
×
434
            (
435
                f"Edge port {edge_port} is not exposed. You may have to add the entry "
436
                'to the "ports" section of the docker-compose file.'
437
            )
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(".*%s" % 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: Union[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: BindMount | VolumeDirMount):
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
                )
UNCOV
999
            except ContainerException as e:
×
UNCOV
1000
                if "is already in progress" in str(e):
×
UNCOV
1001
                    return
×
1002
                raise
×
1003

1004
    def inspect(self) -> Dict[str, Union[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: Optional[CancellableStream] = None
×
1033

1034
    def _can_start_streaming(self):
1✔
1035
        if self._closed.is_set():
×
1036
            raise IOError("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 IOError:
×
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 ContainerExists(
×
1119
                'LocalStack container named "%s" is already running' % self.container.name
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
def prepare_docker_start():
1✔
1156
    # prepare environment for docker start
1157
    container_name = config.MAIN_CONTAINER_NAME
×
1158

1159
    if DOCKER_CLIENT.is_container_running(container_name):
×
1160
        raise ContainerExists('LocalStack container named "%s" is already running' % container_name)
×
1161

1162
    config.dirs.mkdirs()
×
1163

1164

1165
def configure_container(container: Container):
1✔
1166
    """
1167
    Configuration routine for the LocalstackContainer.
1168
    """
1169
    port_configuration = PortMappings(bind_host=config.GATEWAY_LISTEN[0].host)
1✔
1170

1171
    # base configuration
1172
    container.config.image_name = get_docker_image_to_start()
1✔
1173
    container.config.name = config.MAIN_CONTAINER_NAME
1✔
1174
    container.config.volumes = VolumeMappings()
1✔
1175
    container.config.remove = True
1✔
1176
    container.config.ports = port_configuration
1✔
1177
    container.config.entrypoint = os.environ.get("ENTRYPOINT")
1✔
1178
    container.config.command = shlex.split(os.environ.get("CMD", "")) or None
1✔
1179
    container.config.env_vars = {}
1✔
1180

1181
    # parse `DOCKER_FLAGS` and add them appropriately
1182
    user_flags = config.DOCKER_FLAGS
1✔
1183
    user_flags = extract_port_flags(user_flags, container.config.ports)
1✔
1184
    if container.config.additional_flags is None:
1✔
1185
        container.config.additional_flags = user_flags
1✔
1186
    else:
1187
        container.config.additional_flags = f"{container.config.additional_flags} {user_flags}"
×
1188

1189
    # get additional parameters from plux
1190
    hooks.configure_localstack_container.run(container)
1✔
1191

1192
    if config.DEVELOP:
1✔
1193
        container.config.ports.add(config.DEVELOP_PORT)
×
1194

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

1210

1211
@log_duration()
1✔
1212
def prepare_host(console):
1✔
1213
    """
1214
    Prepare the host environment for running LocalStack, this should be called before start_infra_*.
1215
    """
1216
    if os.environ.get(constants.LOCALSTACK_INFRA_PROCESS) in constants.TRUE_STRINGS:
1✔
1217
        return
×
1218

1219
    try:
1✔
1220
        mkdir(config.VOLUME_DIR)
1✔
1221
    except Exception as e:
×
1222
        console.print(f"Error while creating volume dir {config.VOLUME_DIR}: {e}")
×
1223
        if config.DEBUG:
×
1224
            console.print_exception()
×
1225

1226
    setup_logging()
1✔
1227
    hooks.prepare_host.run()
1✔
1228

1229

1230
def start_infra_in_docker(console, cli_params: Dict[str, Any] = None):
1✔
1231
    prepare_docker_start()
×
1232

1233
    # create and prepare container
1234
    container_config = ContainerConfiguration(get_docker_image_to_start())
×
1235
    container = Container(container_config)
×
1236
    ensure_container_image(console, container)
×
1237

1238
    configure_container(container)
×
1239
    container.configure(ContainerConfigurators.cli_params(cli_params or {}))
×
1240

1241
    status = console.status("Starting LocalStack container")
×
1242
    status.start()
×
1243

1244
    # printing the container log is the current way we're occupying the terminal
1245
    def _init_log_printer(line):
×
1246
        """Prints the console rule separator on the first line, then re-configures the callback
1247
        to print."""
1248
        status.stop()
×
1249
        console.rule("LocalStack Runtime Log (press [bold][yellow]CTRL-C[/yellow][/bold] to quit)")
×
1250
        print(line)
×
1251
        log_printer.callback = print
×
1252

1253
    log_printer = ContainerLogPrinter(container, callback=_init_log_printer)
×
1254

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

1269
    shutdown_event = threading.Event()
×
1270
    shutdown_event_lock = threading.RLock()
×
1271
    signal.signal(signal.SIGINT, shutdown_handler)
×
1272

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

1292

1293
def ensure_container_image(console, container: Container):
1✔
1294
    try:
×
1295
        DOCKER_CLIENT.inspect_image(container.config.image_name, pull=False)
×
1296
        return
×
1297
    except NoSuchImage:
×
1298
        console.log("container image not found on host")
×
1299

1300
    with console.status(f"Pulling container image {container.config.image_name}"):
×
1301
        DOCKER_CLIENT.pull_image(container.config.image_name)
×
1302
        console.log("download complete")
×
1303

1304

1305
def start_infra_in_docker_detached(console, cli_params: Dict[str, Any] = None):
1✔
1306
    """
1307
    An alternative to start_infra_in_docker where the terminal is not blocked by the follow on the logfile.
1308
    """
1309
    console.log("preparing environment")
×
1310
    try:
×
1311
        prepare_docker_start()
×
1312
    except ContainerExists as e:
×
1313
        console.print(str(e))
×
1314
        return
×
1315

1316
    # create and prepare container
1317
    console.log("configuring container")
×
1318
    container_config = ContainerConfiguration(get_docker_image_to_start())
×
1319
    container = Container(container_config)
×
1320
    ensure_container_image(console, container)
×
1321
    configure_container(container)
×
1322
    container.configure(ContainerConfigurators.cli_params(cli_params or {}))
×
1323

1324
    container_config.detach = True
×
1325

1326
    # start the Localstack container as a Server
1327
    console.log("starting container")
×
1328
    server = LocalstackContainerServer(container_config)
×
1329
    server.start()
×
1330
    server.wait_is_container_running()
×
1331
    console.log("detaching")
×
1332

1333

1334
def wait_container_is_ready(timeout: Optional[float] = None):
1✔
1335
    """Blocks until the localstack main container is running and the ready marker has been printed."""
1336
    container_name = config.MAIN_CONTAINER_NAME
×
1337
    started = time.time()
×
1338

1339
    def is_container_running():
×
1340
        return DOCKER_CLIENT.is_container_running(container_name)
×
1341

1342
    if not poll_condition(is_container_running, timeout=timeout):
×
1343
        return False
×
1344

1345
    stream = DOCKER_CLIENT.stream_container_logs(container_name)
×
1346

1347
    # create a timer that will terminate the log stream after the remaining timeout
1348
    timer = None
×
1349
    if timeout:
×
1350
        waited = time.time() - started
×
1351
        remaining = timeout - waited
×
1352
        # check the rare case that the timeout has already been reached
1353
        if remaining <= 0:
×
1354
            stream.close()
×
1355
            return False
×
1356
        timer = threading.Timer(remaining, stream.close)
×
1357
        timer.start()
×
1358

1359
    try:
×
1360
        for line in stream:
×
1361
            line = line.decode("utf-8").strip()
×
1362
            if line == constants.READY_MARKER_OUTPUT:
×
1363
                return True
×
1364

1365
        # EOF was reached or the stream was closed
1366
        return False
×
1367
    finally:
1368
        call_safe(stream.close)
×
1369
        if timer:
×
1370
            # make sure the timer is stopped (does nothing if it has already run)
1371
            timer.cancel()
×
1372

1373

1374
# ---------------
1375
# UTIL FUNCTIONS
1376
# ---------------
1377

1378

1379
def in_ci():
1✔
1380
    """Whether or not we are running in a CI environment"""
1381
    for key in ("CI", "TRAVIS"):
1✔
1382
        if os.environ.get(key, "") not in [False, "", "0", "false"]:
1✔
1383
            return True
1✔
1384
    return False
×
1385

1386

1387
def is_auth_token_configured() -> bool:
1✔
1388
    """Whether an API key is set in the environment."""
1389
    return (
×
1390
        True
1391
        if os.environ.get("LOCALSTACK_AUTH_TOKEN", "").strip()
1392
        or os.environ.get("LOCALSTACK_API_KEY", "").strip()
1393
        else False
1394
    )
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