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,834 @@
1
+ import builtins
2
+ import io
3
+ import json
4
+ import logging
5
+ import os
6
+ import zipfile
7
+ from abc import ABC, abstractmethod
8
+ from functools import singledispatch
9
+ from pathlib import Path
10
+ from typing import IO, Any, Optional, TypedDict
11
+ from urllib import parse
12
+ from urllib.parse import urlparse
13
+
14
+ import click
15
+ import requests
16
+ import yaml
17
+ from click import ClickException
18
+ from localstack_cli import config, constants
19
+ from localstack_cli.cli import console
20
+ from localstack_cli.constants import APPLICATION_JSON, HEADER_CONTENT_TYPE, LOCALHOST_HOSTNAME
21
+ from localstack_cli.pro.core.bootstrap import auth
22
+ from localstack_cli.pro.core.bootstrap.auth import get_platform_auth_headers
23
+ from localstack_cli.pro.core.bootstrap.pods.constants import INTERNAL_REQUEST_PARAMS_HEADER
24
+ from localstack_cli.pro.core.bootstrap.pods.remotes.api import CloudPodsRemotesClient
25
+ from localstack_cli.pro.core.bootstrap.pods.remotes.configs import (
26
+ DEFAULT_REMOTE_SCHEME,
27
+ RemoteConfig,
28
+ RemoteConfigParams,
29
+ )
30
+ from localstack_cli.pro.core.bootstrap.pods.remotes.params import get_remote_params_callable
31
+ from localstack_cli.pro.core.config import CLI_INJECT_POD_IDENTITY, POD_LOAD_CLI_TIMEOUT
32
+ from localstack_cli.pro.core.constants import (
33
+ API_PATH_PODS,
34
+ CLOUDPODS_METADATA_FILE,
35
+ HEADER_POD_SECRET,
36
+ )
37
+ from localstack_cli.utils.bootstrap import in_ci
38
+ from localstack_cli.utils.http import safe_requests
39
+ from localstack_cli.utils.strings import to_str
40
+ from packaging import version
41
+ from requests.structures import CaseInsensitiveDict
42
+ from rich.progress import Progress
43
+
44
+ LOG = logging.getLogger(__name__)
45
+
46
+ # header constants
47
+ HEADER_LS_API_KEY = "ls-api-key"
48
+ HEADER_LS_VERSION = "ls-version"
49
+ HEADER_AUTHORIZATION = "Authorization"
50
+
51
+
52
+ DiffResult = dict[str, list[dict[str, Any]]]
53
+ """Maps a service to a list of localstack.pro.core.persistence.pods.diff.models.Operation"""
54
+
55
+
56
+ class CloudPodNotFound(Exception):
57
+ def __init__(self, pod_name: str) -> None:
58
+ super().__init__(f"Cloud pod '{pod_name}' not found")
59
+
60
+
61
+ class PodInfo(TypedDict, total=False):
62
+ """A set of info about a pod. Mostly used to display information on the CLI."""
63
+
64
+ name: str
65
+ pod_id: str
66
+ version: int
67
+ services: list[str]
68
+ description: str
69
+ size: int
70
+ remote: str
71
+ localstack_version: str
72
+ encrypted: bool
73
+
74
+
75
+ def fetch_state_response_from_instance(
76
+ services: list[str] | None = None,
77
+ ) -> tuple[requests.Response, PodInfo]:
78
+ url = f"{get_runtime_pods_endpoint()}/state"
79
+ params = ",".join(services) if services else ""
80
+
81
+ # TODO: temporary fix to bypass Gateway 503 responses on shutdown
82
+ headers = {INTERNAL_REQUEST_PARAMS_HEADER: "{}"}
83
+
84
+ is_ci: bool = in_ci()
85
+ result = requests.get(url, params={"services": params}, headers=headers, stream=not is_ci)
86
+ if not result.ok:
87
+ raise Exception(
88
+ f"An error occurred while retrieving the LocalStack state (code {result.status_code})"
89
+ )
90
+
91
+ metadata = PodInfo(
92
+ services=result.headers.get("x-localstack-pod-services", "").split(","),
93
+ size=int(result.headers.get("x-localstack-pod-size", 0)),
94
+ )
95
+
96
+ return result, metadata
97
+
98
+
99
+ def write_state_zip_to_file(
100
+ open_file: IO[bytes], services: list[str] | None = None, chunk_size: int = 100_000
101
+ ) -> PodInfo:
102
+ """
103
+ Retrieves the state from LocalStack by calling the container endpoint (/_localstack/pods/state).
104
+
105
+ Extract this state from LocalStack but write straight to a file object rather than buffering
106
+ in memory.
107
+
108
+ This specifically helps the state export command which writes to a local file, and enables it
109
+ to handle arbitrary file sizes.
110
+
111
+ :param open_file: open file object to write the bytes to
112
+ :param services: specify a list of services for which we want to extract the in-memory state;
113
+ if None, it extracts all
114
+ :param chunk_size: optional chunk size for the download (bytes)
115
+ :return: a PodInfo object containing the metadata for the pod
116
+ """
117
+ result, metadata = fetch_state_response_from_instance(services)
118
+ content_length = int(result.headers.get("Content-Length", 0))
119
+
120
+ with Progress() as progress:
121
+ load_task = progress.add_task("Retrieving state from the container", total=content_length)
122
+ for chunk in result.iter_content(chunk_size=chunk_size):
123
+ open_file.write(chunk)
124
+ progress.update(load_task, advance=len(chunk))
125
+
126
+ return metadata
127
+
128
+
129
+ def get_state_zip_from_instance(services: list[str] | None = None) -> tuple[bytes, PodInfo]:
130
+ """
131
+ Retrieves the state from LocalStack by calling the container endpoint (/_localstack/pods/state).
132
+
133
+ :param services: specify a list of services for which we want to extract the in-memory state; if None, it extracts
134
+ all.
135
+
136
+ :return: a tuple with the bytes content and a PodInfo object containing a set of metadata.
137
+ """
138
+ result, metadata = fetch_state_response_from_instance(services)
139
+ content_length = int(result.headers.get("Content-Length", 0))
140
+ is_ci: bool = in_ci()
141
+ content = result.content if is_ci else b""
142
+
143
+ with Progress() as progress:
144
+ load_task = progress.add_task("Retrieving state from the container", total=content_length)
145
+ for chunk in result.iter_content(chunk_size=100_000):
146
+ content += chunk
147
+ progress.update(load_task, advance=len(chunk))
148
+
149
+ return content, metadata
150
+
151
+
152
+ class CloudPodRemoteAttributes(TypedDict, total=False):
153
+ """A set of attributes for a Cloud Pod stored in a remote storage."""
154
+
155
+ is_public: bool
156
+ description: str | None
157
+ services: list[str] | None
158
+
159
+
160
+ class PodSaveRequest(TypedDict, total=False):
161
+ """The payload for a pod save request"""
162
+
163
+ remote: dict[str, str | dict] | None
164
+ attributes: CloudPodRemoteAttributes | None
165
+
166
+
167
+ class CloudPodsService(ABC):
168
+ """Service Interface for the Cloud Pods Operations."""
169
+
170
+ @abstractmethod
171
+ def save(
172
+ self,
173
+ pod_name: str,
174
+ attributes: CloudPodRemoteAttributes | None = None,
175
+ remote: RemoteConfigParams | None = None,
176
+ local: bool = False,
177
+ # TODO: consider defining version as str rather than int
178
+ version: int | None = None,
179
+ secret: str | None = None,
180
+ ) -> PodInfo:
181
+ """
182
+ Saves a new version of a Cloud Pod to a given storage (by default, our platform). In the process, it also
183
+ creates a version on the pods' fyle system.
184
+ :param pod_name: the name of the Cloud Pod
185
+ :param attributes: a set of attributes for the Cloud Pod
186
+ :param remote: the name/parameters of the remote storage. The default None equals to our platform
187
+ :param local: flag that prevent the registration of the pod with the remote (it only creates the version
188
+ locally). Mostly used for testing purposes.
189
+ :param version: the version of the pod to save (creates a new version if not specified)
190
+ :param secret: secret for encryption of the cloud pod content.
191
+ """
192
+
193
+ @abstractmethod
194
+ def delete(
195
+ self,
196
+ pod_name: str,
197
+ remote: RemoteConfigParams | None = None,
198
+ delete_from_remote: bool = True,
199
+ ) -> None:
200
+ """
201
+ Delete a Cloud Pod from the pods' file system and the remote storage.
202
+ :param pod_name: the name of the Cloud Pod
203
+ :param remote: the name/parameters of the remote storage. The default None equals to our platform
204
+ :param delete_from_remote: delete the pod from the remote storage. `False` would only delete the pod cache from
205
+ the local filesystem
206
+ """
207
+
208
+ @abstractmethod
209
+ def load(
210
+ self,
211
+ pod_name: str,
212
+ remote: RemoteConfigParams | None = None,
213
+ version: int | None = None,
214
+ merge_strategy: str | None = None,
215
+ ignore_version_mismatches: bool = True,
216
+ secret: str | None = None,
217
+ ) -> None:
218
+ """
219
+ Loads a Cloud Pod into the LocalStack running instance. Depending on whether the latest pod's version is
220
+ available locally or not, it will also download it from the remote storage.
221
+ :param pod_name: the name of the Cloud Pod
222
+ :param remote: the name/parameters of the remote storage. The default None equals to our platform
223
+ :param version: the version of the Cloud Pod to load
224
+ :param merge_strategy: the merge strategy to use when loading the pod
225
+ :param ignore_version_mismatches: automatically approve the pod loading without any check or user confirmation
226
+ :param secret: eventual encryption key (for enterprise users)
227
+ """
228
+
229
+ @abstractmethod
230
+ def list(self, remote: RemoteConfigParams | None = None) -> list:
231
+ """
232
+ List all the Cloud Pods available in the remote storage and in the pods' file system.
233
+ :param remote: the name/parameters of the remote
234
+ :return: a list of Cloud Pods
235
+ """
236
+
237
+ @abstractmethod
238
+ def get_versions(
239
+ self, pod_name: str, remote: RemoteConfigParams | None = None
240
+ ) -> builtins.list:
241
+ """
242
+ List all the versions of a Cloud Pod available in the remote storage.
243
+ :param pod_name: the name of the Cloud Pod
244
+ :param remote: the name/parameters of the remote storage. The default None equals to our platform
245
+ :return: a list of version for the given pod
246
+ """
247
+
248
+ def diff(
249
+ self,
250
+ pod_name: str,
251
+ remote: RemoteConfigParams | None = None,
252
+ version: int | None = None,
253
+ ) -> DiffResult:
254
+ """
255
+ Return a list of operations that represent a diff between a state stored in a Cloud Pod and the one on the
256
+ runtime.
257
+ :param pod_name: the name of the Cloud Pod to run the diff with.
258
+ :param version: the version of the Cloud Pod to run the diff with.
259
+ :param remote: the name/parameters of the remote storage.
260
+ :return: a map between service and the list of operations representing a diff
261
+ """
262
+
263
+ def _get_cloud_pods_info(self, pod_name: str) -> dict:
264
+ """
265
+ This utility function make a call to platform and return the model object, i.e., a Cloud Pod entity.
266
+ todo: start exporting models from the platform to handle these cases
267
+ """
268
+ response = requests.get(create_platform_url(pod_name), headers=get_platform_auth_headers())
269
+ if response.status_code == 404:
270
+ raise CloudPodNotFound(pod_name)
271
+ if not response.ok:
272
+ _raise_exception_with_formatted_message(
273
+ f"Unable to get info for pod: {pod_name}", response
274
+ )
275
+ return json.loads(response.content)
276
+
277
+ def _get_localstack_pod_version(
278
+ self,
279
+ pod_name: str,
280
+ cloud_pods_dict: dict,
281
+ version: int | None = None,
282
+ ) -> str | None:
283
+ """Returns the LocalStack version originating the Cloud Pod"""
284
+ versions: list[dict] = cloud_pods_dict["versions"]
285
+ max_version = int(cloud_pods_dict["max_version"])
286
+ if version and version > max_version:
287
+ raise Exception(
288
+ f"Unable to load pod {pod_name} with version {version}. The maximum version available in the"
289
+ f" remote storage is {max_version}"
290
+ )
291
+ # todo: generate some model to handle platform responses?
292
+ matching_versions = list(filter(lambda v: v["version"] == version, versions))
293
+ my_version = matching_versions[0] if matching_versions else versions[-1]
294
+ ls_pod_version: str | None = my_version["localstack_version"]
295
+ return ls_pod_version
296
+
297
+ def get_state_data(self) -> dict:
298
+ """
299
+ Gets a representation of the service data in the running LocalStack instance.
300
+ """
301
+ response = requests.get(
302
+ url=f"{get_runtime_pods_endpoint()}/state/metamodel", headers=_get_headers()
303
+ )
304
+ if not response.ok:
305
+ _raise_exception_with_formatted_message(
306
+ "Unable to get state data from the LocalStack instance", response
307
+ )
308
+ return json.loads(response.content)
309
+
310
+ def set_remote_attributes( # noqa
311
+ self,
312
+ pod_name: str,
313
+ attributes: CloudPodRemoteAttributes,
314
+ remote: RemoteConfigParams | None = None,
315
+ ) -> None:
316
+ """
317
+ Updates a set of attributes for a Cloud Pod stored in a remote storage.
318
+ :param pod_name: the name of the Cloud Pod
319
+ :param attributes: the attributes to update
320
+ :param remote: the name/parameters of the remote storage. The default None equals to our platform
321
+ """
322
+ if remote:
323
+ LOG.debug(
324
+ "Trying to set attributes for remote '%s'. Currently we support attributes "
325
+ "only for the default remote",
326
+ remote,
327
+ )
328
+ return
329
+ url = create_platform_url(pod_name)
330
+ auth_header = auth.get_platform_auth_headers()
331
+ response = safe_requests.patch(
332
+ url, headers=auth_header, json={"is_public": attributes["is_public"]}
333
+ )
334
+ if not response.ok:
335
+ raise Exception(
336
+ f"Error setting remote attributes for Cloud Pod {pod_name} (code {response.status_code}): "
337
+ f"{response.text}"
338
+ )
339
+
340
+
341
+ def _get_headers() -> dict[str, str]:
342
+ """Returns the headers for the HTTP request with the content type and the bearer token if available."""
343
+ headers = {HEADER_CONTENT_TYPE: APPLICATION_JSON}
344
+ # If this flag is enabled, we inject the authorization header via the API.
345
+ # This header will be used to authenticate platform calls to the platform from within the container.
346
+ # When they are not provided, the header will be constructed from the credentials used to start the pro
347
+ # container. This mechanism allows to detach, if needed, container identity from pod identity.
348
+ if CLI_INJECT_POD_IDENTITY:
349
+ headers.update(CaseInsensitiveDict(auth.get_platform_auth_headers()))
350
+ return headers
351
+
352
+
353
+ def _raise_exception_with_formatted_message(message: str, response: requests.Response):
354
+ """Raises an exception with a formatted message that includes the response status code and text."""
355
+ raise Exception(f"{message}: {response.text}")
356
+
357
+
358
+ def _get_remote_params_payload(remote: RemoteConfigParams | None) -> PodSaveRequest:
359
+ """Uses the remote plugins in the client to get the remote parameters."""
360
+ if not remote:
361
+ return {}
362
+
363
+ remote_config = _get_remote_configuration(remote, render_params=False)
364
+ remote_params_callable = get_remote_params_callable(remote_config.remote_url)
365
+ if not remote_params_callable:
366
+ return {}
367
+
368
+ remote.remote_params = remote_params_callable()
369
+ return {"remote": remote.to_dict()}
370
+
371
+
372
+ class CloudPodsClient(CloudPodsService):
373
+ """
374
+ Client implementation of the Cloud Pods Service. It is a thin client that makes HTTP calls to the Cloud Pods
375
+ endpoints in the container.
376
+ """
377
+
378
+ def __init__(self, interactive: bool = False) -> None:
379
+ self.interactive = interactive
380
+
381
+ def _process_response(self, response, message: str):
382
+ status = console.status(message)
383
+ status.start()
384
+
385
+ for event in response.iter_lines():
386
+ _event = json.loads(event)
387
+ if _event["event"] == "log":
388
+ status.update(_event["message"])
389
+ if _event["event"] == "service":
390
+ service, result, operation = (
391
+ _event["service"],
392
+ _event["status"],
393
+ _event["operation"],
394
+ )
395
+ message = (
396
+ f"{service}: {operation} succeeded"
397
+ if result == "ok"
398
+ else f"{service}: {operation} failed"
399
+ )
400
+ status.update(message)
401
+ elif _event["event"] == "completion":
402
+ status.stop()
403
+ if _event["status"] == "error":
404
+ raise Exception(_event.get("message"))
405
+ if _event["operation"] == "save":
406
+ pod_info = PodInfo(**_event["info"])
407
+ return pod_info
408
+ status.stop()
409
+
410
+ def save(
411
+ self,
412
+ pod_name: str,
413
+ attributes: CloudPodRemoteAttributes | None = None,
414
+ remote: RemoteConfigParams | None = None,
415
+ local: bool = False,
416
+ version: int | None = None,
417
+ secret: str | None = None,
418
+ ) -> PodInfo:
419
+ url = f"{get_runtime_pods_endpoint(secret)}/{pod_name}?"
420
+ if local:
421
+ url += "&local=true"
422
+ if version:
423
+ url += f"&version={version}"
424
+ payload: PodSaveRequest = _get_remote_params_payload(remote)
425
+ payload.update({"attributes": attributes})
426
+ headers = _get_headers()
427
+ if secret:
428
+ headers[HEADER_POD_SECRET] = secret
429
+
430
+ response = requests.post(url=url, json=payload, headers=headers, stream=self.interactive)
431
+ if not response.ok:
432
+ _raise_exception_with_formatted_message(f"Unable to save pod {pod_name}", response)
433
+
434
+ pod_info = {}
435
+ if self.interactive:
436
+ pod_info = self._process_response(response, message=f"Saving Cloud Pod {pod_name}")
437
+ else:
438
+ for line in response.iter_lines():
439
+ line = json.loads(line)
440
+ if line["event"] == "pod_info":
441
+ pod_info = PodInfo(**line["extra"])
442
+ elif line["event"] == "exception":
443
+ raise Exception(line["message"])
444
+ return pod_info
445
+
446
+ def delete(
447
+ self,
448
+ pod_name: str,
449
+ remote: RemoteConfigParams | None = None,
450
+ delete_from_remote: bool = True,
451
+ ):
452
+ url = f"{get_runtime_pods_endpoint()}/{pod_name}"
453
+ if not delete_from_remote:
454
+ url += "?local=true"
455
+ payload = _get_remote_params_payload(remote)
456
+ response = requests.delete(url=url, json=payload, headers=_get_headers())
457
+ if not response.ok:
458
+ _raise_exception_with_formatted_message(
459
+ f"Unable to delete Cloud Pod '{pod_name}'", response
460
+ )
461
+
462
+ def load(
463
+ self,
464
+ pod_name: str,
465
+ remote: RemoteConfigParams | None = None,
466
+ version: int | None = None,
467
+ merge_strategy: str | None = None,
468
+ ignore_version_mismatches: bool = False,
469
+ secret: str | None = None,
470
+ ) -> None:
471
+ # If we are in CI, we do not ask for a confirmation
472
+ if in_ci():
473
+ ignore_version_mismatches = True
474
+ remote_config = _get_remote_configuration(remote, render_params=False) if remote else None
475
+ is_custom_remote = remote_config and remote_config.scheme != DEFAULT_REMOTE_SCHEME
476
+
477
+ # At the moment, we do not store the information about the originating LocalStack version for additional
478
+ # remotes. Therefore, we skip here the compatibility check.
479
+ if not is_custom_remote and not ignore_version_mismatches:
480
+ pod_info = self._get_cloud_pods_info(pod_name)
481
+ ls_pod_version: str = self._get_localstack_pod_version(
482
+ pod_name=pod_name, version=version, cloud_pods_dict=pod_info
483
+ )
484
+ ls_version: str = get_ls_version_from_health()
485
+ if not is_compatible_version(ls_pod_version, ls_version) and not click.confirm(
486
+ f"This Cloud Pod was created with LocalStack {ls_pod_version} but you are running "
487
+ f"LocalStack {ls_version}. Cloud Pods might be incompatible across different LocalStack versions.\n"
488
+ f"Loading a Cloud Pod with mismatching version might lead to a corrupted state of the emulator. "
489
+ f"Do you want to continue?"
490
+ ):
491
+ raise click.Abort("LocalStack version mismatch")
492
+
493
+ url = f"{get_runtime_pods_endpoint()}/{pod_name}"
494
+ query_args = {}
495
+ if version:
496
+ query_args["version"] = version
497
+ if merge_strategy:
498
+ query_args["merge"] = merge_strategy
499
+ if query_args:
500
+ url += f"?{parse.urlencode(query_args)}"
501
+
502
+ payload = _get_remote_params_payload(remote)
503
+ headers = _get_headers()
504
+ if secret:
505
+ headers[HEADER_POD_SECRET] = secret
506
+ response = requests.put(url=url, json=payload, headers=headers, stream=self.interactive)
507
+ if not response.ok:
508
+ _raise_exception_with_formatted_message(f"Unable to load pod {pod_name}", response)
509
+
510
+ if self.interactive:
511
+ self._process_response(response, message=f"Loading Cloud Pod {pod_name}")
512
+
513
+ def list(self, remote: RemoteConfigParams | None = None, creator: str | None = None) -> list:
514
+ payload = _get_remote_params_payload(remote)
515
+ url = get_runtime_pods_endpoint()
516
+ if creator:
517
+ url += f"?creator={creator}"
518
+ response = requests.get(url, json=payload, headers=_get_headers())
519
+ if not response.ok:
520
+ _raise_exception_with_formatted_message("Unable to list Cloud Pods", response)
521
+ return json.loads(response.content).get("cloudpods", [])
522
+
523
+ def get_versions(
524
+ self, pod_name: str, remote: RemoteConfigParams | None = None
525
+ ) -> builtins.list:
526
+ """
527
+ Returns the list of versions for a cloud pod on a given remote
528
+ :param pod_name: the name of the Cloud Pod
529
+ :param remote: the name of the remote storage. None equals to our platform
530
+ """
531
+ payload = _get_remote_params_payload(remote)
532
+ response = requests.get(
533
+ url=f"{get_runtime_pods_endpoint()}/{pod_name}/versions",
534
+ json=payload,
535
+ headers=_get_headers(),
536
+ )
537
+ if response.status_code == 404:
538
+ raise Exception(f"Cloud Pod {pod_name} not found")
539
+ if not response.ok:
540
+ _raise_exception_with_formatted_message(
541
+ f"Unable to get versions for pod {pod_name}", response
542
+ )
543
+ return json.loads(response.content).get("versions", [])
544
+
545
+ def diff(
546
+ self,
547
+ pod_name: str,
548
+ remote: RemoteConfigParams | None = None,
549
+ version: int | None = None,
550
+ ) -> DiffResult:
551
+ url = f"{get_runtime_pods_endpoint()}/{pod_name}/diff"
552
+ query_args = {}
553
+ if version:
554
+ query_args["version"] = version
555
+ url += f"?{parse.urlencode(query_args)}"
556
+ payload = _get_remote_params_payload(remote)
557
+ response = requests.get(
558
+ url=url,
559
+ json=payload,
560
+ headers=_get_headers(),
561
+ )
562
+ if not response.ok:
563
+ _raise_exception_with_formatted_message(
564
+ f"Unable to get diff for pod {pod_name}", response
565
+ )
566
+ return json.loads(response.content)
567
+
568
+
569
+ def _get_remote_configuration(
570
+ params: RemoteConfigParams, render_params: bool = True
571
+ ) -> RemoteConfig:
572
+ """
573
+ Get the remote configuration given a remote URL.
574
+ :param params: remote parameters
575
+ :param render_params: whether to render the remote parameters into the remote URL
576
+ """
577
+ remotes_client = CloudPodsRemotesClient()
578
+ try:
579
+ remote = remotes_client.get_remote(name=params.remote_name)
580
+ except Exception as e:
581
+ raise ClickException(
582
+ f"Error getting configuration for the remote {params.remote_name}"
583
+ ) from e
584
+ remote_url = remote["remote_url"]
585
+ if render_params:
586
+ remote_url = params.render_url(remote_url)
587
+ LOG.debug("Remote configuration: %s", remote)
588
+ return RemoteConfig(remote_url=remote_url)
589
+
590
+
591
+ # ------------
592
+ # CLI HELPERS
593
+ # ------------
594
+
595
+
596
+ def get_runtime_pods_endpoint(passphrase: str | None = None) -> str:
597
+ """
598
+ Returns the runtime endpoint for the pods api.
599
+ If a passphrase is provided, we enforce HTTPS. Moreover, we resolve the hostname and warn the user if LocalStack
600
+ is not running locally.
601
+ """
602
+ if not passphrase:
603
+ return f"{config.external_service_url()}{API_PATH_PODS}"
604
+
605
+ edge_url = config.external_service_url(protocol="https")
606
+ if LOCALHOST_HOSTNAME not in edge_url:
607
+ LOG.warning(
608
+ "LocalStack is not running locally and we are sending the encryption passphrase to a different host!"
609
+ )
610
+ return f"{edge_url}{API_PATH_PODS}"
611
+
612
+
613
+ class StateService:
614
+ """Service for the local operations on Cloud Pods, i.e., exporting and importing a Cloud Pod to/from host."""
615
+
616
+ def export_pod(self, target: str, services: Optional[list[str]] = None) -> PodInfo: # noqa
617
+ """
618
+ Export a Cloud Pod to the local file system. It does not export the product space (i.e., objects and YAML files
619
+ with information about the versions). It will save a zip file named as the pod's name in the given directory
620
+ (if existing).
621
+ :param target: the path on the local disk where the Cloud Pod will be saved
622
+ """
623
+
624
+ # check the path
625
+ _path = urlparse(target)
626
+ path: str = os.path.abspath(os.path.join(_path.netloc, _path.path))
627
+ parent_path = Path(path).parent.absolute()
628
+ if not os.path.exists(parent_path):
629
+ raise Exception(f"{parent_path} is not a valid path")
630
+
631
+ with open(path, "wb") as outfile:
632
+ metadata = write_state_zip_to_file(outfile, services)
633
+
634
+ yaml_dict: dict = get_environment_metadata()
635
+ yaml_dict["name"] = os.path.basename(target)
636
+ yaml_dict.update(metadata)
637
+
638
+ # add YAML file with some metadata inside the zip
639
+ with zipfile.ZipFile(file=path, mode="a") as zip_file:
640
+ zip_file.writestr(CLOUDPODS_METADATA_FILE, yaml.dump(yaml_dict))
641
+ return metadata
642
+
643
+ def import_pod(self, source: str, show_progress: bool = True) -> None: # noqa
644
+ """
645
+ Load a Cloud Pod from the local file system into the LocalStack running instance.
646
+ :param source: the path on the local disk where the Cloud Pod is saved
647
+ :param show_progress: flag to show visual progress in the CLI;
648
+ """
649
+ url = urlparse(source)
650
+ path = os.path.abspath(os.path.join(url.netloc, url.path))
651
+ if not os.path.exists(path):
652
+ raise Exception(f"Path {path} does not exist")
653
+ if not os.path.isfile(path):
654
+ raise Exception(f"Path {path} is not a file")
655
+ with open(path, mode="rb") as infile:
656
+ with zipfile.ZipFile(infile, "r") as zip_file:
657
+ yaml_metadata = read_metadata_from_pod(zip_file) or {}
658
+
659
+ services = yaml_metadata.get("services", [])
660
+ is_licensed_user: bool = get_environment_metadata().get("pro")
661
+ # we check if the Cloud Pod has been generated by a pro user, and we are trying to load it as a community one
662
+ if yaml_metadata.get("pro", False) and not is_licensed_user:
663
+ console.print(
664
+ "Warning: You are trying to load a Cloud Pod generated with a Pro license."
665
+ "The loaded state might be incomplete."
666
+ )
667
+ infile.seek(0)
668
+ load_local_state_from_open_zipfile(
669
+ file=infile, number_services=len(services), show_progress=show_progress
670
+ )
671
+
672
+
673
+ def list_public_pods() -> list[str]:
674
+ """List all the public pods"""
675
+ url = create_platform_url("public")
676
+ auth_header = auth.get_platform_auth_headers()
677
+ response = safe_requests.get(url, headers=auth_header)
678
+ if not response.ok:
679
+ raise Exception(to_str(response.content))
680
+ content = json.loads(response.content)
681
+ return [pod["pod_name"] for pod in content]
682
+
683
+
684
+ #####################
685
+ # Utility functions #
686
+ #####################
687
+
688
+
689
+ @singledispatch
690
+ def read_metadata_from_pod(zip_file: zipfile.ZipFile) -> dict | None:
691
+ try:
692
+ yaml_metadata = yaml.safe_load(zip_file.read(CLOUDPODS_METADATA_FILE))
693
+ return yaml_metadata
694
+ except KeyError:
695
+ LOG.debug("No %s file in the archive", CLOUDPODS_METADATA_FILE)
696
+
697
+
698
+ @read_metadata_from_pod.register(bytes)
699
+ def _(zip_file: bytes) -> dict | None:
700
+ zip_file = zipfile.ZipFile(io.BytesIO(zip_file), "r")
701
+ return read_metadata_from_pod(zip_file)
702
+
703
+
704
+ @read_metadata_from_pod.register(str)
705
+ def _(zip_file: str) -> dict | None:
706
+ with zipfile.ZipFile(zip_file) as zp:
707
+ return read_metadata_from_pod(zp)
708
+
709
+
710
+ def call_post_load_endpoint(content: bytes | IO[bytes], stream: bool) -> requests.Response:
711
+ """Calls the endpoint to load a state into LocalStack.
712
+ :param content: the content to load
713
+ :param stream: flag for a chunked response
714
+ :return the response from the endpoint, if the status is ok.
715
+ :raise an Exception if a non-ok status is returned
716
+ """
717
+ url = get_runtime_pods_endpoint()
718
+ try:
719
+ response = requests.post(url, data=content, timeout=POD_LOAD_CLI_TIMEOUT, stream=stream)
720
+ if not stream:
721
+ LOG.debug("Loaded services from local state file: %s", response.content)
722
+ except requests.exceptions.Timeout as e:
723
+ raise Exception(
724
+ "Timeout exceed for the pod load operation. To avoid this issue, try to increase the"
725
+ "value of the POD_LOAD_CLI_TIMEOUT configuration variable."
726
+ ) from e
727
+ if not response.ok:
728
+ raise Exception(f"Unable to load LocalStack state via {url}")
729
+ return response
730
+
731
+
732
+ def load_state_with_progress_bar(content: bytes | IO[bytes], number_services: int = 0) -> None:
733
+ result = call_post_load_endpoint(content, stream=True)
734
+ i = 0
735
+ with Progress() as progress:
736
+ load_task = progress.add_task("Loading state", total=number_services)
737
+ for line in result.iter_lines():
738
+ content = json.loads(line)
739
+ LOG.debug("Loaded service: %s", content)
740
+ service, status = content["service"], "✅" if content["status"] else "❌"
741
+ progress.log(f"{service}: {status}")
742
+ i += 1
743
+ progress.update(load_task, completed=i)
744
+
745
+
746
+ def load_local_state(content: bytes, number_services: int = 0, show_progress: bool = True) -> None:
747
+ """
748
+ Call to the _localstack/pods endpoint to inject a Cloud Pod into the system.
749
+ :param number_services: the number of services contained in the state to load. Used to display a progress bar.
750
+ :param content: the content of the Cloud Pod to be loaded into LocalStack.
751
+ :param show_progress: flag to show visual progress in the CLI.
752
+ :raises Exception: if the call to the endpoint does not return an ok status code (less than 400) or if a timeout
753
+ exceeds.
754
+ """
755
+ if show_progress:
756
+ load_state_with_progress_bar(content=content, number_services=number_services)
757
+ else:
758
+ call_post_load_endpoint(content=content, stream=False)
759
+
760
+
761
+ def load_local_state_from_open_zipfile(
762
+ file: IO[bytes], number_services: int = 0, show_progress: bool = False
763
+ ) -> None:
764
+ if show_progress:
765
+ load_state_with_progress_bar(content=file, number_services=number_services)
766
+ else:
767
+ call_post_load_endpoint(content=file, stream=False)
768
+
769
+
770
+ def get_environment_metadata() -> dict:
771
+ endpoint = get_runtime_pods_endpoint()
772
+ url = f"{endpoint}/environment"
773
+ result = requests.get(url)
774
+ if not result.ok:
775
+ raise Exception(f"Unable to retrieve environment metadata from {url}")
776
+ return json.loads(result.content)
777
+
778
+
779
+ # ---------------
780
+ # UTIL FUNCTIONS
781
+ # ---------------
782
+ def reset_state(services: list = None) -> None:
783
+ """
784
+ Call the reset endpoint in the persistence plugin to reset the LocalStack container.
785
+ If a list of services is provided, it calls POST _localstack/state/<service>/reset for each service.
786
+ It not, it calls POST _localstack/state/reset that reset the entire LocalStack instance.
787
+
788
+ :param services: the subset of services to reset (optional)
789
+ """
790
+
791
+ def _call_reset(_url: str) -> None:
792
+ response = requests.post(_url)
793
+ if not response.ok:
794
+ LOG.debug("Reset call to %s failed: status code %s", _url, response.status_code)
795
+ raise Exception("Failed to reset LocalStack")
796
+
797
+ if not services:
798
+ url = f"{config.external_service_url()}/_localstack/state/reset"
799
+ _call_reset(url)
800
+ return
801
+
802
+ for service in services:
803
+ url = f"{config.external_service_url()}/_localstack/state/{service}/reset"
804
+ _call_reset(url)
805
+
806
+
807
+ def get_ls_version_from_health() -> str:
808
+ """Returns the localstack ext version fetched from the health end point."""
809
+ try:
810
+ health_url = f"{config.external_service_url()}/_localstack/health"
811
+ health_response = requests.get(health_url).json()
812
+ return health_response["version"]
813
+ except Exception:
814
+ return ""
815
+
816
+
817
+ def create_platform_url(path: str | None = None, api_endpoint: str | None = None) -> str:
818
+ api_endpoint = api_endpoint or constants.API_ENDPOINT
819
+ base_url = f"{api_endpoint}/cloudpods"
820
+ if not path:
821
+ return base_url
822
+ path = path if path.startswith("/") else f"/{path}"
823
+ return f"{base_url}{path}"
824
+
825
+
826
+ def is_compatible_version(version_one: str | None, version_two: str | None) -> bool:
827
+ """Check if two given version are equal up to the patch level."""
828
+
829
+ # if we cannot parse the version, we assume that it is not compatible
830
+ if not version_one or not version_two:
831
+ return False
832
+ v1 = version.parse(version_one)
833
+ v2 = version.parse(version_two)
834
+ return v1.base_version == v2.base_version