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.
- localstack_cli/__init__.py +0 -0
- localstack_cli/cli/__init__.py +10 -0
- localstack_cli/cli/console.py +11 -0
- localstack_cli/cli/core_plugin.py +12 -0
- localstack_cli/cli/exceptions.py +19 -0
- localstack_cli/cli/localstack.py +951 -0
- localstack_cli/cli/lpm.py +138 -0
- localstack_cli/cli/main.py +22 -0
- localstack_cli/cli/plugin.py +39 -0
- localstack_cli/cli/plugins.py +134 -0
- localstack_cli/cli/profiles.py +65 -0
- localstack_cli/config.py +1689 -0
- localstack_cli/constants.py +165 -0
- localstack_cli/logging/__init__.py +0 -0
- localstack_cli/logging/format.py +194 -0
- localstack_cli/logging/setup.py +142 -0
- localstack_cli/packages/__init__.py +25 -0
- localstack_cli/packages/api.py +418 -0
- localstack_cli/packages/core.py +416 -0
- localstack_cli/pro/__init__.py +0 -0
- localstack_cli/pro/core/__init__.py +0 -0
- localstack_cli/pro/core/bootstrap/__init__.py +1 -0
- localstack_cli/pro/core/bootstrap/auth.py +213 -0
- localstack_cli/pro/core/bootstrap/dns_utils.py +55 -0
- localstack_cli/pro/core/bootstrap/entitlements.py +117 -0
- localstack_cli/pro/core/bootstrap/extensions/__init__.py +3 -0
- localstack_cli/pro/core/bootstrap/extensions/__main__.py +106 -0
- localstack_cli/pro/core/bootstrap/extensions/autoinstall.py +63 -0
- localstack_cli/pro/core/bootstrap/extensions/bootstrap.py +97 -0
- localstack_cli/pro/core/bootstrap/extensions/repository.py +374 -0
- localstack_cli/pro/core/bootstrap/licensingv2.py +1259 -0
- localstack_cli/pro/core/bootstrap/pods/__init__.py +0 -0
- localstack_cli/pro/core/bootstrap/pods/api_types.py +17 -0
- localstack_cli/pro/core/bootstrap/pods/constants.py +26 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/__init__.py +0 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/api.py +75 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/configs.py +69 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/params.py +86 -0
- localstack_cli/pro/core/bootstrap/pods_client.py +834 -0
- localstack_cli/pro/core/cli/__init__.py +0 -0
- localstack_cli/pro/core/cli/auth.py +226 -0
- localstack_cli/pro/core/cli/aws.py +16 -0
- localstack_cli/pro/core/cli/cli.py +99 -0
- localstack_cli/pro/core/cli/click_utils.py +21 -0
- localstack_cli/pro/core/cli/cloud_pods.py +465 -0
- localstack_cli/pro/core/cli/diff_view.py +41 -0
- localstack_cli/pro/core/cli/ephemeral.py +199 -0
- localstack_cli/pro/core/cli/extensions.py +492 -0
- localstack_cli/pro/core/cli/iam.py +180 -0
- localstack_cli/pro/core/cli/license.py +90 -0
- localstack_cli/pro/core/cli/localstack.py +118 -0
- localstack_cli/pro/core/cli/replicator.py +378 -0
- localstack_cli/pro/core/cli/state.py +183 -0
- localstack_cli/pro/core/cli/tree_view.py +235 -0
- localstack_cli/pro/core/config.py +556 -0
- localstack_cli/pro/core/constants.py +54 -0
- localstack_cli/pro/core/plugins.py +169 -0
- localstack_cli/runtime/__init__.py +6 -0
- localstack_cli/runtime/exceptions.py +7 -0
- localstack_cli/runtime/hooks.py +73 -0
- localstack_cli/testing/__init__.py +1 -0
- localstack_cli/testing/config.py +4 -0
- localstack_cli/utils/__init__.py +0 -0
- localstack_cli/utils/analytics/__init__.py +12 -0
- localstack_cli/utils/analytics/cli.py +67 -0
- localstack_cli/utils/analytics/client.py +111 -0
- localstack_cli/utils/analytics/events.py +30 -0
- localstack_cli/utils/analytics/logger.py +48 -0
- localstack_cli/utils/analytics/metadata.py +250 -0
- localstack_cli/utils/analytics/publisher.py +160 -0
- localstack_cli/utils/analytics/service_request_aggregator.py +133 -0
- localstack_cli/utils/archives.py +271 -0
- localstack_cli/utils/batching.py +258 -0
- localstack_cli/utils/bootstrap.py +1418 -0
- localstack_cli/utils/checksum.py +313 -0
- localstack_cli/utils/collections.py +554 -0
- localstack_cli/utils/common.py +229 -0
- localstack_cli/utils/container_networking.py +142 -0
- localstack_cli/utils/container_utils/__init__.py +0 -0
- localstack_cli/utils/container_utils/container_client.py +1585 -0
- localstack_cli/utils/container_utils/docker_cmd_client.py +987 -0
- localstack_cli/utils/container_utils/docker_sdk_client.py +1018 -0
- localstack_cli/utils/crypto.py +294 -0
- localstack_cli/utils/docker_utils.py +272 -0
- localstack_cli/utils/files.py +327 -0
- localstack_cli/utils/functions.py +92 -0
- localstack_cli/utils/http.py +326 -0
- localstack_cli/utils/json.py +219 -0
- localstack_cli/utils/net.py +516 -0
- localstack_cli/utils/no_exit_argument_parser.py +19 -0
- localstack_cli/utils/numbers.py +49 -0
- localstack_cli/utils/objects.py +235 -0
- localstack_cli/utils/patch.py +260 -0
- localstack_cli/utils/platform.py +77 -0
- localstack_cli/utils/run.py +514 -0
- localstack_cli/utils/server/__init__.py +0 -0
- localstack_cli/utils/server/tcp_proxy.py +108 -0
- localstack_cli/utils/serving.py +187 -0
- localstack_cli/utils/ssl.py +71 -0
- localstack_cli/utils/strings.py +245 -0
- localstack_cli/utils/sync.py +267 -0
- localstack_cli/utils/threads.py +163 -0
- localstack_cli/utils/time.py +81 -0
- localstack_cli/utils/urls.py +21 -0
- localstack_cli/utils/venv.py +100 -0
- localstack_cli/utils/xml.py +41 -0
- localstack_cli/version.py +34 -0
- playground_ls_cli-4.14.1.dev8.dist-info/METADATA +95 -0
- playground_ls_cli-4.14.1.dev8.dist-info/RECORD +112 -0
- playground_ls_cli-4.14.1.dev8.dist-info/WHEEL +5 -0
- playground_ls_cli-4.14.1.dev8.dist-info/entry_points.txt +17 -0
- 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
|
+
)
|