localstack-core 4.7.1.dev139__py3-none-any.whl → 4.10.1.dev7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- localstack/aws/api/cloudformation/__init__.py +1 -0
- localstack/aws/api/cloudwatch/__init__.py +41 -1
- localstack/aws/api/config/__init__.py +4 -0
- localstack/aws/api/core.py +4 -0
- localstack/aws/api/ec2/__init__.py +1113 -56
- localstack/aws/api/iam/__init__.py +7 -0
- localstack/aws/api/kinesis/__init__.py +19 -0
- localstack/aws/api/kms/__init__.py +6 -0
- localstack/aws/api/lambda_/__init__.py +13 -0
- localstack/aws/api/logs/__init__.py +15 -0
- localstack/aws/api/redshift/__init__.py +9 -3
- localstack/aws/api/route53/__init__.py +2 -0
- localstack/aws/api/s3/__init__.py +12 -0
- localstack/aws/api/s3control/__init__.py +32 -0
- localstack/aws/api/ssm/__init__.py +2 -0
- localstack/aws/client.py +7 -2
- localstack/aws/forwarder.py +52 -5
- localstack/aws/handlers/analytics.py +1 -1
- localstack/aws/handlers/logging.py +12 -2
- localstack/aws/handlers/metric_handler.py +41 -1
- localstack/aws/handlers/service.py +32 -9
- localstack/aws/protocol/parser.py +440 -21
- localstack/aws/protocol/serializer.py +684 -64
- localstack/aws/protocol/service_router.py +120 -20
- localstack/aws/skeleton.py +4 -2
- localstack/aws/spec-patches.json +58 -0
- localstack/aws/spec.py +33 -13
- localstack/cli/exceptions.py +1 -1
- localstack/cli/localstack.py +4 -4
- localstack/cli/lpm.py +3 -4
- localstack/cli/profiles.py +1 -2
- localstack/config.py +18 -12
- localstack/constants.py +4 -29
- localstack/dev/kubernetes/__main__.py +1 -1
- localstack/dev/run/paths.py +1 -1
- localstack/dns/plugins.py +5 -1
- localstack/dns/server.py +12 -3
- localstack/packages/api.py +9 -8
- localstack/packages/core.py +2 -2
- localstack/packages/plugins.py +0 -8
- localstack/runtime/init.py +1 -1
- localstack/services/apigateway/legacy/provider.py +53 -3
- localstack/services/apigateway/next_gen/execute_api/integrations/aws.py +3 -0
- localstack/services/apigateway/next_gen/execute_api/integrations/http.py +3 -3
- localstack/services/apigateway/next_gen/execute_api/test_invoke.py +50 -6
- localstack/services/apigateway/next_gen/provider.py +5 -0
- localstack/services/cloudformation/engine/entities.py +12 -1
- localstack/services/cloudformation/engine/v2/change_set_model.py +0 -3
- localstack/services/cloudformation/engine/v2/change_set_model_describer.py +14 -0
- localstack/services/cloudformation/engine/v2/change_set_model_executor.py +13 -15
- localstack/services/cloudformation/engine/v2/change_set_model_preproc.py +118 -24
- localstack/services/cloudformation/engine/v2/change_set_model_transform.py +4 -1
- localstack/services/cloudformation/engine/v2/change_set_model_validator.py +5 -14
- localstack/services/cloudformation/engine/v2/change_set_model_visitor.py +1 -0
- localstack/services/cloudformation/engine/v2/resolving.py +6 -4
- localstack/services/cloudformation/engine/yaml_parser.py +9 -2
- localstack/services/cloudformation/resource_provider.py +5 -1
- localstack/services/cloudformation/resources.py +24149 -0
- localstack/services/cloudformation/v2/entities.py +6 -3
- localstack/services/cloudformation/v2/provider.py +172 -27
- localstack/services/cloudformation/v2/types.py +8 -4
- localstack/services/cloudwatch/provider_v2.py +25 -28
- localstack/services/dynamodb/packages.py +2 -1
- localstack/services/dynamodb/provider.py +42 -0
- localstack/services/dynamodb/v2/provider.py +42 -0
- localstack/services/ecr/resource_providers/aws_ecr_repository.py +5 -2
- localstack/services/es/provider.py +2 -2
- localstack/services/events/event_rule_engine.py +31 -13
- localstack/services/events/models.py +4 -5
- localstack/services/events/target.py +17 -9
- localstack/services/iam/provider.py +11 -116
- localstack/services/iam/resources/policy_simulator.py +133 -0
- localstack/services/kinesis/models.py +15 -2
- localstack/services/kinesis/provider.py +77 -0
- localstack/services/kms/provider.py +14 -5
- localstack/services/lambda_/invocation/internal_sqs_queue.py +5 -9
- localstack/services/lambda_/packages.py +1 -1
- localstack/services/logs/provider.py +1 -1
- localstack/services/moto.py +2 -1
- localstack/services/opensearch/cluster.py +15 -7
- localstack/services/opensearch/packages.py +26 -7
- localstack/services/opensearch/provider.py +6 -1
- localstack/services/opensearch/versions.py +56 -7
- localstack/services/s3/constants.py +5 -2
- localstack/services/s3/cors.py +4 -4
- localstack/services/s3/notifications.py +1 -1
- localstack/services/s3/presigned_url.py +27 -43
- localstack/services/s3/provider.py +67 -11
- localstack/services/s3/utils.py +42 -11
- localstack/services/ses/provider.py +16 -7
- localstack/services/sns/constants.py +7 -1
- localstack/services/sns/v2/models.py +167 -0
- localstack/services/sns/v2/provider.py +860 -2
- localstack/services/sns/v2/utils.py +130 -0
- localstack/services/sqs/developer_api.py +205 -0
- localstack/services/sqs/models.py +42 -3
- localstack/services/sqs/provider.py +8 -309
- localstack/services/sqs/query_api.py +1 -1
- localstack/services/sqs/utils.py +121 -2
- localstack/services/stepfunctions/asl/jsonata/jsonata.py +1 -1
- localstack/testing/aws/cloudformation_utils.py +1 -1
- localstack/testing/pytest/cloudformation/fixtures.py +3 -3
- localstack/testing/pytest/container.py +4 -5
- localstack/testing/pytest/fixtures.py +20 -19
- localstack/testing/pytest/in_memory_localstack.py +0 -4
- localstack/testing/pytest/marking.py +13 -4
- localstack/testing/pytest/stepfunctions/utils.py +4 -3
- localstack/testing/pytest/util.py +1 -1
- localstack/testing/pytest/validation_tracking.py +1 -2
- localstack/testing/snapshots/transformer_utility.py +5 -0
- localstack/utils/analytics/events.py +2 -2
- localstack/utils/analytics/metadata.py +1 -2
- localstack/utils/analytics/metrics/counter.py +6 -8
- localstack/utils/analytics/publisher.py +1 -2
- localstack/utils/analytics/service_request_aggregator.py +2 -2
- localstack/utils/archives.py +11 -11
- localstack/utils/aws/arns.py +17 -9
- localstack/utils/aws/aws_responses.py +7 -7
- localstack/utils/aws/aws_stack.py +2 -3
- localstack/utils/aws/message_forwarding.py +1 -2
- localstack/utils/aws/request_context.py +4 -5
- localstack/utils/batch_policy.py +3 -3
- localstack/utils/bootstrap.py +7 -7
- localstack/utils/catalog/catalog.py +139 -0
- localstack/utils/catalog/catalog_loader.py +11 -0
- localstack/utils/catalog/common.py +58 -0
- localstack/utils/catalog/plugins.py +28 -0
- localstack/utils/cloudwatch/cloudwatch_util.py +5 -5
- localstack/utils/collections.py +7 -8
- localstack/utils/config_listener.py +1 -1
- localstack/utils/container_networking.py +2 -3
- localstack/utils/container_utils/container_client.py +115 -131
- localstack/utils/container_utils/docker_cmd_client.py +42 -42
- localstack/utils/container_utils/docker_sdk_client.py +63 -62
- localstack/utils/diagnose.py +2 -3
- localstack/utils/docker_utils.py +3 -4
- localstack/utils/files.py +31 -7
- localstack/utils/functions.py +3 -2
- localstack/utils/http.py +4 -5
- localstack/utils/json.py +19 -5
- localstack/utils/kinesis/kinesis_connector.py +2 -1
- localstack/utils/net.py +6 -6
- localstack/utils/no_exit_argument_parser.py +2 -2
- localstack/utils/numbers.py +9 -2
- localstack/utils/objects.py +6 -5
- localstack/utils/patch.py +2 -1
- localstack/utils/run.py +10 -9
- localstack/utils/scheduler.py +11 -11
- localstack/utils/server/tcp_proxy.py +2 -2
- localstack/utils/serving.py +2 -3
- localstack/utils/strings.py +10 -11
- localstack/utils/sync.py +126 -1
- localstack/utils/tagging.py +1 -4
- localstack/utils/testutil.py +5 -4
- localstack/utils/threads.py +2 -2
- localstack/utils/time.py +11 -3
- localstack/utils/urls.py +1 -3
- localstack/version.py +2 -2
- {localstack_core-4.7.1.dev139.dist-info → localstack_core-4.10.1.dev7.dist-info}/METADATA +17 -12
- {localstack_core-4.7.1.dev139.dist-info → localstack_core-4.10.1.dev7.dist-info}/RECORD +168 -164
- {localstack_core-4.7.1.dev139.dist-info → localstack_core-4.10.1.dev7.dist-info}/entry_points.txt +4 -2
- localstack_core-4.10.1.dev7.dist-info/plux.json +1 -0
- localstack/packages/terraform.py +0 -46
- localstack/services/cloudformation/deploy.html +0 -144
- localstack/services/cloudformation/deploy_ui.py +0 -47
- localstack/services/cloudformation/plugins.py +0 -12
- localstack_core-4.7.1.dev139.dist-info/plux.json +0 -1
- {localstack_core-4.7.1.dev139.data → localstack_core-4.10.1.dev7.data}/scripts/localstack +0 -0
- {localstack_core-4.7.1.dev139.data → localstack_core-4.10.1.dev7.data}/scripts/localstack-supervisor +0 -0
- {localstack_core-4.7.1.dev139.data → localstack_core-4.10.1.dev7.data}/scripts/localstack.bat +0 -0
- {localstack_core-4.7.1.dev139.dist-info → localstack_core-4.10.1.dev7.dist-info}/WHEEL +0 -0
- {localstack_core-4.7.1.dev139.dist-info → localstack_core-4.10.1.dev7.dist-info}/licenses/LICENSE.txt +0 -0
- {localstack_core-4.7.1.dev139.dist-info → localstack_core-4.10.1.dev7.dist-info}/top_level.txt +0 -0
|
@@ -40,14 +40,13 @@ from localstack.http.request import get_raw_path
|
|
|
40
40
|
from localstack.services.s3.constants import (
|
|
41
41
|
DEFAULT_PRE_SIGNED_ACCESS_KEY_ID,
|
|
42
42
|
DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY,
|
|
43
|
+
S3_HOST_ID,
|
|
43
44
|
SIGNATURE_V2_PARAMS,
|
|
44
45
|
SIGNATURE_V4_PARAMS,
|
|
45
46
|
)
|
|
46
47
|
from localstack.services.s3.utils import (
|
|
47
|
-
S3_VIRTUAL_HOST_FORWARDED_HEADER,
|
|
48
48
|
capitalize_header_name_from_snake_case,
|
|
49
49
|
extract_bucket_name_and_key_from_headers_and_path,
|
|
50
|
-
forwarded_from_virtual_host_addressed_request,
|
|
51
50
|
is_bucket_name_valid,
|
|
52
51
|
is_presigned_url_request,
|
|
53
52
|
uses_host_addressing,
|
|
@@ -85,8 +84,6 @@ IGNORED_SIGV4_HEADERS = [
|
|
|
85
84
|
"x-amz-content-sha256",
|
|
86
85
|
]
|
|
87
86
|
|
|
88
|
-
FAKE_HOST_ID = "9Gjjt1m+cjU4OPvX9O9/8RuvnG41MRb/18Oux2o5H5MY7ISNTlXN+Dz9IG62/ILVxhAGI0qyPfg="
|
|
89
|
-
|
|
90
87
|
HOST_COMBINATION_REGEX = r"^(.*)(:[\d]{0,6})"
|
|
91
88
|
PORT_REPLACEMENT = [":80", ":443", f":{config.GATEWAY_LISTEN[0].port}", ""]
|
|
92
89
|
|
|
@@ -156,7 +153,7 @@ def create_signature_does_not_match_sig_v2(
|
|
|
156
153
|
"The request signature we calculated does not match the signature you provided. Check your key and signing method."
|
|
157
154
|
)
|
|
158
155
|
ex.AWSAccessKeyId = access_key_id
|
|
159
|
-
ex.HostId =
|
|
156
|
+
ex.HostId = S3_HOST_ID
|
|
160
157
|
ex.SignatureProvided = request_signature
|
|
161
158
|
ex.StringToSign = string_to_sign
|
|
162
159
|
ex.StringToSignBytes = to_bytes(string_to_sign).hex(sep=" ", bytes_per_sep=2).upper()
|
|
@@ -299,7 +296,7 @@ def is_valid_sig_v2(query_args: set) -> bool:
|
|
|
299
296
|
LOG.info("Presign signature calculation failed")
|
|
300
297
|
raise AccessDenied(
|
|
301
298
|
"Query-string authentication requires the Signature, Expires and AWSAccessKeyId parameters",
|
|
302
|
-
HostId=
|
|
299
|
+
HostId=S3_HOST_ID,
|
|
303
300
|
)
|
|
304
301
|
|
|
305
302
|
return True
|
|
@@ -317,7 +314,7 @@ def is_valid_sig_v4(query_args: set) -> bool:
|
|
|
317
314
|
LOG.info("Presign signature calculation failed")
|
|
318
315
|
raise AuthorizationQueryParametersError(
|
|
319
316
|
"Query-string authentication version 4 requires the X-Amz-Algorithm, X-Amz-Credential, X-Amz-Signature, X-Amz-Date, X-Amz-SignedHeaders, and X-Amz-Expires parameters.",
|
|
320
|
-
HostId=
|
|
317
|
+
HostId=S3_HOST_ID,
|
|
321
318
|
)
|
|
322
319
|
|
|
323
320
|
return True
|
|
@@ -351,7 +348,7 @@ def validate_presigned_url_s3(context: RequestContext) -> None:
|
|
|
351
348
|
)
|
|
352
349
|
else:
|
|
353
350
|
raise AccessDenied(
|
|
354
|
-
"Request has expired", HostId=
|
|
351
|
+
"Request has expired", HostId=S3_HOST_ID, Expires=expires, ServerTime=time.time()
|
|
355
352
|
)
|
|
356
353
|
|
|
357
354
|
auth_signer = HmacV1QueryAuthValidation(credentials=signing_credentials, expires=expires)
|
|
@@ -450,7 +447,7 @@ def validate_presigned_url_s3v4(context: RequestContext) -> None:
|
|
|
450
447
|
else:
|
|
451
448
|
raise AccessDenied(
|
|
452
449
|
"There were headers present in the request which were not signed",
|
|
453
|
-
HostId=
|
|
450
|
+
HostId=S3_HOST_ID,
|
|
454
451
|
HeadersNotSigned=", ".join(sigv4_context.missing_signed_headers),
|
|
455
452
|
)
|
|
456
453
|
|
|
@@ -482,7 +479,7 @@ def validate_presigned_url_s3v4(context: RequestContext) -> None:
|
|
|
482
479
|
else:
|
|
483
480
|
raise AccessDenied(
|
|
484
481
|
"Request has expired",
|
|
485
|
-
HostId=
|
|
482
|
+
HostId=S3_HOST_ID,
|
|
486
483
|
Expires=expiration_time.timestamp(),
|
|
487
484
|
ServerTime=time.time(),
|
|
488
485
|
X_Amz_Expires=x_amz_expires,
|
|
@@ -568,34 +565,21 @@ class S3SigV4SignatureContext:
|
|
|
568
565
|
self._query_parameters
|
|
569
566
|
)
|
|
570
567
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
self.
|
|
578
|
-
|
|
579
|
-
|
|
568
|
+
netloc = urlparse.urlparse(self.request.url).netloc
|
|
569
|
+
self.host = netloc
|
|
570
|
+
self._original_host = netloc
|
|
571
|
+
if (host_addressed := uses_host_addressing(self._headers)) and not is_bucket_name_valid(
|
|
572
|
+
self._bucket
|
|
573
|
+
):
|
|
574
|
+
raise InvalidBucketName(BucketName=self._bucket)
|
|
575
|
+
|
|
576
|
+
if not host_addressed and not self.request.path.startswith(f"/{self._bucket}"):
|
|
577
|
+
# if in path style, check that the path starts with the bucket
|
|
578
|
+
# our path has been sanitized, we should use the un-sanitized one
|
|
580
579
|
splitted_path = self.request.path.split("/", maxsplit=2)
|
|
581
|
-
self.path = f"/{splitted_path[-1]}"
|
|
582
|
-
|
|
580
|
+
self.path = f"/{self._bucket}/{splitted_path[-1]}"
|
|
583
581
|
else:
|
|
584
|
-
|
|
585
|
-
self.host = netloc
|
|
586
|
-
self._original_host = netloc
|
|
587
|
-
if (host_addressed := uses_host_addressing(self._headers)) and not is_bucket_name_valid(
|
|
588
|
-
self._bucket
|
|
589
|
-
):
|
|
590
|
-
raise InvalidBucketName(BucketName=self._bucket)
|
|
591
|
-
|
|
592
|
-
if not host_addressed and not self.request.path.startswith(f"/{self._bucket}"):
|
|
593
|
-
# if in path style, check that the path starts with the bucket
|
|
594
|
-
# our path has been sanitized, we should use the un-sanitized one
|
|
595
|
-
splitted_path = self.request.path.split("/", maxsplit=2)
|
|
596
|
-
self.path = f"/{self._bucket}/{splitted_path[-1]}"
|
|
597
|
-
else:
|
|
598
|
-
self.path = self.request.path
|
|
582
|
+
self.path = self.request.path
|
|
599
583
|
|
|
600
584
|
# we need to URL encode the path, as the key needs to be urlencoded for the signature to match
|
|
601
585
|
self.path = urlparse.quote(self.path)
|
|
@@ -714,7 +698,7 @@ class S3SigV4SignatureContext:
|
|
|
714
698
|
if not (split_creds := credential.split("/")) or len(split_creds) != 5:
|
|
715
699
|
raise AuthorizationQueryParametersError(
|
|
716
700
|
'Error parsing the X-Amz-Credential parameter; the Credential is mal-formed; expecting "<YOUR-AKID>/YYYYMMDD/REGION/SERVICE/aws4_request".',
|
|
717
|
-
HostId=
|
|
701
|
+
HostId=S3_HOST_ID,
|
|
718
702
|
)
|
|
719
703
|
|
|
720
704
|
return split_creds[2]
|
|
@@ -775,7 +759,7 @@ def validate_post_policy(
|
|
|
775
759
|
"Bucket POST must contain a field named 'key'. If it is specified, please check the order of the fields.",
|
|
776
760
|
ArgumentName="key",
|
|
777
761
|
ArgumentValue="",
|
|
778
|
-
HostId=
|
|
762
|
+
HostId=S3_HOST_ID,
|
|
779
763
|
)
|
|
780
764
|
|
|
781
765
|
form_dict = {k.lower(): v for k, v in request_form.items()}
|
|
@@ -791,7 +775,7 @@ def validate_post_policy(
|
|
|
791
775
|
|
|
792
776
|
if not is_v2 and not is_v4:
|
|
793
777
|
ex: AccessDenied = AccessDenied("Access Denied")
|
|
794
|
-
ex.HostId =
|
|
778
|
+
ex.HostId = S3_HOST_ID
|
|
795
779
|
raise ex
|
|
796
780
|
|
|
797
781
|
try:
|
|
@@ -810,7 +794,7 @@ def validate_post_policy(
|
|
|
810
794
|
if expiration := policy_decoded.get("expiration"):
|
|
811
795
|
if is_expired(_parse_policy_expiration_date(expiration)):
|
|
812
796
|
ex: AccessDenied = AccessDenied("Invalid according to Policy: Policy expired.")
|
|
813
|
-
ex.HostId =
|
|
797
|
+
ex.HostId = S3_HOST_ID
|
|
814
798
|
raise ex
|
|
815
799
|
|
|
816
800
|
# TODO: validate the signature
|
|
@@ -832,7 +816,7 @@ def validate_post_policy(
|
|
|
832
816
|
str_condition = str(condition).replace("'", '"')
|
|
833
817
|
raise AccessDenied(
|
|
834
818
|
f"Invalid according to Policy: Policy Condition failed: {str_condition}",
|
|
835
|
-
HostId=
|
|
819
|
+
HostId=S3_HOST_ID,
|
|
836
820
|
)
|
|
837
821
|
|
|
838
822
|
|
|
@@ -885,7 +869,7 @@ def _verify_condition(condition: list | dict, form: dict, additional_policy_meta
|
|
|
885
869
|
"Your proposed upload exceeds the maximum allowed size",
|
|
886
870
|
ProposedSize=size,
|
|
887
871
|
MaxSizeAllowed=end,
|
|
888
|
-
HostId=
|
|
872
|
+
HostId=S3_HOST_ID,
|
|
889
873
|
)
|
|
890
874
|
else:
|
|
891
875
|
return True
|
|
@@ -934,7 +918,7 @@ def _is_match_with_signature_fields(
|
|
|
934
918
|
f"Bucket POST must contain a field named '{argument_name}'. If it is specified, please check the order of the fields.",
|
|
935
919
|
ArgumentName=argument_name,
|
|
936
920
|
ArgumentValue="",
|
|
937
|
-
HostId=
|
|
921
|
+
HostId=S3_HOST_ID,
|
|
938
922
|
)
|
|
939
923
|
|
|
940
924
|
return True
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import base64
|
|
2
|
+
import contextlib
|
|
2
3
|
import copy
|
|
3
4
|
import datetime
|
|
4
5
|
import json
|
|
@@ -8,6 +9,7 @@ from collections import defaultdict
|
|
|
8
9
|
from inspect import signature
|
|
9
10
|
from io import BytesIO
|
|
10
11
|
from operator import itemgetter
|
|
12
|
+
from threading import RLock
|
|
11
13
|
from typing import IO
|
|
12
14
|
from urllib import parse as urlparse
|
|
13
15
|
from zoneinfo import ZoneInfo
|
|
@@ -23,6 +25,7 @@ from localstack.aws.api.s3 import (
|
|
|
23
25
|
AccountId,
|
|
24
26
|
AnalyticsConfiguration,
|
|
25
27
|
AnalyticsId,
|
|
28
|
+
AuthorizationHeaderMalformed,
|
|
26
29
|
BadDigest,
|
|
27
30
|
Body,
|
|
28
31
|
Bucket,
|
|
@@ -235,6 +238,7 @@ from localstack.services.s3.constants import (
|
|
|
235
238
|
ARCHIVES_STORAGE_CLASSES,
|
|
236
239
|
CHECKSUM_ALGORITHMS,
|
|
237
240
|
DEFAULT_BUCKET_ENCRYPTION,
|
|
241
|
+
S3_HOST_ID,
|
|
238
242
|
)
|
|
239
243
|
from localstack.services.s3.cors import S3CorsHandler, s3_cors_request_handler
|
|
240
244
|
from localstack.services.s3.exceptions import (
|
|
@@ -277,6 +281,7 @@ from localstack.services.s3.utils import (
|
|
|
277
281
|
get_canned_acl,
|
|
278
282
|
get_class_attrs_from_spec_class,
|
|
279
283
|
get_failed_precondition_copy_source,
|
|
284
|
+
get_failed_upload_part_copy_source_preconditions,
|
|
280
285
|
get_full_default_bucket_location,
|
|
281
286
|
get_kms_key_arn,
|
|
282
287
|
get_lifecycle_rule_from_object,
|
|
@@ -337,6 +342,8 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
337
342
|
self._storage_backend = storage_backend or EphemeralS3ObjectStore(DEFAULT_S3_TMP_DIR)
|
|
338
343
|
self._notification_dispatcher = NotificationDispatcher()
|
|
339
344
|
self._cors_handler = S3CorsHandler(BucketCorsIndex())
|
|
345
|
+
# TODO: add lock for keys for PutObject, only way to support precondition writes for versioned buckets
|
|
346
|
+
self._preconditions_locks = defaultdict(lambda: defaultdict(RLock))
|
|
340
347
|
|
|
341
348
|
# runtime cache of Lifecycle Expiration headers, as they need to be calculated everytime we fetch an object
|
|
342
349
|
# in case the rules have changed
|
|
@@ -381,7 +388,7 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
381
388
|
"""
|
|
382
389
|
if s3_bucket.notification_configuration:
|
|
383
390
|
if not s3_notif_ctx:
|
|
384
|
-
s3_notif_ctx = S3EventNotificationContext.
|
|
391
|
+
s3_notif_ctx = S3EventNotificationContext.from_request_context(
|
|
385
392
|
context,
|
|
386
393
|
s3_bucket=s3_bucket,
|
|
387
394
|
s3_object=s3_object,
|
|
@@ -469,6 +476,16 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
469
476
|
context: RequestContext,
|
|
470
477
|
request: CreateBucketRequest,
|
|
471
478
|
) -> CreateBucketOutput:
|
|
479
|
+
if context.region == "aws-global":
|
|
480
|
+
# TODO: extend this logic to probably all the provider, and maybe all services. S3 is the most impacted
|
|
481
|
+
# right now so this will help users to properly set a region in their config
|
|
482
|
+
# See the `TestS3.test_create_bucket_aws_global` test
|
|
483
|
+
raise AuthorizationHeaderMalformed(
|
|
484
|
+
f"The authorization header is malformed; the region 'aws-global' is wrong; expecting '{AWS_REGION_US_EAST_1}'",
|
|
485
|
+
HostId=S3_HOST_ID,
|
|
486
|
+
Region=AWS_REGION_US_EAST_1,
|
|
487
|
+
)
|
|
488
|
+
|
|
472
489
|
bucket_name = request["Bucket"]
|
|
473
490
|
|
|
474
491
|
if not is_bucket_name_valid(bucket_name):
|
|
@@ -577,6 +594,7 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
577
594
|
store.global_bucket_map.pop(bucket)
|
|
578
595
|
self._cors_handler.invalidate_cache()
|
|
579
596
|
self._expiration_cache.pop(bucket, None)
|
|
597
|
+
self._preconditions_locks.pop(bucket, None)
|
|
580
598
|
# clean up the storage backend
|
|
581
599
|
self._storage_backend.delete_bucket(bucket)
|
|
582
600
|
|
|
@@ -636,6 +654,16 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
636
654
|
expected_bucket_owner: AccountId = None,
|
|
637
655
|
**kwargs,
|
|
638
656
|
) -> HeadBucketOutput:
|
|
657
|
+
if context.region == "aws-global":
|
|
658
|
+
# TODO: extend this logic to probably all the provider, and maybe all services. S3 is the most impacted
|
|
659
|
+
# right now so this will help users to properly set a region in their config
|
|
660
|
+
# See the `TestS3.test_create_bucket_aws_global` test
|
|
661
|
+
raise AuthorizationHeaderMalformed(
|
|
662
|
+
f"The authorization header is malformed; the region 'aws-global' is wrong; expecting '{AWS_REGION_US_EAST_1}'",
|
|
663
|
+
HostId=S3_HOST_ID,
|
|
664
|
+
Region=AWS_REGION_US_EAST_1,
|
|
665
|
+
)
|
|
666
|
+
|
|
639
667
|
store = self.get_store(context.account_id, context.region)
|
|
640
668
|
if not (s3_bucket := store.buckets.get(bucket)):
|
|
641
669
|
if not (account_id := store.global_bucket_map.get(bucket)):
|
|
@@ -727,6 +755,12 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
727
755
|
system_metadata["ContentType"] = "binary/octet-stream"
|
|
728
756
|
|
|
729
757
|
version_id = generate_version_id(s3_bucket.versioning_status)
|
|
758
|
+
if version_id != "null":
|
|
759
|
+
# if we are in a versioned bucket, we need to lock around the full key (all the versions)
|
|
760
|
+
# because object versions have locks per version
|
|
761
|
+
precondition_lock = self._preconditions_locks[bucket_name][key]
|
|
762
|
+
else:
|
|
763
|
+
precondition_lock = contextlib.nullcontext()
|
|
730
764
|
|
|
731
765
|
etag_content_md5 = ""
|
|
732
766
|
if content_md5 := request.get("ContentMD5"):
|
|
@@ -809,7 +843,10 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
809
843
|
if encodings:
|
|
810
844
|
s3_object.system_metadata["ContentEncoding"] = ",".join(encodings)
|
|
811
845
|
|
|
812
|
-
with
|
|
846
|
+
with (
|
|
847
|
+
precondition_lock,
|
|
848
|
+
self._storage_backend.open(bucket_name, s3_object, mode="w") as s3_stored_object,
|
|
849
|
+
):
|
|
813
850
|
# as we are inside the lock here, if multiple concurrent requests happen for the same object, it's the first
|
|
814
851
|
# one to finish to succeed, and subsequent will raise exceptions. Once the first write finishes, we're
|
|
815
852
|
# opening the lock and other requests can check this condition
|
|
@@ -1248,7 +1285,7 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
1248
1285
|
delete_marker_id = generate_version_id(s3_bucket.versioning_status)
|
|
1249
1286
|
delete_marker = S3DeleteMarker(key=key, version_id=delete_marker_id)
|
|
1250
1287
|
s3_bucket.objects.set(key, delete_marker)
|
|
1251
|
-
s3_notif_ctx = S3EventNotificationContext.
|
|
1288
|
+
s3_notif_ctx = S3EventNotificationContext.from_request_context(
|
|
1252
1289
|
context,
|
|
1253
1290
|
s3_bucket=s3_bucket,
|
|
1254
1291
|
s3_object=delete_marker,
|
|
@@ -1281,6 +1318,10 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
1281
1318
|
store.TAGS.tags.pop(get_unique_key_id(bucket, key, version_id), None)
|
|
1282
1319
|
self._notify(context, s3_bucket=s3_bucket, s3_object=s3_object)
|
|
1283
1320
|
|
|
1321
|
+
if key not in s3_bucket.objects:
|
|
1322
|
+
# we clean up keys that do not have any object versions in them anymore
|
|
1323
|
+
self._preconditions_locks[bucket].pop(key, None)
|
|
1324
|
+
|
|
1284
1325
|
return response
|
|
1285
1326
|
|
|
1286
1327
|
def delete_objects(
|
|
@@ -1314,6 +1355,7 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
1314
1355
|
errors = []
|
|
1315
1356
|
|
|
1316
1357
|
to_remove = []
|
|
1358
|
+
versioned_keys = set()
|
|
1317
1359
|
for to_delete_object in objects:
|
|
1318
1360
|
object_key = to_delete_object.get("Key")
|
|
1319
1361
|
version_id = to_delete_object.get("VersionId")
|
|
@@ -1351,7 +1393,7 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
1351
1393
|
delete_marker_id = generate_version_id(s3_bucket.versioning_status)
|
|
1352
1394
|
delete_marker = S3DeleteMarker(key=object_key, version_id=delete_marker_id)
|
|
1353
1395
|
s3_bucket.objects.set(object_key, delete_marker)
|
|
1354
|
-
s3_notif_ctx = S3EventNotificationContext.
|
|
1396
|
+
s3_notif_ctx = S3EventNotificationContext.from_request_context(
|
|
1355
1397
|
context,
|
|
1356
1398
|
s3_bucket=s3_bucket,
|
|
1357
1399
|
s3_object=delete_marker,
|
|
@@ -1394,6 +1436,8 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
1394
1436
|
continue
|
|
1395
1437
|
|
|
1396
1438
|
s3_bucket.objects.pop(object_key=object_key, version_id=version_id)
|
|
1439
|
+
versioned_keys.add(object_key)
|
|
1440
|
+
|
|
1397
1441
|
if not quiet:
|
|
1398
1442
|
deleted_object = DeletedObject(
|
|
1399
1443
|
Key=object_key,
|
|
@@ -1411,6 +1455,11 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
1411
1455
|
self._notify(context, s3_bucket=s3_bucket, s3_object=found_object)
|
|
1412
1456
|
store.TAGS.tags.pop(get_unique_key_id(bucket, object_key, version_id), None)
|
|
1413
1457
|
|
|
1458
|
+
for versioned_key in versioned_keys:
|
|
1459
|
+
# we clean up keys that do not have any object versions in them anymore
|
|
1460
|
+
if versioned_key not in s3_bucket.objects:
|
|
1461
|
+
self._preconditions_locks[bucket].pop(versioned_key, None)
|
|
1462
|
+
|
|
1414
1463
|
# TODO: request charged
|
|
1415
1464
|
self._storage_backend.remove(bucket, to_remove)
|
|
1416
1465
|
response: DeleteObjectsOutput = {}
|
|
@@ -2179,7 +2228,7 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
2179
2228
|
# TODO: add a way to transition from ongoing-request=true to false? for now it is instant
|
|
2180
2229
|
s3_object.restore = f'ongoing-request="false", expiry-date="{restore_expiration_date}"'
|
|
2181
2230
|
|
|
2182
|
-
s3_notif_ctx_initiated = S3EventNotificationContext.
|
|
2231
|
+
s3_notif_ctx_initiated = S3EventNotificationContext.from_request_context(
|
|
2183
2232
|
context,
|
|
2184
2233
|
s3_bucket=s3_bucket,
|
|
2185
2234
|
s3_object=s3_object,
|
|
@@ -2483,10 +2532,6 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
2483
2532
|
request: UploadPartCopyRequest,
|
|
2484
2533
|
) -> UploadPartCopyOutput:
|
|
2485
2534
|
# TODO: handle following parameters:
|
|
2486
|
-
# CopySourceIfMatch: Optional[CopySourceIfMatch]
|
|
2487
|
-
# CopySourceIfModifiedSince: Optional[CopySourceIfModifiedSince]
|
|
2488
|
-
# CopySourceIfNoneMatch: Optional[CopySourceIfNoneMatch]
|
|
2489
|
-
# CopySourceIfUnmodifiedSince: Optional[CopySourceIfUnmodifiedSince]
|
|
2490
2535
|
# SSECustomerAlgorithm: Optional[SSECustomerAlgorithm]
|
|
2491
2536
|
# SSECustomerKey: Optional[SSECustomerKey]
|
|
2492
2537
|
# SSECustomerKeyMD5: Optional[SSECustomerKeyMD5]
|
|
@@ -2549,6 +2594,14 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
2549
2594
|
if source_range:
|
|
2550
2595
|
range_data = parse_copy_source_range_header(source_range, src_s3_object.size)
|
|
2551
2596
|
|
|
2597
|
+
if precondition := get_failed_upload_part_copy_source_preconditions(
|
|
2598
|
+
request, src_s3_object.last_modified, src_s3_object.etag
|
|
2599
|
+
):
|
|
2600
|
+
raise PreconditionFailed(
|
|
2601
|
+
"At least one of the pre-conditions you specified did not hold",
|
|
2602
|
+
Condition=precondition,
|
|
2603
|
+
)
|
|
2604
|
+
|
|
2552
2605
|
s3_part = S3Part(part_number=part_number)
|
|
2553
2606
|
if s3_multipart.checksum_algorithm:
|
|
2554
2607
|
s3_part.checksum_algorithm = s3_multipart.checksum_algorithm
|
|
@@ -2622,6 +2675,7 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
2622
2675
|
)
|
|
2623
2676
|
|
|
2624
2677
|
elif if_none_match:
|
|
2678
|
+
# TODO: improve concurrency mechanism for `if_none_match` and `if_match`
|
|
2625
2679
|
if if_none_match != "*":
|
|
2626
2680
|
raise NotImplementedException(
|
|
2627
2681
|
"A header you provided implies functionality that is not implemented",
|
|
@@ -3293,7 +3347,7 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
3293
3347
|
|
|
3294
3348
|
s3_object = s3_bucket.get_object(key=key, version_id=version_id, http_method="DELETE")
|
|
3295
3349
|
|
|
3296
|
-
store.TAGS.tags.pop(get_unique_key_id(bucket, key, version_id), None)
|
|
3350
|
+
store.TAGS.tags.pop(get_unique_key_id(bucket, key, s3_object.version_id), None)
|
|
3297
3351
|
response = DeleteObjectTaggingOutput()
|
|
3298
3352
|
if s3_object.version_id:
|
|
3299
3353
|
response["VersionId"] = s3_object.version_id
|
|
@@ -3854,7 +3908,9 @@ class S3Provider(S3Api, ServiceLifecycleHook):
|
|
|
3854
3908
|
if retention and retention["RetainUntilDate"] < datetime.datetime.now(datetime.UTC):
|
|
3855
3909
|
# weirdly, this date is format as following: Tue Dec 31 16:00:00 PST 2019
|
|
3856
3910
|
# it contains the timezone as PST, even if you target a bucket in Europe or Asia
|
|
3857
|
-
pst_datetime = retention["RetainUntilDate"].astimezone(
|
|
3911
|
+
pst_datetime = retention["RetainUntilDate"].astimezone(
|
|
3912
|
+
tz=ZoneInfo("America/Los_Angeles")
|
|
3913
|
+
)
|
|
3858
3914
|
raise InvalidArgument(
|
|
3859
3915
|
"The retain until date must be in the future!",
|
|
3860
3916
|
ArgumentName="RetainUntilDate",
|
localstack/services/s3/utils.py
CHANGED
|
@@ -7,6 +7,7 @@ import logging
|
|
|
7
7
|
import re
|
|
8
8
|
import time
|
|
9
9
|
import zlib
|
|
10
|
+
from collections.abc import Mapping
|
|
10
11
|
from enum import StrEnum
|
|
11
12
|
from secrets import token_bytes
|
|
12
13
|
from typing import Any, Literal, NamedTuple, Protocol
|
|
@@ -50,6 +51,7 @@ from localstack.aws.api.s3 import (
|
|
|
50
51
|
SSEKMSKeyId,
|
|
51
52
|
TaggingHeader,
|
|
52
53
|
TagSet,
|
|
54
|
+
UploadPartCopyRequest,
|
|
53
55
|
UploadPartRequest,
|
|
54
56
|
)
|
|
55
57
|
from localstack.aws.api.s3 import Type as GranteeType
|
|
@@ -62,7 +64,6 @@ from localstack.services.s3.constants import (
|
|
|
62
64
|
AUTHENTICATED_USERS_ACL_GRANTEE,
|
|
63
65
|
CHECKSUM_ALGORITHMS,
|
|
64
66
|
LOG_DELIVERY_ACL_GRANTEE,
|
|
65
|
-
S3_VIRTUAL_HOST_FORWARDED_HEADER,
|
|
66
67
|
SIGNATURE_V2_PARAMS,
|
|
67
68
|
SIGNATURE_V4_PARAMS,
|
|
68
69
|
SYSTEM_METADATA_SETTABLE_HEADERS,
|
|
@@ -403,6 +404,45 @@ def parse_copy_source_range_header(copy_source_range: str, object_size: int) ->
|
|
|
403
404
|
)
|
|
404
405
|
|
|
405
406
|
|
|
407
|
+
def get_failed_upload_part_copy_source_preconditions(
|
|
408
|
+
request: UploadPartCopyRequest, last_modified: datetime.datetime, etag: ETag
|
|
409
|
+
) -> str | None:
|
|
410
|
+
"""
|
|
411
|
+
Utility which parses the conditions from a S3 UploadPartCopy request.
|
|
412
|
+
Note: The order in which these conditions are checked if used in conjunction matters
|
|
413
|
+
|
|
414
|
+
:param UploadPartCopyRequest request: The S3 UploadPartCopy request.
|
|
415
|
+
:param datetime last_modified: The time the source object was last modified.
|
|
416
|
+
:param ETag etag: The ETag of the source object.
|
|
417
|
+
|
|
418
|
+
:returns: The name of the failed precondition.
|
|
419
|
+
"""
|
|
420
|
+
if_match = request.get("CopySourceIfMatch")
|
|
421
|
+
if_none_match = request.get("CopySourceIfNoneMatch")
|
|
422
|
+
if_unmodified_since = request.get("CopySourceIfUnmodifiedSince")
|
|
423
|
+
if_modified_since = request.get("CopySourceIfModifiedSince")
|
|
424
|
+
|
|
425
|
+
if if_match:
|
|
426
|
+
if if_match.strip('"') != etag.strip('"'):
|
|
427
|
+
return "x-amz-copy-source-If-Match"
|
|
428
|
+
if if_modified_since and if_modified_since > last_modified:
|
|
429
|
+
return "x-amz-copy-source-If-Modified-Since"
|
|
430
|
+
# CopySourceIfMatch is unaffected by CopySourceIfUnmodifiedSince so return early
|
|
431
|
+
if if_unmodified_since:
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
if if_unmodified_since and if_unmodified_since < last_modified:
|
|
435
|
+
return "x-amz-copy-source-If-Unmodified-Since"
|
|
436
|
+
|
|
437
|
+
if if_none_match and if_none_match.strip('"') == etag.strip('"'):
|
|
438
|
+
return "x-amz-copy-source-If-None-Match"
|
|
439
|
+
|
|
440
|
+
if if_modified_since and last_modified < if_modified_since < datetime.datetime.now(
|
|
441
|
+
tz=_gmt_zone_info
|
|
442
|
+
):
|
|
443
|
+
return "x-amz-copy-source-If-Modified-Since"
|
|
444
|
+
|
|
445
|
+
|
|
406
446
|
def get_full_default_bucket_location(bucket_name: BucketName) -> str:
|
|
407
447
|
host_definition = localstack_host()
|
|
408
448
|
if host_definition.host != constants.LOCALHOST_HOSTNAME:
|
|
@@ -482,7 +522,7 @@ def is_valid_canonical_id(canonical_id: str) -> bool:
|
|
|
482
522
|
return False
|
|
483
523
|
|
|
484
524
|
|
|
485
|
-
def uses_host_addressing(headers:
|
|
525
|
+
def uses_host_addressing(headers: Mapping[str, str]) -> str | None:
|
|
486
526
|
"""
|
|
487
527
|
Determines if the request is targeting S3 with virtual host addressing
|
|
488
528
|
:param headers: the request headers
|
|
@@ -511,15 +551,6 @@ def get_system_metadata_from_request(request: dict) -> Metadata:
|
|
|
511
551
|
return metadata
|
|
512
552
|
|
|
513
553
|
|
|
514
|
-
def forwarded_from_virtual_host_addressed_request(headers: dict[str, str]) -> bool:
|
|
515
|
-
"""
|
|
516
|
-
Determines if the request was forwarded from a v-host addressing style into a path one
|
|
517
|
-
"""
|
|
518
|
-
# we can assume that the host header we are receiving here is actually the header we originally received
|
|
519
|
-
# from the client (because the edge service is forwarding the request in memory)
|
|
520
|
-
return S3_VIRTUAL_HOST_FORWARDED_HEADER in headers
|
|
521
|
-
|
|
522
|
-
|
|
523
554
|
def extract_bucket_name_and_key_from_headers_and_path(
|
|
524
555
|
headers: dict[str, str], path: str
|
|
525
556
|
) -> tuple[str | None, str | None]:
|
|
@@ -8,7 +8,6 @@ from datetime import UTC, date, datetime, time
|
|
|
8
8
|
from typing import TYPE_CHECKING, Any
|
|
9
9
|
|
|
10
10
|
from botocore.exceptions import ClientError
|
|
11
|
-
from moto.core.parsers import XFormedDict
|
|
12
11
|
from moto.ses import ses_backends
|
|
13
12
|
from moto.ses.models import SESBackend
|
|
14
13
|
|
|
@@ -183,6 +182,8 @@ class SesProvider(SesApi, ServiceLifecycleHook):
|
|
|
183
182
|
#
|
|
184
183
|
|
|
185
184
|
def on_after_init(self):
|
|
185
|
+
self._apply_patches()
|
|
186
|
+
|
|
186
187
|
# Allow sent emails to be retrieved from the SES emails endpoint
|
|
187
188
|
register_ses_api_resource()
|
|
188
189
|
|
|
@@ -198,6 +199,12 @@ class SesProvider(SesApi, ServiceLifecycleHook):
|
|
|
198
199
|
return entity.replace("From:", "").strip()
|
|
199
200
|
return None
|
|
200
201
|
|
|
202
|
+
def _apply_patches(self) -> None:
|
|
203
|
+
# Suppress Moto's validation of receipt rule actions. These validations use Moto's implementation of S3, Lambda
|
|
204
|
+
# and SQS, which fail because these services have been internalised in LocalStack.
|
|
205
|
+
# Besides, AWS does not run the same validations as evidenced by our AWS-validated tests.
|
|
206
|
+
SESBackend._validate_receipt_rule_actions = lambda *_: None
|
|
207
|
+
|
|
201
208
|
#
|
|
202
209
|
# Implementations for SES operations
|
|
203
210
|
#
|
|
@@ -518,8 +525,10 @@ class SesProvider(SesApi, ServiceLifecycleHook):
|
|
|
518
525
|
backend.create_receipt_rule_set(rule_set_name)
|
|
519
526
|
original_rule_set = backend.describe_receipt_rule_set(original_rule_set_name)
|
|
520
527
|
|
|
528
|
+
after = None
|
|
521
529
|
for rule in original_rule_set.rules:
|
|
522
|
-
backend.create_receipt_rule(rule_set_name, rule)
|
|
530
|
+
backend.create_receipt_rule(rule_set_name, rule, after)
|
|
531
|
+
after = rule["Name"]
|
|
523
532
|
|
|
524
533
|
return CloneReceiptRuleSetResponse()
|
|
525
534
|
|
|
@@ -548,7 +557,7 @@ class SesProvider(SesApi, ServiceLifecycleHook):
|
|
|
548
557
|
)
|
|
549
558
|
|
|
550
559
|
backend = get_ses_backend(context)
|
|
551
|
-
if identity not in backend.
|
|
560
|
+
if identity not in backend.email_identities:
|
|
552
561
|
raise MessageRejected(f"Identity {identity} is not verified or does not exist.")
|
|
553
562
|
|
|
554
563
|
# Store the setting in the backend
|
|
@@ -678,7 +687,7 @@ class SNSEmitter:
|
|
|
678
687
|
def notify_event_destinations(
|
|
679
688
|
context: RequestContext,
|
|
680
689
|
# FIXME: Moto stores the Event Destinations as a single value when it should be a list
|
|
681
|
-
event_destinations:
|
|
690
|
+
event_destinations: EventDestination | list[EventDestination],
|
|
682
691
|
payload: EventDestinationPayload,
|
|
683
692
|
email_type: EmailType,
|
|
684
693
|
):
|
|
@@ -688,14 +697,14 @@ def notify_event_destinations(
|
|
|
688
697
|
event_destinations = [event_destinations]
|
|
689
698
|
|
|
690
699
|
for event_destination in event_destinations:
|
|
691
|
-
if not event_destination["
|
|
700
|
+
if not event_destination["Enabled"]:
|
|
692
701
|
continue
|
|
693
702
|
|
|
694
|
-
sns_destination_arn = event_destination.get("
|
|
703
|
+
sns_destination_arn = event_destination.get("SNSDestination", {}).get("TopicARN")
|
|
695
704
|
if not sns_destination_arn:
|
|
696
705
|
continue
|
|
697
706
|
|
|
698
|
-
matching_event_types = event_destination.get("
|
|
707
|
+
matching_event_types = event_destination.get("MatchingEventTypes") or []
|
|
699
708
|
if EventType.send in matching_event_types:
|
|
700
709
|
emitter.emit_send_event(
|
|
701
710
|
payload, sns_destination_arn, emit_source_arn=email_type != EmailType.TEMPLATED
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from string import ascii_letters, digits
|
|
3
|
+
from typing import get_args
|
|
4
|
+
|
|
5
|
+
from localstack.services.sns.v2.models import SnsApplicationPlatforms
|
|
3
6
|
|
|
4
7
|
SNS_PROTOCOLS = [
|
|
5
8
|
"http",
|
|
@@ -13,7 +16,7 @@ SNS_PROTOCOLS = [
|
|
|
13
16
|
"firehose",
|
|
14
17
|
]
|
|
15
18
|
|
|
16
|
-
VALID_SUBSCRIPTION_ATTR_NAME = [
|
|
19
|
+
VALID_SUBSCRIPTION_ATTR_NAME: list[str] = [
|
|
17
20
|
"DeliveryPolicy",
|
|
18
21
|
"FilterPolicy",
|
|
19
22
|
"FilterPolicyScope",
|
|
@@ -39,3 +42,6 @@ SUBSCRIPTION_TOKENS_ENDPOINT = "/_aws/sns/subscription-tokens"
|
|
|
39
42
|
SNS_CERT_ENDPOINT = "/_aws/sns/SimpleNotificationService-6c6f63616c737461636b69736e696365.pem"
|
|
40
43
|
|
|
41
44
|
DUMMY_SUBSCRIPTION_PRINCIPAL = "arn:{partition}:iam::{account_id}:user/DummySNSPrincipal"
|
|
45
|
+
E164_REGEX = re.compile(r"^\+?[1-9]\d{1,14}$")
|
|
46
|
+
|
|
47
|
+
VALID_APPLICATION_PLATFORMS = list(get_args(SnsApplicationPlatforms))
|