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,1259 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import copy
|
|
3
|
+
import dataclasses
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import textwrap
|
|
11
|
+
import threading
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from json import JSONDecodeError, JSONEncoder
|
|
15
|
+
from pathlib import PurePosixPath
|
|
16
|
+
from typing import Any, Protocol, cast
|
|
17
|
+
|
|
18
|
+
import dateutil.parser
|
|
19
|
+
from localstack_cli import config, constants
|
|
20
|
+
from localstack_cli.constants import VERSION
|
|
21
|
+
from localstack_cli.pro.core import config as pro_config
|
|
22
|
+
from localstack_cli.pro.core.bootstrap.entitlements import (
|
|
23
|
+
ProductEntitlements,
|
|
24
|
+
ProductInfo,
|
|
25
|
+
)
|
|
26
|
+
from localstack_cli.pro.core.constants import PLATFORM_PLUGIN_NAMESPACE
|
|
27
|
+
from localstack_cli.utils.objects import singleton_factory
|
|
28
|
+
from localstack_cli.utils.strings import md5
|
|
29
|
+
from plux import Plugin, PluginDisabled, PluginLifecycleListener, PluginSpec
|
|
30
|
+
|
|
31
|
+
LOG = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
#################
|
|
34
|
+
# API #
|
|
35
|
+
#################
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ENV_LOCALSTACK_API_KEY = "LOCALSTACK_API_KEY"
|
|
39
|
+
ENV_LOCALSTACK_AUTH_TOKEN = "LOCALSTACK_AUTH_TOKEN"
|
|
40
|
+
LICENSE_FILE_NAME = "license.json"
|
|
41
|
+
AWS_SERVICE_PLUGIN_NAMESPACE = "localstack.aws.provider"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LicensingError(Exception):
|
|
45
|
+
"""Base exception for errors during license requests, validation, or activation."""
|
|
46
|
+
|
|
47
|
+
default_message = None
|
|
48
|
+
|
|
49
|
+
def __init__(self, msg: str = None, *args):
|
|
50
|
+
msg = msg or self.default_message
|
|
51
|
+
if msg:
|
|
52
|
+
super().__init__(msg, *args)
|
|
53
|
+
else:
|
|
54
|
+
super().__init__(*args)
|
|
55
|
+
|
|
56
|
+
def get_user_friendly(self) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Returns a user-friendly message for this key activation error.
|
|
59
|
+
:return: a string that can be used as log output
|
|
60
|
+
"""
|
|
61
|
+
return textwrap.dedent(
|
|
62
|
+
f"""
|
|
63
|
+
===============================================
|
|
64
|
+
License activation failed! 🔑❌
|
|
65
|
+
|
|
66
|
+
Reason: {self}
|
|
67
|
+
|
|
68
|
+
Due to this error, Localstack has quit. LocalStack pro features can only be used with a valid license.
|
|
69
|
+
|
|
70
|
+
- Please check that your credentials are set up correctly and that you have an active license.
|
|
71
|
+
You can find your credentials in our webapp at https://app.localstack.cloud.
|
|
72
|
+
- If you want to continue using LocalStack without pro features, set `LOCALSTACK_ACKNOWLEDGE_ACCOUNT_REQUIREMENT=1` during the grace period.
|
|
73
|
+
"""
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def get_user_friendly_cli(self):
|
|
77
|
+
reason = "".join(textwrap.fill(str(self))).strip()
|
|
78
|
+
# for reasons I don't understand and am too lazy to figure out, textwrap.dedent indents the text
|
|
79
|
+
# when using a string that was formatted with textwrap.fill. so we don't use it here.
|
|
80
|
+
return f"""
|
|
81
|
+
=============================================
|
|
82
|
+
You tried to use a LocalStack feature that requires a paid subscription,
|
|
83
|
+
but the license activation has failed! 🔑❌
|
|
84
|
+
|
|
85
|
+
Reason: {reason}
|
|
86
|
+
|
|
87
|
+
Due to this error, LocalStack has quit.
|
|
88
|
+
|
|
89
|
+
- Please check that your credentials are set up correctly and that you have an active license.
|
|
90
|
+
You can find your credentials in our webapp at https://app.localstack.cloud.
|
|
91
|
+
- If you haven't yet, sign up on the webapp and get a free trial!
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class LicenseFormatError(LicensingError):
|
|
96
|
+
"""Indicates that something is wrong with the format of the license."""
|
|
97
|
+
|
|
98
|
+
default_message = "A parsing error was encountered while parsing the license file or while decoding the license secret."
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class LicenseRequestError(LicensingError):
|
|
102
|
+
"""Base exceptions for errors that occur while requesting a new license from auth credentials."""
|
|
103
|
+
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class LicensingServerUnavailableError(LicenseRequestError):
|
|
108
|
+
"""Indicates that the licensing server is not available."""
|
|
109
|
+
|
|
110
|
+
default_message = (
|
|
111
|
+
f"Could not reach the LocalStack licensing server at {constants.API_ENDPOINT}. "
|
|
112
|
+
"Please make sure outbound HTTPS traffic is allowed"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CredentialsTypeError(LicenseRequestError):
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class CredentialsMissingError(LicensingError):
|
|
121
|
+
default_message = (
|
|
122
|
+
"No credentials were found in the environment. Please make sure to either set the "
|
|
123
|
+
f"{ENV_LOCALSTACK_AUTH_TOKEN} variable to a valid auth token. If you are using the CLI, "
|
|
124
|
+
f"you can also run `localstack auth set-token`."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CredentialsFormatError(LicenseRequestError):
|
|
129
|
+
default_message = "There was an error while validating the format of the credentials defined in your environment."
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AuthTokenFormatError(CredentialsFormatError):
|
|
133
|
+
default_message = (
|
|
134
|
+
f"The auth token defined in {ENV_LOCALSTACK_AUTH_TOKEN} has an invalid format. "
|
|
135
|
+
f"Please make sure that the environment variable {ENV_LOCALSTACK_AUTH_TOKEN} contains a valid auth "
|
|
136
|
+
f"token. You can find your auth token in the LocalStack web app https://app.localstack.cloud."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CredentialsInvalidError(LicenseRequestError):
|
|
141
|
+
default_message = (
|
|
142
|
+
"The credentials defined in your environment are invalid. Please make sure to set the "
|
|
143
|
+
f"{ENV_LOCALSTACK_AUTH_TOKEN} variable to a valid auth token. You can find your auth "
|
|
144
|
+
f"token in the LocalStack web app https://app.localstack.cloud."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class LicenseValidationError(LicensingError):
|
|
149
|
+
"""Base exception for errors that occur while trying to validate a license."""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class LicenseSignatureMismatchError(LicenseValidationError):
|
|
153
|
+
"""Indicates that the signature of a license did not match the calculated signature."""
|
|
154
|
+
|
|
155
|
+
default_message = (
|
|
156
|
+
"Calculated signature and license signature do not match. Please check that the "
|
|
157
|
+
"credentials in your environment match the one for your license."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class LicenseActivationError(LicensingError):
|
|
162
|
+
"""Base exception for errors that occur during license activation."""
|
|
163
|
+
|
|
164
|
+
default_message = "The license could not be activated for unknown reasons."
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class LicenseExpiredError(LicenseActivationError):
|
|
168
|
+
default_message = "The license has expired."
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class LicenseStaleError(LicenseActivationError):
|
|
172
|
+
"""Offline activation cannot be performed"""
|
|
173
|
+
|
|
174
|
+
default_message = (
|
|
175
|
+
"Offline activation failed! Your local license file that is used to activate LocalStack has "
|
|
176
|
+
"expired. Please connect to the Internet and restart LocalStack to renew it."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def __init__(self, license: "License", msg=None, *args):
|
|
180
|
+
super().__init__(msg, *args)
|
|
181
|
+
self.license = license
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class LicenseStatusError(LicenseActivationError):
|
|
185
|
+
default_message = "Unexpected license status."
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class LocalstackVersionMismatchError(LicenseActivationError):
|
|
189
|
+
"""License files are not valid across minor versions of LocalStack, even if the license allows all product
|
|
190
|
+
versions."""
|
|
191
|
+
|
|
192
|
+
default_message = (
|
|
193
|
+
"The license file was created for a different LocalStack version than the one in use."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ProductMismatchError(LicenseActivationError):
|
|
198
|
+
default_message = (
|
|
199
|
+
"The product you are trying to activate does not match the ones in the license agreement."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Credentials:
|
|
204
|
+
"""
|
|
205
|
+
Credentials are used to authenticate the user when requesting a license.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def to_bytes(self) -> bytes:
|
|
209
|
+
raise NotImplementedError
|
|
210
|
+
|
|
211
|
+
def encoded(self) -> str:
|
|
212
|
+
raise NotImplementedError
|
|
213
|
+
|
|
214
|
+
def is_valid(self) -> bool:
|
|
215
|
+
raise NotImplementedError
|
|
216
|
+
|
|
217
|
+
def __eq__(self, other):
|
|
218
|
+
return other.to_bytes() == self.to_bytes()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ApiKeyCredentials(Credentials):
|
|
222
|
+
"""
|
|
223
|
+
A wrapper to use a LOCALSTACK_API_KEY as credentials.
|
|
224
|
+
|
|
225
|
+
Deprecated in favor of LOCALSTACK_AUTH_TOKEN credentials.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
api_key: str
|
|
229
|
+
|
|
230
|
+
def __init__(self, api_key: str):
|
|
231
|
+
self.api_key = api_key
|
|
232
|
+
|
|
233
|
+
def __repr__(self):
|
|
234
|
+
return f'"{self.api_key[:3]}..."({len(self.api_key)})'
|
|
235
|
+
|
|
236
|
+
def encoded(self) -> str:
|
|
237
|
+
return self.api_key
|
|
238
|
+
|
|
239
|
+
def is_valid(self) -> bool:
|
|
240
|
+
return True # TODO add basic API key syntax validation
|
|
241
|
+
|
|
242
|
+
def to_bytes(self) -> bytes:
|
|
243
|
+
return self.api_key.encode("utf-8")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class AuthToken(Credentials):
|
|
247
|
+
"""
|
|
248
|
+
The LOCALSTACK_AUTH_TOKEN is a 36+ character (39+ prefixed) semi-pronounceable token that looks similar
|
|
249
|
+
to a UUID.
|
|
250
|
+
The prefix always starts with ls and ends with a "-".
|
|
251
|
+
Here are some examples and their constituent parts::
|
|
252
|
+
|
|
253
|
+
lsci-sekU1905-PohE-0982-duXA-pUQITIxi3829
|
|
254
|
+
^^^^
|
|
255
|
+
prefix
|
|
256
|
+
|
|
257
|
+
ls-WeNE7839-VifA-viLo-LImo-SehU0277efb9
|
|
258
|
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
259
|
+
payload (32 chars)
|
|
260
|
+
|
|
261
|
+
ls-Wefu6414-YUvE-8314-ZiLA-zIlOnocAfb0d
|
|
262
|
+
^^^^
|
|
263
|
+
checksum
|
|
264
|
+
|
|
265
|
+
The last 4 digits of the token are the checksum. The checksum consists of the first 4 characters of the
|
|
266
|
+
md5 hexdigest of the 32 payload characters of the token.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
TOKEN_PREFIX = "ls[a-zA-Z]*-"
|
|
270
|
+
MIN_TOKEN_PREFIX_LENGTH = 3
|
|
271
|
+
|
|
272
|
+
TOKEN_REGEX = re.compile(
|
|
273
|
+
rf"^{TOKEN_PREFIX}[a-zA-Z]{{4}}[a-zA-Z0-9]{{4}}-[a-zA-Z0-9]{{4}}-[a-zA-Z0-9]{{4}}-[a-zA-Z0-9]{{4}}-[a-zA-Z0-9]{{12}}$"
|
|
274
|
+
)
|
|
275
|
+
MIN_TOKEN_LENGTH = 36 + MIN_TOKEN_PREFIX_LENGTH
|
|
276
|
+
|
|
277
|
+
token: str
|
|
278
|
+
prefix: str
|
|
279
|
+
payload: str
|
|
280
|
+
|
|
281
|
+
def __init__(self, token: str):
|
|
282
|
+
self.token = token
|
|
283
|
+
# prefix is everything until the first "-"
|
|
284
|
+
self.prefix, _, payload_checksum = token.partition("-")
|
|
285
|
+
self.prefix += "-"
|
|
286
|
+
# the payload is the part after the prefix without the last four characters
|
|
287
|
+
self.payload = payload_checksum[:-4]
|
|
288
|
+
# the checksum are the last four characters
|
|
289
|
+
self.checksum = payload_checksum[-4:]
|
|
290
|
+
|
|
291
|
+
def __repr__(self):
|
|
292
|
+
n = 8 + len(self.prefix) # show the first 8 characters plus the prefix
|
|
293
|
+
return f"{self.token[:n]}-****-****-****-************"
|
|
294
|
+
|
|
295
|
+
def encoded(self) -> str:
|
|
296
|
+
return self.token
|
|
297
|
+
|
|
298
|
+
def to_bytes(self) -> bytes:
|
|
299
|
+
return self.token.encode("utf-8")
|
|
300
|
+
|
|
301
|
+
def is_valid(self) -> bool:
|
|
302
|
+
return self.is_syntax_valid() and self.is_checksum_valid()
|
|
303
|
+
|
|
304
|
+
def is_syntax_valid(self) -> bool:
|
|
305
|
+
if len(self.token) < self.MIN_TOKEN_LENGTH:
|
|
306
|
+
return False
|
|
307
|
+
return bool(self.TOKEN_REGEX.match(self.token))
|
|
308
|
+
|
|
309
|
+
def is_checksum_valid(self) -> bool:
|
|
310
|
+
"""
|
|
311
|
+
Checks whether the token checksum is correct. The checksum is the last 4 characters of the token and
|
|
312
|
+
consists of the first 4 digits of the md5 hexdigest of the first 32 characters (without the prefix)
|
|
313
|
+
of the token.
|
|
314
|
+
|
|
315
|
+
:return: true if the checksum is valid
|
|
316
|
+
"""
|
|
317
|
+
if len(self.token) < self.MIN_TOKEN_LENGTH:
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
return md5(self.payload)[:4] == self.checksum
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class LicenseStatus(Enum):
|
|
324
|
+
UNKNOWN = "UNKNOWN"
|
|
325
|
+
ACTIVE = "ACTIVE"
|
|
326
|
+
INACTIVE = "INACTIVE"
|
|
327
|
+
EXPIRED = "EXPIRED"
|
|
328
|
+
SUSPENDED = "SUSPENDED"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclasses.dataclass
|
|
332
|
+
class License:
|
|
333
|
+
id: str
|
|
334
|
+
"""The unique ID of the license"""
|
|
335
|
+
|
|
336
|
+
license_format: str
|
|
337
|
+
"""The license format version, e.g., '1'"""
|
|
338
|
+
|
|
339
|
+
signature: str | None
|
|
340
|
+
"""The base64 encoded signature used to validate whether the license was signed correctly"""
|
|
341
|
+
|
|
342
|
+
def copy(self) -> "License":
|
|
343
|
+
"""
|
|
344
|
+
Create a deep copy of this license document. Uses ``copy.deepcopy`` by default.
|
|
345
|
+
:return: a copy of this license document.
|
|
346
|
+
"""
|
|
347
|
+
return copy.deepcopy(self)
|
|
348
|
+
|
|
349
|
+
def to_log_string(self) -> str:
|
|
350
|
+
return self.id
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclasses.dataclass
|
|
354
|
+
class LicenseV1(License):
|
|
355
|
+
license_type: str
|
|
356
|
+
"""The license type (basically the plan: Free, Trial, Pro, Team, Enterprise, ...)"""
|
|
357
|
+
|
|
358
|
+
issue_date: datetime
|
|
359
|
+
"""When the license was issued"""
|
|
360
|
+
|
|
361
|
+
expiry_date: datetime
|
|
362
|
+
"""When the license expires"""
|
|
363
|
+
|
|
364
|
+
products: list[ProductInfo] = dataclasses.field(default_factory=list)
|
|
365
|
+
"""The products that are subject of this license."""
|
|
366
|
+
|
|
367
|
+
license_status: LicenseStatus = LicenseStatus.UNKNOWN
|
|
368
|
+
"""The license status (e.g., ACTIVE, EXPIRED, ...)."""
|
|
369
|
+
|
|
370
|
+
license_secret: str | None = None
|
|
371
|
+
"""The base64 encoded secret that contains the necessary information to activate the license offline."""
|
|
372
|
+
|
|
373
|
+
last_activated: datetime | None = None
|
|
374
|
+
"""The last time the user checked in"""
|
|
375
|
+
|
|
376
|
+
reactivate_after: datetime | None = None
|
|
377
|
+
"""The next time a user needs to check in"""
|
|
378
|
+
|
|
379
|
+
offline_data: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
380
|
+
"""Additional data stored in the offline license file which is not part of the signature."""
|
|
381
|
+
|
|
382
|
+
def to_log_string(self):
|
|
383
|
+
# TODO remove this once API keys have been phased out,
|
|
384
|
+
# or when the ID a "fake" license issued for an API key is not the API key itself anymore
|
|
385
|
+
# The ID of "fake" licenses for API keys is the API key itself. Redact it in that case.
|
|
386
|
+
# Real licenses are "license_<uuid>", i.e. at least 44 chars long.
|
|
387
|
+
prefix = self.id if len(self.id) > 30 else f"{self.id[:3]}..."
|
|
388
|
+
return f"{prefix}:{self.license_type}"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class LicenseSigner:
|
|
392
|
+
"""
|
|
393
|
+
This is (in the future) server-side code to generate license keys and sign license files.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
def calculate_signature(self, license: License) -> str:
|
|
397
|
+
raise NotImplementedError
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class LicensingClient:
|
|
401
|
+
"""
|
|
402
|
+
The licensing client implements the communication between LocalStack and the licensing server. The
|
|
403
|
+
abstractions are used by the ``LicensedLocalstackEnvironment``. We also have a special
|
|
404
|
+
``ApiKeyLicensingClient`` that implements the "self-signed" licenses that make this code backwards
|
|
405
|
+
compatible with ``LOCALSTACK_API_KEY`` (deprecated).
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
def request_new_license(self, credentials: Credentials) -> License:
|
|
409
|
+
"""
|
|
410
|
+
Requests a new license from the license server using the given credentials.
|
|
411
|
+
|
|
412
|
+
:param credentials: the entities credentials (like the auth token)
|
|
413
|
+
:return: a new license
|
|
414
|
+
:raises LicensingError: there was an error requesting the license
|
|
415
|
+
"""
|
|
416
|
+
raise NotImplementedError
|
|
417
|
+
|
|
418
|
+
def validate_license(self, credentials: Credentials, license: License):
|
|
419
|
+
"""
|
|
420
|
+
Checks whether the license is valid (has a valid signature, was not tampered with).
|
|
421
|
+
|
|
422
|
+
:param credentials: the credentials needed to validate the license
|
|
423
|
+
:param license: the license to validate
|
|
424
|
+
:raises LicenseValidationError: if the license is somehow invalid
|
|
425
|
+
"""
|
|
426
|
+
raise NotImplementedError
|
|
427
|
+
|
|
428
|
+
def activate_license_offline(self, credentials: Credentials, license: License):
|
|
429
|
+
"""
|
|
430
|
+
Performs an offline license activation of the given license file that was read from a disk. This is
|
|
431
|
+
purely client-side and obviously inherently trust-based.
|
|
432
|
+
|
|
433
|
+
:param license: the license file cached on the client
|
|
434
|
+
"""
|
|
435
|
+
raise NotImplementedError
|
|
436
|
+
|
|
437
|
+
def activate_license_online(self, credentials: Credentials, license: License) -> License:
|
|
438
|
+
"""
|
|
439
|
+
Performs a license activation with the licensing server and returns a new license file if successful.
|
|
440
|
+
|
|
441
|
+
:param license: the license file cached on the client
|
|
442
|
+
:return: a new license
|
|
443
|
+
"""
|
|
444
|
+
raise NotImplementedError
|
|
445
|
+
|
|
446
|
+
#####################
|
|
447
|
+
# v1 implementation #
|
|
448
|
+
#####################
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class _LicenseJsonEncoder(JSONEncoder):
|
|
452
|
+
"""
|
|
453
|
+
Special JSONEncoder used to serialize the license file.
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
def default(self, obj: Any) -> Any:
|
|
457
|
+
if isinstance(obj, datetime):
|
|
458
|
+
return obj.isoformat(timespec="seconds")
|
|
459
|
+
if isinstance(obj, bytes):
|
|
460
|
+
return base64.b64encode(obj).decode("utf-8")
|
|
461
|
+
if isinstance(obj, Credentials):
|
|
462
|
+
return obj.encoded()
|
|
463
|
+
if isinstance(obj, LicenseStatus):
|
|
464
|
+
return obj.value
|
|
465
|
+
return super().default(obj)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class LicenseSerializer:
|
|
469
|
+
def serialize(self, license: License) -> bytes:
|
|
470
|
+
if not license.license_format:
|
|
471
|
+
raise LicenseFormatError("missing license_format attribute")
|
|
472
|
+
|
|
473
|
+
if license.license_format == "1":
|
|
474
|
+
# license file format 1 is simply a json doc
|
|
475
|
+
doc = dataclasses.asdict(license)
|
|
476
|
+
doc.pop("credentials", None) # don't save credentials in license
|
|
477
|
+
return json.dumps(doc, cls=_LicenseJsonEncoder, indent=2).encode("utf-8")
|
|
478
|
+
|
|
479
|
+
raise LicenseFormatError(f"unknown license format version {license.license_format}")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class LicenseParser:
|
|
483
|
+
def parse(self, document: bytes) -> License:
|
|
484
|
+
try:
|
|
485
|
+
attributes = json.loads(document)
|
|
486
|
+
except JSONDecodeError as e:
|
|
487
|
+
raise LicenseFormatError(f"could not de-serialize json license: {e}") from e
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
if attributes.get("license_format") != "1":
|
|
491
|
+
raise LicenseFormatError("unknown license format")
|
|
492
|
+
|
|
493
|
+
date_fields = ["issue_date", "expiry_date", "reactivate_after"]
|
|
494
|
+
for field in date_fields:
|
|
495
|
+
attributes[field] = dateutil.parser.parse(attributes[field])
|
|
496
|
+
|
|
497
|
+
attributes["license_status"] = LicenseStatus(attributes["license_status"])
|
|
498
|
+
|
|
499
|
+
return LicenseV1(**attributes)
|
|
500
|
+
except (KeyError, ValueError) as e:
|
|
501
|
+
raise LicenseFormatError(f"error parsing license file: {e}") from e
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class LicenseV1ClientBase(LicensingClient):
|
|
505
|
+
def validate_license(self, credentials: Credentials, license: LicenseV1):
|
|
506
|
+
try:
|
|
507
|
+
now = datetime.now(tz=timezone.utc)
|
|
508
|
+
|
|
509
|
+
if license.expiry_date < now:
|
|
510
|
+
raise LicenseExpiredError()
|
|
511
|
+
|
|
512
|
+
if license.license_status != LicenseStatus.ACTIVE:
|
|
513
|
+
raise LicenseStatusError(
|
|
514
|
+
f"expected license to be ACTIVE, was {license.license_status}"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
if license.reactivate_after < now:
|
|
518
|
+
raise LicenseStaleError(license)
|
|
519
|
+
|
|
520
|
+
if license_version := license.offline_data.get("localstack_version"):
|
|
521
|
+
if license_version.split(".")[:2] != VERSION.split(".")[:2]:
|
|
522
|
+
raise LocalstackVersionMismatchError()
|
|
523
|
+
else:
|
|
524
|
+
# we also raise a LocalstackVersionMismatchError if the localstack_version field is not there,
|
|
525
|
+
# this clears any old license files that don't yet have this field
|
|
526
|
+
raise LocalstackVersionMismatchError()
|
|
527
|
+
|
|
528
|
+
if LicenseSignerV1(credentials).calculate_signature(license) != license.signature:
|
|
529
|
+
raise LicenseSignatureMismatchError()
|
|
530
|
+
except KeyError as e:
|
|
531
|
+
raise LicenseFormatError(f"missing attribute: {e}")
|
|
532
|
+
|
|
533
|
+
def activate_license_offline(self, credentials: Credentials, license: LicenseV1):
|
|
534
|
+
self.validate_license(credentials, license)
|
|
535
|
+
|
|
536
|
+
if not self.current_product_version_matches(license):
|
|
537
|
+
raise ProductMismatchError()
|
|
538
|
+
|
|
539
|
+
def current_product_version_matches(self, license_: LicenseV1) -> bool:
|
|
540
|
+
try:
|
|
541
|
+
allowed_version = license_.products[0]["version"].split(".")
|
|
542
|
+
current_version = VERSION.split(".")
|
|
543
|
+
|
|
544
|
+
# FIXME: hack until we've figured out a better way to handle versions
|
|
545
|
+
if license_.products[0]["version"].startswith("*"):
|
|
546
|
+
return True
|
|
547
|
+
|
|
548
|
+
vt1 = allowed_version[0], allowed_version[1]
|
|
549
|
+
vt2 = current_version[0], current_version[1]
|
|
550
|
+
except IndexError as e:
|
|
551
|
+
raise LicenseFormatError() from e
|
|
552
|
+
|
|
553
|
+
return vt1 == vt2
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class LicenseV1Client(LicenseV1ClientBase):
|
|
557
|
+
def request_new_license(self, credentials: Credentials) -> License:
|
|
558
|
+
if not credentials:
|
|
559
|
+
raise CredentialsMissingError()
|
|
560
|
+
|
|
561
|
+
if not credentials.is_valid():
|
|
562
|
+
raise CredentialsFormatError()
|
|
563
|
+
|
|
564
|
+
license_ = self._request_license(credentials)
|
|
565
|
+
license_.offline_data["localstack_version"] = VERSION
|
|
566
|
+
return license_
|
|
567
|
+
|
|
568
|
+
def _get_machine_data(self) -> dict:
|
|
569
|
+
from localstack_cli.utils.analytics.metadata import get_client_metadata
|
|
570
|
+
|
|
571
|
+
metadata = get_client_metadata()
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
"id": metadata.machine_id,
|
|
575
|
+
"cli": config.is_env_true("LOCALSTACK_CLI"),
|
|
576
|
+
"ci": metadata.is_ci,
|
|
577
|
+
"system": get_system_information_summary(),
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
def _get_product_data(self) -> dict:
|
|
581
|
+
from localstack_cli.pro.core.constants import VERSION
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
"name": "localstack-pro",
|
|
585
|
+
"version": VERSION,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
def _perform_licensing_request(self, path: str, payload: dict):
|
|
589
|
+
import requests
|
|
590
|
+
from localstack_cli.utils.http import get_proxies
|
|
591
|
+
from localstack_cli.utils.sync import retry
|
|
592
|
+
|
|
593
|
+
proxies = get_proxies()
|
|
594
|
+
|
|
595
|
+
def _request():
|
|
596
|
+
try:
|
|
597
|
+
return requests.post(
|
|
598
|
+
f"{constants.API_ENDPOINT}{path}",
|
|
599
|
+
json.dumps(payload),
|
|
600
|
+
verify=not config.is_env_true("SSL_NO_VERIFY"),
|
|
601
|
+
proxies=proxies,
|
|
602
|
+
timeout=10,
|
|
603
|
+
)
|
|
604
|
+
except requests.exceptions.RequestException as e:
|
|
605
|
+
raise LicensingServerUnavailableError() from e
|
|
606
|
+
|
|
607
|
+
return retry(_request, retries=2, sleep=1)
|
|
608
|
+
|
|
609
|
+
def activate_license_online(self, credentials: Credentials, license: LicenseV1) -> LicenseV1:
|
|
610
|
+
if isinstance(credentials, AuthToken):
|
|
611
|
+
credentials_type = "licensing-auth-token"
|
|
612
|
+
token = credentials.token
|
|
613
|
+
elif isinstance(credentials, ApiKeyCredentials):
|
|
614
|
+
credentials_type = "licensing-api-key"
|
|
615
|
+
token = credentials.api_key
|
|
616
|
+
else:
|
|
617
|
+
raise CredentialsTypeError(f"{type(credentials)}")
|
|
618
|
+
|
|
619
|
+
response = self._perform_licensing_request(
|
|
620
|
+
"/license/activate",
|
|
621
|
+
{
|
|
622
|
+
"license_id": license.id,
|
|
623
|
+
"credentials": {
|
|
624
|
+
"type": credentials_type,
|
|
625
|
+
"token": token,
|
|
626
|
+
},
|
|
627
|
+
"product": self._get_product_data(),
|
|
628
|
+
"machine": self._get_machine_data(),
|
|
629
|
+
},
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
if response.ok:
|
|
633
|
+
return cast(LicenseV1, LicenseParser().parse(response.content))
|
|
634
|
+
|
|
635
|
+
self._server_error_to_exception(response)
|
|
636
|
+
|
|
637
|
+
def _request_license(self, credentials: AuthToken | ApiKeyCredentials) -> LicenseV1:
|
|
638
|
+
if isinstance(credentials, AuthToken):
|
|
639
|
+
credentials_type = "licensing-auth-token"
|
|
640
|
+
token = credentials.token
|
|
641
|
+
elif isinstance(credentials, ApiKeyCredentials):
|
|
642
|
+
credentials_type = "licensing-api-key"
|
|
643
|
+
token = credentials.api_key
|
|
644
|
+
else:
|
|
645
|
+
raise CredentialsTypeError(f"{type(credentials)}")
|
|
646
|
+
|
|
647
|
+
response = self._perform_licensing_request(
|
|
648
|
+
"/license/request",
|
|
649
|
+
{
|
|
650
|
+
"credentials": {
|
|
651
|
+
"type": credentials_type,
|
|
652
|
+
"token": token,
|
|
653
|
+
},
|
|
654
|
+
"product": self._get_product_data(),
|
|
655
|
+
"machine": self._get_machine_data(),
|
|
656
|
+
},
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
if response.ok:
|
|
660
|
+
return LicenseParser().parse(response.content)
|
|
661
|
+
|
|
662
|
+
self._server_error_to_exception(response)
|
|
663
|
+
|
|
664
|
+
def _server_error_to_exception(self, response):
|
|
665
|
+
try:
|
|
666
|
+
doc = response.json()
|
|
667
|
+
if not doc.get("message"):
|
|
668
|
+
raise LicensingError(f"Unexpected license server error: {response.text}")
|
|
669
|
+
message = doc["message"]
|
|
670
|
+
except Exception:
|
|
671
|
+
raise LicensingError(f"Unexpected license server error: {response.text}")
|
|
672
|
+
|
|
673
|
+
if message == "licensing.credentials.invalid":
|
|
674
|
+
raise CredentialsInvalidError()
|
|
675
|
+
if message == "licensing.credentials.format":
|
|
676
|
+
raise AuthTokenFormatError()
|
|
677
|
+
if message == "licensing.activation_error":
|
|
678
|
+
raise LicenseActivationError()
|
|
679
|
+
if message == "licensing.license.not_found":
|
|
680
|
+
raise LicenseActivationError("The license with the given ID was not found.")
|
|
681
|
+
if message.startswith("licensing.license.invalid_status"):
|
|
682
|
+
parts = message.split(":")
|
|
683
|
+
raise LicenseStatusError(f"Expected license to be ACTIVE, was {parts[1]}")
|
|
684
|
+
if message == "licensing.license.expired":
|
|
685
|
+
raise LicenseExpiredError()
|
|
686
|
+
# TODO: complete
|
|
687
|
+
|
|
688
|
+
else:
|
|
689
|
+
raise LicensingError(f"Unexpected license server error: {response.text}")
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
class LicensedLocalstackEnvironment:
|
|
693
|
+
"""
|
|
694
|
+
The LicensedLocalstackEnvironment implements a generic license activation flow over the base abstractions
|
|
695
|
+
``License``, ``LicensingClient``, ``LicenseParser`` and ``LicenseSerializer``.
|
|
696
|
+
|
|
697
|
+
It is stateful and relates to one license that can be cached/loaded from disk
|
|
698
|
+
"""
|
|
699
|
+
|
|
700
|
+
license: License | None
|
|
701
|
+
"""The activated license set after ``activate`` was called."""
|
|
702
|
+
|
|
703
|
+
def __init__(
|
|
704
|
+
self,
|
|
705
|
+
client: LicensingClient,
|
|
706
|
+
parser: LicenseParser = None,
|
|
707
|
+
serializer: LicenseSerializer = None,
|
|
708
|
+
):
|
|
709
|
+
self.client = client
|
|
710
|
+
self.parser = parser or LicenseParser()
|
|
711
|
+
self.serializer = serializer or LicenseSerializer()
|
|
712
|
+
|
|
713
|
+
self.license = None
|
|
714
|
+
self.license_file_path = None
|
|
715
|
+
self._product_entitlements: ProductEntitlements | None = None
|
|
716
|
+
self._activated = False
|
|
717
|
+
self._mutex = threading.RLock()
|
|
718
|
+
|
|
719
|
+
@property
|
|
720
|
+
def activated(self) -> bool:
|
|
721
|
+
"""Returns true if the license has been activated successfully."""
|
|
722
|
+
return self._activated
|
|
723
|
+
|
|
724
|
+
def _set_license(self, license: License | None) -> None:
|
|
725
|
+
self.license = license
|
|
726
|
+
self._product_entitlements = None
|
|
727
|
+
|
|
728
|
+
@property
|
|
729
|
+
def product_entitlements(self) -> ProductEntitlements:
|
|
730
|
+
"""
|
|
731
|
+
Returns the product entitlements derived from the active license document.
|
|
732
|
+
"""
|
|
733
|
+
if not self.activated or not isinstance(self.license, LicenseV1):
|
|
734
|
+
return ProductEntitlements([])
|
|
735
|
+
|
|
736
|
+
if self._product_entitlements is None:
|
|
737
|
+
self._product_entitlements = ProductEntitlements(self.license.products)
|
|
738
|
+
|
|
739
|
+
return self._product_entitlements
|
|
740
|
+
|
|
741
|
+
def activate(self, offline_only: bool = False):
|
|
742
|
+
"""
|
|
743
|
+
This high-level method activates the license using credentials from the environment.
|
|
744
|
+
Once completed, it sets ``self.license`` to the license document that was activated.
|
|
745
|
+
This method will also set the env var ``PRO_ACTIVATED=1`` after successful license activation.
|
|
746
|
+
|
|
747
|
+
See ``activate_license`` for the activation algorithm.
|
|
748
|
+
|
|
749
|
+
The method is thread safe and idempotent, so you can call it multiple times and use it as guard::
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
get_licensed_environment().activate()
|
|
753
|
+
except LicensingError as e:
|
|
754
|
+
...
|
|
755
|
+
|
|
756
|
+
If you want to check that the environment is activated, you can also::
|
|
757
|
+
|
|
758
|
+
if get_licensed_environment().activated:
|
|
759
|
+
...
|
|
760
|
+
|
|
761
|
+
:param offline_only: if set to True, no online license activation will be attempted,
|
|
762
|
+
and the license activation will fail immediately if the offline activation fails.
|
|
763
|
+
"""
|
|
764
|
+
with self._mutex:
|
|
765
|
+
if self.activated:
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
self.activate_license(offline_only)
|
|
769
|
+
|
|
770
|
+
os.environ[constants.ENV_PRO_ACTIVATED] = "1"
|
|
771
|
+
|
|
772
|
+
def activate_license(self, offline_only: bool = False):
|
|
773
|
+
"""
|
|
774
|
+
The license activation procedure works as follows:
|
|
775
|
+
|
|
776
|
+
* Read the licensing credentials from the environment (raise an error if none are available)
|
|
777
|
+
* Try to find a cached license in one of the read locations (``get_license_file_read_locations``)
|
|
778
|
+
* If one was found, try to activate the license offline first.
|
|
779
|
+
* If the license exists but is stale (needs to "check in" with the license server), then use the
|
|
780
|
+
license id of the cached license and try to activate it.
|
|
781
|
+
* If no license exists or the cached one could not be activated, try to request a new one
|
|
782
|
+
* Requesting a license means, on the server, checking whether the user has an active subscription,
|
|
783
|
+
and if so, check out a new license for them usable for offline activation.
|
|
784
|
+
|
|
785
|
+
:param offline_only: whether to only attempt an offline activation
|
|
786
|
+
"""
|
|
787
|
+
# TODO: clean up
|
|
788
|
+
credentials = self.require_valid_credentials()
|
|
789
|
+
|
|
790
|
+
license = None
|
|
791
|
+
try:
|
|
792
|
+
# first, try and activate one of the locally cached licenses
|
|
793
|
+
try:
|
|
794
|
+
result = self._try_activate_offline(credentials)
|
|
795
|
+
|
|
796
|
+
if result and self.activated:
|
|
797
|
+
license, file_path = result
|
|
798
|
+
|
|
799
|
+
self._set_license(license)
|
|
800
|
+
self.license_file_path = file_path
|
|
801
|
+
LOG.info(
|
|
802
|
+
"Successfully activated cached license %s from %s 🔑✅",
|
|
803
|
+
license.to_log_string(),
|
|
804
|
+
file_path,
|
|
805
|
+
)
|
|
806
|
+
return
|
|
807
|
+
except LicenseStaleError as e:
|
|
808
|
+
license = e.license
|
|
809
|
+
if offline_only:
|
|
810
|
+
raise
|
|
811
|
+
|
|
812
|
+
except LicensingError as e:
|
|
813
|
+
if offline_only:
|
|
814
|
+
raise
|
|
815
|
+
LOG.debug(
|
|
816
|
+
"Attempting online activation after offline activation failed: %s:%s",
|
|
817
|
+
type(e).__name__,
|
|
818
|
+
e,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
if license:
|
|
822
|
+
try:
|
|
823
|
+
license = self.client.activate_license_online(credentials, license)
|
|
824
|
+
self.client.validate_license(credentials, license)
|
|
825
|
+
self._activated = True
|
|
826
|
+
self._set_license(license)
|
|
827
|
+
LOG.info("Successfully activated license %s 🔑✅", license.to_log_string())
|
|
828
|
+
self.save_license()
|
|
829
|
+
return
|
|
830
|
+
except LicensingError as e:
|
|
831
|
+
LOG.debug(
|
|
832
|
+
"There was an error activating the license: %s. Trying to get a new one.", e
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
license = self.client.request_new_license(credentials)
|
|
836
|
+
self.client.validate_license(credentials, license)
|
|
837
|
+
self._activated = True
|
|
838
|
+
self._set_license(license)
|
|
839
|
+
LOG.info(
|
|
840
|
+
"Successfully requested and activated new license %s 🔑✅", license.to_log_string()
|
|
841
|
+
)
|
|
842
|
+
self.save_license()
|
|
843
|
+
|
|
844
|
+
def _try_activate_offline(self, credentials: Credentials) -> tuple[License, str] | None:
|
|
845
|
+
errors = []
|
|
846
|
+
# try to activate the license offline from license file locations one by one. some may be read-only
|
|
847
|
+
for license_file_path in self.get_license_file_read_locations():
|
|
848
|
+
if not os.path.isfile(license_file_path):
|
|
849
|
+
continue
|
|
850
|
+
|
|
851
|
+
try:
|
|
852
|
+
with open(license_file_path, "rb") as fd:
|
|
853
|
+
license = self.parser.parse(fd.read())
|
|
854
|
+
self.client.activate_license_offline(credentials, license)
|
|
855
|
+
self._activated = True
|
|
856
|
+
|
|
857
|
+
return license, license_file_path
|
|
858
|
+
except LicensingError as e:
|
|
859
|
+
LOG.debug("Failed to activate license file %s: %s", license_file_path, e)
|
|
860
|
+
errors.append(e)
|
|
861
|
+
|
|
862
|
+
if errors:
|
|
863
|
+
# raise the first error. there may be more than one license file in the locations, so we pick
|
|
864
|
+
# the one with the highest priority to report.
|
|
865
|
+
# TODO: one potential error could be LicenseActivationRequiredError, which should actually be the
|
|
866
|
+
# one to prefer
|
|
867
|
+
raise errors[0]
|
|
868
|
+
|
|
869
|
+
return None
|
|
870
|
+
|
|
871
|
+
def has_product_license(self, product_name: str) -> bool:
|
|
872
|
+
"""
|
|
873
|
+
.. deprecated::
|
|
874
|
+
Use ``get_product_entitlements().has_entitlement()`` instead.
|
|
875
|
+
|
|
876
|
+
Checks whether the given product is licensed under the currently activated license. Raises a
|
|
877
|
+
LicensingError if the license hasn't been activated yet.
|
|
878
|
+
|
|
879
|
+
The comparison uses fnmatch to compare, so the license could contain a ProductInfo record
|
|
880
|
+
``localstack.extensions/*`` which would give access to all restricted extensions.
|
|
881
|
+
|
|
882
|
+
:param product_name: the product name to check, for example ``localstack.extensions/outages``.
|
|
883
|
+
:return: true if the given product is part of the license
|
|
884
|
+
"""
|
|
885
|
+
return self.product_entitlements.has_entitlement(product_name)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def save_license(self):
|
|
889
|
+
"""
|
|
890
|
+
Serializes the active license into the license file write location. When running in the container,
|
|
891
|
+
this will be ``/var/lib/localstack/cache/license.json``, and in the CLI this will be the
|
|
892
|
+
system-specific ``~/.cache/localstack-cli``, which we then mount into the container.
|
|
893
|
+
|
|
894
|
+
:return:
|
|
895
|
+
"""
|
|
896
|
+
if not self.activated:
|
|
897
|
+
raise ValueError("not yet activated")
|
|
898
|
+
if not self.license:
|
|
899
|
+
raise ValueError("no license to save")
|
|
900
|
+
|
|
901
|
+
file_path = self.get_license_file_write_location()
|
|
902
|
+
|
|
903
|
+
LOG.debug("Caching license file to %s", file_path)
|
|
904
|
+
try:
|
|
905
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
906
|
+
|
|
907
|
+
with open(file_path, "wb") as fd:
|
|
908
|
+
fd.write(self.serializer.serialize(self.license))
|
|
909
|
+
except OSError as e:
|
|
910
|
+
LOG.debug("Error caching license file to %s: %s", file_path, e)
|
|
911
|
+
|
|
912
|
+
def get_license_file_write_location(self) -> str:
|
|
913
|
+
if config.is_env_true("LOCALSTACK_CLI"):
|
|
914
|
+
# in the case of the CLI, we write to the CLI cache dir. this switch is left here on purpose to
|
|
915
|
+
# make the two different control paths more and explicit clear. moreover, we should at some point
|
|
916
|
+
# get rid of `config.dirs` in the CLI code.
|
|
917
|
+
return os.path.join(config.dirs.cache, LICENSE_FILE_NAME)
|
|
918
|
+
else:
|
|
919
|
+
# in the case of an infra process, we'll write it to the cache dir
|
|
920
|
+
return os.path.join(config.dirs.cache, LICENSE_FILE_NAME)
|
|
921
|
+
|
|
922
|
+
def get_license_file_read_locations(self) -> list[str]:
|
|
923
|
+
"""
|
|
924
|
+
Returns a list of file paths where license files may be located that can be activated offline. The
|
|
925
|
+
paths may be read-only.
|
|
926
|
+
|
|
927
|
+
:return: a list of file paths
|
|
928
|
+
"""
|
|
929
|
+
if config.is_env_true("LOCALSTACK_CLI"):
|
|
930
|
+
return [
|
|
931
|
+
os.path.join(
|
|
932
|
+
config.dirs.cache, LICENSE_FILE_NAME
|
|
933
|
+
), # this will be ~/.cache/localstack-cli
|
|
934
|
+
]
|
|
935
|
+
else:
|
|
936
|
+
return [
|
|
937
|
+
os.path.join(
|
|
938
|
+
config.dirs.config, LICENSE_FILE_NAME
|
|
939
|
+
), # license mountpoint from the CLI
|
|
940
|
+
os.path.join(config.dirs.cache, LICENSE_FILE_NAME),
|
|
941
|
+
# checking static libs can be helpful for enterprise licensing that have perpetual license
|
|
942
|
+
# baked into the image
|
|
943
|
+
os.path.join(config.dirs.static_libs, LICENSE_FILE_NAME),
|
|
944
|
+
]
|
|
945
|
+
|
|
946
|
+
def require_valid_credentials(self) -> Credentials:
|
|
947
|
+
"""
|
|
948
|
+
Returns the credentials from the environment or raises a CredentialsMissingError if they don't exist.
|
|
949
|
+
|
|
950
|
+
:return: a credentials object
|
|
951
|
+
"""
|
|
952
|
+
credentials = get_credentials_from_environment()
|
|
953
|
+
|
|
954
|
+
if not credentials:
|
|
955
|
+
raise CredentialsMissingError()
|
|
956
|
+
|
|
957
|
+
if not credentials.is_valid():
|
|
958
|
+
raise CredentialsInvalidError()
|
|
959
|
+
|
|
960
|
+
return credentials
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
class DevLocalstackEnvironment(LicensedLocalstackEnvironment):
|
|
964
|
+
"""
|
|
965
|
+
Special LicensedLocalstackEnvironment used when ``test`` is used as auth token or api key. This
|
|
966
|
+
environment only works if you have access to the localstack-pro source code, which we assume gives you
|
|
967
|
+
access to all features of localstack as well as restricted extensions.
|
|
968
|
+
"""
|
|
969
|
+
|
|
970
|
+
def __init__(
|
|
971
|
+
self,
|
|
972
|
+
client: LicensingClient,
|
|
973
|
+
parser: LicenseParser = None,
|
|
974
|
+
serializer: LicenseSerializer = None,
|
|
975
|
+
):
|
|
976
|
+
super().__init__(client=client, parser=parser, serializer=serializer)
|
|
977
|
+
self._dev_product_entitlements = self._build_dev_entitlements()
|
|
978
|
+
|
|
979
|
+
def activate_license(self, offline_only=False):
|
|
980
|
+
LOG.debug("Using test license, skipping activation.")
|
|
981
|
+
self._activated = True
|
|
982
|
+
|
|
983
|
+
@property
|
|
984
|
+
def product_entitlements(self) -> ProductEntitlements:
|
|
985
|
+
return self._dev_product_entitlements
|
|
986
|
+
|
|
987
|
+
@staticmethod
|
|
988
|
+
def _build_dev_entitlements() -> ProductEntitlements:
|
|
989
|
+
allow_all = pro_config.DEV_PRODUCT_ENTITLEMENTS_ALLOW_ALL
|
|
990
|
+
|
|
991
|
+
try:
|
|
992
|
+
products: list[ProductInfo] = json.loads(pro_config.DEV_PRODUCT_ENTITLEMENTS_LIST)
|
|
993
|
+
except json.JSONDecodeError:
|
|
994
|
+
LOG.warning("Invalid DEV_PRODUCT_ENTITLEMENTS_LIST; ignoring override")
|
|
995
|
+
return ProductEntitlements([], allow_all=allow_all)
|
|
996
|
+
|
|
997
|
+
if not products:
|
|
998
|
+
return ProductEntitlements([], allow_all=allow_all)
|
|
999
|
+
|
|
1000
|
+
if not isinstance(products, list):
|
|
1001
|
+
LOG.warning("DEV_PRODUCT_ENTITLEMENTS_LIST must be a JSON list; ignoring override")
|
|
1002
|
+
return ProductEntitlements([], allow_all=allow_all)
|
|
1003
|
+
|
|
1004
|
+
return ProductEntitlements(products, allow_all=allow_all)
|
|
1005
|
+
|
|
1006
|
+
def get_license_file_locations(self) -> list[str]:
|
|
1007
|
+
return []
|
|
1008
|
+
|
|
1009
|
+
def enable_decryption(self):
|
|
1010
|
+
raise LicenseActivationError("Cannot activate pro code when using test credentials")
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
class LicenseSignerV1(LicenseSigner):
|
|
1014
|
+
def __init__(self, credentials: Credentials):
|
|
1015
|
+
self.credentials = credentials
|
|
1016
|
+
|
|
1017
|
+
def calculate_signature(self, license: LicenseV1) -> str:
|
|
1018
|
+
"""
|
|
1019
|
+
The V1 signature is a sha256 hmac with the licensing credentials as key. The components of the
|
|
1020
|
+
license are then used to calculate the hmac.
|
|
1021
|
+
|
|
1022
|
+
:param license: the license to sign
|
|
1023
|
+
:return: the hexdigest of the signature
|
|
1024
|
+
:raises LicenseFormatError: if the license doesn't contain the values necessary for the signature
|
|
1025
|
+
"""
|
|
1026
|
+
encoding = "utf-8"
|
|
1027
|
+
|
|
1028
|
+
try:
|
|
1029
|
+
# we are signing the license with the user's auth token, which is a bit silly of course,
|
|
1030
|
+
# but in the future this could be based on shared secrets or public-key crypto.
|
|
1031
|
+
sign_key = self.credentials.to_bytes()
|
|
1032
|
+
|
|
1033
|
+
h = hmac.new(sign_key, digestmod="sha256")
|
|
1034
|
+
h.update(license.id.encode(encoding))
|
|
1035
|
+
|
|
1036
|
+
for product in sorted(license.products, key=lambda p: (p["name"], p["version"])):
|
|
1037
|
+
h.update(product["name"].encode(encoding))
|
|
1038
|
+
h.update(product["version"].encode(encoding))
|
|
1039
|
+
|
|
1040
|
+
h.update(license.license_format.encode(encoding))
|
|
1041
|
+
h.update(license.issue_date.isoformat(timespec="seconds").encode(encoding))
|
|
1042
|
+
h.update(license.expiry_date.isoformat(timespec="seconds").encode(encoding))
|
|
1043
|
+
h.update(license.license_type.encode(encoding))
|
|
1044
|
+
h.update(license.license_status.value.encode(encoding))
|
|
1045
|
+
if license.reactivate_after:
|
|
1046
|
+
h.update(license.reactivate_after.isoformat(timespec="seconds").encode(encoding))
|
|
1047
|
+
except (KeyError, AttributeError) as e:
|
|
1048
|
+
raise LicenseFormatError(f"{e}") from e
|
|
1049
|
+
except ValueError as e:
|
|
1050
|
+
raise LicenseFormatError(f"error in license attribute value: {e}") from e
|
|
1051
|
+
|
|
1052
|
+
return h.hexdigest()
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
@singleton_factory
|
|
1056
|
+
def get_system_information_summary() -> str:
|
|
1057
|
+
"""
|
|
1058
|
+
Returns a string that contains three comma separated values: The operating system, kernel version,
|
|
1059
|
+
and architecture. We either use the docker socket to resolve the information, if that is not available
|
|
1060
|
+
we fall back ``platform.uname()``. If we're in docker and we don't have the docker socket available,
|
|
1061
|
+
we add ``(Container)`` to the operating system type to indicate that we don't have any additional
|
|
1062
|
+
information.
|
|
1063
|
+
|
|
1064
|
+
Some examples:
|
|
1065
|
+
|
|
1066
|
+
If the Docker socket is available:
|
|
1067
|
+
- Docker Desktop,5.15.90.1-microsoft-standard-WSL2,x86_64
|
|
1068
|
+
- Linux Mint 21.1,5.19.0-32-generic,x86_64
|
|
1069
|
+
|
|
1070
|
+
If the Docker socket is not available, and we're on the host:
|
|
1071
|
+
- Windows,10,AMD64
|
|
1072
|
+
- Linux,5.19.0-32-generic,x86_64
|
|
1073
|
+
|
|
1074
|
+
If the Docker socket is not available, and we're in the container:
|
|
1075
|
+
- Linux(Container),5.19.0-32-generic,x86_64
|
|
1076
|
+
|
|
1077
|
+
:return: a string representing the system's information
|
|
1078
|
+
"""
|
|
1079
|
+
try:
|
|
1080
|
+
# try to get the system from the docker socket
|
|
1081
|
+
from localstack_cli.utils.docker_utils import DOCKER_CLIENT
|
|
1082
|
+
|
|
1083
|
+
system = DOCKER_CLIENT.get_system_info()
|
|
1084
|
+
|
|
1085
|
+
return ",".join(
|
|
1086
|
+
[
|
|
1087
|
+
system["OperatingSystem"],
|
|
1088
|
+
system["KernelVersion"],
|
|
1089
|
+
system["Architecture"],
|
|
1090
|
+
]
|
|
1091
|
+
)
|
|
1092
|
+
except Exception as e:
|
|
1093
|
+
print(e)
|
|
1094
|
+
pass
|
|
1095
|
+
|
|
1096
|
+
uname = platform.uname()
|
|
1097
|
+
|
|
1098
|
+
if config.is_in_docker:
|
|
1099
|
+
return ",".join(
|
|
1100
|
+
[
|
|
1101
|
+
f"{uname.system}(Container)",
|
|
1102
|
+
uname.release,
|
|
1103
|
+
uname.machine,
|
|
1104
|
+
]
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
return ",".join(
|
|
1108
|
+
[
|
|
1109
|
+
uname.system,
|
|
1110
|
+
uname.release,
|
|
1111
|
+
uname.machine,
|
|
1112
|
+
]
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def get_credentials_from_environment() -> Credentials | None:
|
|
1117
|
+
"""
|
|
1118
|
+
Reads the credentials from the environment necessary to activate the localstack pro code, and returns
|
|
1119
|
+
an appropriate object representing the credentials. This can either be an ``AuthToken`` if
|
|
1120
|
+
``LOCALSTACK_AUTH_TOKEN`` is used, or ``ApiKeyCredentials`` if ``LOCALSTACK_API_KEY`` is used. If both are
|
|
1121
|
+
set, a ``CredentialsFormatError`` is raised. Otherwise, None is returned.
|
|
1122
|
+
|
|
1123
|
+
If there are no credentials set in the environment, but we're running in the CLI, we will also check the
|
|
1124
|
+
auth cache file that is populated via ``localstack auth set-token``.
|
|
1125
|
+
|
|
1126
|
+
:return: a Credentials object or None
|
|
1127
|
+
"""
|
|
1128
|
+
api_key = os.environ.get(ENV_LOCALSTACK_API_KEY, "").strip("'\" ")
|
|
1129
|
+
auth_token = os.environ.get(ENV_LOCALSTACK_AUTH_TOKEN, "").strip("'\" ")
|
|
1130
|
+
|
|
1131
|
+
if not auth_token and config.is_env_true("LOCALSTACK_CLI"):
|
|
1132
|
+
# try and get the credentials from the auth cache
|
|
1133
|
+
from localstack_cli.pro.core.bootstrap.auth import get_auth_cache
|
|
1134
|
+
|
|
1135
|
+
try:
|
|
1136
|
+
auth_token = get_auth_cache().get("LOCALSTACK_AUTH_TOKEN")
|
|
1137
|
+
except Exception:
|
|
1138
|
+
pass
|
|
1139
|
+
|
|
1140
|
+
if api_key and auth_token:
|
|
1141
|
+
raise CredentialsFormatError(
|
|
1142
|
+
f"please specify either {ENV_LOCALSTACK_API_KEY} or {ENV_LOCALSTACK_AUTH_TOKEN}, not both"
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
if api_key:
|
|
1146
|
+
return ApiKeyCredentials(api_key)
|
|
1147
|
+
|
|
1148
|
+
if auth_token:
|
|
1149
|
+
return AuthToken(auth_token)
|
|
1150
|
+
|
|
1151
|
+
return None
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
class RequiresLicenseMarker(Protocol):
|
|
1155
|
+
"""
|
|
1156
|
+
Protocol for classes that advertise whether they require a license. The field can be attached to
|
|
1157
|
+
Extensions which will make them go through a license check before they are loaded.
|
|
1158
|
+
"""
|
|
1159
|
+
|
|
1160
|
+
requires_license: bool
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
class LicensedPluginLoaderGuard(PluginLifecycleListener):
|
|
1164
|
+
"""
|
|
1165
|
+
A PluginLifecycleListener that checks, after plugin initialization, if the plugin requires a license,
|
|
1166
|
+
and if so, that the extension can be used as part of the current license agreement.
|
|
1167
|
+
|
|
1168
|
+
It's based on a field ``Plugin.requires_plugin`` that is
|
|
1169
|
+
"""
|
|
1170
|
+
|
|
1171
|
+
def __init__(self, environment: LicensedLocalstackEnvironment = None):
|
|
1172
|
+
self.environment = environment or get_licensed_environment()
|
|
1173
|
+
|
|
1174
|
+
def on_init_after(self, plugin_spec: PluginSpec, plugin: Plugin | RequiresLicenseMarker):
|
|
1175
|
+
try:
|
|
1176
|
+
requires_license = plugin.requires_license
|
|
1177
|
+
except AttributeError:
|
|
1178
|
+
return
|
|
1179
|
+
|
|
1180
|
+
if not requires_license:
|
|
1181
|
+
return
|
|
1182
|
+
|
|
1183
|
+
product_name = f"{plugin_spec.namespace}/{plugin_spec.name}"
|
|
1184
|
+
|
|
1185
|
+
if product_name not in get_product_entitlements(self.environment):
|
|
1186
|
+
# do not show licensing warning for platform plugins, since they are not opt-in by the user
|
|
1187
|
+
# TODO: remove duplication of plugin namespace
|
|
1188
|
+
if plugin_spec.namespace not in [
|
|
1189
|
+
PLATFORM_PLUGIN_NAMESPACE,
|
|
1190
|
+
AWS_SERVICE_PLUGIN_NAMESPACE,
|
|
1191
|
+
]:
|
|
1192
|
+
LOG.warning(
|
|
1193
|
+
"Disabled plugin %s since it is not part of the current license agreement 🔑❌",
|
|
1194
|
+
product_name,
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
raise PluginDisabled(
|
|
1198
|
+
plugin_spec.namespace,
|
|
1199
|
+
plugin_spec.name,
|
|
1200
|
+
reason="This feature is not part of the active license agreement",
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
@singleton_factory
|
|
1205
|
+
def get_licensed_environment() -> LicensedLocalstackEnvironment:
|
|
1206
|
+
"""
|
|
1207
|
+
Returns a ``LicensedLocalstackEnvironment`` singleton from the environment. If ``test`` is set as
|
|
1208
|
+
credentials, a ``DevLocalstackEnvironment`` will be returned.
|
|
1209
|
+
|
|
1210
|
+
The environment is used to activate the license and decrypt the localstack pro code. Example::
|
|
1211
|
+
|
|
1212
|
+
env = get_licensed_environment()
|
|
1213
|
+
try:
|
|
1214
|
+
# activate the license and enable code decryption
|
|
1215
|
+
env.activate()
|
|
1216
|
+
except LicensingError as e:
|
|
1217
|
+
# license activation failed
|
|
1218
|
+
|
|
1219
|
+
:return: a LicensedLocalstackEnvironment singleton
|
|
1220
|
+
"""
|
|
1221
|
+
credentials = get_credentials_from_environment()
|
|
1222
|
+
client = LicenseV1Client()
|
|
1223
|
+
|
|
1224
|
+
if credentials and credentials.to_bytes() == b"test":
|
|
1225
|
+
return DevLocalstackEnvironment(client=client)
|
|
1226
|
+
|
|
1227
|
+
return LicensedLocalstackEnvironment(client=client)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def get_product_entitlements(
|
|
1231
|
+
licensed_environment: LicensedLocalstackEnvironment = None,
|
|
1232
|
+
) -> ProductEntitlements:
|
|
1233
|
+
env = licensed_environment or get_licensed_environment()
|
|
1234
|
+
return env.product_entitlements
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def configure_container_licensing(cfg):
|
|
1238
|
+
"""
|
|
1239
|
+
Configures the container with licensing information from the host.
|
|
1240
|
+
|
|
1241
|
+
:param cfg: the ContainerConfiguration
|
|
1242
|
+
"""
|
|
1243
|
+
from localstack_cli.utils.container_utils.container_client import BindMount
|
|
1244
|
+
|
|
1245
|
+
# make sure the licensing credentials make it into the container correctly if they are defined in
|
|
1246
|
+
# `auth.json`
|
|
1247
|
+
credentials = get_credentials_from_environment()
|
|
1248
|
+
|
|
1249
|
+
if isinstance(credentials, AuthToken):
|
|
1250
|
+
cfg.env_vars[ENV_LOCALSTACK_AUTH_TOKEN] = credentials.encoded()
|
|
1251
|
+
elif isinstance(credentials, ApiKeyCredentials):
|
|
1252
|
+
cfg.env_vars[ENV_LOCALSTACK_API_KEY] = credentials.encoded()
|
|
1253
|
+
|
|
1254
|
+
# mount license file if available
|
|
1255
|
+
license_file = os.path.join(config.dirs.cache, LICENSE_FILE_NAME)
|
|
1256
|
+
if os.path.exists(license_file):
|
|
1257
|
+
# don't join with os.path.join since this needs to be a unix path in the container
|
|
1258
|
+
target = str(PurePosixPath(config.Directories.for_container().config) / LICENSE_FILE_NAME)
|
|
1259
|
+
cfg.volumes.add(BindMount(license_file, target, read_only=True))
|