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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. localstack_cli/__init__.py +0 -0
  2. localstack_cli/cli/__init__.py +10 -0
  3. localstack_cli/cli/console.py +11 -0
  4. localstack_cli/cli/core_plugin.py +12 -0
  5. localstack_cli/cli/exceptions.py +19 -0
  6. localstack_cli/cli/localstack.py +951 -0
  7. localstack_cli/cli/lpm.py +138 -0
  8. localstack_cli/cli/main.py +22 -0
  9. localstack_cli/cli/plugin.py +39 -0
  10. localstack_cli/cli/plugins.py +134 -0
  11. localstack_cli/cli/profiles.py +65 -0
  12. localstack_cli/config.py +1689 -0
  13. localstack_cli/constants.py +165 -0
  14. localstack_cli/logging/__init__.py +0 -0
  15. localstack_cli/logging/format.py +194 -0
  16. localstack_cli/logging/setup.py +142 -0
  17. localstack_cli/packages/__init__.py +25 -0
  18. localstack_cli/packages/api.py +418 -0
  19. localstack_cli/packages/core.py +416 -0
  20. localstack_cli/pro/__init__.py +0 -0
  21. localstack_cli/pro/core/__init__.py +0 -0
  22. localstack_cli/pro/core/bootstrap/__init__.py +1 -0
  23. localstack_cli/pro/core/bootstrap/auth.py +213 -0
  24. localstack_cli/pro/core/bootstrap/dns_utils.py +55 -0
  25. localstack_cli/pro/core/bootstrap/entitlements.py +117 -0
  26. localstack_cli/pro/core/bootstrap/extensions/__init__.py +3 -0
  27. localstack_cli/pro/core/bootstrap/extensions/__main__.py +106 -0
  28. localstack_cli/pro/core/bootstrap/extensions/autoinstall.py +63 -0
  29. localstack_cli/pro/core/bootstrap/extensions/bootstrap.py +97 -0
  30. localstack_cli/pro/core/bootstrap/extensions/repository.py +374 -0
  31. localstack_cli/pro/core/bootstrap/licensingv2.py +1259 -0
  32. localstack_cli/pro/core/bootstrap/pods/__init__.py +0 -0
  33. localstack_cli/pro/core/bootstrap/pods/api_types.py +17 -0
  34. localstack_cli/pro/core/bootstrap/pods/constants.py +26 -0
  35. localstack_cli/pro/core/bootstrap/pods/remotes/__init__.py +0 -0
  36. localstack_cli/pro/core/bootstrap/pods/remotes/api.py +75 -0
  37. localstack_cli/pro/core/bootstrap/pods/remotes/configs.py +69 -0
  38. localstack_cli/pro/core/bootstrap/pods/remotes/params.py +86 -0
  39. localstack_cli/pro/core/bootstrap/pods_client.py +834 -0
  40. localstack_cli/pro/core/cli/__init__.py +0 -0
  41. localstack_cli/pro/core/cli/auth.py +226 -0
  42. localstack_cli/pro/core/cli/aws.py +16 -0
  43. localstack_cli/pro/core/cli/cli.py +99 -0
  44. localstack_cli/pro/core/cli/click_utils.py +21 -0
  45. localstack_cli/pro/core/cli/cloud_pods.py +465 -0
  46. localstack_cli/pro/core/cli/diff_view.py +41 -0
  47. localstack_cli/pro/core/cli/ephemeral.py +199 -0
  48. localstack_cli/pro/core/cli/extensions.py +492 -0
  49. localstack_cli/pro/core/cli/iam.py +180 -0
  50. localstack_cli/pro/core/cli/license.py +90 -0
  51. localstack_cli/pro/core/cli/localstack.py +118 -0
  52. localstack_cli/pro/core/cli/replicator.py +378 -0
  53. localstack_cli/pro/core/cli/state.py +183 -0
  54. localstack_cli/pro/core/cli/tree_view.py +235 -0
  55. localstack_cli/pro/core/config.py +556 -0
  56. localstack_cli/pro/core/constants.py +54 -0
  57. localstack_cli/pro/core/plugins.py +169 -0
  58. localstack_cli/runtime/__init__.py +6 -0
  59. localstack_cli/runtime/exceptions.py +7 -0
  60. localstack_cli/runtime/hooks.py +73 -0
  61. localstack_cli/testing/__init__.py +1 -0
  62. localstack_cli/testing/config.py +4 -0
  63. localstack_cli/utils/__init__.py +0 -0
  64. localstack_cli/utils/analytics/__init__.py +12 -0
  65. localstack_cli/utils/analytics/cli.py +67 -0
  66. localstack_cli/utils/analytics/client.py +111 -0
  67. localstack_cli/utils/analytics/events.py +30 -0
  68. localstack_cli/utils/analytics/logger.py +48 -0
  69. localstack_cli/utils/analytics/metadata.py +250 -0
  70. localstack_cli/utils/analytics/publisher.py +160 -0
  71. localstack_cli/utils/analytics/service_request_aggregator.py +133 -0
  72. localstack_cli/utils/archives.py +271 -0
  73. localstack_cli/utils/batching.py +258 -0
  74. localstack_cli/utils/bootstrap.py +1418 -0
  75. localstack_cli/utils/checksum.py +313 -0
  76. localstack_cli/utils/collections.py +554 -0
  77. localstack_cli/utils/common.py +229 -0
  78. localstack_cli/utils/container_networking.py +142 -0
  79. localstack_cli/utils/container_utils/__init__.py +0 -0
  80. localstack_cli/utils/container_utils/container_client.py +1585 -0
  81. localstack_cli/utils/container_utils/docker_cmd_client.py +987 -0
  82. localstack_cli/utils/container_utils/docker_sdk_client.py +1018 -0
  83. localstack_cli/utils/crypto.py +294 -0
  84. localstack_cli/utils/docker_utils.py +272 -0
  85. localstack_cli/utils/files.py +327 -0
  86. localstack_cli/utils/functions.py +92 -0
  87. localstack_cli/utils/http.py +326 -0
  88. localstack_cli/utils/json.py +219 -0
  89. localstack_cli/utils/net.py +516 -0
  90. localstack_cli/utils/no_exit_argument_parser.py +19 -0
  91. localstack_cli/utils/numbers.py +49 -0
  92. localstack_cli/utils/objects.py +235 -0
  93. localstack_cli/utils/patch.py +260 -0
  94. localstack_cli/utils/platform.py +77 -0
  95. localstack_cli/utils/run.py +514 -0
  96. localstack_cli/utils/server/__init__.py +0 -0
  97. localstack_cli/utils/server/tcp_proxy.py +108 -0
  98. localstack_cli/utils/serving.py +187 -0
  99. localstack_cli/utils/ssl.py +71 -0
  100. localstack_cli/utils/strings.py +245 -0
  101. localstack_cli/utils/sync.py +267 -0
  102. localstack_cli/utils/threads.py +163 -0
  103. localstack_cli/utils/time.py +81 -0
  104. localstack_cli/utils/urls.py +21 -0
  105. localstack_cli/utils/venv.py +100 -0
  106. localstack_cli/utils/xml.py +41 -0
  107. localstack_cli/version.py +34 -0
  108. playground_ls_cli-4.14.1.dev8.dist-info/METADATA +95 -0
  109. playground_ls_cli-4.14.1.dev8.dist-info/RECORD +112 -0
  110. playground_ls_cli-4.14.1.dev8.dist-info/WHEEL +5 -0
  111. playground_ls_cli-4.14.1.dev8.dist-info/entry_points.txt +17 -0
  112. playground_ls_cli-4.14.1.dev8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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))