localstack-core 4.10.1.dev7__py3-none-any.whl → 4.10.1.dev42__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 (77) hide show
  1. localstack/aws/api/acm/__init__.py +122 -122
  2. localstack/aws/api/apigateway/__init__.py +560 -559
  3. localstack/aws/api/cloudcontrol/__init__.py +63 -63
  4. localstack/aws/api/cloudformation/__init__.py +1040 -969
  5. localstack/aws/api/cloudwatch/__init__.py +375 -375
  6. localstack/aws/api/config/__init__.py +784 -786
  7. localstack/aws/api/dynamodb/__init__.py +753 -759
  8. localstack/aws/api/dynamodbstreams/__init__.py +74 -74
  9. localstack/aws/api/ec2/__init__.py +8901 -8818
  10. localstack/aws/api/es/__init__.py +453 -453
  11. localstack/aws/api/events/__init__.py +552 -552
  12. localstack/aws/api/firehose/__init__.py +541 -543
  13. localstack/aws/api/iam/__init__.py +639 -572
  14. localstack/aws/api/kinesis/__init__.py +235 -147
  15. localstack/aws/api/kms/__init__.py +340 -336
  16. localstack/aws/api/lambda_/__init__.py +574 -573
  17. localstack/aws/api/logs/__init__.py +676 -675
  18. localstack/aws/api/opensearch/__init__.py +814 -785
  19. localstack/aws/api/pipes/__init__.py +336 -336
  20. localstack/aws/api/redshift/__init__.py +1188 -1166
  21. localstack/aws/api/resource_groups/__init__.py +175 -175
  22. localstack/aws/api/resourcegroupstaggingapi/__init__.py +67 -67
  23. localstack/aws/api/route53/__init__.py +254 -254
  24. localstack/aws/api/route53resolver/__init__.py +396 -396
  25. localstack/aws/api/s3/__init__.py +1350 -1349
  26. localstack/aws/api/s3control/__init__.py +594 -594
  27. localstack/aws/api/scheduler/__init__.py +118 -118
  28. localstack/aws/api/secretsmanager/__init__.py +193 -193
  29. localstack/aws/api/ses/__init__.py +227 -227
  30. localstack/aws/api/sns/__init__.py +115 -115
  31. localstack/aws/api/sqs/__init__.py +100 -100
  32. localstack/aws/api/ssm/__init__.py +1977 -1971
  33. localstack/aws/api/stepfunctions/__init__.py +323 -323
  34. localstack/aws/api/sts/__init__.py +90 -66
  35. localstack/aws/api/support/__init__.py +112 -112
  36. localstack/aws/api/swf/__init__.py +378 -386
  37. localstack/aws/api/transcribe/__init__.py +425 -425
  38. localstack/aws/handlers/service.py +11 -1
  39. localstack/aws/protocol/parser.py +1 -1
  40. localstack/aws/scaffold.py +15 -17
  41. localstack/cli/localstack.py +6 -1
  42. localstack/dev/kubernetes/__main__.py +38 -3
  43. localstack/services/apigateway/helpers.py +5 -9
  44. localstack/services/apigateway/legacy/provider.py +32 -9
  45. localstack/services/apigateway/patches.py +0 -9
  46. localstack/services/cloudformation/provider.py +2 -2
  47. localstack/services/cloudformation/v2/provider.py +6 -6
  48. localstack/services/kinesis/packages.py +1 -1
  49. localstack/services/kms/models.py +34 -4
  50. localstack/services/kms/provider.py +93 -16
  51. localstack/services/lambda_/api_utils.py +3 -1
  52. localstack/services/lambda_/packages.py +1 -1
  53. localstack/services/lambda_/provider.py +1 -1
  54. localstack/services/lambda_/runtimes.py +8 -3
  55. localstack/services/logs/provider.py +36 -19
  56. localstack/services/s3/provider.py +1 -1
  57. localstack/services/sns/v2/models.py +24 -1
  58. localstack/services/sns/v2/provider.py +144 -12
  59. localstack/services/sns/v2/utils.py +8 -0
  60. localstack/services/sqs/models.py +37 -10
  61. localstack/testing/snapshots/transformer_utility.py +2 -0
  62. localstack/testing/testselection/matching.py +0 -1
  63. localstack/utils/aws/client_types.py +0 -8
  64. localstack/utils/catalog/catalog_loader.py +111 -3
  65. localstack/utils/crypto.py +109 -0
  66. localstack/version.py +2 -2
  67. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.10.1.dev42.dist-info}/METADATA +6 -5
  68. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.10.1.dev42.dist-info}/RECORD +76 -76
  69. localstack_core-4.10.1.dev42.dist-info/plux.json +1 -0
  70. localstack_core-4.10.1.dev7.dist-info/plux.json +0 -1
  71. {localstack_core-4.10.1.dev7.data → localstack_core-4.10.1.dev42.data}/scripts/localstack +0 -0
  72. {localstack_core-4.10.1.dev7.data → localstack_core-4.10.1.dev42.data}/scripts/localstack-supervisor +0 -0
  73. {localstack_core-4.10.1.dev7.data → localstack_core-4.10.1.dev42.data}/scripts/localstack.bat +0 -0
  74. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.10.1.dev42.dist-info}/WHEEL +0 -0
  75. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.10.1.dev42.dist-info}/entry_points.txt +0 -0
  76. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.10.1.dev42.dist-info}/licenses/LICENSE.txt +0 -0
  77. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.10.1.dev42.dist-info}/top_level.txt +0 -0
@@ -7,6 +7,7 @@ from collections import defaultdict
7
7
  from typing import Any
8
8
 
9
9
  from botocore.model import OperationModel, ServiceModel
10
+ from plux.core.plugin import PluginDisabled
10
11
 
11
12
  from localstack import config
12
13
  from localstack.http import Response
@@ -222,7 +223,16 @@ class ServiceExceptionSerializer(ExceptionHandler):
222
223
  operation = context.service.operation_model(context.service.operation_names[0])
223
224
  msg = f"exception while calling {service_name} with unknown operation: {message}"
224
225
 
225
- status_code = 501 if config.FAIL_FAST else 500
226
+ # Check for license restricted plugin message and set status code to 501
227
+ if (
228
+ isinstance(exception, PluginDisabled)
229
+ and "not part of the active license agreement"
230
+ in str(getattr(exception, "reason", "")).lower()
231
+ ):
232
+ status_code = 501
233
+ msg = f"exception while calling {service_name}.{operation.name}: {str(getattr(exception, 'reason', ''))}"
234
+ else:
235
+ status_code = 501 if config.FAIL_FAST else 500
226
236
 
227
237
  error = CommonServiceException(
228
238
  "InternalError", msg, status_code=status_code
@@ -1032,7 +1032,7 @@ class BaseCBORRequestParser(RequestParser, ABC):
1032
1032
  return method(stream, additional_info)
1033
1033
  else:
1034
1034
  raise ProtocolParserError(
1035
- f"Unsupported inital byte found for data item- "
1035
+ f"Unsupported initial byte found for data item- "
1036
1036
  f"Major type:{major_type}, Additional info: "
1037
1037
  f"{additional_info}"
1038
1038
  )
@@ -188,9 +188,7 @@ class ShapeNode:
188
188
  if member in self.shape.required_members:
189
189
  output.write(f" {member}: IO[{q}{to_valid_python_name(shape.name)}{q}]\n")
190
190
  else:
191
- output.write(
192
- f" {member}: Optional[IO[{q}{to_valid_python_name(shape.name)}{q}]]\n"
193
- )
191
+ output.write(f" {member}: {q}IO[{to_valid_python_name(shape.name)}] | None{q}\n")
194
192
  del remaining_members[member]
195
193
  # render the streaming payload first
196
194
  if self.is_response and self.response_operation.has_streaming_output:
@@ -199,25 +197,26 @@ class ShapeNode:
199
197
  shape_name = to_valid_python_name(shape.name)
200
198
  if member in self.shape.required_members:
201
199
  output.write(
202
- f" {member}: Union[{q}{shape_name}{q}, IO[{q}{shape_name}{q}], Iterable[{q}{shape_name}{q}]]\n"
200
+ f" {member}: {q}{shape_name} | IO[{shape_name}] | Iterable[{shape_name}]{q}\n"
203
201
  )
204
202
  else:
205
203
  output.write(
206
- f" {member}: Optional[Union[{q}{shape_name}{q}, IO[{q}{shape_name}{q}], Iterable[{q}{shape_name}{q}]]]\n"
204
+ f" {member}: {q}{shape_name} | IO[{shape_name}] | Iterable[{shape_name}] | None{q}\n"
207
205
  )
208
206
  del remaining_members[member]
209
207
 
210
208
  for k, v in remaining_members.items():
209
+ shape_name = to_valid_python_name(v.name)
211
210
  if k in self.shape.required_members:
212
211
  if v.serialization.get("eventstream"):
213
- output.write(f" {k}: Iterator[{q}{to_valid_python_name(v.name)}{q}]\n")
212
+ output.write(f" {k}: Iterator[{q}{shape_name}{q}]\n")
214
213
  else:
215
- output.write(f" {k}: {q}{to_valid_python_name(v.name)}{q}\n")
214
+ output.write(f" {k}: {q}{shape_name}{q}\n")
216
215
  else:
217
216
  if v.serialization.get("eventstream"):
218
- output.write(f" {k}: Iterator[{q}{to_valid_python_name(v.name)}{q}]\n")
217
+ output.write(f" {k}: Iterator[{q}{shape_name}{q}]\n")
219
218
  else:
220
- output.write(f" {k}: Optional[{q}{to_valid_python_name(v.name)}{q}]\n")
219
+ output.write(f" {k}: {q}{shape_name} | None{q}\n")
221
220
 
222
221
  def _print_as_typed_dict(self, output, doc=True, quote_types=False):
223
222
  name = to_valid_python_name(self.shape.name)
@@ -236,7 +235,7 @@ class ShapeNode:
236
235
  if v.serialization.get("eventstream"):
237
236
  output.write(f' "{k}": Iterator[{q}{member_name}{q}],\n')
238
237
  else:
239
- output.write(f' "{k}": Optional[{q}{member_name}{q}],\n')
238
+ output.write(f' "{k}": {q}{member_name} | None{q},\n')
240
239
  output.write("}, total=False)")
241
240
 
242
241
  def print_shape_doc(self, output, shape):
@@ -256,11 +255,11 @@ class ShapeNode:
256
255
  self._print_structure_declaration(output, doc, quote_types)
257
256
  elif isinstance(shape, ListShape):
258
257
  output.write(
259
- f"{to_valid_python_name(shape.name)} = List[{q}{to_valid_python_name(shape.member.name)}{q}]"
258
+ f"{to_valid_python_name(shape.name)} = list[{q}{to_valid_python_name(shape.member.name)}{q}]"
260
259
  )
261
260
  elif isinstance(shape, MapShape):
262
261
  output.write(
263
- f"{to_valid_python_name(shape.name)} = Dict[{q}{to_valid_python_name(shape.key.name)}{q}, {q}{to_valid_python_name(shape.value.name)}{q}]"
262
+ f"{to_valid_python_name(shape.name)} = dict[{q}{to_valid_python_name(shape.key.name)}{q}, {q}{to_valid_python_name(shape.value.name)}{q}]"
264
263
  )
265
264
  elif isinstance(shape, StringShape):
266
265
  if shape.enum:
@@ -316,9 +315,8 @@ class ShapeNode:
316
315
  def generate_service_types(output, service: ServiceModel, doc=True):
317
316
  output.write("from datetime import datetime\n")
318
317
  output.write("from enum import StrEnum\n")
319
- output.write(
320
- "from typing import Dict, List, Optional, Iterator, Iterable, IO, Union, TypedDict\n"
321
- )
318
+ output.write("from typing import IO, TypedDict\n")
319
+ output.write("from collections.abc import Iterable, Iterator\n")
322
320
  output.write("\n")
323
321
  output.write(
324
322
  "from localstack.aws.api import handler, RequestContext, ServiceException, ServiceRequest"
@@ -372,8 +370,8 @@ def generate_service_api(output, service: ServiceModel, doc=True):
372
370
 
373
371
  output.write(f"class {class_name}:\n")
374
372
  output.write("\n")
375
- output.write(f' service = "{service.service_name}"\n')
376
- output.write(f' version = "{service.api_version}"\n')
373
+ output.write(f' service: str = "{service.service_name}"\n')
374
+ output.write(f' version: str = "{service.api_version}"\n')
377
375
  for op_name in service.operation_names:
378
376
  operation: OperationModel = service.operation_model(op_name)
379
377
 
@@ -433,7 +433,7 @@ def _print_service_table(services: dict[str, str]) -> None:
433
433
 
434
434
  @localstack.command(name="start", short_help="Start LocalStack")
435
435
  @click.option("--docker", is_flag=True, help="Start LocalStack in a docker container [default]")
436
- @click.option("--host", is_flag=True, help="Start LocalStack directly on the host")
436
+ @click.option("--host", is_flag=True, help="Start LocalStack directly on the host", deprecated=True)
437
437
  @click.option("--no-banner", is_flag=True, help="Disable LocalStack banner", default=False)
438
438
  @click.option(
439
439
  "-d", "--detached", is_flag=True, help="Start LocalStack in the background", default=False
@@ -532,6 +532,11 @@ def cmd_start(
532
532
  console.log("starting LocalStack in Docker mode :whale:")
533
533
 
534
534
  if host:
535
+ console.log(
536
+ "Warning: Starting LocalStack in host mode from the CLI is deprecated and will be removed soon. Please use the default Docker mode instead.",
537
+ style="bold red",
538
+ )
539
+
535
540
  # call hooks to prepare host
536
541
  bootstrap.prepare_host(console)
537
542
 
@@ -9,6 +9,7 @@ EDGE_SERVICE_NODE_PORT = 30066
9
9
  NODE_PORT_START = 30010
10
10
  SERVICE_PORT_START = 4510
11
11
  NUMBER_OF_SERVICE_PORTS = 50
12
+ EDGE_SERVICE_DNS_PORT = 31053
12
13
 
13
14
 
14
15
  @dataclasses.dataclass
@@ -144,7 +145,12 @@ def generate_mount_points(
144
145
  return mount_points
145
146
 
146
147
 
147
- def generate_k8s_cluster_config(mount_points: list[MountPoint], port: int = 4566):
148
+ def generate_k8s_cluster_config(
149
+ mount_points: list[MountPoint],
150
+ port: int = 4566,
151
+ expose_dns: bool = False,
152
+ dns_port: int = 53,
153
+ ):
148
154
  volumes = [
149
155
  {
150
156
  "volume": f"{mount_point.host_path}:{mount_point.node_path}",
@@ -177,6 +183,16 @@ def generate_k8s_cluster_config(mount_points: list[MountPoint], port: int = 4566
177
183
  },
178
184
  ]
179
185
 
186
+ if expose_dns:
187
+ ports.append(
188
+ {
189
+ "nodeFilters": [
190
+ "server:0",
191
+ ],
192
+ "port": f"{dns_port}:{EDGE_SERVICE_DNS_PORT}",
193
+ }
194
+ )
195
+
180
196
  config = {
181
197
  "apiVersion": "k3d.io/v1alpha5",
182
198
  "kind": "Simple",
@@ -279,7 +295,10 @@ def generate_k8s_helm_overrides(
279
295
  "end": SERVICE_PORT_START + NUMBER_OF_SERVICE_PORTS,
280
296
  "nodePortStart": NODE_PORT_START,
281
297
  },
282
- "dnsService": True,
298
+ "dnsService": {
299
+ "enabled": True,
300
+ "nodePort": EDGE_SERVICE_DNS_PORT,
301
+ },
283
302
  }
284
303
  overrides = {
285
304
  "debug": True,
@@ -355,6 +374,18 @@ def print_file(content: dict, file_name: str):
355
374
  help="Port to expose from the kubernetes node",
356
375
  type=click.IntRange(0, 65535),
357
376
  )
377
+ @click.option(
378
+ "--expose-dns",
379
+ is_flag=True,
380
+ default=False,
381
+ help="Expose DNS port from the kubernetes node.",
382
+ )
383
+ @click.option(
384
+ "--dns-port",
385
+ default=53,
386
+ help="DNS port to expose from the kubernetes node. It is applied only if --expose-dns is set.",
387
+ type=click.IntRange(0, 65535),
388
+ )
358
389
  @click.argument("command", nargs=-1, required=False)
359
390
  def run(
360
391
  pro: bool = None,
@@ -367,13 +398,17 @@ def run(
367
398
  command: str = None,
368
399
  env: list[str] = None,
369
400
  port: int = None,
401
+ expose_dns: bool = False,
402
+ dns_port: int = 53,
370
403
  ):
371
404
  """
372
405
  A tool for localstack developers to generate the kubernetes cluster configuration file and the overrides to mount the localstack code into the cluster.
373
406
  """
374
407
  mount_points = generate_mount_points(pro, mount_moto, mount_entrypoints)
375
408
 
376
- config = generate_k8s_cluster_config(mount_points, port=port)
409
+ config = generate_k8s_cluster_config(
410
+ mount_points, port=port, expose_dns=expose_dns, dns_port=dns_port
411
+ )
377
412
 
378
413
  overrides = generate_k8s_helm_overrides(mount_points, pro=pro, env=env)
379
414
 
@@ -23,7 +23,7 @@ from localstack.aws.api.apigateway import (
23
23
  IntegrationType,
24
24
  Model,
25
25
  NotFoundException,
26
- PutRestApiRequest,
26
+ PutMode,
27
27
  RequestValidator,
28
28
  )
29
29
  from localstack.constants import (
@@ -39,8 +39,7 @@ from localstack.services.apigateway.models import (
39
39
  apigateway_stores,
40
40
  )
41
41
  from localstack.utils import common
42
- from localstack.utils.json import parse_json_or_yaml
43
- from localstack.utils.strings import short_uid, to_bytes, to_str
42
+ from localstack.utils.strings import short_uid, to_bytes
44
43
  from localstack.utils.urls import localstack_host
45
44
 
46
45
  LOG = logging.getLogger(__name__)
@@ -472,11 +471,9 @@ def add_documentation_parts(rest_api_container, documentation):
472
471
 
473
472
 
474
473
  def import_api_from_openapi_spec(
475
- rest_api: MotoRestAPI, context: RequestContext, request: PutRestApiRequest
474
+ rest_api: MotoRestAPI, context: RequestContext, open_api_spec: dict, mode: PutMode
476
475
  ) -> tuple[MotoRestAPI, list[str]]:
477
476
  """Import an API from an OpenAPI spec document"""
478
- body = parse_json_or_yaml(to_str(request["body"].read()))
479
-
480
477
  warnings = []
481
478
 
482
479
  # TODO There is an issue with the botocore specs so the parameters doesn't get populated as it should
@@ -484,15 +481,14 @@ def import_api_from_openapi_spec(
484
481
  # query_params = request.get("parameters") or {}
485
482
  query_params: dict = context.request.values.to_dict()
486
483
 
487
- resolved_schema = resolve_references(copy.deepcopy(body), rest_api_id=rest_api.id)
484
+ resolved_schema = resolve_references(copy.deepcopy(open_api_spec), rest_api_id=rest_api.id)
488
485
  account_id = context.account_id
489
486
  region_name = context.region
490
487
 
491
488
  # TODO:
492
- # 1. validate the "mode" property of the spec document, "merge" or "overwrite", and properly apply it
489
+ # 1. properly apply the mode (overwrite or merge)
493
490
  # for now, it only considers it for the binaryMediaTypes
494
491
  # 2. validate the document type, "swagger" or "openapi"
495
- mode = request.get("mode", "merge")
496
492
 
497
493
  rest_api.version = (
498
494
  str(version) if (version := resolved_schema.get("info", {}).get("version")) else None
@@ -476,11 +476,19 @@ class ApigatewayProvider(ApigatewayApi, ServiceLifecycleHook):
476
476
 
477
477
  @handler("PutRestApi", expand=False)
478
478
  def put_rest_api(self, context: RequestContext, request: PutRestApiRequest) -> RestApi:
479
+ body_data = request["body"].read()
480
+ try:
481
+ openapi_spec = parse_json_or_yaml(to_str(body_data))
482
+ except Exception:
483
+ raise BadRequestException("Invalid OpenAPI input.")
479
484
  # TODO: take into account the mode: overwrite or merge
480
485
  # the default is now `merge`, but we are removing everything
481
486
  rest_api = get_moto_rest_api(context, request["restApiId"])
482
487
  rest_api, warnings = import_api_from_openapi_spec(
483
- rest_api, context=context, request=request
488
+ rest_api,
489
+ context=context,
490
+ open_api_spec=openapi_spec,
491
+ mode=request.get("mode") or PutMode.merge,
484
492
  )
485
493
 
486
494
  rest_api.root_resource_id = get_moto_rest_api_root_resource(rest_api)
@@ -1512,7 +1520,10 @@ class ApigatewayProvider(ApigatewayApi, ServiceLifecycleHook):
1512
1520
  **kwargs,
1513
1521
  ) -> DocumentationPartIds:
1514
1522
  body_data = body.read()
1515
- openapi_spec = parse_json_or_yaml(to_str(body_data))
1523
+ try:
1524
+ openapi_spec = parse_json_or_yaml(to_str(body_data))
1525
+ except Exception:
1526
+ raise BadRequestException("Unable to build importer with provided input.")
1516
1527
 
1517
1528
  rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id)
1518
1529
 
@@ -2012,7 +2023,11 @@ class ApigatewayProvider(ApigatewayApi, ServiceLifecycleHook):
2012
2023
  body_data = body.read()
2013
2024
 
2014
2025
  # create rest api
2015
- openapi_spec = parse_json_or_yaml(to_str(body_data))
2026
+ try:
2027
+ openapi_spec = parse_json_or_yaml(to_str(body_data))
2028
+ except Exception:
2029
+ raise BadRequestException("Invalid OpenAPI input.")
2030
+
2016
2031
  create_api_request = CreateRestApiRequest(name=openapi_spec.get("info").get("title"))
2017
2032
  create_api_context = create_custom_context(
2018
2033
  context,
@@ -2053,12 +2068,20 @@ class ApigatewayProvider(ApigatewayApi, ServiceLifecycleHook):
2053
2068
  **kwargs,
2054
2069
  ) -> Integration:
2055
2070
  try:
2056
- response: Integration = call_moto(context)
2057
- except CommonServiceException as e:
2058
- # the Exception raised by moto does not have the right message not status code
2059
- if e.code == "NotFoundException":
2060
- raise NotFoundException("Invalid Integration identifier specified")
2061
- raise
2071
+ moto_rest_api = get_moto_rest_api(context, rest_api_id)
2072
+ except NotFoundException:
2073
+ raise NotFoundException("Invalid Resource identifier specified")
2074
+
2075
+ if not (moto_resource := moto_rest_api.resources.get(resource_id)):
2076
+ raise NotFoundException("Invalid Resource identifier specified")
2077
+
2078
+ if not (moto_method := moto_resource.resource_methods.get(http_method)):
2079
+ raise NotFoundException("Invalid Method identifier specified")
2080
+
2081
+ if not moto_method.method_integration:
2082
+ raise NotFoundException("Invalid Integration identifier specified")
2083
+
2084
+ response: Integration = call_moto(context)
2062
2085
 
2063
2086
  if integration_responses := response.get("integrationResponses"):
2064
2087
  for integration_response in integration_responses.values():
@@ -5,7 +5,6 @@ import logging
5
5
  from moto.apigateway import models as apigateway_models
6
6
  from moto.apigateway.exceptions import (
7
7
  DeploymentNotFoundException,
8
- NoIntegrationDefined,
9
8
  RestAPINotFound,
10
9
  StageStillActive,
11
10
  )
@@ -113,14 +112,6 @@ def apply_patches():
113
112
  )
114
113
  return result
115
114
 
116
- # patch integration error responses
117
- @patch(apigateway_models.Resource.get_integration)
118
- def apigateway_models_resource_get_integration(fn, self, method_type):
119
- resource_method = self.resource_methods.get(method_type, {})
120
- if not resource_method.method_integration:
121
- raise NoIntegrationDefined()
122
- return resource_method.method_integration
123
-
124
115
  @patch(apigateway_models.RestAPI.to_dict)
125
116
  def apigateway_models_rest_api_to_dict(fn, self):
126
117
  resp = fn(self)
@@ -952,8 +952,8 @@ class CloudformationProvider(CloudformationApi):
952
952
  def describe_stack_events(
953
953
  self,
954
954
  context: RequestContext,
955
- stack_name: StackName = None,
956
- next_token: NextToken = None,
955
+ stack_name: StackName,
956
+ next_token: NextToken | None = None,
957
957
  **kwargs,
958
958
  ) -> DescribeStackEventsOutput:
959
959
  if stack_name is None:
@@ -135,15 +135,15 @@ SSM_PARAMETER_TYPE_RE = re.compile(
135
135
 
136
136
 
137
137
  def is_stack_arn(stack_name_or_id: str) -> bool:
138
- return ARN_STACK_REGEX.match(stack_name_or_id) is not None
138
+ return stack_name_or_id and ARN_STACK_REGEX.match(stack_name_or_id) is not None
139
139
 
140
140
 
141
141
  def is_changeset_arn(change_set_name_or_id: str) -> bool:
142
- return ARN_CHANGESET_REGEX.match(change_set_name_or_id) is not None
142
+ return change_set_name_or_id and ARN_CHANGESET_REGEX.match(change_set_name_or_id) is not None
143
143
 
144
144
 
145
145
  def is_stack_set_arn(stack_set_name_or_id: str) -> bool:
146
- return ARN_STACK_SET_REGEX.match(stack_set_name_or_id) is not None
146
+ return stack_set_name_or_id and ARN_STACK_SET_REGEX.match(stack_set_name_or_id) is not None
147
147
 
148
148
 
149
149
  class StackNotFoundError(ValidationError):
@@ -1349,8 +1349,8 @@ class CloudformationProviderV2(CloudformationProvider, ServiceLifecycleHook):
1349
1349
  def describe_stack_events(
1350
1350
  self,
1351
1351
  context: RequestContext,
1352
- stack_name: StackName = None,
1353
- next_token: NextToken = None,
1352
+ stack_name: StackName,
1353
+ next_token: NextToken | None = None,
1354
1354
  **kwargs,
1355
1355
  ) -> DescribeStackEventsOutput:
1356
1356
  if not stack_name:
@@ -1388,7 +1388,7 @@ class CloudformationProviderV2(CloudformationProvider, ServiceLifecycleHook):
1388
1388
  stack_name, message_override=f"Stack with id {stack_name} does not exist"
1389
1389
  )
1390
1390
  else:
1391
- raise StackNotFoundError(stack_name)
1391
+ raise ValidationError("StackName is required if ChangeSetName is not specified.")
1392
1392
 
1393
1393
  if template_stage == TemplateStage.Processed and "Transform" in stack.template_body:
1394
1394
  template_body = json.dumps(stack.processed_template)
@@ -7,7 +7,7 @@ from localstack.packages import InstallTarget, Package
7
7
  from localstack.packages.core import GitHubReleaseInstaller, NodePackageInstaller
8
8
  from localstack.packages.java import JavaInstallerMixin, java_package
9
9
 
10
- _KINESIS_MOCK_VERSION = os.environ.get("KINESIS_MOCK_VERSION") or "0.4.13"
10
+ _KINESIS_MOCK_VERSION = os.environ.get("KINESIS_MOCK_VERSION") or "0.5.1"
11
11
 
12
12
 
13
13
  class KinesisMockEngine(StrEnum):
@@ -173,6 +173,7 @@ class KmsCryptoKey:
173
173
  public_key: bytes | None
174
174
  private_key: bytes | None
175
175
  key_material: bytes
176
+ pending_key_material: bytes | None
176
177
  key_spec: str
177
178
 
178
179
  @staticmethod
@@ -217,6 +218,7 @@ class KmsCryptoKey:
217
218
  def __init__(self, key_spec: str, key_material: bytes | None = None):
218
219
  self.private_key = None
219
220
  self.public_key = None
221
+ self.pending_key_material = None
220
222
  # Technically, key_material, being a symmetric encryption key, is only relevant for
221
223
  # key_spec == SYMMETRIC_DEFAULT.
222
224
  # But LocalStack uses symmetric encryption with this key_material even for other specs. Asymmetric keys are
@@ -248,8 +250,9 @@ class KmsCryptoKey:
248
250
  self._serialize_key(key)
249
251
 
250
252
  def load_key_material(self, material: bytes):
251
- if self.key_spec in [
252
- KeySpec.SYMMETRIC_DEFAULT,
253
+ if self.key_spec == KeySpec.SYMMETRIC_DEFAULT:
254
+ self.pending_key_material = material
255
+ elif self.key_spec in [
253
256
  KeySpec.HMAC_224,
254
257
  KeySpec.HMAC_256,
255
258
  KeySpec.HMAC_384,
@@ -323,9 +326,28 @@ class KmsKey:
323
326
  # remove the _custom_key_material_ tag from the tags to not readily expose the custom key material
324
327
  del self.tags[TAG_KEY_CUSTOM_KEY_MATERIAL]
325
328
  self.crypto_key = KmsCryptoKey(self.metadata.get("KeySpec"), custom_key_material)
329
+ self._internal_key_id = uuid.uuid4()
330
+
331
+ # The KMS implementation always provides a crypto key with key material which doesn't suit scenarios where a
332
+ # KMS Key may have no key material e.g. for external keys. Don't expose the CurrentKeyMaterialId in those cases.
333
+ if custom_key_material or (
334
+ self.metadata["Origin"] == "AWS_KMS"
335
+ and self.metadata["KeySpec"] == KeySpec.SYMMETRIC_DEFAULT
336
+ ):
337
+ self.metadata["CurrentKeyMaterialId"] = self.generate_key_material_id(
338
+ self.crypto_key.key_material
339
+ )
340
+
326
341
  self.rotation_period_in_days = 365
327
342
  self.next_rotation_date = None
328
343
 
344
+ def generate_key_material_id(self, key_material: bytes) -> str:
345
+ # The KeyMaterialId depends on the key material and the KeyId. Use an internal ID to prevent brute forcing
346
+ # the value of the key material from the public KeyId and KeyMaterialId.
347
+ # https://docs.aws.amazon.com/kms/latest/APIReference/API_ImportKeyMaterial.html
348
+ key_material_id_hex = uuid.uuid5(self._internal_key_id, key_material).hex
349
+ return str(key_material_id_hex) * 2
350
+
329
351
  def calculate_and_set_arn(self, account_id, region):
330
352
  self.metadata["Arn"] = kms_key_arn(self.metadata.get("KeyId"), account_id, region)
331
353
 
@@ -746,8 +768,16 @@ class KmsKey:
746
768
  f"The on-demand rotations limit has been reached for the given keyId. "
747
769
  f"No more on-demand rotations can be performed for this key: {self.metadata['Arn']}"
748
770
  )
749
- self.previous_keys.append(self.crypto_key.key_material)
750
- self.crypto_key = KmsCryptoKey(KeySpec.SYMMETRIC_DEFAULT)
771
+ current_key_material = self.crypto_key.key_material
772
+ pending_key_material = self.crypto_key.pending_key_material
773
+
774
+ self.previous_keys.append(current_key_material)
775
+
776
+ # If there is no pending material stored on the key, then key material will be generated.
777
+ self.crypto_key = KmsCryptoKey(KeySpec.SYMMETRIC_DEFAULT, pending_key_material)
778
+ self.metadata["CurrentKeyMaterialId"] = self.generate_key_material_id(
779
+ self.crypto_key.key_material
780
+ )
751
781
 
752
782
 
753
783
  class KmsGrant: