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

localstack / localstack / 20449761985

22 Dec 2025 09:22PM UTC coverage: 86.912% (-0.008%) from 86.92%
20449761985

push

github

web-flow
APIGW: improve store typing (#13552)

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

130 existing lines in 7 files now uncovered.

70016 of 80560 relevant lines covered (86.91%)

0.87 hits per line

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

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

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

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

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

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

136

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

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

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

155
        return wrapped
1✔
156

157
    return wrapper
1✔
158

159

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

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

177

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

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

189

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

193

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

220

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

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

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

238
    return container_version
×
239

240

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

245
    setup_logging_from_config()
1✔
246

247

248
# --------------
249
# INFRA STARTUP
250
# --------------
251

252

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

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

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

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

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

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

284
        result.add(service)
1✔
285

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

290
    return result
1✔
291

292

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

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

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

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

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

321
    return resolve_apis(services)
1✔
322

323

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

327

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

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

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

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

352
        services = SERVICE_PLUGINS.list_available()
1✔
353

354
    return resolve_apis(services)
1✔
355

356

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

360
    return main()
×
361

362

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

368
    from localstack.cli import console
×
369

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

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

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

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

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

421
    # docker-compose file validation cases
422

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

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

433
    if not port_exposed(edge_port):
×
434
        warns.append(
×
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
    # print warning/info messages
440
    for warning in warns:
×
441
        console.print("[yellow]:warning:[/yellow]", warning)
×
442
    if not warns:
×
443
        return True
×
444
    return False
×
445

446

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

455

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

469

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

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

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

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

493
        return _cfg
1✔
494

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

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

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

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

523
            for non_prefixed_env_var in non_prefixed_env_vars:
1✔
524
                # Show a deprecation warning for each individual env var collected above
525
                LOG.warning(
1✔
526
                    "Non-prefixed environment variable %(env_var)s is forwarded to the LocalStack container! "
527
                    "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.",
528
                    {"env_var": non_prefixed_env_var},
529
                )
530

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

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

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

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

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

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

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

578
        return _cfg
1✔
579

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

586
        return _cfg
×
587

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

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

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

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

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

617
        return _cfg
1✔
618

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

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

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

637
        return _cfg
1✔
638

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

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

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

652
        return _cfg
1✔
653

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

659
        return _cfg
1✔
660

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

666
        return _cfg
1✔
667

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

673
        return _cfg
1✔
674

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

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

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

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

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

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

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

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

712
        return _cfg
1✔
713

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

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

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

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

736
        return _cfg
1✔
737

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

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

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

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

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

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

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

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

784
        return _cfg
1✔
785

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

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

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

799
        return _cfg
1✔
800

801

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

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

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

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

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

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

834

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

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

851

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

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

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

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

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

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

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

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

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

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

928

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1020

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

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

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

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

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

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

1054

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1149

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

1153

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

1157

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

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

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

1168
    config.dirs.mkdirs()
×
1169

1170

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

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

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

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

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

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

1216

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

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

1232
    setup_logging()
1✔
1233
    hooks.prepare_host.run()
1✔
1234

1235

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

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

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

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

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

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

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

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

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

1298

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

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

1310

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

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

1331
    container_config.detach = True
×
1332

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

1340

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

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

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

1352
    stream = DOCKER_CLIENT.stream_container_logs(container_name)
×
1353

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

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

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

1380

1381
# ---------------
1382
# UTIL FUNCTIONS
1383
# ---------------
1384

1385

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

1393

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

© 2025 Coveralls, Inc