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,294 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from asn1crypto import algos, cms, core
|
|
8
|
+
from asn1crypto import x509 as asn1_x509
|
|
9
|
+
from cryptography.hazmat.backends import default_backend
|
|
10
|
+
from cryptography.hazmat.primitives import hashes
|
|
11
|
+
from cryptography.hazmat.primitives import padding as sym_padding
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
14
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
15
|
+
|
|
16
|
+
from .files import TMP_FILES, file_exists_not_empty, load_file, new_tmp_file, save_file
|
|
17
|
+
from .strings import short_uid, to_bytes, to_str
|
|
18
|
+
from .sync import synchronized
|
|
19
|
+
from .urls import localstack_host
|
|
20
|
+
|
|
21
|
+
LOG = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# block size for symmetric encrypt/decrypt operations
|
|
24
|
+
BLOCK_SIZE = 16
|
|
25
|
+
|
|
26
|
+
# lock for creating certificate files
|
|
27
|
+
SSL_CERT_LOCK = threading.RLock()
|
|
28
|
+
|
|
29
|
+
# markers that indicate the start/end of sections in PEM cert files
|
|
30
|
+
PEM_CERT_START = "-----BEGIN CERTIFICATE-----"
|
|
31
|
+
PEM_CERT_END = "-----END CERTIFICATE-----"
|
|
32
|
+
PEM_KEY_START_REGEX = r"-----BEGIN(.*)PRIVATE KEY-----"
|
|
33
|
+
PEM_KEY_END_REGEX = r"-----END(.*)PRIVATE KEY-----"
|
|
34
|
+
|
|
35
|
+
OID_AES256_CBC = "2.16.840.1.101.3.4.1.42"
|
|
36
|
+
OID_MGF1 = "1.2.840.113549.1.1.8"
|
|
37
|
+
OID_RSAES_OAEP = "1.2.840.113549.1.1.7"
|
|
38
|
+
OID_SHA256 = "2.16.840.1.101.3.4.2.1"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@synchronized(lock=SSL_CERT_LOCK)
|
|
42
|
+
def generate_ssl_cert(
|
|
43
|
+
target_file=None,
|
|
44
|
+
overwrite=False,
|
|
45
|
+
random=False,
|
|
46
|
+
return_content=False,
|
|
47
|
+
serial_number=None,
|
|
48
|
+
):
|
|
49
|
+
# Note: Do NOT import "OpenSSL" at the root scope
|
|
50
|
+
# (Our test Lambdas are importing this file but don't have the module installed)
|
|
51
|
+
from OpenSSL import crypto
|
|
52
|
+
|
|
53
|
+
def all_exist(*files):
|
|
54
|
+
return all(os.path.exists(f) for f in files)
|
|
55
|
+
|
|
56
|
+
def store_cert_key_files(base_filename):
|
|
57
|
+
key_file_name = f"{base_filename}.key"
|
|
58
|
+
cert_file_name = f"{base_filename}.crt"
|
|
59
|
+
# TODO: Cleaner code to load the cert dynamically
|
|
60
|
+
# extract key and cert from target_file and store into separate files
|
|
61
|
+
content = load_file(target_file)
|
|
62
|
+
key_start = re.search(PEM_KEY_START_REGEX, content)
|
|
63
|
+
key_start = key_start.group(0)
|
|
64
|
+
key_end = re.search(PEM_KEY_END_REGEX, content)
|
|
65
|
+
key_end = key_end.group(0)
|
|
66
|
+
key_content = content[content.index(key_start) : content.index(key_end) + len(key_end)]
|
|
67
|
+
cert_content = content[
|
|
68
|
+
content.index(PEM_CERT_START) : content.rindex(PEM_CERT_END) + len(PEM_CERT_END)
|
|
69
|
+
]
|
|
70
|
+
save_file(key_file_name, key_content)
|
|
71
|
+
save_file(cert_file_name, cert_content)
|
|
72
|
+
return cert_file_name, key_file_name
|
|
73
|
+
|
|
74
|
+
if target_file and not overwrite and file_exists_not_empty(target_file):
|
|
75
|
+
try:
|
|
76
|
+
cert_file_name, key_file_name = store_cert_key_files(target_file)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
# fall back to temporary files if we cannot store/overwrite the files above
|
|
79
|
+
LOG.info(
|
|
80
|
+
"Error storing key/cert SSL files (falling back to random tmp file names): %s", e
|
|
81
|
+
)
|
|
82
|
+
target_file_tmp = new_tmp_file()
|
|
83
|
+
cert_file_name, key_file_name = store_cert_key_files(target_file_tmp)
|
|
84
|
+
if all_exist(cert_file_name, key_file_name):
|
|
85
|
+
return target_file, cert_file_name, key_file_name
|
|
86
|
+
if random and target_file:
|
|
87
|
+
if "." in target_file:
|
|
88
|
+
target_file = target_file.replace(".", f".{short_uid()}.", 1)
|
|
89
|
+
else:
|
|
90
|
+
target_file = f"{target_file}.{short_uid()}"
|
|
91
|
+
|
|
92
|
+
# create a key pair
|
|
93
|
+
k = crypto.PKey()
|
|
94
|
+
k.generate_key(crypto.TYPE_RSA, 2048)
|
|
95
|
+
|
|
96
|
+
host_definition = localstack_host()
|
|
97
|
+
|
|
98
|
+
# create a self-signed cert
|
|
99
|
+
cert = crypto.X509()
|
|
100
|
+
subj = cert.get_subject()
|
|
101
|
+
subj.C = "AU"
|
|
102
|
+
subj.ST = "Some-State"
|
|
103
|
+
subj.L = "Some-Locality"
|
|
104
|
+
subj.O = "LocalStack Org" # noqa
|
|
105
|
+
subj.OU = "Testing"
|
|
106
|
+
subj.CN = "localhost"
|
|
107
|
+
# Note: new requirements for recent OSX versions: https://support.apple.com/en-us/HT210176
|
|
108
|
+
# More details: https://www.iol.unh.edu/blog/2019/10/10/macos-catalina-and-chrome-trust
|
|
109
|
+
serial_number = serial_number or 1001
|
|
110
|
+
cert.set_version(2)
|
|
111
|
+
cert.set_serial_number(serial_number)
|
|
112
|
+
cert.gmtime_adj_notBefore(0)
|
|
113
|
+
cert.gmtime_adj_notAfter(2 * 365 * 24 * 60 * 60)
|
|
114
|
+
cert.set_issuer(cert.get_subject())
|
|
115
|
+
cert.set_pubkey(k)
|
|
116
|
+
alt_names = (
|
|
117
|
+
f"DNS:localhost,DNS:test.localhost.atlassian.io,DNS:localhost.localstack.cloud,DNS:{host_definition.host}IP:127.0.0.1"
|
|
118
|
+
).encode()
|
|
119
|
+
cert.add_extensions(
|
|
120
|
+
[
|
|
121
|
+
crypto.X509Extension(b"subjectAltName", False, alt_names),
|
|
122
|
+
crypto.X509Extension(b"basicConstraints", True, b"CA:false"),
|
|
123
|
+
crypto.X509Extension(
|
|
124
|
+
b"keyUsage", True, b"nonRepudiation,digitalSignature,keyEncipherment"
|
|
125
|
+
),
|
|
126
|
+
crypto.X509Extension(b"extendedKeyUsage", True, b"serverAuth"),
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
cert.sign(k, "SHA256")
|
|
130
|
+
|
|
131
|
+
cert_file = io.StringIO()
|
|
132
|
+
key_file = io.StringIO()
|
|
133
|
+
cert_file.write(to_str(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)))
|
|
134
|
+
key_file.write(to_str(crypto.dump_privatekey(crypto.FILETYPE_PEM, k)))
|
|
135
|
+
cert_file_content = cert_file.getvalue().strip()
|
|
136
|
+
key_file_content = key_file.getvalue().strip()
|
|
137
|
+
file_content = f"{key_file_content}\n{cert_file_content}"
|
|
138
|
+
if target_file:
|
|
139
|
+
key_file_name = f"{target_file}.key"
|
|
140
|
+
cert_file_name = f"{target_file}.crt"
|
|
141
|
+
# check existence to avoid permission denied issues:
|
|
142
|
+
# https://github.com/localstack/localstack/issues/1607
|
|
143
|
+
if not all_exist(target_file, key_file_name, cert_file_name):
|
|
144
|
+
for i in range(2):
|
|
145
|
+
try:
|
|
146
|
+
save_file(target_file, file_content)
|
|
147
|
+
save_file(key_file_name, key_file_content)
|
|
148
|
+
save_file(cert_file_name, cert_file_content)
|
|
149
|
+
break
|
|
150
|
+
except Exception as e:
|
|
151
|
+
if i > 0:
|
|
152
|
+
raise
|
|
153
|
+
LOG.info(
|
|
154
|
+
"Unable to store certificate file under %s, using tmp file instead: %s",
|
|
155
|
+
target_file,
|
|
156
|
+
e,
|
|
157
|
+
)
|
|
158
|
+
# Fix for https://github.com/localstack/localstack/issues/1743
|
|
159
|
+
target_file = f"{new_tmp_file()}.pem"
|
|
160
|
+
key_file_name = f"{target_file}.key"
|
|
161
|
+
cert_file_name = f"{target_file}.crt"
|
|
162
|
+
TMP_FILES.append(target_file)
|
|
163
|
+
TMP_FILES.append(key_file_name)
|
|
164
|
+
TMP_FILES.append(cert_file_name)
|
|
165
|
+
if not return_content:
|
|
166
|
+
return target_file, cert_file_name, key_file_name
|
|
167
|
+
return file_content
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def pad(s: bytes) -> bytes:
|
|
171
|
+
return s + to_bytes((BLOCK_SIZE - len(s) % BLOCK_SIZE) * chr(BLOCK_SIZE - len(s) % BLOCK_SIZE))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def unpad(s: bytes) -> bytes:
|
|
175
|
+
return s[0 : -s[-1]]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def encrypt(key: bytes, message: bytes, iv: bytes = None, aad: bytes = None) -> tuple[bytes, bytes]:
|
|
179
|
+
iv = iv or b"0" * BLOCK_SIZE
|
|
180
|
+
cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend())
|
|
181
|
+
encryptor = cipher.encryptor()
|
|
182
|
+
encryptor.authenticate_additional_data(aad)
|
|
183
|
+
encrypted = encryptor.update(pad(message)) + encryptor.finalize()
|
|
184
|
+
return encrypted, encryptor.tag
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def decrypt(
|
|
188
|
+
key: bytes, encrypted: bytes, iv: bytes = None, tag: bytes = None, aad: bytes = None
|
|
189
|
+
) -> bytes:
|
|
190
|
+
iv = iv or b"0" * BLOCK_SIZE
|
|
191
|
+
cipher = Cipher(algorithms.AES(key), modes.GCM(iv, tag), backend=default_backend())
|
|
192
|
+
decryptor = cipher.decryptor()
|
|
193
|
+
decryptor.authenticate_additional_data(aad)
|
|
194
|
+
decrypted = decryptor.update(encrypted) + decryptor.finalize()
|
|
195
|
+
decrypted = unpad(decrypted)
|
|
196
|
+
return decrypted
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def pkcs7_envelope_encrypt(plaintext: bytes, recipient_pubkey: RSAPublicKey) -> bytes:
|
|
200
|
+
"""
|
|
201
|
+
Create a PKCS7 wrapper of some plaintext decryptable by recipient_pubkey. Uses RSA-OAEP with SHA-256
|
|
202
|
+
to encrypt the AES-256-CBC content key. Hazmat's PKCS7EnvelopeBuilder doesn't support RSA-OAEP with SHA-256,
|
|
203
|
+
so we need to build the pieces manually and then put them together in an envelope with asn1crypto.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
# Encrypt the plaintext with an AES session key, then encrypt the session key to the recipient_pubkey
|
|
207
|
+
session_key = os.urandom(32)
|
|
208
|
+
iv = os.urandom(16)
|
|
209
|
+
encrypted_session_key = recipient_pubkey.encrypt(
|
|
210
|
+
session_key,
|
|
211
|
+
padding.OAEP(
|
|
212
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
cipher = Cipher(algorithms.AES(session_key), modes.CBC(iv), backend=default_backend())
|
|
216
|
+
encryptor = cipher.encryptor()
|
|
217
|
+
padder = sym_padding.PKCS7(algorithms.AES.block_size).padder()
|
|
218
|
+
padded_plaintext = padder.update(plaintext) + padder.finalize()
|
|
219
|
+
encrypted_content = encryptor.update(padded_plaintext) + encryptor.finalize()
|
|
220
|
+
|
|
221
|
+
# Now put together the envelope.
|
|
222
|
+
# Add the recipient with their copy of the session key
|
|
223
|
+
recipient_identifier = cms.RecipientIdentifier(
|
|
224
|
+
name="issuer_and_serial_number",
|
|
225
|
+
value=cms.IssuerAndSerialNumber(
|
|
226
|
+
{
|
|
227
|
+
"issuer": asn1_x509.Name.build({"common_name": "recipient"}),
|
|
228
|
+
"serial_number": 1,
|
|
229
|
+
}
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
key_enc_algorithm = cms.KeyEncryptionAlgorithm(
|
|
233
|
+
{
|
|
234
|
+
"algorithm": OID_RSAES_OAEP,
|
|
235
|
+
"parameters": algos.RSAESOAEPParams(
|
|
236
|
+
{
|
|
237
|
+
"hash_algorithm": algos.DigestAlgorithm(
|
|
238
|
+
{
|
|
239
|
+
"algorithm": OID_SHA256,
|
|
240
|
+
}
|
|
241
|
+
),
|
|
242
|
+
"mask_gen_algorithm": algos.MaskGenAlgorithm(
|
|
243
|
+
{
|
|
244
|
+
"algorithm": OID_MGF1,
|
|
245
|
+
"parameters": algos.DigestAlgorithm(
|
|
246
|
+
{
|
|
247
|
+
"algorithm": OID_SHA256,
|
|
248
|
+
}
|
|
249
|
+
),
|
|
250
|
+
}
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
recipient_info = cms.KeyTransRecipientInfo(
|
|
257
|
+
{
|
|
258
|
+
"version": "v0",
|
|
259
|
+
"rid": recipient_identifier,
|
|
260
|
+
"key_encryption_algorithm": key_enc_algorithm,
|
|
261
|
+
"encrypted_key": encrypted_session_key,
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Add the encrypted content
|
|
266
|
+
content_enc_algorithm = cms.EncryptionAlgorithm(
|
|
267
|
+
{
|
|
268
|
+
"algorithm": OID_AES256_CBC,
|
|
269
|
+
"parameters": core.OctetString(iv),
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
encrypted_content_info = cms.EncryptedContentInfo(
|
|
273
|
+
{
|
|
274
|
+
"content_type": "data",
|
|
275
|
+
"content_encryption_algorithm": content_enc_algorithm,
|
|
276
|
+
"encrypted_content": encrypted_content,
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
enveloped_data = cms.EnvelopedData(
|
|
280
|
+
{
|
|
281
|
+
"version": "v0",
|
|
282
|
+
"recipient_infos": [recipient_info],
|
|
283
|
+
"encrypted_content_info": encrypted_content_info,
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Finally add a wrapper and return its bytes
|
|
288
|
+
content_info = cms.ContentInfo(
|
|
289
|
+
{
|
|
290
|
+
"content_type": "enveloped_data",
|
|
291
|
+
"content": enveloped_data,
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
return content_info.dump()
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import logging
|
|
3
|
+
import platform
|
|
4
|
+
import random
|
|
5
|
+
|
|
6
|
+
from localstack_cli import config
|
|
7
|
+
from localstack_cli.constants import DEFAULT_VOLUME_DIR, DOCKER_IMAGE_NAME_PRO
|
|
8
|
+
from localstack_cli.utils.collections import ensure_list
|
|
9
|
+
from localstack_cli.utils.container_utils.container_client import (
|
|
10
|
+
ContainerClient,
|
|
11
|
+
DockerNotAvailable,
|
|
12
|
+
PortMappings,
|
|
13
|
+
VolumeInfo,
|
|
14
|
+
)
|
|
15
|
+
from localstack_cli.utils.net import IntOrPort, Port, PortNotAvailableException, PortRange
|
|
16
|
+
from localstack_cli.utils.objects import singleton_factory
|
|
17
|
+
from localstack_cli.utils.strings import to_str
|
|
18
|
+
|
|
19
|
+
LOG = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# port range instance used to reserve Docker container ports
|
|
22
|
+
PORT_START = 0
|
|
23
|
+
PORT_END = 65536
|
|
24
|
+
RANDOM_PORT_START = 1024
|
|
25
|
+
RANDOM_PORT_END = 65536
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_docker_sdk_installed() -> bool:
|
|
29
|
+
try:
|
|
30
|
+
import docker # noqa: F401
|
|
31
|
+
|
|
32
|
+
return True
|
|
33
|
+
except ModuleNotFoundError:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_docker_client() -> ContainerClient:
|
|
38
|
+
# never use the sdk client if it is not installed or not in docker - too risky for wrong version
|
|
39
|
+
if config.LEGACY_DOCKER_CLIENT or not is_docker_sdk_installed() or not config.is_in_docker:
|
|
40
|
+
from localstack_cli.utils.container_utils.docker_cmd_client import CmdDockerClient
|
|
41
|
+
|
|
42
|
+
LOG.debug(
|
|
43
|
+
"Using CmdDockerClient. LEGACY_DOCKER_CLIENT: %s, SDK installed: %s",
|
|
44
|
+
config.LEGACY_DOCKER_CLIENT,
|
|
45
|
+
is_docker_sdk_installed(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return CmdDockerClient()
|
|
49
|
+
else:
|
|
50
|
+
from localstack_cli.utils.container_utils.docker_sdk_client import SdkDockerClient
|
|
51
|
+
|
|
52
|
+
LOG.debug(
|
|
53
|
+
"Using SdkDockerClient. LEGACY_DOCKER_CLIENT: %s, SDK installed: %s",
|
|
54
|
+
config.LEGACY_DOCKER_CLIENT,
|
|
55
|
+
is_docker_sdk_installed(),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return SdkDockerClient()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_current_container_id() -> str:
|
|
62
|
+
"""
|
|
63
|
+
Returns the ID of the current container, or raises a ValueError if we're not in docker.
|
|
64
|
+
|
|
65
|
+
:return: the ID of the current container
|
|
66
|
+
"""
|
|
67
|
+
if not config.is_in_docker:
|
|
68
|
+
raise ValueError("not in docker")
|
|
69
|
+
|
|
70
|
+
container_id = platform.node()
|
|
71
|
+
if not container_id:
|
|
72
|
+
raise OSError("no hostname returned to use as container id")
|
|
73
|
+
|
|
74
|
+
return container_id
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def inspect_current_container_mounts() -> list[VolumeInfo]:
|
|
78
|
+
return DOCKER_CLIENT.inspect_container_volumes(get_current_container_id())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@functools.lru_cache
|
|
82
|
+
def get_default_volume_dir_mount() -> VolumeInfo | None:
|
|
83
|
+
"""
|
|
84
|
+
Returns the volume information of LocalStack's DEFAULT_VOLUME_DIR (/var/lib/localstack), if mounted,
|
|
85
|
+
else it returns None. If we're not currently in docker a VauleError is raised. in a container, a ValueError is
|
|
86
|
+
raised.
|
|
87
|
+
|
|
88
|
+
:return: the volume info of the default volume dir or None
|
|
89
|
+
"""
|
|
90
|
+
for volume in inspect_current_container_mounts():
|
|
91
|
+
if volume.destination.rstrip("/") == DEFAULT_VOLUME_DIR:
|
|
92
|
+
return volume
|
|
93
|
+
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_host_path_for_path_in_docker(path):
|
|
98
|
+
"""
|
|
99
|
+
Returns the calculated host location for a given subpath of DEFAULT_VOLUME_DIR inside the localstack container.
|
|
100
|
+
The path **has** to be a subdirectory of DEFAULT_VOLUME_DIR (the dir itself *will not* work).
|
|
101
|
+
|
|
102
|
+
:param path: Path to be replaced (subpath of DEFAULT_VOLUME_DIR)
|
|
103
|
+
:return: Path on the host
|
|
104
|
+
"""
|
|
105
|
+
if config.is_in_docker and DOCKER_CLIENT.has_docker():
|
|
106
|
+
volume = get_default_volume_dir_mount()
|
|
107
|
+
|
|
108
|
+
if volume:
|
|
109
|
+
if volume.type != "bind":
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"Mount to {DEFAULT_VOLUME_DIR} needs to be a bind mount for mounting to work"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if not path.startswith(f"{DEFAULT_VOLUME_DIR}/") and path != DEFAULT_VOLUME_DIR:
|
|
115
|
+
# We should be able to replace something here.
|
|
116
|
+
# if this warning is printed, the usage of this function is probably wrong.
|
|
117
|
+
# Please check if the target path is indeed prefixed by /var/lib/localstack
|
|
118
|
+
# if this happens, mounts may fail
|
|
119
|
+
LOG.warning(
|
|
120
|
+
"Error while performing automatic host path replacement for path '%s' to source '%s'",
|
|
121
|
+
path,
|
|
122
|
+
volume.source,
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
relative_path = path.removeprefix(DEFAULT_VOLUME_DIR)
|
|
126
|
+
result = volume.source + relative_path
|
|
127
|
+
return result
|
|
128
|
+
else:
|
|
129
|
+
raise ValueError(f"No volume mounted to {DEFAULT_VOLUME_DIR}")
|
|
130
|
+
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def container_ports_can_be_bound(
|
|
135
|
+
ports: IntOrPort | list[IntOrPort],
|
|
136
|
+
address: str | None = None,
|
|
137
|
+
) -> bool:
|
|
138
|
+
"""Determine whether a given list of ports can be bound by Docker containers
|
|
139
|
+
|
|
140
|
+
:param ports: single port or list of ports to check
|
|
141
|
+
:return: True iff all ports can be bound
|
|
142
|
+
"""
|
|
143
|
+
port_mappings = PortMappings(bind_host=address or "")
|
|
144
|
+
ports = ensure_list(ports)
|
|
145
|
+
for port in ports:
|
|
146
|
+
port = Port.wrap(port)
|
|
147
|
+
port_mappings.add(port.port, port.port, protocol=port.protocol)
|
|
148
|
+
try:
|
|
149
|
+
result = DOCKER_CLIENT.run_container(
|
|
150
|
+
_get_ports_check_docker_image(),
|
|
151
|
+
entrypoint="sh",
|
|
152
|
+
command=["-c", "echo test123"],
|
|
153
|
+
ports=port_mappings,
|
|
154
|
+
remove=True,
|
|
155
|
+
)
|
|
156
|
+
except DockerNotAvailable as e:
|
|
157
|
+
LOG.warning("Cannot perform port check because Docker is not available.")
|
|
158
|
+
raise e
|
|
159
|
+
except Exception as e:
|
|
160
|
+
if "port is already allocated" not in str(e) and "address already in use" not in str(e):
|
|
161
|
+
LOG.warning(
|
|
162
|
+
"Unexpected error when attempting to determine container port status",
|
|
163
|
+
exc_info=LOG.isEnabledFor(logging.DEBUG),
|
|
164
|
+
)
|
|
165
|
+
return False
|
|
166
|
+
# TODO(srw): sometimes the command output from the docker container is "None", particularly when this function is
|
|
167
|
+
# invoked multiple times consecutively. Work out why.
|
|
168
|
+
if to_str(result[0] or "").strip() != "test123":
|
|
169
|
+
LOG.warning(
|
|
170
|
+
"Unexpected output when attempting to determine container port status: %s", result
|
|
171
|
+
)
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _DockerPortRange(PortRange):
|
|
176
|
+
"""
|
|
177
|
+
PortRange which checks whether the port can be bound on the host instead of inside the container.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def _port_can_be_bound(self, port: IntOrPort) -> bool:
|
|
181
|
+
return container_ports_can_be_bound(port)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
reserved_docker_ports = _DockerPortRange(PORT_START, PORT_END)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def is_port_available_for_containers(port: IntOrPort) -> bool:
|
|
188
|
+
"""Check whether the given port can be bound by containers and is not currently reserved"""
|
|
189
|
+
return not is_container_port_reserved(port) and container_ports_can_be_bound(port)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def reserve_container_port(port: IntOrPort, duration: int = None):
|
|
193
|
+
"""Reserve the given container port for a short period of time"""
|
|
194
|
+
reserved_docker_ports.reserve_port(port, duration=duration)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def is_container_port_reserved(port: IntOrPort) -> bool:
|
|
198
|
+
"""Return whether the given container port is currently reserved"""
|
|
199
|
+
port = Port.wrap(port)
|
|
200
|
+
return reserved_docker_ports.is_port_reserved(port)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def reserve_available_container_port(
|
|
204
|
+
duration: int = None,
|
|
205
|
+
port_start: int = None,
|
|
206
|
+
port_end: int = None,
|
|
207
|
+
protocol: str = None,
|
|
208
|
+
) -> int:
|
|
209
|
+
"""
|
|
210
|
+
Determine a free port within the given port range that can be bound by a Docker container, and reserve
|
|
211
|
+
the port for the given number of seconds
|
|
212
|
+
|
|
213
|
+
:param duration: the number of seconds to reserve the port (default: ~6 seconds)
|
|
214
|
+
:param port_start: the start of the port range to check (default: 1024)
|
|
215
|
+
:param port_end: the end of the port range to check (default: 65536)
|
|
216
|
+
:param protocol: the network protocol (default: tcp)
|
|
217
|
+
:return: a random port
|
|
218
|
+
:raises PortNotAvailableException: if no port is available within the given range
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
protocol = protocol or "tcp"
|
|
222
|
+
|
|
223
|
+
def _random_port():
|
|
224
|
+
port = None
|
|
225
|
+
while not port or reserved_docker_ports.is_port_reserved(port):
|
|
226
|
+
port_number = random.randint(
|
|
227
|
+
RANDOM_PORT_START if port_start is None else port_start,
|
|
228
|
+
RANDOM_PORT_END if port_end is None else port_end,
|
|
229
|
+
)
|
|
230
|
+
port = Port(port=port_number, protocol=protocol)
|
|
231
|
+
return port
|
|
232
|
+
|
|
233
|
+
retries = 10
|
|
234
|
+
for i in range(retries):
|
|
235
|
+
port = _random_port()
|
|
236
|
+
try:
|
|
237
|
+
reserve_container_port(port, duration=duration)
|
|
238
|
+
return port.port
|
|
239
|
+
except PortNotAvailableException as e:
|
|
240
|
+
LOG.debug("Could not bind port %s, trying the next one: %s", port, e)
|
|
241
|
+
|
|
242
|
+
raise PortNotAvailableException(
|
|
243
|
+
f"Unable to determine available Docker container port after {retries} retries"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@singleton_factory
|
|
248
|
+
def _get_ports_check_docker_image() -> str:
|
|
249
|
+
"""
|
|
250
|
+
Determine the Docker image to use for Docker port availability checks.
|
|
251
|
+
Uses either PORTS_CHECK_DOCKER_IMAGE (if configured), or otherwise inspects the running container's image.
|
|
252
|
+
"""
|
|
253
|
+
if config.PORTS_CHECK_DOCKER_IMAGE:
|
|
254
|
+
# explicit configuration takes precedence
|
|
255
|
+
return config.PORTS_CHECK_DOCKER_IMAGE
|
|
256
|
+
if not config.is_in_docker:
|
|
257
|
+
# local import to prevent circular imports
|
|
258
|
+
from localstack_cli.utils.bootstrap import get_docker_image_to_start
|
|
259
|
+
|
|
260
|
+
# Use whatever image the user is trying to run LocalStack with, since they either have
|
|
261
|
+
# it already, or need it by definition to start LocalStack.
|
|
262
|
+
return get_docker_image_to_start()
|
|
263
|
+
try:
|
|
264
|
+
# inspect the running container to determine the image
|
|
265
|
+
container = DOCKER_CLIENT.inspect_container(get_current_container_id())
|
|
266
|
+
return container["Config"]["Image"]
|
|
267
|
+
except Exception:
|
|
268
|
+
# fall back to using the default Docker image
|
|
269
|
+
return DOCKER_IMAGE_NAME_PRO
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
DOCKER_CLIENT: ContainerClient = create_docker_client()
|