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,516 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import random
|
|
3
|
+
import re
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import MutableMapping
|
|
7
|
+
from contextlib import closing
|
|
8
|
+
from typing import Any, NamedTuple
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import dns.resolver
|
|
12
|
+
from dnslib import DNSRecord
|
|
13
|
+
|
|
14
|
+
from localstack_cli import config, constants
|
|
15
|
+
|
|
16
|
+
from .collections import CustomExpiryTTLCache
|
|
17
|
+
from .numbers import is_number
|
|
18
|
+
from .objects import singleton_factory
|
|
19
|
+
from .sync import retry
|
|
20
|
+
|
|
21
|
+
LOG = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# regular expression for IPv4 addresses
|
|
24
|
+
IP_REGEX = (
|
|
25
|
+
r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# many linux kernels use 32768-60999, RFC 6335 is 49152-65535, so we use a mix here
|
|
29
|
+
DYNAMIC_PORT_RANGE_START = 32768
|
|
30
|
+
DYNAMIC_PORT_RANGE_END = 65536
|
|
31
|
+
|
|
32
|
+
DEFAULT_PORT_RESERVED_SECONDS = 6
|
|
33
|
+
"""Default nuber of seconds a port is reserved in a PortRange."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Port(NamedTuple):
|
|
37
|
+
"""Represents a network port, with port number and protocol (TCP/UDP)"""
|
|
38
|
+
|
|
39
|
+
port: int
|
|
40
|
+
"""the port number"""
|
|
41
|
+
protocol: str
|
|
42
|
+
"""network protocol name (usually 'tcp' or 'udp')"""
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def wrap(cls, port: "IntOrPort") -> "Port":
|
|
46
|
+
"""Return the given port as a Port object, using 'tcp' as the default protocol."""
|
|
47
|
+
if isinstance(port, Port):
|
|
48
|
+
return port
|
|
49
|
+
return Port(port=port, protocol="tcp")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# simple helper type to encapsulate int/Port argument types
|
|
53
|
+
IntOrPort = int | Port
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_port_open(
|
|
57
|
+
port_or_url: int | str,
|
|
58
|
+
http_path: str = None,
|
|
59
|
+
expect_success: bool = True,
|
|
60
|
+
protocols: str | list[str] | None = None,
|
|
61
|
+
quiet: bool = True,
|
|
62
|
+
):
|
|
63
|
+
from localstack_cli.utils.http import safe_requests
|
|
64
|
+
|
|
65
|
+
protocols = protocols or ["tcp"]
|
|
66
|
+
port = port_or_url
|
|
67
|
+
if is_number(port):
|
|
68
|
+
port = int(port)
|
|
69
|
+
host = "localhost"
|
|
70
|
+
protocol = "http"
|
|
71
|
+
protocols = protocols if isinstance(protocols, list) else [protocols]
|
|
72
|
+
if isinstance(port, str):
|
|
73
|
+
url = urlparse(port_or_url)
|
|
74
|
+
port = url.port
|
|
75
|
+
host = url.hostname
|
|
76
|
+
protocol = url.scheme
|
|
77
|
+
nw_protocols = []
|
|
78
|
+
nw_protocols += [socket.SOCK_STREAM] if "tcp" in protocols else []
|
|
79
|
+
nw_protocols += [socket.SOCK_DGRAM] if "udp" in protocols else []
|
|
80
|
+
for nw_protocol in nw_protocols:
|
|
81
|
+
with closing(
|
|
82
|
+
socket.socket(socket.AF_INET if ":" not in host else socket.AF_INET6, nw_protocol)
|
|
83
|
+
) as sock:
|
|
84
|
+
sock.settimeout(1)
|
|
85
|
+
if nw_protocol == socket.SOCK_DGRAM:
|
|
86
|
+
try:
|
|
87
|
+
if port == 53:
|
|
88
|
+
dnshost = "127.0.0.1" if host == "localhost" else host
|
|
89
|
+
resolver = dns.resolver.Resolver()
|
|
90
|
+
resolver.nameservers = [dnshost]
|
|
91
|
+
resolver.timeout = 1
|
|
92
|
+
resolver.lifetime = 1
|
|
93
|
+
answers = resolver.query("google.com", "A")
|
|
94
|
+
assert len(answers) > 0
|
|
95
|
+
else:
|
|
96
|
+
sock.sendto(b"", (host, port))
|
|
97
|
+
sock.recvfrom(1024)
|
|
98
|
+
except Exception:
|
|
99
|
+
if not quiet:
|
|
100
|
+
LOG.error(
|
|
101
|
+
"Error connecting to UDP port %s:%s",
|
|
102
|
+
host,
|
|
103
|
+
port,
|
|
104
|
+
exc_info=LOG.isEnabledFor(logging.DEBUG),
|
|
105
|
+
)
|
|
106
|
+
return False
|
|
107
|
+
elif nw_protocol == socket.SOCK_STREAM:
|
|
108
|
+
result = sock.connect_ex((host, port))
|
|
109
|
+
if result != 0:
|
|
110
|
+
if not quiet:
|
|
111
|
+
LOG.warning(
|
|
112
|
+
"Error connecting to TCP port %s:%s (result=%s)", host, port, result
|
|
113
|
+
)
|
|
114
|
+
return False
|
|
115
|
+
if "tcp" not in protocols or not http_path:
|
|
116
|
+
return True
|
|
117
|
+
host = f"[{host}]" if ":" in host else host
|
|
118
|
+
url = f"{protocol}://{host}:{port}{http_path}"
|
|
119
|
+
try:
|
|
120
|
+
response = safe_requests.get(url, verify=False)
|
|
121
|
+
return not expect_success or response.status_code < 400
|
|
122
|
+
except Exception:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def wait_for_port_open(
|
|
127
|
+
port: int, http_path: str = None, expect_success=True, retries=10, sleep_time=0.5
|
|
128
|
+
):
|
|
129
|
+
"""Ping the given TCP network port until it becomes available (for a given number of retries).
|
|
130
|
+
If 'http_path' is set, make a GET request to this path and assert a non-error response."""
|
|
131
|
+
return wait_for_port_status(
|
|
132
|
+
port,
|
|
133
|
+
http_path=http_path,
|
|
134
|
+
expect_success=expect_success,
|
|
135
|
+
retries=retries,
|
|
136
|
+
sleep_time=sleep_time,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def wait_for_port_closed(
|
|
141
|
+
port: int, http_path: str = None, expect_success=True, retries=10, sleep_time=0.5
|
|
142
|
+
):
|
|
143
|
+
return wait_for_port_status(
|
|
144
|
+
port,
|
|
145
|
+
http_path=http_path,
|
|
146
|
+
expect_success=expect_success,
|
|
147
|
+
retries=retries,
|
|
148
|
+
sleep_time=sleep_time,
|
|
149
|
+
expect_closed=True,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def wait_for_port_status(
|
|
154
|
+
port: int,
|
|
155
|
+
http_path: str = None,
|
|
156
|
+
expect_success=True,
|
|
157
|
+
retries=10,
|
|
158
|
+
sleep_time=0.5,
|
|
159
|
+
expect_closed=False,
|
|
160
|
+
):
|
|
161
|
+
"""Ping the given TCP network port until it becomes (un)available (for a given number of retries)."""
|
|
162
|
+
|
|
163
|
+
def check():
|
|
164
|
+
status = is_port_open(port, http_path=http_path, expect_success=expect_success)
|
|
165
|
+
if bool(status) != (not expect_closed):
|
|
166
|
+
raise Exception(
|
|
167
|
+
"Port {} (path: {}) was not {}".format(
|
|
168
|
+
port, http_path, "closed" if expect_closed else "open"
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return retry(check, sleep=sleep_time, retries=retries)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def port_can_be_bound(port: IntOrPort, address: str = "") -> bool:
|
|
176
|
+
"""
|
|
177
|
+
Return whether a local port (TCP or UDP) can be bound to. Note that this is a stricter check
|
|
178
|
+
than is_port_open(...) above, as is_port_open() may return False if the port is
|
|
179
|
+
not accessible (i.e., does not respond), yet cannot be bound to.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
port = Port.wrap(port)
|
|
183
|
+
if port.protocol == "tcp":
|
|
184
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
185
|
+
elif port.protocol == "udp":
|
|
186
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
187
|
+
else:
|
|
188
|
+
LOG.debug("Unsupported network protocol '%s' for port check", port.protocol)
|
|
189
|
+
return False
|
|
190
|
+
sock.bind((address, port.port))
|
|
191
|
+
return True
|
|
192
|
+
except OSError:
|
|
193
|
+
# either the port is used or we don't have permission to bind it
|
|
194
|
+
return False
|
|
195
|
+
except Exception:
|
|
196
|
+
LOG.error("cannot bind port %s", port, exc_info=LOG.isEnabledFor(logging.DEBUG))
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_free_udp_port(blocklist: list[int] = None) -> int:
|
|
201
|
+
blocklist = blocklist or []
|
|
202
|
+
for i in range(10):
|
|
203
|
+
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
204
|
+
udp.bind(("", 0))
|
|
205
|
+
addr, port = udp.getsockname()
|
|
206
|
+
udp.close()
|
|
207
|
+
if port not in blocklist:
|
|
208
|
+
return port
|
|
209
|
+
raise Exception(f"Unable to determine free UDP port with blocklist {blocklist}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_free_tcp_port(blocklist: list[int] = None) -> int:
|
|
213
|
+
"""
|
|
214
|
+
Tries to bind a socket to port 0 and returns the port that was assigned by the system. If the port is
|
|
215
|
+
in the given ``blocklist``, or the port is marked as reserved in ``dynamic_port_range``, the procedure
|
|
216
|
+
is repeated for up to 50 times.
|
|
217
|
+
|
|
218
|
+
:param blocklist: an optional list of ports that are not allowed as random ports
|
|
219
|
+
:return: a free TCP port
|
|
220
|
+
"""
|
|
221
|
+
blocklist = blocklist or []
|
|
222
|
+
for i in range(50):
|
|
223
|
+
tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
224
|
+
tcp.bind(("", 0))
|
|
225
|
+
addr, port = tcp.getsockname()
|
|
226
|
+
tcp.close()
|
|
227
|
+
if port not in blocklist and not dynamic_port_range.is_port_reserved(port):
|
|
228
|
+
try:
|
|
229
|
+
dynamic_port_range.mark_reserved(port)
|
|
230
|
+
except ValueError:
|
|
231
|
+
# depending on the ephemeral port range of the system, the allocated port may be outside what
|
|
232
|
+
# we defined as dynamic port range
|
|
233
|
+
pass
|
|
234
|
+
return port
|
|
235
|
+
raise Exception(f"Unable to determine free TCP port with blocklist {blocklist}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_free_tcp_port_range(num_ports: int, max_attempts: int = 50) -> "PortRange":
|
|
239
|
+
"""
|
|
240
|
+
Attempts to get a contiguous range of free ports from the dynamic port range. For instance,
|
|
241
|
+
``get_free_tcp_port_range(4)`` may return the following result: ``PortRange(44000:44004)``.
|
|
242
|
+
|
|
243
|
+
:param num_ports: the number of ports in the range
|
|
244
|
+
:param max_attempts: the number of times to retry if a contiguous range was not found
|
|
245
|
+
:return: a port range of free TCP ports
|
|
246
|
+
:raises PortNotAvailableException: if max_attempts was reached to re-try
|
|
247
|
+
"""
|
|
248
|
+
if num_ports < 2:
|
|
249
|
+
raise ValueError(f"invalid number of ports {num_ports}")
|
|
250
|
+
|
|
251
|
+
def _is_port_range_free(_range: PortRange):
|
|
252
|
+
for _port in _range:
|
|
253
|
+
if dynamic_port_range.is_port_reserved(_port) or not port_can_be_bound(_port):
|
|
254
|
+
return False
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
for _ in range(max_attempts):
|
|
258
|
+
# try to find a suitable starting point (leave enough space at the end)
|
|
259
|
+
port_range_start = random.randint(
|
|
260
|
+
dynamic_port_range.start, dynamic_port_range.end - num_ports - 1
|
|
261
|
+
)
|
|
262
|
+
port_range = PortRange(port_range_start, port_range_start + num_ports - 1)
|
|
263
|
+
|
|
264
|
+
# check that each port in the range is available (has not been reserved and can be bound)
|
|
265
|
+
# we don't use dynamic_port_range.reserve_port because in case the port range check fails at some port
|
|
266
|
+
# all ports up until then would be reserved
|
|
267
|
+
if not _is_port_range_free(port_range):
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# port range found! mark them as reserved in the dynamic port range and return
|
|
271
|
+
for port in port_range:
|
|
272
|
+
dynamic_port_range.mark_reserved(port)
|
|
273
|
+
return port_range
|
|
274
|
+
|
|
275
|
+
raise PortNotAvailableException("reached max_attempts when trying to find port range")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def resolve_hostname(hostname: str) -> str | None:
|
|
279
|
+
"""Resolve the given hostname and return its IP address, or None if it cannot be resolved."""
|
|
280
|
+
try:
|
|
281
|
+
return socket.gethostbyname(hostname)
|
|
282
|
+
except OSError:
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def is_ip_address(addr: str) -> bool:
|
|
287
|
+
try:
|
|
288
|
+
socket.inet_aton(addr)
|
|
289
|
+
return True
|
|
290
|
+
except OSError:
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def is_ipv4_address(address: str) -> bool:
|
|
295
|
+
"""
|
|
296
|
+
Checks if passed string looks like an IPv4 address
|
|
297
|
+
:param address: Possible IPv4 address
|
|
298
|
+
:return: True if string looks like IPv4 address, False otherwise
|
|
299
|
+
"""
|
|
300
|
+
return bool(re.match(IP_REGEX, address))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class PortNotAvailableException(Exception):
|
|
304
|
+
"""Exception which indicates that the PortRange could not reserve a port."""
|
|
305
|
+
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class PortRange:
|
|
310
|
+
"""Manages a range of ports that can be reserved and requested."""
|
|
311
|
+
|
|
312
|
+
def __init__(self, start: int, end: int):
|
|
313
|
+
"""
|
|
314
|
+
Create a new port range. The port range is inclusive, meaning ``PortRange(5000,5005)`` is 6 ports
|
|
315
|
+
including both 5000 and 5005. This is different from ``range`` which is not inclusive, i.e.::
|
|
316
|
+
|
|
317
|
+
PortRange(5000, 5005).as_range() == range(5000, 5005 + 1)
|
|
318
|
+
|
|
319
|
+
:param start: the start port (inclusive)
|
|
320
|
+
:param end: the end of the range (inclusive).
|
|
321
|
+
"""
|
|
322
|
+
self.start = start
|
|
323
|
+
self.end = end
|
|
324
|
+
|
|
325
|
+
# cache for locally available ports (ports are reserved for a short period of a few seconds)
|
|
326
|
+
self._ports_cache: MutableMapping[Port, Any] = CustomExpiryTTLCache(
|
|
327
|
+
maxsize=len(self),
|
|
328
|
+
ttl=DEFAULT_PORT_RESERVED_SECONDS,
|
|
329
|
+
)
|
|
330
|
+
self._ports_lock = threading.RLock()
|
|
331
|
+
|
|
332
|
+
def subrange(self, start: int = None, end: int = None) -> "PortRange":
|
|
333
|
+
"""
|
|
334
|
+
Creates a new PortRange object from this range which is a sub-range of this port range. The new
|
|
335
|
+
object will use the same port cache and locks of this port range, so you can constrain port
|
|
336
|
+
reservations of an existing port range but have reservations synced between them.
|
|
337
|
+
|
|
338
|
+
:param start: the start of the subrange
|
|
339
|
+
:param end: the end of the subrange
|
|
340
|
+
:raises ValueError: if start or end are outside the current port range
|
|
341
|
+
:return: a new PortRange object synced to this one
|
|
342
|
+
"""
|
|
343
|
+
start = start if start is not None else self.start
|
|
344
|
+
end = end if end is not None else self.end
|
|
345
|
+
|
|
346
|
+
if start < self.start:
|
|
347
|
+
raise ValueError(f"start not in range ({start} < {self.start})")
|
|
348
|
+
if end > self.end:
|
|
349
|
+
raise ValueError(f"end not in range ({end} < {self.end})")
|
|
350
|
+
|
|
351
|
+
# ensures that we return an instance of a subclass
|
|
352
|
+
port_range = type(self)(start, end)
|
|
353
|
+
port_range._ports_cache = self._ports_cache
|
|
354
|
+
port_range._ports_lock = self._ports_lock
|
|
355
|
+
return port_range
|
|
356
|
+
|
|
357
|
+
def as_range(self) -> range:
|
|
358
|
+
"""
|
|
359
|
+
Returns a ``range(start, end+1)`` object representing this port range.
|
|
360
|
+
|
|
361
|
+
:return: a range
|
|
362
|
+
"""
|
|
363
|
+
return range(self.start, self.end + 1)
|
|
364
|
+
|
|
365
|
+
def reserve_port(self, port: IntOrPort | None = None, duration: int | None = None) -> int:
|
|
366
|
+
"""
|
|
367
|
+
Reserves the given port (if it is still free). If the given port is None, it reserves a free port from the
|
|
368
|
+
configured port range for external services. If a port is given, it has to be within the configured
|
|
369
|
+
range of external services (i.e., in the range [self.start, self.end)).
|
|
370
|
+
|
|
371
|
+
:param port: explicit port to check or None if a random port from the configured range should be selected
|
|
372
|
+
:param duration: the time in seconds the port is reserved for (defaults to a few seconds)
|
|
373
|
+
:return: reserved, free port number (int)
|
|
374
|
+
:raises PortNotAvailableException: if the given port is outside the configured range, it is already bound or
|
|
375
|
+
reserved, or if the given port is none and there is no free port in the configured service range.
|
|
376
|
+
"""
|
|
377
|
+
ports_range = self.as_range()
|
|
378
|
+
port = Port.wrap(port) if port is not None else port
|
|
379
|
+
if port is not None and port.port not in ports_range:
|
|
380
|
+
raise PortNotAvailableException(
|
|
381
|
+
f"The requested port ({port}) is not in the port range ({ports_range})."
|
|
382
|
+
)
|
|
383
|
+
with self._ports_lock:
|
|
384
|
+
if port is not None:
|
|
385
|
+
return self._try_reserve_port(port, duration=duration)
|
|
386
|
+
else:
|
|
387
|
+
for port_in_range in ports_range:
|
|
388
|
+
try:
|
|
389
|
+
return self._try_reserve_port(port_in_range, duration=duration)
|
|
390
|
+
except PortNotAvailableException:
|
|
391
|
+
# We ignore the fact that this single port is reserved, we just check the next one
|
|
392
|
+
pass
|
|
393
|
+
raise PortNotAvailableException(
|
|
394
|
+
f"No free network ports available in {self!r} (currently reserved: %s)",
|
|
395
|
+
list(self._ports_cache.keys()),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def is_port_reserved(self, port: IntOrPort) -> bool:
|
|
399
|
+
"""
|
|
400
|
+
Checks whether the port has been reserved in this PortRange. Does not check whether the port can be
|
|
401
|
+
bound or not, and does not check whether the port is in range.
|
|
402
|
+
|
|
403
|
+
:param port: the port to check
|
|
404
|
+
:return: true if the port is reserved within the range
|
|
405
|
+
"""
|
|
406
|
+
port = Port.wrap(port)
|
|
407
|
+
return self._ports_cache.get(port) is not None
|
|
408
|
+
|
|
409
|
+
def mark_reserved(self, port: IntOrPort, duration: int = None):
|
|
410
|
+
"""
|
|
411
|
+
Marks the given port as reserved for the given duration, regardless of whether it is free for not.
|
|
412
|
+
|
|
413
|
+
:param port: the port to reserve
|
|
414
|
+
:param duration: the duration
|
|
415
|
+
:raises ValueError: if the port is not in this port range
|
|
416
|
+
"""
|
|
417
|
+
port = Port.wrap(port)
|
|
418
|
+
|
|
419
|
+
if port.port not in self.as_range():
|
|
420
|
+
raise ValueError(f"port {port} not in {self!r}")
|
|
421
|
+
|
|
422
|
+
with self._ports_lock:
|
|
423
|
+
# reserve the port for a short period of time
|
|
424
|
+
self._ports_cache[port] = "__reserved__"
|
|
425
|
+
if duration:
|
|
426
|
+
self._ports_cache.set_expiry(port, duration)
|
|
427
|
+
|
|
428
|
+
def _try_reserve_port(self, port: IntOrPort, duration: int) -> int:
|
|
429
|
+
"""Checks if the given port is currently not reserved and can be bound."""
|
|
430
|
+
port = Port.wrap(port)
|
|
431
|
+
|
|
432
|
+
if self.is_port_reserved(port):
|
|
433
|
+
raise PortNotAvailableException(f"The given port ({port}) is already reserved.")
|
|
434
|
+
if not self._port_can_be_bound(port):
|
|
435
|
+
raise PortNotAvailableException(f"The given port ({port}) is already in use.")
|
|
436
|
+
|
|
437
|
+
self.mark_reserved(port, duration)
|
|
438
|
+
return port.port
|
|
439
|
+
|
|
440
|
+
def _port_can_be_bound(self, port: IntOrPort) -> bool:
|
|
441
|
+
"""
|
|
442
|
+
Internal check whether the port can be bound. Will open a socket connection and see if the port is
|
|
443
|
+
available. Can be overwritten by subclasses to provide a custom implementation.
|
|
444
|
+
|
|
445
|
+
:param port: the port to check
|
|
446
|
+
:return: true if the port is free on the system
|
|
447
|
+
"""
|
|
448
|
+
return port_can_be_bound(port)
|
|
449
|
+
|
|
450
|
+
def __len__(self):
|
|
451
|
+
return self.end - self.start + 1
|
|
452
|
+
|
|
453
|
+
def __iter__(self):
|
|
454
|
+
return self.as_range().__iter__()
|
|
455
|
+
|
|
456
|
+
def __repr__(self):
|
|
457
|
+
return f"PortRange({self.start}:{self.end})"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@singleton_factory
|
|
461
|
+
def get_docker_host_from_container() -> str:
|
|
462
|
+
"""
|
|
463
|
+
Get the hostname/IP to connect to the host from within a Docker container (e.g., Lambda function).
|
|
464
|
+
The logic is roughly as follows:
|
|
465
|
+
1. return `host.docker.internal` if we're running in host mode, in a non-Linux OS
|
|
466
|
+
2. return the IP address that `host.docker.internal` (or alternatively `host.containers.internal`)
|
|
467
|
+
resolves to, if we're inside Docker
|
|
468
|
+
3. return the Docker bridge IP (config.DOCKER_BRIDGE_IP) as a fallback, if option (2) fails
|
|
469
|
+
"""
|
|
470
|
+
result = config.DOCKER_BRIDGE_IP
|
|
471
|
+
try:
|
|
472
|
+
if not config.is_in_docker and not config.is_in_linux:
|
|
473
|
+
# If we're running outside Docker (in host mode), and would like the Lambda containers to be able
|
|
474
|
+
# to access services running on the local machine, return `host.docker.internal` accordingly
|
|
475
|
+
result = "host.docker.internal"
|
|
476
|
+
if config.is_in_docker:
|
|
477
|
+
try:
|
|
478
|
+
result = socket.gethostbyname("host.docker.internal")
|
|
479
|
+
except OSError:
|
|
480
|
+
result = socket.gethostbyname("host.containers.internal")
|
|
481
|
+
except OSError:
|
|
482
|
+
# TODO if neither host resolves, we might be in linux. We could just use the default gateway then
|
|
483
|
+
pass
|
|
484
|
+
return result
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def get_addressable_container_host(default_local_hostname: str = None) -> str:
|
|
488
|
+
"""
|
|
489
|
+
Return the target host to address endpoints exposed by Docker containers, depending on
|
|
490
|
+
the current execution context.
|
|
491
|
+
|
|
492
|
+
If we're currently executing within Docker, then return get_docker_host_from_container(); otherwise, return
|
|
493
|
+
the value of `LOCALHOST_HOSTNAME`, assuming that container endpoints are exposed and accessible under localhost.
|
|
494
|
+
|
|
495
|
+
:param default_local_hostname: local hostname to return, if running outside Docker (defaults to LOCALHOST_HOSTNAME)
|
|
496
|
+
"""
|
|
497
|
+
default_local_hostname = default_local_hostname or constants.LOCALHOST_HOSTNAME
|
|
498
|
+
return get_docker_host_from_container() if config.is_in_docker else default_local_hostname
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def send_dns_query(
|
|
502
|
+
name: str,
|
|
503
|
+
port: int = 53,
|
|
504
|
+
ip_address: str = "127.0.0.1",
|
|
505
|
+
qtype: str = "A",
|
|
506
|
+
timeout: float = 1.0,
|
|
507
|
+
tcp: bool = False,
|
|
508
|
+
) -> DNSRecord:
|
|
509
|
+
LOG.debug("querying %s:%d for name %s", ip_address, port, name)
|
|
510
|
+
request = DNSRecord.question(qname=name, qtype=qtype)
|
|
511
|
+
reply_bytes = request.send(dest=ip_address, port=port, tcp=tcp, timeout=timeout, ipv6=False)
|
|
512
|
+
return DNSRecord.parse(reply_bytes)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
dynamic_port_range = PortRange(DYNAMIC_PORT_RANGE_START, DYNAMIC_PORT_RANGE_END)
|
|
516
|
+
"""The dynamic port range."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
|
|
5
|
+
LOG = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NoExitArgumentParser(argparse.ArgumentParser):
|
|
9
|
+
"""Implements the `exit_on_error=False` behavior introduced in Python 3.9 to support older Python versions
|
|
10
|
+
and prevents further SystemExit for other error categories.
|
|
11
|
+
* Limitations of error categories: https://stackoverflow.com/a/67891066/6875981
|
|
12
|
+
* ArgumentParser subclassing example: https://stackoverflow.com/a/59072378/6875981
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def exit(self, status: int = ..., message: str | None = ...) -> NoReturn:
|
|
16
|
+
LOG.warning("Error in argument parser but preventing exit: %s", message)
|
|
17
|
+
|
|
18
|
+
def error(self, message: str) -> NoReturn:
|
|
19
|
+
raise NotImplementedError(f"Unsupported flag by this Docker client: {message}")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_number(number: float, decimals: int = 2):
|
|
5
|
+
# Note: interestingly, f"{number:.3g}" seems to yield incorrect results in some cases.
|
|
6
|
+
# The logic below seems to be the most stable/reliable.
|
|
7
|
+
result = f"{number:.{decimals}f}"
|
|
8
|
+
if "." in result:
|
|
9
|
+
result = result.rstrip("0").rstrip(".")
|
|
10
|
+
return result
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_number(s: Any) -> bool:
|
|
14
|
+
# booleans inherit from int
|
|
15
|
+
#
|
|
16
|
+
# >>> a.__class__.__mro__
|
|
17
|
+
# (<class 'bool'>, <class 'int'>, <class 'object'>)
|
|
18
|
+
if s is False or s is True:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
float(s) # for int, long and float
|
|
23
|
+
return True
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_number(s: Any) -> int | float:
|
|
29
|
+
"""Cast the string representation of the given object to a number (int or float), or raise ValueError."""
|
|
30
|
+
try:
|
|
31
|
+
return int(str(s))
|
|
32
|
+
except ValueError:
|
|
33
|
+
return float(str(s))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_bytes(count: float, default: str = "n/a"):
|
|
37
|
+
"""Format a bytes number as a human-readable unit, e.g., 1.3GB or 21.53MB"""
|
|
38
|
+
if not is_number(count):
|
|
39
|
+
return default
|
|
40
|
+
cnt = float(count)
|
|
41
|
+
if cnt < 0:
|
|
42
|
+
return default
|
|
43
|
+
units = ("B", "KB", "MB", "GB", "TB")
|
|
44
|
+
for unit in units:
|
|
45
|
+
if cnt < 1000 or unit == units[-1]:
|
|
46
|
+
# FIXME: will return '1e+03TB' for 1000TB
|
|
47
|
+
return f"{format_number(cnt, decimals=3)}{unit}"
|
|
48
|
+
cnt = cnt / 1000.0
|
|
49
|
+
return count
|