playground-ls-cli 4.14.1.dev8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. localstack_cli/__init__.py +0 -0
  2. localstack_cli/cli/__init__.py +10 -0
  3. localstack_cli/cli/console.py +11 -0
  4. localstack_cli/cli/core_plugin.py +12 -0
  5. localstack_cli/cli/exceptions.py +19 -0
  6. localstack_cli/cli/localstack.py +951 -0
  7. localstack_cli/cli/lpm.py +138 -0
  8. localstack_cli/cli/main.py +22 -0
  9. localstack_cli/cli/plugin.py +39 -0
  10. localstack_cli/cli/plugins.py +134 -0
  11. localstack_cli/cli/profiles.py +65 -0
  12. localstack_cli/config.py +1689 -0
  13. localstack_cli/constants.py +165 -0
  14. localstack_cli/logging/__init__.py +0 -0
  15. localstack_cli/logging/format.py +194 -0
  16. localstack_cli/logging/setup.py +142 -0
  17. localstack_cli/packages/__init__.py +25 -0
  18. localstack_cli/packages/api.py +418 -0
  19. localstack_cli/packages/core.py +416 -0
  20. localstack_cli/pro/__init__.py +0 -0
  21. localstack_cli/pro/core/__init__.py +0 -0
  22. localstack_cli/pro/core/bootstrap/__init__.py +1 -0
  23. localstack_cli/pro/core/bootstrap/auth.py +213 -0
  24. localstack_cli/pro/core/bootstrap/dns_utils.py +55 -0
  25. localstack_cli/pro/core/bootstrap/entitlements.py +117 -0
  26. localstack_cli/pro/core/bootstrap/extensions/__init__.py +3 -0
  27. localstack_cli/pro/core/bootstrap/extensions/__main__.py +106 -0
  28. localstack_cli/pro/core/bootstrap/extensions/autoinstall.py +63 -0
  29. localstack_cli/pro/core/bootstrap/extensions/bootstrap.py +97 -0
  30. localstack_cli/pro/core/bootstrap/extensions/repository.py +374 -0
  31. localstack_cli/pro/core/bootstrap/licensingv2.py +1259 -0
  32. localstack_cli/pro/core/bootstrap/pods/__init__.py +0 -0
  33. localstack_cli/pro/core/bootstrap/pods/api_types.py +17 -0
  34. localstack_cli/pro/core/bootstrap/pods/constants.py +26 -0
  35. localstack_cli/pro/core/bootstrap/pods/remotes/__init__.py +0 -0
  36. localstack_cli/pro/core/bootstrap/pods/remotes/api.py +75 -0
  37. localstack_cli/pro/core/bootstrap/pods/remotes/configs.py +69 -0
  38. localstack_cli/pro/core/bootstrap/pods/remotes/params.py +86 -0
  39. localstack_cli/pro/core/bootstrap/pods_client.py +834 -0
  40. localstack_cli/pro/core/cli/__init__.py +0 -0
  41. localstack_cli/pro/core/cli/auth.py +226 -0
  42. localstack_cli/pro/core/cli/aws.py +16 -0
  43. localstack_cli/pro/core/cli/cli.py +99 -0
  44. localstack_cli/pro/core/cli/click_utils.py +21 -0
  45. localstack_cli/pro/core/cli/cloud_pods.py +465 -0
  46. localstack_cli/pro/core/cli/diff_view.py +41 -0
  47. localstack_cli/pro/core/cli/ephemeral.py +199 -0
  48. localstack_cli/pro/core/cli/extensions.py +492 -0
  49. localstack_cli/pro/core/cli/iam.py +180 -0
  50. localstack_cli/pro/core/cli/license.py +90 -0
  51. localstack_cli/pro/core/cli/localstack.py +118 -0
  52. localstack_cli/pro/core/cli/replicator.py +378 -0
  53. localstack_cli/pro/core/cli/state.py +183 -0
  54. localstack_cli/pro/core/cli/tree_view.py +235 -0
  55. localstack_cli/pro/core/config.py +556 -0
  56. localstack_cli/pro/core/constants.py +54 -0
  57. localstack_cli/pro/core/plugins.py +169 -0
  58. localstack_cli/runtime/__init__.py +6 -0
  59. localstack_cli/runtime/exceptions.py +7 -0
  60. localstack_cli/runtime/hooks.py +73 -0
  61. localstack_cli/testing/__init__.py +1 -0
  62. localstack_cli/testing/config.py +4 -0
  63. localstack_cli/utils/__init__.py +0 -0
  64. localstack_cli/utils/analytics/__init__.py +12 -0
  65. localstack_cli/utils/analytics/cli.py +67 -0
  66. localstack_cli/utils/analytics/client.py +111 -0
  67. localstack_cli/utils/analytics/events.py +30 -0
  68. localstack_cli/utils/analytics/logger.py +48 -0
  69. localstack_cli/utils/analytics/metadata.py +250 -0
  70. localstack_cli/utils/analytics/publisher.py +160 -0
  71. localstack_cli/utils/analytics/service_request_aggregator.py +133 -0
  72. localstack_cli/utils/archives.py +271 -0
  73. localstack_cli/utils/batching.py +258 -0
  74. localstack_cli/utils/bootstrap.py +1418 -0
  75. localstack_cli/utils/checksum.py +313 -0
  76. localstack_cli/utils/collections.py +554 -0
  77. localstack_cli/utils/common.py +229 -0
  78. localstack_cli/utils/container_networking.py +142 -0
  79. localstack_cli/utils/container_utils/__init__.py +0 -0
  80. localstack_cli/utils/container_utils/container_client.py +1585 -0
  81. localstack_cli/utils/container_utils/docker_cmd_client.py +987 -0
  82. localstack_cli/utils/container_utils/docker_sdk_client.py +1018 -0
  83. localstack_cli/utils/crypto.py +294 -0
  84. localstack_cli/utils/docker_utils.py +272 -0
  85. localstack_cli/utils/files.py +327 -0
  86. localstack_cli/utils/functions.py +92 -0
  87. localstack_cli/utils/http.py +326 -0
  88. localstack_cli/utils/json.py +219 -0
  89. localstack_cli/utils/net.py +516 -0
  90. localstack_cli/utils/no_exit_argument_parser.py +19 -0
  91. localstack_cli/utils/numbers.py +49 -0
  92. localstack_cli/utils/objects.py +235 -0
  93. localstack_cli/utils/patch.py +260 -0
  94. localstack_cli/utils/platform.py +77 -0
  95. localstack_cli/utils/run.py +514 -0
  96. localstack_cli/utils/server/__init__.py +0 -0
  97. localstack_cli/utils/server/tcp_proxy.py +108 -0
  98. localstack_cli/utils/serving.py +187 -0
  99. localstack_cli/utils/ssl.py +71 -0
  100. localstack_cli/utils/strings.py +245 -0
  101. localstack_cli/utils/sync.py +267 -0
  102. localstack_cli/utils/threads.py +163 -0
  103. localstack_cli/utils/time.py +81 -0
  104. localstack_cli/utils/urls.py +21 -0
  105. localstack_cli/utils/venv.py +100 -0
  106. localstack_cli/utils/xml.py +41 -0
  107. localstack_cli/version.py +34 -0
  108. playground_ls_cli-4.14.1.dev8.dist-info/METADATA +95 -0
  109. playground_ls_cli-4.14.1.dev8.dist-info/RECORD +112 -0
  110. playground_ls_cli-4.14.1.dev8.dist-info/WHEEL +5 -0
  111. playground_ls_cli-4.14.1.dev8.dist-info/entry_points.txt +17 -0
  112. playground_ls_cli-4.14.1.dev8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1418 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import functools
5
+ import logging
6
+ import os
7
+ import re
8
+ import shlex
9
+ import signal
10
+ import threading
11
+ import time
12
+ from collections.abc import Callable, Iterable
13
+ from functools import wraps
14
+ from typing import Any
15
+
16
+ from localstack_cli import config, constants
17
+ from localstack_cli.config import (
18
+ HostAndPort,
19
+ default_ip,
20
+ is_env_not_false,
21
+ load_environment,
22
+ )
23
+ from localstack_cli.constants import VERSION
24
+ from localstack_cli.runtime import hooks
25
+ from localstack_cli.utils.container_networking import get_main_container_name
26
+ from localstack_cli.utils.container_utils.container_client import (
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_cli.utils.container_utils.docker_cmd_client import CmdDockerClient
41
+ from localstack_cli.utils.docker_utils import DOCKER_CLIENT
42
+ from localstack_cli.utils.files import cache_dir, mkdir
43
+ from localstack_cli.utils.functions import call_safe
44
+ from localstack_cli.utils.net import get_free_tcp_port, get_free_tcp_port_range
45
+ from localstack_cli.utils.run import is_command_available, run, to_str
46
+ from localstack_cli.utils.serving import Server
47
+ from localstack_cli.utils.strings import short_uid
48
+ from localstack_cli.utils.sync import poll_condition
49
+
50
+ LOG = logging.getLogger(__name__)
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 = {
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 = {
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 = {
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):
138
+ """Function decorator to log the duration of function invocations."""
139
+
140
+ def wrapper(f):
141
+ @wraps(f)
142
+ def wrapped(*args, **kwargs):
143
+ from time import perf_counter
144
+
145
+ start_time = perf_counter()
146
+ try:
147
+ return f(*args, **kwargs)
148
+ finally:
149
+ end_time = perf_counter()
150
+ func_name = name or f.__name__
151
+ duration = (end_time - start_time) * 1000
152
+ if duration > min_ms:
153
+ LOG.info('Execution of "%s" took %.2fms', func_name, duration)
154
+
155
+ return wrapped
156
+
157
+ return wrapper
158
+
159
+
160
+ def get_docker_image_details(image_name: str = None) -> dict[str, str]:
161
+ image_name = image_name or get_docker_image_to_start()
162
+ try:
163
+ result = DOCKER_CLIENT.inspect_image(image_name)
164
+ except ContainerException:
165
+ return {}
166
+
167
+ digests = result.get("RepoDigests")
168
+ sha256 = digests[0].rpartition(":")[2] if digests else "Unavailable"
169
+ result = {
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
176
+
177
+
178
+ def get_image_environment_variable(env_name: str) -> str | None:
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:
191
+ return os.path.join(config.dirs.mounted_tmp, f"{container_name}_container.log")
192
+
193
+
194
+ def get_server_version_from_running_container() -> str:
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:
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():
242
+ """Determine and set log level. The singleton factory makes sure the logging is only set up once."""
243
+ from localstack_cli.logging.setup import setup_logging_from_config
244
+
245
+ setup_logging_from_config()
246
+
247
+
248
+ # --------------
249
+ # INFRA STARTUP
250
+ # --------------
251
+
252
+
253
+ def resolve_apis(services: Iterable[str]) -> set[str]:
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 = []
269
+ result = set()
270
+
271
+ # perform a graph search
272
+ stack.extend(services)
273
+ while stack:
274
+ service = stack.pop()
275
+
276
+ if service in result:
277
+ continue
278
+
279
+ # resolve composites (like "serverless"), but do not add it to the list of results
280
+ if service in API_COMPOSITES:
281
+ stack.extend(API_COMPOSITES[service])
282
+ continue
283
+
284
+ result.add(service)
285
+
286
+ # add dependencies to stack
287
+ if service in API_DEPENDENCIES:
288
+ stack.extend(API_DEPENDENCIES[service])
289
+
290
+ return result
291
+
292
+
293
+ @functools.lru_cache
294
+ def get_enabled_apis() -> set[str]:
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
+ Note: This function requires runtime dependencies. It will raise ImportError in standalone CLI mode.
303
+ """
304
+ try:
305
+ from localstack_cli.services.plugins import SERVICE_PLUGINS
306
+ except ImportError as e:
307
+ raise ImportError(
308
+ "get_enabled_apis requires runtime dependencies. Not available in standalone CLI mode."
309
+ ) from e
310
+
311
+ services_env = os.environ.get("SERVICES", "").strip()
312
+ services = SERVICE_PLUGINS.list_available()
313
+
314
+ if services_env and is_env_not_false("STRICT_SERVICE_LOADING"):
315
+ # SERVICES and STRICT_SERVICE_LOADING are set
316
+ # we filter the result of SERVICE_PLUGINS.list_available() to cross the user-provided list with
317
+ # the available ones
318
+ enabled_services = []
319
+ for service_port in re.split(r"\s*,\s*", services_env):
320
+ # Only extract the service name, discard the port
321
+ parts = re.split(r"[:=]", service_port)
322
+ service = parts[0]
323
+ enabled_services.append(service)
324
+
325
+ services = [service for service in enabled_services if service in services]
326
+ # TODO: log a message if a service was not supported? see with pro loading
327
+
328
+ return resolve_apis(services)
329
+
330
+
331
+ def is_api_enabled(api: str) -> bool:
332
+ return api in get_enabled_apis()
333
+
334
+
335
+ @functools.lru_cache
336
+ def get_preloaded_services() -> set[str]:
337
+ """
338
+ Returns the list of APIs that are marked to be eager loaded through the combination of SERVICES variable and
339
+ EAGER_SERVICE_LOADING. If the SERVICES variable is empty, then it will return all available services.
340
+ Meta-services like "serverless" or "cognito", and dependencies are resolved.
341
+
342
+ The result is cached, so it's safe to call. Clear the cache with get_preloaded_services.cache_clear().
343
+
344
+ Note: This function requires runtime dependencies when SERVICES is not set.
345
+ """
346
+ services_env = os.environ.get("SERVICES", "").strip()
347
+ services = []
348
+
349
+ if services_env:
350
+ # SERVICES and EAGER_SERVICE_LOADING are set
351
+ # SERVICES env var might contain ports, but we do not support these anymore
352
+ for service_port in re.split(r"\s*,\s*", services_env):
353
+ # Only extract the service name, discard the port
354
+ parts = re.split(r"[:=]", service_port)
355
+ service = parts[0]
356
+ services.append(service)
357
+
358
+ if not services:
359
+ try:
360
+ from localstack_cli.services.plugins import SERVICE_PLUGINS
361
+ except ImportError as e:
362
+ raise ImportError(
363
+ "get_preloaded_services requires runtime dependencies when SERVICES is not set. "
364
+ "Not available in standalone CLI mode."
365
+ ) from e
366
+
367
+ services = SERVICE_PLUGINS.list_available()
368
+
369
+ return resolve_apis(services)
370
+
371
+
372
+ def start_infra_locally():
373
+ """Start LocalStack in host mode - requires runtime dependencies."""
374
+ try:
375
+ from localstack_cli.runtime.main import main
376
+ except ImportError as e:
377
+ raise ImportError(
378
+ "Cannot start LocalStack in host mode. Runtime dependencies not installed. "
379
+ "Use Docker mode instead: `localstack start`"
380
+ ) from e
381
+
382
+ return main()
383
+
384
+
385
+ def validate_localstack_config(name: str):
386
+ # TODO: separate functionality from CLI output
387
+ # (use exceptions to communicate errors, and return list of warnings)
388
+ from subprocess import CalledProcessError
389
+
390
+ from localstack_cli.cli import console
391
+
392
+ dirname = os.getcwd()
393
+ compose_file_name = name if os.path.isabs(name) else os.path.join(dirname, name)
394
+ warns = []
395
+
396
+ # some systems do not have "docker-compose" aliased to "docker compose", and older systems do not have
397
+ # "docker compose" at all. By preferring the old way and falling back on the new, we should get docker compose in
398
+ # any way, if installed
399
+ if is_command_available("docker-compose"):
400
+ compose_command = ["docker-compose"]
401
+ else:
402
+ compose_command = ["docker", "compose"]
403
+ # validating docker-compose file
404
+ cmd = [*compose_command, "-f", compose_file_name, "config"]
405
+ try:
406
+ run(cmd, shell=False, print_error=False)
407
+ except CalledProcessError as e:
408
+ msg = f"{e}\n{to_str(e.output)}".strip()
409
+ raise ValueError(msg)
410
+
411
+ import yaml # keep import here to avoid issues in test Lambdas
412
+
413
+ # validating docker-compose variable
414
+ with open(compose_file_name) as file:
415
+ compose_content = yaml.full_load(file)
416
+ services_config = compose_content.get("services", {})
417
+ ls_service_name = [
418
+ name for name, svc in services_config.items() if "localstack" in svc.get("image", "")
419
+ ]
420
+ if not ls_service_name:
421
+ raise Exception(
422
+ 'No LocalStack service found in config (looking for image names containing "localstack")'
423
+ )
424
+ if len(ls_service_name) > 1:
425
+ warns.append(f"Multiple candidates found for LocalStack service: {ls_service_name}")
426
+ ls_service_name = ls_service_name[0]
427
+ ls_service_details = services_config[ls_service_name]
428
+ image_name = ls_service_details.get("image", "")
429
+ if image_name.split(":")[0] not in constants.OFFICIAL_IMAGES:
430
+ warns.append(
431
+ f'Using custom image "{image_name}", we recommend using an officially supported image: {constants.OFFICIAL_IMAGES}'
432
+ )
433
+
434
+ # prepare config options
435
+ container_name = ls_service_details.get("container_name") or ""
436
+ docker_ports = (port.split(":")[-2] for port in ls_service_details.get("ports", []))
437
+ docker_env = {
438
+ env.split("=")[0]: env.split("=")[1] for env in ls_service_details.get("environment", {})
439
+ }
440
+ edge_port = config.GATEWAY_LISTEN[0].port
441
+ main_container = config.MAIN_CONTAINER_NAME
442
+
443
+ # docker-compose file validation cases
444
+
445
+ if (main_container not in container_name) and not docker_env.get("MAIN_CONTAINER_NAME"):
446
+ warns.append(
447
+ f'Please use "container_name: {main_container}" or add "MAIN_CONTAINER_NAME" in "environment".'
448
+ )
449
+
450
+ def port_exposed(port):
451
+ for exposed in docker_ports:
452
+ if re.match(rf"^([0-9]+-)?{port}(-[0-9]+)?$", exposed):
453
+ return True
454
+
455
+ if not port_exposed(edge_port):
456
+ warns.append(
457
+ f"Edge port {edge_port} is not exposed. You may have to add the entry "
458
+ 'to the "ports" section of the docker-compose file.'
459
+ )
460
+
461
+ # print warning/info messages
462
+ for warning in warns:
463
+ console.print("[yellow]:warning:[/yellow]", warning)
464
+ if not warns:
465
+ return True
466
+ return False
467
+
468
+
469
+ def get_docker_image_to_start():
470
+ return os.environ.get("IMAGE_NAME", constants.DOCKER_IMAGE_NAME_PRO)
471
+
472
+
473
+ def extract_port_flags(user_flags, port_mappings: PortMappings):
474
+ regex = r"-p\s+([0-9]+)(\-([0-9]+))?:([0-9]+)(\-([0-9]+))?"
475
+ matches = re.match(f".*{regex}", user_flags)
476
+ if matches:
477
+ for match in re.findall(regex, user_flags):
478
+ start = int(match[0])
479
+ end = int(match[2] or match[0])
480
+ start_target = int(match[3] or start)
481
+ end_target = int(match[5] or end)
482
+ port_mappings.add([start, end], [start_target, end_target])
483
+ user_flags = re.sub(regex, r"", user_flags)
484
+ return user_flags
485
+
486
+
487
+ class ContainerConfigurators:
488
+ """
489
+ A set of useful container configurators that are typical for starting the localstack container.
490
+ """
491
+
492
+ @staticmethod
493
+ def mount_docker_socket(cfg: ContainerConfiguration):
494
+ source = config.DOCKER_SOCK
495
+ target = "/var/run/docker.sock"
496
+ if cfg.volumes.find_target_mapping(target):
497
+ return
498
+ cfg.volumes.add(BindMount(source, target))
499
+ cfg.env_vars["DOCKER_HOST"] = f"unix://{target}"
500
+
501
+ @staticmethod
502
+ def mount_localstack_volume(host_path: str | os.PathLike = None):
503
+ host_path = host_path or config.VOLUME_DIR
504
+
505
+ def _cfg(cfg: ContainerConfiguration):
506
+ if cfg.volumes.find_target_mapping(constants.DEFAULT_VOLUME_DIR):
507
+ return
508
+ cfg.volumes.add(BindMount(str(host_path), constants.DEFAULT_VOLUME_DIR))
509
+
510
+ return _cfg
511
+
512
+ @staticmethod
513
+ def config_env_vars(cfg: ContainerConfiguration):
514
+ """Sets all env vars from config.CONFIG_ENV_VARS."""
515
+
516
+ profile_env = {}
517
+ if config.LOADED_PROFILES:
518
+ load_environment(profiles=",".join(config.LOADED_PROFILES), env=profile_env)
519
+
520
+ non_prefixed_env_vars = []
521
+ for env_var in config.CONFIG_ENV_VARS:
522
+ value = os.environ.get(env_var, None)
523
+ if value is not None:
524
+ if (
525
+ env_var != "CI"
526
+ and not env_var.startswith("LOCALSTACK_")
527
+ and env_var not in profile_env
528
+ ):
529
+ # Collect all env vars that are directly forwarded from the system env
530
+ # to the container which has not been prefixed with LOCALSTACK_ here.
531
+ # Suppress the "CI" env var.
532
+ # Suppress if the env var was set from the profile.
533
+ non_prefixed_env_vars.append(env_var)
534
+ cfg.env_vars[env_var] = value
535
+
536
+ # collectively log deprecation warnings for non-prefixed sys env vars
537
+ if non_prefixed_env_vars:
538
+ from localstack_cli.utils.analytics import log
539
+
540
+ for non_prefixed_env_var in non_prefixed_env_vars:
541
+ # Show a deprecation warning for each individual env var collected above
542
+ LOG.warning(
543
+ "Non-prefixed environment variable %(env_var)s is forwarded to the LocalStack container! "
544
+ "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.",
545
+ {"env_var": non_prefixed_env_var},
546
+ )
547
+
548
+ log.event(
549
+ event="non_prefixed_cli_env_vars", payload={"env_vars": non_prefixed_env_vars}
550
+ )
551
+
552
+ @staticmethod
553
+ def random_gateway_port(cfg: ContainerConfiguration):
554
+ """Gets a random port on the host and maps it to the default edge port 4566."""
555
+ return ContainerConfigurators.gateway_listen(get_free_tcp_port())(cfg)
556
+
557
+ @staticmethod
558
+ def default_gateway_port(cfg: ContainerConfiguration):
559
+ """Adds 4566 to the list of port mappings"""
560
+ return ContainerConfigurators.gateway_listen(constants.DEFAULT_PORT_EDGE)(cfg)
561
+
562
+ @staticmethod
563
+ def gateway_listen(
564
+ port: int | Iterable[int] | HostAndPort | Iterable[HostAndPort],
565
+ ):
566
+ """
567
+ Uses the given ports to configure GATEWAY_LISTEN. For instance, ``gateway_listen([4566, 443])`` would
568
+ result in the port mappings 4566:4566, 443:443, as well as ``GATEWAY_LISTEN=:4566,:443``.
569
+
570
+ :param port: a single or list of ports, can either be int ports or HostAndPort instances
571
+ :return: a configurator
572
+ """
573
+ if isinstance(port, int):
574
+ ports = [HostAndPort("", port)]
575
+ elif isinstance(port, HostAndPort):
576
+ ports = [port]
577
+ else:
578
+ ports = []
579
+ for p in port:
580
+ if isinstance(p, int):
581
+ ports.append(HostAndPort("", p))
582
+ else:
583
+ ports.append(p)
584
+
585
+ def _cfg(cfg: ContainerConfiguration):
586
+ for _p in ports:
587
+ cfg.ports.add(_p.port)
588
+
589
+ # gateway listen should be compiled s.t. even if we set "127.0.0.1:4566" from the host,
590
+ # it will be correctly exposed on "0.0.0.0:4566" in the container.
591
+ cfg.env_vars["GATEWAY_LISTEN"] = ",".join(
592
+ [f"{p.host if p.host != default_ip else ''}:{p.port}" for p in ports]
593
+ )
594
+
595
+ return _cfg
596
+
597
+ @staticmethod
598
+ def container_name(name: str):
599
+ def _cfg(cfg: ContainerConfiguration):
600
+ cfg.name = name
601
+ cfg.env_vars["MAIN_CONTAINER_NAME"] = cfg.name
602
+
603
+ return _cfg
604
+
605
+ @staticmethod
606
+ def random_container_name(cfg: ContainerConfiguration):
607
+ cfg.name = f"localstack-{short_uid()}"
608
+ cfg.env_vars["MAIN_CONTAINER_NAME"] = cfg.name
609
+
610
+ @staticmethod
611
+ def default_container_name(cfg: ContainerConfiguration):
612
+ cfg.name = config.MAIN_CONTAINER_NAME
613
+ cfg.env_vars["MAIN_CONTAINER_NAME"] = cfg.name
614
+
615
+ @staticmethod
616
+ def service_port_range(cfg: ContainerConfiguration):
617
+ cfg.ports.add([config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END])
618
+ cfg.env_vars["EXTERNAL_SERVICE_PORTS_START"] = config.EXTERNAL_SERVICE_PORTS_START
619
+ cfg.env_vars["EXTERNAL_SERVICE_PORTS_END"] = config.EXTERNAL_SERVICE_PORTS_END
620
+
621
+ @staticmethod
622
+ def random_service_port_range(num: int = 50):
623
+ """
624
+ Tries to find a contiguous list of random ports on the host to map to the external service port
625
+ range in the container.
626
+ """
627
+
628
+ def _cfg(cfg: ContainerConfiguration):
629
+ port_range = get_free_tcp_port_range(num)
630
+ cfg.ports.add([port_range.start, port_range.end])
631
+ cfg.env_vars["EXTERNAL_SERVICE_PORTS_START"] = str(port_range.start)
632
+ cfg.env_vars["EXTERNAL_SERVICE_PORTS_END"] = str(port_range.end)
633
+
634
+ return _cfg
635
+
636
+ @staticmethod
637
+ def debug(cfg: ContainerConfiguration):
638
+ cfg.env_vars["DEBUG"] = "1"
639
+
640
+ @classmethod
641
+ def develop(cls, cfg: ContainerConfiguration):
642
+ cls.env_vars(
643
+ {
644
+ "DEVELOP": "1",
645
+ }
646
+ )(cfg)
647
+ cls.port(5678)(cfg)
648
+
649
+ @staticmethod
650
+ def network(network: str):
651
+ def _cfg(cfg: ContainerConfiguration):
652
+ cfg.network = network
653
+
654
+ return _cfg
655
+
656
+ @staticmethod
657
+ def custom_command(cmd: list[str]):
658
+ """
659
+ Overwrites the container command and unsets the default entrypoint.
660
+
661
+ :param cmd: the command to run in the container
662
+ :return: a configurator
663
+ """
664
+
665
+ def _cfg(cfg: ContainerConfiguration):
666
+ cfg.command = cmd
667
+ cfg.entrypoint = ""
668
+
669
+ return _cfg
670
+
671
+ @staticmethod
672
+ def env_vars(env_vars: dict[str, str]):
673
+ def _cfg(cfg: ContainerConfiguration):
674
+ cfg.env_vars.update(env_vars)
675
+
676
+ return _cfg
677
+
678
+ @staticmethod
679
+ def port(*args, **kwargs):
680
+ def _cfg(cfg: ContainerConfiguration):
681
+ cfg.ports.add(*args, **kwargs)
682
+
683
+ return _cfg
684
+
685
+ @staticmethod
686
+ def volume(volume: VolumeMappingSpecification):
687
+ def _cfg(cfg: ContainerConfiguration):
688
+ cfg.volumes.add(volume)
689
+
690
+ return _cfg
691
+
692
+ @staticmethod
693
+ def cli_params(params: dict[str, Any]):
694
+ """
695
+ Parse docker CLI parameters and add them to the config. The currently known CLI params are::
696
+
697
+ --network=my-network <- stored in "network"
698
+ -e FOO=BAR -e BAR=ed <- stored in "env"
699
+ -p 4566:4566 -p 4510-4559 <- stored in "publish"
700
+ -v ./bar:/foo/bar <- stored in "volume"
701
+
702
+ When parsed by click, the parameters would look like this::
703
+
704
+ {
705
+ "network": "my-network",
706
+ "env": ("FOO=BAR", "BAR=ed"),
707
+ "publish": ("4566:4566", "4510-4559"),
708
+ "volume": ("./bar:/foo/bar",),
709
+ }
710
+
711
+ :param params: a dict of parsed parameters
712
+ :return: a configurator
713
+ """
714
+
715
+ # TODO: consolidate with container_client.Util.parse_additional_flags
716
+ def _cfg(cfg: ContainerConfiguration):
717
+ if params.get("network"):
718
+ cfg.network = params.get("network")
719
+
720
+ if params.get("host_dns"):
721
+ cfg.ports.add(config.DNS_PORT, config.DNS_PORT, "udp")
722
+ cfg.ports.add(config.DNS_PORT, config.DNS_PORT, "tcp")
723
+
724
+ # processed parsed -e, -p, and -v flags
725
+ ContainerConfigurators.env_cli_params(params.get("env"))(cfg)
726
+ ContainerConfigurators.port_cli_params(params.get("publish"))(cfg)
727
+ ContainerConfigurators.volume_cli_params(params.get("volume"))(cfg)
728
+
729
+ return _cfg
730
+
731
+ @staticmethod
732
+ def env_cli_params(params: Iterable[str] = None):
733
+ """
734
+ Configures environment variables from additional CLI input through the ``-e`` options.
735
+
736
+ :param params: a list of environment variable declarations, e.g.,: ``("foo=bar", "baz=ed")``
737
+ :return: a configurator
738
+ """
739
+
740
+ def _cfg(cfg: ContainerConfiguration):
741
+ if not params:
742
+ return
743
+
744
+ for e in params:
745
+ if "=" in e:
746
+ k, v = e.split("=", maxsplit=1)
747
+ cfg.env_vars[k] = v
748
+ else:
749
+ # there's currently no way in our abstraction to only pass the variable name (as
750
+ # you can do in docker) so we resolve the value here.
751
+ cfg.env_vars[e] = os.getenv(e)
752
+
753
+ return _cfg
754
+
755
+ @staticmethod
756
+ def port_cli_params(params: Iterable[str] = None):
757
+ """
758
+ Configures port variables from additional CLI input through the ``-p`` options.
759
+
760
+ :param params: a list of port assignments, e.g.,: ``("4000-5000", "8080:80")``
761
+ :return: a configurator
762
+ """
763
+
764
+ def _cfg(cfg: ContainerConfiguration):
765
+ if not params:
766
+ return
767
+
768
+ for port_mapping in params:
769
+ port_split = port_mapping.split(":")
770
+ protocol = "tcp"
771
+ if len(port_split) == 1:
772
+ host_port = container_port = port_split[0]
773
+ elif len(port_split) == 2:
774
+ host_port, container_port = port_split
775
+ elif len(port_split) == 3:
776
+ _, host_port, container_port = port_split
777
+ else:
778
+ raise ValueError(f"Invalid port string provided: {port_mapping}")
779
+
780
+ host_port_split = host_port.split("-")
781
+ if len(host_port_split) == 2:
782
+ host_port = [int(host_port_split[0]), int(host_port_split[1])]
783
+ elif len(host_port_split) == 1:
784
+ host_port = int(host_port)
785
+ else:
786
+ raise ValueError(f"Invalid port string provided: {port_mapping}")
787
+
788
+ if "/" in container_port:
789
+ container_port, protocol = container_port.split("/")
790
+
791
+ container_port_split = container_port.split("-")
792
+ if len(container_port_split) == 2:
793
+ container_port = [int(container_port_split[0]), int(container_port_split[1])]
794
+ elif len(container_port_split) == 1:
795
+ container_port = int(container_port)
796
+ else:
797
+ raise ValueError(f"Invalid port string provided: {port_mapping}")
798
+
799
+ cfg.ports.add(host_port, container_port, protocol)
800
+
801
+ return _cfg
802
+
803
+ @staticmethod
804
+ def volume_cli_params(params: Iterable[str] = None):
805
+ """
806
+ Configures volumes from additional CLI input through the ``-v`` options.
807
+
808
+ :param params: a list of volume declarations, e.g.,: ``("./bar:/foo/bar",)``
809
+ :return: a configurator
810
+ """
811
+
812
+ def _cfg(cfg: ContainerConfiguration):
813
+ for param in params:
814
+ cfg.volumes.append(BindMount.parse(param))
815
+
816
+ return _cfg
817
+
818
+
819
+ def get_gateway_port(container: Container) -> int:
820
+ """
821
+ Heuristically determines for the given container the port the gateway will be reachable from the host.
822
+ Parses the container's ``GATEWAY_LISTEN`` if necessary and finds the appropriate port mapping.
823
+
824
+ :param container: the localstack container
825
+ :return: the gateway port reachable from the host
826
+ """
827
+ candidates: list[int]
828
+
829
+ gateway_listen = container.config.env_vars.get("GATEWAY_LISTEN")
830
+ if gateway_listen:
831
+ candidates = [
832
+ HostAndPort.parse(
833
+ value,
834
+ default_host=constants.LOCALHOST_HOSTNAME,
835
+ default_port=constants.DEFAULT_PORT_EDGE,
836
+ ).port
837
+ for value in gateway_listen.split(",")
838
+ ]
839
+ else:
840
+ candidates = [constants.DEFAULT_PORT_EDGE]
841
+
842
+ exposed = container.config.ports.to_dict()
843
+
844
+ for candidate in candidates:
845
+ port = exposed.get(f"{candidate}/tcp")
846
+ if port:
847
+ return port
848
+
849
+ raise ValueError("no gateway port mapping found")
850
+
851
+
852
+ def get_gateway_url(
853
+ container: Container,
854
+ hostname: str = constants.LOCALHOST_HOSTNAME,
855
+ protocol: str = "http",
856
+ ) -> str:
857
+ """
858
+ Returns the localstack container's gateway URL reachable from the host. In most cases this will be
859
+ ``http://localhost.localstack.cloud:4566``.
860
+
861
+ :param container: the container
862
+ :param hostname: the hostname to use (default localhost.localstack.cloud)
863
+ :param protocol: the URI scheme (default http)
864
+ :return: a URL
865
+ `"""
866
+ return f"{protocol}://{hostname}:{get_gateway_port(container)}"
867
+
868
+
869
+ class Container:
870
+ def __init__(
871
+ self, container_config: ContainerConfiguration, docker_client: ContainerClient | None = None
872
+ ):
873
+ self.config = container_config
874
+ # marker to access the running container
875
+ self.running_container: RunningContainer | None = None
876
+ self.container_client = docker_client or DOCKER_CLIENT
877
+
878
+ def configure(self, configurators: ContainerConfigurator | Iterable[ContainerConfigurator]):
879
+ """
880
+ Apply the given configurators to the config of this container.
881
+
882
+ :param configurators:
883
+ :return:
884
+ """
885
+ try:
886
+ iterator = iter(configurators)
887
+ except TypeError:
888
+ configurators(self.config)
889
+ return
890
+
891
+ for configurator in iterator:
892
+ configurator(self.config)
893
+
894
+ def start(self, attach: bool = False) -> RunningContainer:
895
+ # FIXME: this is pretty awkward, but additional_flags in the LocalstackContainer API was
896
+ # always a list of ["-e FOO=BAR", ...], whereas in the DockerClient it is expected to be
897
+ # a string. so we need to re-assemble it here. the better way would be to not use
898
+ # additional_flags here all together. it is still used in ext in
899
+ # `configure_pro_container` which could be refactored to use the additional port bindings.
900
+ cfg = copy.deepcopy(self.config)
901
+ if not cfg.additional_flags:
902
+ cfg.additional_flags = ""
903
+
904
+ # TODO: there could be a --network flag in `additional_flags`. we solve a similar problem
905
+ # for the ports using `extract_port_flags`. maybe it would be better to consolidate all
906
+ # this into the ContainerConfig object, like ContainerConfig.update_from_flags(str).
907
+ self._ensure_container_network(cfg.network)
908
+
909
+ try:
910
+ id = self.container_client.create_container_from_config(cfg)
911
+ except ContainerException as e:
912
+ if LOG.isEnabledFor(logging.DEBUG):
913
+ LOG.exception("Error while creating container")
914
+ else:
915
+ LOG.error(
916
+ "Error while creating container: %s\n%s", e.message, to_str(e.stderr or "?")
917
+ )
918
+ raise
919
+
920
+ try:
921
+ self.container_client.start_container(id, attach=attach)
922
+ except ContainerException as e:
923
+ LOG.error(
924
+ "Error while starting LocalStack container: %s\n%s",
925
+ e.message,
926
+ to_str(e.stderr),
927
+ exc_info=LOG.isEnabledFor(logging.DEBUG),
928
+ )
929
+ raise
930
+
931
+ self.running_container = RunningContainer(id, container_config=self.config)
932
+ return self.running_container
933
+
934
+ def _ensure_container_network(self, network: str | None = None):
935
+ """Makes sure the configured container network exists"""
936
+ if network:
937
+ if network in ["host", "bridge"]:
938
+ return
939
+ try:
940
+ self.container_client.inspect_network(network)
941
+ except NoSuchNetwork:
942
+ LOG.debug("Container network %s not found, creating it", network)
943
+ self.container_client.create_network(network)
944
+
945
+
946
+ class RunningContainer:
947
+ """
948
+ Represents a LocalStack container that is running.
949
+ """
950
+
951
+ def __init__(
952
+ self,
953
+ id: str,
954
+ container_config: ContainerConfiguration,
955
+ docker_client: ContainerClient | None = None,
956
+ ):
957
+ self.id = id
958
+ self.config = container_config
959
+ self.container_client = docker_client or DOCKER_CLIENT
960
+ self.name = self.container_client.get_container_name(self.id)
961
+ self._shutdown = False
962
+ self._mutex = threading.Lock()
963
+
964
+ def __enter__(self):
965
+ return self
966
+
967
+ def __exit__(self, exc_type, exc_value, traceback):
968
+ self.shutdown()
969
+
970
+ def ip_address(self, docker_network: str | None = None) -> str:
971
+ """
972
+ Get the IP address of the container
973
+
974
+ Optionally specify the docker network
975
+ """
976
+ if docker_network is None:
977
+ return self.container_client.get_container_ip(container_name_or_id=self.id)
978
+ else:
979
+ return self.container_client.get_container_ipv4_for_network(
980
+ container_name_or_id=self.id, container_network=docker_network
981
+ )
982
+
983
+ def is_running(self) -> bool:
984
+ try:
985
+ self.container_client.inspect_container(self.id)
986
+ return True
987
+ except NoSuchContainer:
988
+ return False
989
+
990
+ def get_logs(self) -> str:
991
+ return self.container_client.get_container_logs(self.id, safe=True)
992
+
993
+ def stream_logs(self) -> CancellableStream:
994
+ return self.container_client.stream_container_logs(self.id)
995
+
996
+ def wait_until_ready(self, timeout: float = None) -> bool:
997
+ return poll_condition(self.is_running, timeout)
998
+
999
+ def shutdown(self, timeout: int = 10, remove: bool = True):
1000
+ with self._mutex:
1001
+ if self._shutdown:
1002
+ return
1003
+ self._shutdown = True
1004
+
1005
+ try:
1006
+ self.container_client.stop_container(container_name=self.id, timeout=timeout)
1007
+ except NoSuchContainer:
1008
+ pass
1009
+
1010
+ if remove:
1011
+ try:
1012
+ self.container_client.remove_container(
1013
+ container_name=self.id, force=True, check_existence=False
1014
+ )
1015
+ except ContainerException as e:
1016
+ if "is already in progress" in str(e):
1017
+ return
1018
+ raise
1019
+
1020
+ def inspect(self) -> dict[str, dict | str]:
1021
+ return self.container_client.inspect_container(container_name_or_id=self.id)
1022
+
1023
+ def attach(self):
1024
+ self.container_client.attach_to_container(container_name_or_id=self.id)
1025
+
1026
+ def exec_in_container(self, *args, **kwargs):
1027
+ return self.container_client.exec_in_container(
1028
+ *args, container_name_or_id=self.id, **kwargs
1029
+ )
1030
+
1031
+ def stopped(self) -> Container:
1032
+ """
1033
+ Convert this running instance to a stopped instance ready to be restarted
1034
+ """
1035
+ return Container(container_config=self.config, docker_client=self.container_client)
1036
+
1037
+
1038
+ class ContainerLogPrinter:
1039
+ """
1040
+ Waits on a container to start and then uses ``stream_logs`` to print each line of the logs.
1041
+ """
1042
+
1043
+ def __init__(self, container: Container, callback: Callable[[str], None] = print):
1044
+ self.container = container
1045
+ self.callback = callback
1046
+
1047
+ self._closed = threading.Event()
1048
+ self._stream: CancellableStream | None = None
1049
+
1050
+ def _can_start_streaming(self):
1051
+ if self._closed.is_set():
1052
+ raise OSError("Already stopped")
1053
+ if not self.container.running_container:
1054
+ return False
1055
+ return self.container.running_container.is_running()
1056
+
1057
+ def run(self):
1058
+ try:
1059
+ poll_condition(self._can_start_streaming)
1060
+ except OSError:
1061
+ return
1062
+ self._stream = self.container.running_container.stream_logs()
1063
+ for line in self._stream:
1064
+ self.callback(line.rstrip(b"\r\n").decode("utf-8"))
1065
+
1066
+ def close(self):
1067
+ self._closed.set()
1068
+ if self._stream:
1069
+ self._stream.close()
1070
+
1071
+
1072
+ class LocalstackContainerServer(Server):
1073
+ container: Container | RunningContainer
1074
+
1075
+ def __init__(
1076
+ self, container_configuration: ContainerConfiguration | Container | None = None
1077
+ ) -> None:
1078
+ super().__init__(config.GATEWAY_LISTEN[0].port, config.GATEWAY_LISTEN[0].host)
1079
+
1080
+ if container_configuration is None:
1081
+ port_configuration = PortMappings(bind_host=config.GATEWAY_LISTEN[0].host)
1082
+ for addr in config.GATEWAY_LISTEN:
1083
+ port_configuration.add(addr.port)
1084
+
1085
+ container_configuration = ContainerConfiguration(
1086
+ image_name=get_docker_image_to_start(),
1087
+ name=config.MAIN_CONTAINER_NAME,
1088
+ volumes=VolumeMappings(),
1089
+ remove=True,
1090
+ ports=port_configuration,
1091
+ entrypoint=os.environ.get("ENTRYPOINT"),
1092
+ command=shlex.split(os.environ.get("CMD", "")) or None,
1093
+ env_vars={},
1094
+ )
1095
+
1096
+ if isinstance(container_configuration, Container):
1097
+ self.container = container_configuration
1098
+ else:
1099
+ self.container = Container(container_configuration)
1100
+
1101
+ def is_up(self) -> bool:
1102
+ """
1103
+ Checks whether the container is running, and the Ready marker has been printed to the logs.
1104
+ """
1105
+ if not self.is_container_running():
1106
+ return False
1107
+
1108
+ logs = self.container.get_logs()
1109
+
1110
+ if constants.READY_MARKER_OUTPUT not in logs.splitlines():
1111
+ return False
1112
+
1113
+ # also checks the edge port health status
1114
+ return super().is_up()
1115
+
1116
+ def is_container_running(self) -> bool:
1117
+ # if we have not started the container then we are not up
1118
+ if not isinstance(self.container, RunningContainer):
1119
+ return False
1120
+
1121
+ return self.container.is_running()
1122
+
1123
+ def wait_is_container_running(self, timeout=None) -> bool:
1124
+ return poll_condition(self.is_container_running, timeout)
1125
+
1126
+ def start(self) -> bool:
1127
+ if isinstance(self.container, RunningContainer):
1128
+ raise RuntimeError("cannot start container as container reference has been started")
1129
+
1130
+ return super().start()
1131
+
1132
+ def do_run(self):
1133
+ if self.is_container_running():
1134
+ raise ContainerRunning(
1135
+ f'LocalStack container named "{self.container.name}" is already running'
1136
+ )
1137
+
1138
+ config.dirs.mkdirs()
1139
+ if not isinstance(self.container, Container):
1140
+ raise ValueError(f"Invalid container type: {type(self.container)}")
1141
+
1142
+ LOG.debug("starting LocalStack container")
1143
+ self.container = self.container.start(attach=False)
1144
+ if isinstance(DOCKER_CLIENT, CmdDockerClient):
1145
+ DOCKER_CLIENT.default_run_outfile = get_container_default_logfile_location(
1146
+ self.container.config.name
1147
+ )
1148
+
1149
+ # block the current thread
1150
+ self.container.attach()
1151
+ return self.container
1152
+
1153
+ def shutdown(self):
1154
+ if not isinstance(self.container, RunningContainer):
1155
+ raise ValueError(f"Container {self.container} not started")
1156
+
1157
+ return super().shutdown()
1158
+
1159
+ def do_shutdown(self):
1160
+ try:
1161
+ self.container.shutdown(timeout=10)
1162
+ self.container = self.container.stopped()
1163
+ except Exception as e:
1164
+ LOG.info("error cleaning up localstack container %s: %s", self.container.name, e)
1165
+
1166
+
1167
+ class ContainerExists(Exception):
1168
+ pass
1169
+
1170
+
1171
+ class ContainerRunning(Exception):
1172
+ pass
1173
+
1174
+
1175
+ def prepare_docker_start():
1176
+ # prepare environment for docker start
1177
+ container_name = config.MAIN_CONTAINER_NAME
1178
+
1179
+ if DOCKER_CLIENT.is_container_running(container_name):
1180
+ raise ContainerRunning(f'LocalStack container named "{container_name}" is already running')
1181
+
1182
+ if container_name in DOCKER_CLIENT.get_all_container_names():
1183
+ raise ContainerExists(f'LocalStack container named "{container_name}" already exists')
1184
+
1185
+ config.dirs.mkdirs()
1186
+
1187
+
1188
+ def configure_container(container: Container):
1189
+ """
1190
+ Configuration routine for the LocalstackContainer.
1191
+ """
1192
+ port_configuration = PortMappings(bind_host=config.GATEWAY_LISTEN[0].host)
1193
+
1194
+ # base configuration
1195
+ container.config.image_name = get_docker_image_to_start()
1196
+ container.config.name = config.MAIN_CONTAINER_NAME
1197
+ container.config.volumes = VolumeMappings()
1198
+ container.config.remove = True
1199
+ container.config.ports = port_configuration
1200
+ container.config.entrypoint = os.environ.get("ENTRYPOINT")
1201
+ container.config.command = shlex.split(os.environ.get("CMD", "")) or None
1202
+ container.config.env_vars = {}
1203
+
1204
+ # parse `DOCKER_FLAGS` and add them appropriately
1205
+ user_flags = config.DOCKER_FLAGS
1206
+ user_flags = extract_port_flags(user_flags, container.config.ports)
1207
+ if container.config.additional_flags is None:
1208
+ container.config.additional_flags = user_flags
1209
+ else:
1210
+ container.config.additional_flags = f"{container.config.additional_flags} {user_flags}"
1211
+
1212
+ # get additional parameters from plux
1213
+ hooks.configure_localstack_container.run(container)
1214
+
1215
+ if config.DEVELOP:
1216
+ container.config.ports.add(config.DEVELOP_PORT)
1217
+
1218
+ container.configure(
1219
+ [
1220
+ # external service port range
1221
+ ContainerConfigurators.service_port_range,
1222
+ ContainerConfigurators.mount_localstack_volume(config.VOLUME_DIR),
1223
+ ContainerConfigurators.mount_docker_socket,
1224
+ # overwrites any env vars set in the config that were previously set by configurators
1225
+ ContainerConfigurators.config_env_vars,
1226
+ # ensure that GATEWAY_LISTEN is taken from the config and not
1227
+ # overridden by the `config_env_vars` configurator
1228
+ # (when not specified in the environment).
1229
+ ContainerConfigurators.gateway_listen(config.GATEWAY_LISTEN),
1230
+ ]
1231
+ )
1232
+
1233
+
1234
+ @log_duration()
1235
+ def prepare_host(console):
1236
+ """
1237
+ Prepare the host environment for running LocalStack, this should be called before start_infra_*.
1238
+ """
1239
+ if os.environ.get(constants.LOCALSTACK_INFRA_PROCESS) in constants.TRUE_STRINGS:
1240
+ return
1241
+
1242
+ try:
1243
+ mkdir(config.VOLUME_DIR)
1244
+ except Exception as e:
1245
+ console.print(f"Error while creating volume dir {config.VOLUME_DIR}: {e}")
1246
+ if config.DEBUG:
1247
+ console.print_exception()
1248
+
1249
+ setup_logging()
1250
+ hooks.prepare_host.run()
1251
+
1252
+
1253
+ def start_infra_in_docker(console, cli_params: dict[str, Any] = None):
1254
+ prepare_docker_start()
1255
+
1256
+ # create and prepare container
1257
+ container_config = ContainerConfiguration(get_docker_image_to_start())
1258
+ container = Container(container_config)
1259
+ ensure_container_image(console, container)
1260
+
1261
+ configure_container(container)
1262
+ container.configure(ContainerConfigurators.cli_params(cli_params or {}))
1263
+
1264
+ status = console.status("Starting LocalStack container")
1265
+ status.start()
1266
+
1267
+ # printing the container log is the current way we're occupying the terminal
1268
+ def _init_log_printer(line):
1269
+ """Prints the console rule separator on the first line, then re-configures the callback
1270
+ to print."""
1271
+ status.stop()
1272
+ console.rule("LocalStack Runtime Log (press [bold][yellow]CTRL-C[/yellow][/bold] to quit)")
1273
+ print(line)
1274
+ log_printer.callback = print
1275
+
1276
+ log_printer = ContainerLogPrinter(container, callback=_init_log_printer)
1277
+
1278
+ # Set up signal handler, to enable clean shutdown across different operating systems.
1279
+ # There are subtle differences across operating systems and terminal emulators when it
1280
+ # comes to handling of CTRL-C - in particular, Linux sends SIGINT to the parent process,
1281
+ # whereas macOS sends SIGINT to the process group, which can result in multiple SIGINT signals
1282
+ # being received (e.g., when running the localstack CLI as part of a "npm run .." script).
1283
+ # Hence, using a shutdown handler and synchronization event here, to avoid inconsistencies.
1284
+ def shutdown_handler(*args):
1285
+ with shutdown_event_lock:
1286
+ if shutdown_event.is_set():
1287
+ return
1288
+ shutdown_event.set()
1289
+ print("Shutting down...")
1290
+ server.shutdown()
1291
+
1292
+ shutdown_event = threading.Event()
1293
+ shutdown_event_lock = threading.RLock()
1294
+ signal.signal(signal.SIGINT, shutdown_handler)
1295
+
1296
+ # start the Localstack container as a Server
1297
+ server = LocalstackContainerServer(container)
1298
+ log_printer_thread = threading.Thread(
1299
+ target=log_printer.run, name="container-log-printer", daemon=True
1300
+ )
1301
+ try:
1302
+ server.start()
1303
+ log_printer_thread.start()
1304
+ server.join()
1305
+ error = server.get_error()
1306
+ if error:
1307
+ # if the server failed, raise the error
1308
+ raise error
1309
+ except KeyboardInterrupt:
1310
+ print("ok, bye!")
1311
+ shutdown_handler()
1312
+ finally:
1313
+ log_printer.close()
1314
+
1315
+
1316
+ def ensure_container_image(console, container: Container):
1317
+ try:
1318
+ DOCKER_CLIENT.inspect_image(container.config.image_name, pull=False)
1319
+ return
1320
+ except NoSuchImage:
1321
+ console.log("container image not found on host")
1322
+
1323
+ with console.status(f"Pulling container image {container.config.image_name}"):
1324
+ DOCKER_CLIENT.pull_image(container.config.image_name)
1325
+ console.log("download complete")
1326
+
1327
+
1328
+ def start_infra_in_docker_detached(console, cli_params: dict[str, Any] = None):
1329
+ """
1330
+ An alternative to start_infra_in_docker where the terminal is not blocked by the follow on the logfile.
1331
+ """
1332
+ console.log("preparing environment")
1333
+ try:
1334
+ prepare_docker_start()
1335
+ except ContainerRunning as e:
1336
+ # starting in detached mode is idempotent, return if container is already running
1337
+ console.print(str(e))
1338
+ return
1339
+
1340
+ # create and prepare container
1341
+ console.log("configuring container")
1342
+ container_config = ContainerConfiguration(get_docker_image_to_start())
1343
+ container = Container(container_config)
1344
+ ensure_container_image(console, container)
1345
+ configure_container(container)
1346
+ container.configure(ContainerConfigurators.cli_params(cli_params or {}))
1347
+
1348
+ container_config.detach = True
1349
+
1350
+ # start the Localstack container as a Server
1351
+ console.log("starting container")
1352
+ server = LocalstackContainerServer(container_config)
1353
+ server.start()
1354
+ server.wait_is_container_running()
1355
+ console.log("detaching")
1356
+
1357
+
1358
+ def wait_container_is_ready(timeout: float | None = None):
1359
+ """Blocks until the localstack main container is running and the ready marker has been printed."""
1360
+ container_name = config.MAIN_CONTAINER_NAME
1361
+ started = time.time()
1362
+
1363
+ def is_container_running():
1364
+ return DOCKER_CLIENT.is_container_running(container_name)
1365
+
1366
+ if not poll_condition(is_container_running, timeout=timeout):
1367
+ return False
1368
+
1369
+ stream = DOCKER_CLIENT.stream_container_logs(container_name)
1370
+
1371
+ # create a timer that will terminate the log stream after the remaining timeout
1372
+ timer = None
1373
+ if timeout:
1374
+ waited = time.time() - started
1375
+ remaining = timeout - waited
1376
+ # check the rare case that the timeout has already been reached
1377
+ if remaining <= 0:
1378
+ stream.close()
1379
+ return False
1380
+ timer = threading.Timer(remaining, stream.close)
1381
+ timer.start()
1382
+
1383
+ try:
1384
+ for line in stream:
1385
+ line = line.decode("utf-8").strip()
1386
+ if line == constants.READY_MARKER_OUTPUT:
1387
+ return True
1388
+
1389
+ # EOF was reached or the stream was closed
1390
+ return False
1391
+ finally:
1392
+ call_safe(stream.close)
1393
+ if timer:
1394
+ # make sure the timer is stopped (does nothing if it has already run)
1395
+ timer.cancel()
1396
+
1397
+
1398
+ # ---------------
1399
+ # UTIL FUNCTIONS
1400
+ # ---------------
1401
+
1402
+
1403
+ def in_ci():
1404
+ """Whether or not we are running in a CI environment"""
1405
+ for key in ("CI", "TRAVIS"):
1406
+ if os.environ.get(key, "") not in [False, "", "0", "false"]:
1407
+ return True
1408
+ return False
1409
+
1410
+
1411
+ def is_auth_token_configured() -> bool:
1412
+ """Whether an API key is set in the environment."""
1413
+ return (
1414
+ True
1415
+ if os.environ.get("LOCALSTACK_AUTH_TOKEN", "").strip()
1416
+ or os.environ.get("LOCALSTACK_API_KEY", "").strip()
1417
+ else False
1418
+ )