From e6bbb8a84c30bb71a5218bb33c56f72b5cb887b3 Mon Sep 17 00:00:00 2001 From: aws-sam-cli-bot <46753707+aws-sam-cli-bot@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:37:31 -0700 Subject: [PATCH 1/5] chore: bump version to 1.113.0 --- samtranslator/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samtranslator/__init__.py b/samtranslator/__init__.py index ff02d400a..22c47ece6 100644 --- a/samtranslator/__init__.py +++ b/samtranslator/__init__.py @@ -1 +1 @@ -__version__ = "1.111.0" +__version__ = "1.113.0" From 47ab648a8e3d9e0eaca987705a59a9e9d53332ff Mon Sep 17 00:00:00 2001 From: aws-sam-cli-bot <46753707+aws-sam-cli-bot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:34:02 -0700 Subject: [PATCH 2/5] fix: add unused-ignore to add_multi_constructor type ignore to fix mypy CI --- samtranslator/yaml_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samtranslator/yaml_helper.py b/samtranslator/yaml_helper.py index 60f0e4f16..8a20e7fab 100644 --- a/samtranslator/yaml_helper.py +++ b/samtranslator/yaml_helper.py @@ -7,7 +7,7 @@ def yaml_parse(yamlstr): # type: ignore[no-untyped-def] """Parse a yaml string""" - yaml.SafeLoader.add_multi_constructor("!", intrinsics_multi_constructor) # type: ignore[no-untyped-call] + yaml.SafeLoader.add_multi_constructor("!", intrinsics_multi_constructor) # type: ignore[no-untyped-call,unused-ignore] return yaml.safe_load(yamlstr) From c5c220d9cf12a691e93b50a598a0e6bc037da39d Mon Sep 17 00:00:00 2001 From: Vandita2020 Date: Thu, 1 Oct 2026 20:38:00 +0000 Subject: [PATCH 3/5] feat: Support WebFunction resource --- .cfnlintrc.yaml | 5 + bin/run_cfn_lint.sh | 9 + integration/config/code_key_to_file_map.json | 4 +- integration/config/file_to_s3_map.json | 8 + .../config/region_service_inclusion.yaml | 5 + integration/config/service_names.py | 1 + integration/conftest.py | 41 +- integration/resources/code/webfunc_code.zip | Bin 0 -> 446 bytes integration/resources/code/webfunc_code2.zip | Bin 0 -> 308 bytes .../expected/single/basic_web_function.json | 18 + .../single/web_function_custom_endpoint.json | 18 + .../single/web_function_custom_role.json | 18 + .../web_function_endpoint_response.json | 18 + .../web_function_env_vars_response.json | 18 + .../single/web_function_iam_auth.json | 18 + .../single/web_function_timeout_verify.json | 18 + .../single/web_function_update_code.json | 18 + .../single/web_function_update_timeout.json | 18 + .../web_function_with_env_and_timeout.json | 18 + .../web_function_with_multi_region.json | 18 + .../templates/single/basic_web_function.yaml | 9 + .../single/web_function_custom_endpoint.yaml | 10 + .../single/web_function_custom_role.yaml | 23 + .../web_function_endpoint_response.yaml | 13 + .../web_function_env_vars_response.yaml | 16 + .../single/web_function_iam_auth.yaml | 9 + .../single/web_function_timeout_verify.yaml | 16 + .../single/web_function_update_code.yaml | 13 + .../single/web_function_update_timeout.yaml | 14 + .../web_function_with_env_and_timeout.yaml | 12 + .../web_function_with_multi_region.yaml | 13 + integration/setup/companion-stack.yaml | 38 + integration/single/test_basic_web_function.py | 147 ++++ .../aws_serverless_webfunction.py | 39 + .../internal/schema_source/schema.py | 2 + samtranslator/model/lambda_.py | 71 ++ samtranslator/model/region_utils.py | 23 + samtranslator/model/s3_utils/uri_parser.py | 45 ++ samtranslator/model/sam_resources.py | 500 +++++++++++- samtranslator/schema/schema.json | 233 ++++++ samtranslator/sdk/resource.py | 1 + samtranslator/translator/translator.py | 5 +- samtranslator/translator/verify_logical_id.py | 1 + .../validator/sam_schema/schema_new.json | 1 + schema_source/sam.schema.json | 233 ++++++ tests/model/test_sam_resources.py | 761 +++++++++++++++++- .../error_webfunction_missing_authtype.yaml | 12 + tests/translator/input/webfunction_basic.yaml | 7 + .../input/webfunction_multiregion.yaml | 17 + .../webfunction_with_all_properties.yaml | 31 + .../output/aws-cn/webfunction_basic.json | 186 +++++ .../aws-cn/webfunction_multiregion.json | 193 +++++ .../webfunction_with_all_properties.json | 215 +++++ .../output/aws-us-gov/webfunction_basic.json | 186 +++++ .../aws-us-gov/webfunction_multiregion.json | 193 +++++ .../webfunction_with_all_properties.json | 215 +++++ .../error_webfunction_missing_authtype.json | 15 + .../translator/output/webfunction_basic.json | 186 +++++ .../output/webfunction_multiregion.json | 193 +++++ .../webfunction_with_all_properties.json | 215 +++++ 60 files changed, 4369 insertions(+), 14 deletions(-) create mode 100644 integration/resources/code/webfunc_code.zip create mode 100644 integration/resources/code/webfunc_code2.zip create mode 100644 integration/resources/expected/single/basic_web_function.json create mode 100644 integration/resources/expected/single/web_function_custom_endpoint.json create mode 100644 integration/resources/expected/single/web_function_custom_role.json create mode 100644 integration/resources/expected/single/web_function_endpoint_response.json create mode 100644 integration/resources/expected/single/web_function_env_vars_response.json create mode 100644 integration/resources/expected/single/web_function_iam_auth.json create mode 100644 integration/resources/expected/single/web_function_timeout_verify.json create mode 100644 integration/resources/expected/single/web_function_update_code.json create mode 100644 integration/resources/expected/single/web_function_update_timeout.json create mode 100644 integration/resources/expected/single/web_function_with_env_and_timeout.json create mode 100644 integration/resources/expected/single/web_function_with_multi_region.json create mode 100644 integration/resources/templates/single/basic_web_function.yaml create mode 100644 integration/resources/templates/single/web_function_custom_endpoint.yaml create mode 100644 integration/resources/templates/single/web_function_custom_role.yaml create mode 100644 integration/resources/templates/single/web_function_endpoint_response.yaml create mode 100644 integration/resources/templates/single/web_function_env_vars_response.yaml create mode 100644 integration/resources/templates/single/web_function_iam_auth.yaml create mode 100644 integration/resources/templates/single/web_function_timeout_verify.yaml create mode 100644 integration/resources/templates/single/web_function_update_code.yaml create mode 100644 integration/resources/templates/single/web_function_update_timeout.yaml create mode 100644 integration/resources/templates/single/web_function_with_env_and_timeout.yaml create mode 100644 integration/resources/templates/single/web_function_with_multi_region.yaml create mode 100644 integration/single/test_basic_web_function.py create mode 100644 samtranslator/internal/schema_source/aws_serverless_webfunction.py create mode 100644 samtranslator/model/region_utils.py create mode 100644 tests/translator/input/error_webfunction_missing_authtype.yaml create mode 100644 tests/translator/input/webfunction_basic.yaml create mode 100644 tests/translator/input/webfunction_multiregion.yaml create mode 100644 tests/translator/input/webfunction_with_all_properties.yaml create mode 100644 tests/translator/output/aws-cn/webfunction_basic.json create mode 100644 tests/translator/output/aws-cn/webfunction_multiregion.json create mode 100644 tests/translator/output/aws-cn/webfunction_with_all_properties.json create mode 100644 tests/translator/output/aws-us-gov/webfunction_basic.json create mode 100644 tests/translator/output/aws-us-gov/webfunction_multiregion.json create mode 100644 tests/translator/output/aws-us-gov/webfunction_with_all_properties.json create mode 100644 tests/translator/output/error_webfunction_missing_authtype.json create mode 100644 tests/translator/output/webfunction_basic.json create mode 100644 tests/translator/output/webfunction_multiregion.json create mode 100644 tests/translator/output/webfunction_with_all_properties.json diff --git a/.cfnlintrc.yaml b/.cfnlintrc.yaml index 72eda88df..f92c58778 100644 --- a/.cfnlintrc.yaml +++ b/.cfnlintrc.yaml @@ -47,6 +47,7 @@ ignore_templates: - tests/translator/output/**/connector_sfn_to_function.json - tests/translator/output/**/connector_sns_to_function.json - tests/translator/output/**/connector_table_to_function.json + - tests/translator/output/**/graphqlapi_api_cache.json # Fail by design: intentionally invalid CachingConfig enum values to test error path - tests/translator/output/**/eventbridgerule_with_dlq.json - tests/translator/output/**/function_event_conditions.json - tests/translator/output/**/function_with_condition.json @@ -112,6 +113,10 @@ ignore_templates: - tests/translator/output/**/function_with_logging_config.json # cfnlint is not updated to recognize the LoggingConfig property - tests/translator/output/aws-*/*capacity_provider*.json # Ignore Capacity Provider test format in non-aws partitions - tests/translator/output/**/capacity_provider_managed_resource_tags.json # cfnlint not updated for CapacityProvider mode (Phase 2) + - tests/translator/output/**/webfunction_basic.json # New AWS::Lambda::WebFunction* types cfn-lint does not recognize; DependsOn is intentional (W3005) + - tests/translator/output/**/webfunction_with_all_properties.json # New AWS::Lambda::WebFunction* types cfn-lint does not recognize; DependsOn is intentional (W3005) + - tests/translator/output/**/webfunction_multiregion.json # New AWS::Lambda::WebFunction* types cfn-lint does not recognize; DependsOn is intentional (W3005) + - tests/translator/output/**/basic_function.json # W1030: fixture uses Ref for VpcConfig SubnetIds, which cannot match the subnet-id pattern ignore_checks: - E2531 # Deprecated runtime; not relevant for transform tests diff --git a/bin/run_cfn_lint.sh b/bin/run_cfn_lint.sh index 9936d746e..a72ab25b0 100755 --- a/bin/run_cfn_lint.sh +++ b/bin/run_cfn_lint.sh @@ -3,6 +3,15 @@ set -eux VENV=.venv_cfn_lint +# cfn-lint requires Python >= 3.10, which this project also requires (see python_requires in setup.py). +# Fail loudly rather than skipping: a silent "exit 0" here would turn the lint gate into a no-op on a +# misconfigured interpreter and report success without checking a single template. +PYTHON_MINOR=$(python3 -c "import sys; print(sys.version_info.minor)") +if [ "$PYTHON_MINOR" -lt 10 ]; then + echo "ERROR: cfn-lint requires Python >= 3.10, but python3 is 3.${PYTHON_MINOR}. Use a supported interpreter." >&2 + exit 1 +fi + # Install to separate venv to avoid circular dependency; cfn-lint depends on samtranslator # See https://github.com/aws/serverless-application-model/issues/1042 if [ ! -d "${VENV}" ]; then diff --git a/integration/config/code_key_to_file_map.json b/integration/config/code_key_to_file_map.json index 68709d999..ddcaf0d9c 100644 --- a/integration/config/code_key_to_file_map.json +++ b/integration/config/code_key_to_file_map.json @@ -5,5 +5,7 @@ "definitionuri": "swagger1.json", "microvmcodeuri": "microvm_code.zip", "mtlsuri": "MTLSCert.pem", - "templateurl": "template.yaml" + "templateurl": "template.yaml", + "webfunccodeuri": "webfunc_code.zip", + "webfunccodeuri2": "webfunc_code2.zip" } diff --git a/integration/config/file_to_s3_map.json b/integration/config/file_to_s3_map.json index 8c82f7a5a..a474116ef 100644 --- a/integration/config/file_to_s3_map.json +++ b/integration/config/file_to_s3_map.json @@ -42,5 +42,13 @@ "template.yaml": { "type": "http", "uri": "" + }, + "webfunc_code.zip": { + "type": "s3", + "uri": "" + }, + "webfunc_code2.zip": { + "type": "s3", + "uri": "" } } diff --git a/integration/config/region_service_inclusion.yaml b/integration/config/region_service_inclusion.yaml index 78f214447..6db3ba678 100644 --- a/integration/config/region_service_inclusion.yaml +++ b/integration/config/region_service_inclusion.yaml @@ -1,5 +1,10 @@ regions: us-east-1: - CustomDomain + - WebFunction + us-west-2: + - WebFunction + eu-west-1: + - WebFunction Metadata: SamTransformTest: true diff --git a/integration/config/service_names.py b/integration/config/service_names.py index 3296f64b2..dd26fdc1c 100644 --- a/integration/config/service_names.py +++ b/integration/config/service_names.py @@ -33,6 +33,7 @@ LAMBDA_URL = "LambdaUrl" LAMBDA_MANAGED_INSTANCES = "LambdaManagedInstances" MICROVM_IMAGE = "MicrovmImage" +WEB_FUNCTION = "WebFunction" LAMBDA_ENV_VARS = "LambdaEnvVars" EVENT_INVOKE_CONFIG = "EventInvokeConfig" API_KEY = "ApiKey" diff --git a/integration/conftest.py b/integration/conftest.py index 0b75a7ef1..0da7bcef5 100644 --- a/integration/conftest.py +++ b/integration/conftest.py @@ -34,19 +34,32 @@ def _get_all_buckets(): return s3.buckets.all() -def clean_bucket(s3_bucket_name, s3_client): +def empty_versioned_bucket(s3_bucket_name, s3_client): """ - Empties and deletes the bucket used for the tests + Deletes every object version and delete marker from a bucket. Deleting objects without a VersionId + only adds delete markers on a versioned bucket, so the versions must be removed explicitly, otherwise + the bucket cannot be deleted (and CloudFormation's DeletionPolicy: Delete would leave the stack in + DELETE_FAILED). Works for non-versioned buckets too, where each object has a "null" version. """ - s3 = boto3.resource("s3") - bucket = s3.Bucket(s3_bucket_name) - object_summary_iterator = bucket.objects.all() - - for object_summary in object_summary_iterator: + paginator = s3_client.get_paginator("list_object_versions") + for page in paginator.paginate(Bucket=s3_bucket_name): + to_delete = [ + {"Key": item["Key"], "VersionId": item["VersionId"]} + for item in (page.get("Versions", []) + page.get("DeleteMarkers", [])) + ] + if not to_delete: + continue try: - s3_client.delete_object(Key=object_summary.key, Bucket=s3_bucket_name) + s3_client.delete_objects(Bucket=s3_bucket_name, Delete={"Objects": to_delete}) except ClientError as e: - LOG.error("Unable to delete object %s from bucket %s", object_summary.key, s3_bucket_name, exc_info=e) + LOG.error("Unable to delete object versions from bucket %s", s3_bucket_name, exc_info=e) + + +def clean_bucket(s3_bucket_name, s3_client): + """ + Empties and deletes the bucket used for the tests + """ + empty_versioned_bucket(s3_bucket_name, s3_client) try: s3_client.delete_bucket(Bucket=s3_bucket_name) except ClientError as e: @@ -201,6 +214,16 @@ def delete_companion_stack_once(get_prefix): if os.environ.get("COMPANION_STACK_NAME"): return if not get_prefix: + # PreCreatedS3Bucket has versioning enabled, so CloudFormation cannot delete it while it still holds + # object versions or delete markers — the stack would be left in DELETE_FAILED and leak the bucket. + # Empty all versions first so DeletionPolicy: Delete can succeed. + try: + outputs = get_stack_outputs(get_stack_description(COMPANION_STACK_NAME)) + bucket_name = outputs.get("PreCreatedS3Bucket") + if bucket_name: + empty_versioned_bucket(bucket_name, ClientProvider().s3_client) + except botocore.exceptions.ClientError as e: + LOG.error("Unable to empty companion bucket before deleting the companion stack", exc_info=e) ClientProvider().cfn_client.delete_stack(StackName=COMPANION_STACK_NAME) diff --git a/integration/resources/code/webfunc_code.zip b/integration/resources/code/webfunc_code.zip new file mode 100644 index 0000000000000000000000000000000000000000..17c5ffbbba14e78207f2fb209d1ff64b38536640 GIT binary patch literal 446 zcmWIWW@Zs#U|`^2&?-F|)3H6|p#&oXgDw+DM1~xv384FM&!_>fydCdkQcYbzlzv&?ouzTww@6eSir=&HU6K$G)Y-;lEZ_Q?2 zKfX(Gs5rN!eYv~4y!`ysu)YN66b0t)`@I`ZME*~E=qwPz{Px1OjK2>3$EO~hWFT*F zZm-%WCvm559KRVH)+}Uye9Y^_CZ33&9xeRIOre2!EzT{Ua}v(?*gOcZ?;yr%q4MC`)XE6rrotQ}r{a1s%U zzQTL9@K~Mu6>*cLr8@#j_8IAkoqm3``s2nk4+|KkZAzAE59#qb_x#s?Jx(O_w$eTEa^4UB2-Qtrw$lASLx49U tlN>Xyu$2G>9Rn~78J0AHScs5jg@iO(C4RG%Y!%ta~6~(Cbo-rNO`-gx-?Kn;KnlkLT>!@_SdD?AF}bh9a65 z+_TrNs1NXFWRhdXXIRn*Vj=v;3h^JB-vYc@*+A+TfzS^~UjT6!0IbMm A00000 literal 0 HcmV?d00001 diff --git a/integration/resources/expected/single/basic_web_function.json b/integration/resources/expected/single/basic_web_function.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/basic_web_function.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/web_function_custom_endpoint.json b/integration/resources/expected/single/web_function_custom_endpoint.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/web_function_custom_endpoint.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/web_function_custom_role.json b/integration/resources/expected/single/web_function_custom_role.json new file mode 100644 index 000000000..a855c1caa --- /dev/null +++ b/integration/resources/expected/single/web_function_custom_role.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "CustomRole", + "ResourceType": "AWS::IAM::Role" + }, + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + } +] diff --git a/integration/resources/expected/single/web_function_endpoint_response.json b/integration/resources/expected/single/web_function_endpoint_response.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/web_function_endpoint_response.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/web_function_env_vars_response.json b/integration/resources/expected/single/web_function_env_vars_response.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/web_function_env_vars_response.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/web_function_iam_auth.json b/integration/resources/expected/single/web_function_iam_auth.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/web_function_iam_auth.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/web_function_timeout_verify.json b/integration/resources/expected/single/web_function_timeout_verify.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/web_function_timeout_verify.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/web_function_update_code.json b/integration/resources/expected/single/web_function_update_code.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/web_function_update_code.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/web_function_update_timeout.json b/integration/resources/expected/single/web_function_update_timeout.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/web_function_update_timeout.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/web_function_with_env_and_timeout.json b/integration/resources/expected/single/web_function_with_env_and_timeout.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/web_function_with_env_and_timeout.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/web_function_with_multi_region.json b/integration/resources/expected/single/web_function_with_multi_region.json new file mode 100644 index 000000000..c3f2fdbb1 --- /dev/null +++ b/integration/resources/expected/single/web_function_with_multi_region.json @@ -0,0 +1,18 @@ +[ + { + "LogicalResourceId": "MyWebFunction", + "ResourceType": "AWS::Lambda::WebFunction" + }, + { + "LogicalResourceId": "MyWebFunctionEndpoint", + "ResourceType": "AWS::Lambda::WebFunctionEndpoint" + }, + { + "LogicalResourceId": "MyWebFunctionRevision", + "ResourceType": "AWS::Lambda::WebFunctionRevision" + }, + { + "LogicalResourceId": "MyWebFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/templates/single/basic_web_function.yaml b/integration/resources/templates/single/basic_web_function.yaml new file mode 100644 index 000000000..0af4e52b0 --- /dev/null +++ b/integration/resources/templates/single/basic_web_function.yaml @@ -0,0 +1,9 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_custom_endpoint.yaml b/integration/resources/templates/single/web_function_custom_endpoint.yaml new file mode 100644 index 000000000..8c280f7a5 --- /dev/null +++ b/integration/resources/templates/single/web_function_custom_endpoint.yaml @@ -0,0 +1,10 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged + EndpointName: staging +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_custom_role.yaml b/integration/resources/templates/single/web_function_custom_role.yaml new file mode 100644 index 000000000..5d08525ac --- /dev/null +++ b/integration/resources/templates/single/web_function_custom_role.yaml @@ -0,0 +1,23 @@ +Resources: + CustomRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged + ExecutionRoleArn: !GetAtt CustomRole.Arn +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_endpoint_response.yaml b/integration/resources/templates/single/web_function_endpoint_response.yaml new file mode 100644 index 000000000..2c334e585 --- /dev/null +++ b/integration/resources/templates/single/web_function_endpoint_response.yaml @@ -0,0 +1,13 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged + +Outputs: + Endpoint: + Value: !GetAtt MyWebFunctionEndpoint.DomainName +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_env_vars_response.yaml b/integration/resources/templates/single/web_function_env_vars_response.yaml new file mode 100644 index 000000000..9efa49878 --- /dev/null +++ b/integration/resources/templates/single/web_function_env_vars_response.yaml @@ -0,0 +1,16 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged + EnvironmentVariables: + MY_VAR: my_value + ANOTHER_VAR: another_value + +Outputs: + Endpoint: + Value: !GetAtt MyWebFunctionEndpoint.DomainName +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_iam_auth.yaml b/integration/resources/templates/single/web_function_iam_auth.yaml new file mode 100644 index 000000000..222cd9481 --- /dev/null +++ b/integration/resources/templates/single/web_function_iam_auth.yaml @@ -0,0 +1,9 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: IamAuth +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_timeout_verify.yaml b/integration/resources/templates/single/web_function_timeout_verify.yaml new file mode 100644 index 000000000..a9cdf5561 --- /dev/null +++ b/integration/resources/templates/single/web_function_timeout_verify.yaml @@ -0,0 +1,16 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged + Timeout: 5 + EnvironmentVariables: + TIMEOUT_MS: '0' + +Outputs: + Endpoint: + Value: !GetAtt MyWebFunctionEndpoint.DomainName +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_update_code.yaml b/integration/resources/templates/single/web_function_update_code.yaml new file mode 100644 index 000000000..2c334e585 --- /dev/null +++ b/integration/resources/templates/single/web_function_update_code.yaml @@ -0,0 +1,13 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged + +Outputs: + Endpoint: + Value: !GetAtt MyWebFunctionEndpoint.DomainName +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_update_timeout.yaml b/integration/resources/templates/single/web_function_update_timeout.yaml new file mode 100644 index 000000000..4950c4ad4 --- /dev/null +++ b/integration/resources/templates/single/web_function_update_timeout.yaml @@ -0,0 +1,14 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged + Timeout: 30 + +Outputs: + Endpoint: + Value: !GetAtt MyWebFunctionEndpoint.DomainName +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_with_env_and_timeout.yaml b/integration/resources/templates/single/web_function_with_env_and_timeout.yaml new file mode 100644 index 000000000..8c3c8e89d --- /dev/null +++ b/integration/resources/templates/single/web_function_with_env_and_timeout.yaml @@ -0,0 +1,12 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged + Timeout: 60 + EnvironmentVariables: + MY_VAR: my_value +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/web_function_with_multi_region.yaml b/integration/resources/templates/single/web_function_with_multi_region.yaml new file mode 100644 index 000000000..fd5df62d9 --- /dev/null +++ b/integration/resources/templates/single/web_function_with_multi_region.yaml @@ -0,0 +1,13 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: ${webfunccodeuri} + AuthType: ApplicationManaged + +Outputs: + RevisionId: + Value: !GetAtt MyWebFunctionRevision.RevisionId +Metadata: + SamTransformTest: true diff --git a/integration/setup/companion-stack.yaml b/integration/setup/companion-stack.yaml index 870dd1122..dd0844d35 100644 --- a/integration/setup/companion-stack.yaml +++ b/integration/setup/companion-stack.yaml @@ -58,6 +58,44 @@ Resources: PreCreatedS3Bucket: Type: AWS::S3::Bucket DeletionPolicy: Delete + Properties: + # WebFunction reads code from S3 by version, so versioning must stay enabled. AES256 (SSE-S3) is + # required because an AWS-managed KMS key's policy cannot be modified to grant the Lambda service read. + VersioningConfiguration: + Status: Enabled + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + # Versioning + DeletionPolicy: Delete would otherwise leak the bucket: expire noncurrent versions and + # abort incomplete uploads so leftover versions cannot block the bucket's deletion indefinitely. + LifecycleConfiguration: + Rules: + - Id: ExpireNoncurrentVersions + Status: Enabled + NoncurrentVersionExpiration: + NoncurrentDays: 1 + AbortIncompleteMultipartUpload: + DaysAfterInitiation: 1 + + PreCreatedS3BucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref PreCreatedS3Bucket + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: AllowLambdaServiceAccess + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: + - s3:GetObject + - s3:GetObjectVersion + Resource: !Sub "arn:${AWS::Partition}:s3:::${PreCreatedS3Bucket}/*" + Condition: + StringEquals: + aws:SourceAccount: !Ref AWS::AccountId LMIKMSKey: Type: AWS::KMS::Key diff --git a/integration/single/test_basic_web_function.py b/integration/single/test_basic_web_function.py new file mode 100644 index 000000000..2a41b354f --- /dev/null +++ b/integration/single/test_basic_web_function.py @@ -0,0 +1,147 @@ +import logging +import time +from unittest.case import skipIf + +from parameterized import parameterized + +from integration.config.service_names import WEB_FUNCTION +from integration.helpers.base_test import BaseTest +from integration.helpers.resource import current_region_not_included + +LOG = logging.getLogger(__name__) + +# AWS::Lambda::WebFunction is only available in a subset of regions; only run these deploy-and-invoke +# tests where it is supported (see integration/config/region_service_inclusion.yaml). +_WEB_FUNCTION_UNSUPPORTED = current_region_not_included([WEB_FUNCTION]) + + +@skipIf(_WEB_FUNCTION_UNSUPPORTED, "WebFunction is not supported in this testing region") +class TestBasicWebFunction(BaseTest): + """ + Basic deployment tests for AWS::Serverless::WebFunction + """ + + @parameterized.expand( + [ + "single/basic_web_function", + "single/web_function_with_env_and_timeout", + ] + ) + def test_basic_web_function(self, file_name): + self.create_and_verify_stack(file_name) + + def test_web_function_with_multi_region(self): + # A MultiRegion endpoint requires AutoDeploymentMode: Disabled, which in turn requires RevisionWeights. + # RevisionWeights must reference an existing revision, so this is a two-step flow: first deploy a + # single-region (LatestRevision) function, then update it to MultiRegion routing 100% of traffic to + # the revision the first deploy created. + self.create_and_verify_stack("single/web_function_with_multi_region") + + revision_id = self.get_stack_output("RevisionId")["OutputValue"] + + self.set_template_resource_property("MyWebFunction", "EndpointType", "MultiRegion") + self.set_template_resource_property("MyWebFunction", "AutoDeploymentMode", "Disabled") + self.set_template_resource_property( + "MyWebFunction", "ReplicaRegions", {"IncludeRegions": ["us-east-1", "us-west-2"]} + ) + self.set_template_resource_property("MyWebFunction", "RevisionWeights", {revision_id: 100}) + self.update_stack() + + self.assertEqual(self.get_resource_status_by_logical_id("MyWebFunctionEndpoint"), "UPDATE_COMPLETE") + + +@skipIf(_WEB_FUNCTION_UNSUPPORTED, "WebFunction is not supported in this testing region") +class TestWebFunctionEndpointResponse(BaseTest): + """ + Functional verification tests - verify endpoints respond correctly + """ + + def test_endpoint_returns_200(self): + """Verify the endpoint responds with 200 and correct body""" + self.create_and_verify_stack("single/web_function_endpoint_response") + + endpoint = self.get_stack_output("Endpoint")["OutputValue"] + url = f"https://{endpoint}" + + # Give endpoint time to become active + time.sleep(5) + response = self.verify_get_request_response(url, 200) + self.assertEqual(response.text, "hello") + + def test_env_vars_in_response(self): + """Verify environment variables are accessible in the function""" + self.create_and_verify_stack("single/web_function_env_vars_response") + + endpoint = self.get_stack_output("Endpoint")["OutputValue"] + url = f"https://{endpoint}/env" + + time.sleep(5) + response = self.verify_get_request_response(url, 200) + env_vars = response.json() + self.assertEqual(env_vars.get("MY_VAR"), "my_value") + self.assertEqual(env_vars.get("ANOTHER_VAR"), "another_value") + + def test_timeout_allows_fast_request(self): + """Verify a request that completes within timeout succeeds""" + self.create_and_verify_stack("single/web_function_timeout_verify") + + endpoint = self.get_stack_output("Endpoint")["OutputValue"] + url = f"https://{endpoint}/timeout" + + time.sleep(5) + response = self.verify_get_request_response(url, 200) + self.assertEqual(response.text, "completed") + + +@skipIf(_WEB_FUNCTION_UNSUPPORTED, "WebFunction is not supported in this testing region") +class TestWebFunctionConfigurations(BaseTest): + """ + Configuration variation tests + """ + + def test_custom_execution_role(self): + """Verify WebFunction works with an explicit ExecutionRoleArn (no auto-generated role)""" + self.create_and_verify_stack("single/web_function_custom_role") + + def test_custom_endpoint_name(self): + """Verify WebFunction with a custom EndpointName""" + self.create_and_verify_stack("single/web_function_custom_endpoint") + + def test_iam_auth(self): + """Verify WebFunction with AuthType: IamAuth deploys successfully""" + self.create_and_verify_stack("single/web_function_iam_auth") + + +@skipIf(_WEB_FUNCTION_UNSUPPORTED, "WebFunction is not supported in this testing region") +class TestWebFunctionUpdates(BaseTest): + """ + Update scenario tests + """ + + def test_update_timeout(self): + """Verify updating Timeout creates a new revision""" + self.create_and_verify_stack("single/web_function_update_timeout") + + # Update timeout from 30 to 120 + self.set_template_resource_property("MyWebFunction", "Timeout", 120) + self.update_stack() + self.assertEqual(self.get_resource_status_by_logical_id("MyWebFunctionRevision"), "UPDATE_COMPLETE") + + def test_update_code(self): + """Verify updating CodeUri deploys new code""" + self.create_and_verify_stack("single/web_function_update_code") + + endpoint = self.get_stack_output("Endpoint")["OutputValue"] + url = f"https://{endpoint}" + + time.sleep(5) + response = self.verify_get_request_response(url, 200) + self.assertEqual(response.text, "hello") + + # Update to new code + self.set_template_resource_property("MyWebFunction", "CodeUri", self.get_code_key_s3_uri("webfunccodeuri2")) + self.update_stack() + + time.sleep(5) + response = self.verify_get_request_response(url, 200) + self.assertEqual(response.text, "updated") diff --git a/samtranslator/internal/schema_source/aws_serverless_webfunction.py b/samtranslator/internal/schema_source/aws_serverless_webfunction.py new file mode 100644 index 000000000..14ce85245 --- /dev/null +++ b/samtranslator/internal/schema_source/aws_serverless_webfunction.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any, Literal + +from samtranslator.internal.schema_source.common import ( + BaseModel, + ResourceAttributes, + SamIntrinsicable, +) + + +class Properties(BaseModel): + Runtime: SamIntrinsicable[str] + CodeUri: Any | None = None + FunctionName: SamIntrinsicable[str] | None = None + Timeout: SamIntrinsicable[int] | None = None + EnvironmentVariables: dict[str, Any] | None = None + ExecutionRoleArn: Any | None = None + EndpointName: SamIntrinsicable[str] | None = None + EndpointType: SamIntrinsicable[str] | None = None + EndpointDescription: SamIntrinsicable[str] | None = None + AuthType: SamIntrinsicable[str] + AutoDeploymentMode: SamIntrinsicable[str] | None = None + RevisionWeights: dict[str, Any] | None = None + ReplicaRegions: Any | None = None + RevisionDescription: SamIntrinsicable[str] | None = None + KmsKeyArn: SamIntrinsicable[str] | None = None + LoggingConfig: dict[str, Any] | None = None + ScalingConfig: dict[str, Any] | None = None + ThrottleConfig: dict[str, Any] | None = None + MaxConcurrencyPerEnvironment: SamIntrinsicable[int] | None = None + EntryPoint: SamIntrinsicable[str] | None = None + InlineCode: str | None = None + Tags: dict[str, Any] | None = None + + +class Resource(ResourceAttributes): + Type: Literal["AWS::Serverless::WebFunction"] + Properties: Properties diff --git a/samtranslator/internal/schema_source/schema.py b/samtranslator/internal/schema_source/schema.py index cdf8a14b4..cc3db2d17 100644 --- a/samtranslator/internal/schema_source/schema.py +++ b/samtranslator/internal/schema_source/schema.py @@ -22,6 +22,7 @@ aws_serverless_networkconnector, aws_serverless_simpletable, aws_serverless_statemachine, + aws_serverless_webfunction, aws_serverless_websocketapi, ) from samtranslator.internal.schema_source.common import BaseModel, LenientBaseModel @@ -52,6 +53,7 @@ class Globals(BaseModel): aws_serverless_websocketapi.Resource, aws_serverless_application.Resource, aws_serverless_graphqlapi.Resource, + aws_serverless_webfunction.Resource, aws_serverless_capacity_provider.Resource, aws_serverless_microvmimage.Resource, ] diff --git a/samtranslator/model/lambda_.py b/samtranslator/model/lambda_.py index f5e4f2cc8..395768d7e 100644 --- a/samtranslator/model/lambda_.py +++ b/samtranslator/model/lambda_.py @@ -197,3 +197,74 @@ class LambdaUrl(Resource): "Cors": GeneratedProperty(), "InvokeMode": GeneratedProperty(), } + + +class LambdaWebFunction(Resource): + resource_type = "AWS::Lambda::WebFunction" + property_types = { + "FunctionName": GeneratedProperty(), + "Tags": GeneratedProperty(), + } + + FunctionName: Intrinsicable[str] | None + Tags: list[dict[str, Any]] | None + + # Ref on AWS::Lambda::WebFunction resolves to the function ARN, not the name, and FunctionName is a + # create-only (not read-only) property so it is not available via Fn::GetAtt. "name" therefore returns + # the FunctionName value the resource was constructed with — the same source _construct_function_name + # uses — rather than ref(self.logical_id), which would yield the ARN. (type: ignore because the base + # runtime_attrs type is Callable[[Resource], Any] and cannot express this subclass attribute.) + runtime_attrs = { + "name": lambda self: self.FunctionName, # type: ignore[attr-defined] + "arn": lambda self: fnGetAtt(self.logical_id, "FunctionArn"), + } + + +class LambdaWebFunctionRevision(Resource): + resource_type = "AWS::Lambda::WebFunctionRevision" + property_types = { + "FunctionName": GeneratedProperty(), + "Description": GeneratedProperty(), + "KmsKeyArn": GeneratedProperty(), + "BuildConfig": GeneratedProperty(), + "ServiceConfig": GeneratedProperty(), + } + + FunctionName: Intrinsicable[str] | None + Description: Intrinsicable[str] | None + KmsKeyArn: Intrinsicable[str] | None + BuildConfig: dict[str, Any] | None + ServiceConfig: dict[str, Any] | None + + runtime_attrs = { + "revision_id": lambda self: fnGetAtt(self.logical_id, "RevisionId"), + } + + +class LambdaWebFunctionEndpoint(Resource): + resource_type = "AWS::Lambda::WebFunctionEndpoint" + property_types = { + "FunctionName": GeneratedProperty(), + "EndpointName": GeneratedProperty(), + "EndpointType": GeneratedProperty(), + "AuthType": GeneratedProperty(), + "RevisionWeights": GeneratedProperty(), + "Regions": GeneratedProperty(), + "Description": GeneratedProperty(), + "ScalingConfig": GeneratedProperty(), + "ThrottleConfig": GeneratedProperty(), + } + + FunctionName: Intrinsicable[str] | None + EndpointName: Intrinsicable[str] | None + EndpointType: str | None + AuthType: str | None + RevisionWeights: Any | None + Regions: list[Intrinsicable[str]] | None + Description: Intrinsicable[str] | None + ScalingConfig: dict[str, Any] | None + ThrottleConfig: dict[str, Any] | None + + runtime_attrs = { + "domain_name": lambda self: fnGetAtt(self.logical_id, "DomainName"), + } diff --git a/samtranslator/model/region_utils.py b/samtranslator/model/region_utils.py new file mode 100644 index 000000000..54066e542 --- /dev/null +++ b/samtranslator/model/region_utils.py @@ -0,0 +1,23 @@ +from enum import Enum +from itertools import chain + + +class Region(Enum): + US = ["us-east-1", "us-east-2", "us-west-1", "us-west-2"] + AP = ["ap-south-1", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-southeast-1", "ap-southeast-2"] + CA = ["ca-central-1"] + EU = ["eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1"] + SA = ["sa-east-1"] + SPARSE_GLOBAL = [ + "us-east-1", + "eu-west-1", + "eu-central-1", + "ap-northeast-1", + "ap-southeast-2", + "sa-east-1", + "ap-southeast-1", + ] + + @classmethod + def all_standard(cls) -> list[str]: + return list(chain.from_iterable(region.value for region in cls)) diff --git a/samtranslator/model/s3_utils/uri_parser.py b/samtranslator/model/s3_utils/uri_parser.py index 21d5fb8c5..ea874e197 100644 --- a/samtranslator/model/s3_utils/uri_parser.py +++ b/samtranslator/model/s3_utils/uri_parser.py @@ -126,3 +126,48 @@ def construct_s3_location_object( if "StorageMode" in s3_pointer: code["S3ObjectStorageMode"] = s3_pointer["StorageMode"] return code + + +def construct_webfunction_s3_location_object( + location_uri: str | dict[str, Any], logical_id: str, property_name: str +) -> dict[str, Any]: + """Constructs a Lambda Webfunction S3Object from the SAM CodeUri property. + + :param dict or string location_uri: s3 location dict or string + :param string logical_id: logical_id of the resource calling this function + :param string property_name: name of the property which is used as an input to this function. + :returns: a dict containing the S3 Bucket, Key, and VersionId + :rtype: dict + """ + if isinstance(location_uri, dict): + if not location_uri.get("Bucket") or not location_uri.get("Key"): + raise InvalidResourceException( + logical_id, f"'{property_name}' requires Bucket and Key properties to be specified." + ) + s3_pointer = location_uri + + elif isinstance(location_uri, str): + ssm_pattern = r"{{resolve:(ssm|ssm-secure|secretsmanager):[a-zA-Z0-9_.\-/]+(:\d+)?}}" + match = search(ssm_pattern, location_uri) + if match and match.group(0) and "/" in match.group(0): + raise InvalidResourceException( + logical_id, + f"Unsupported dynamic reference detected in '{property_name}'. Please " + "consider using alternative 'FunctionCode' object format.", + ) + + _s3_pointer = parse_s3_uri(location_uri) + if _s3_pointer is None: + raise InvalidResourceException( + logical_id, + f"'{property_name}' is not a valid S3 Uri of the form " + "'s3://bucket/key' with optional versionId query parameter.", + ) + s3_pointer = _s3_pointer + else: + raise InvalidResourceException(logical_id, f"'{property_name}' must be of type dict or string.") + + code = {"Bucket": s3_pointer["Bucket"], "Key": s3_pointer["Key"]} + if "Version" in s3_pointer: + code["VersionId"] = s3_pointer["Version"] + return code diff --git a/samtranslator/model/sam_resources.py b/samtranslator/model/sam_resources.py index 09145282d..e5aa3985e 100644 --- a/samtranslator/model/sam_resources.py +++ b/samtranslator/model/sam_resources.py @@ -115,8 +115,12 @@ LambdaPermission, LambdaUrl, LambdaVersion, + LambdaWebFunction, + LambdaWebFunctionEndpoint, + LambdaWebFunctionRevision, ) from samtranslator.model.preferences.deployment_preference_collection import DeploymentPreferenceCollection +from samtranslator.model.region_utils import Region as RegionEnum from samtranslator.model.resource_policies import ResourcePolicies from samtranslator.model.role_utils import construct_role_for_resource from samtranslator.model.sns import SNSTopic, SNSTopicPolicy @@ -147,7 +151,11 @@ from .microvm_image.generators import MicroVMImageGenerator from .network_connector.generators import NetworkConnectorGenerator from .packagetype import IMAGE, ZIP -from .s3_utils.uri_parser import construct_image_code_object, construct_s3_location_object +from .s3_utils.uri_parser import ( + construct_image_code_object, + construct_s3_location_object, + construct_webfunction_s3_location_object, +) from .tags.resource_tagging import get_tag_list _CONDITION_CHAR_LIMIT = 255 @@ -1742,6 +1750,496 @@ def to_cloudformation(self, **kwargs: Any) -> list[Resource]: return resources +class SamWebFunction(SamResourceMacro): + """ + SAM resource for AWS::Serverless::WebFunction. + Transforms into AWS::Lambda::WebFunction, AWS::Lambda::WebFunctionRevision, + and AWS::Lambda::WebFunctionEndpoint. + """ + + resource_type = "AWS::Serverless::WebFunction" + + property_types = { + "FunctionName": PropertyType(False, one_of(IS_STR, IS_DICT)), + "Tags": PropertyType(False, IS_DICT), + "ReplicaRegions": PropertyType(False, one_of(IS_LIST, IS_DICT)), + "AuthType": PassThroughProperty(False), + "Runtime": PassThroughProperty(False), + "CodeUri": PassThroughProperty(False), + "InlineCode": PassThroughProperty(False), + "RevisionDescription": PassThroughProperty(False), + "KmsKeyArn": PassThroughProperty(False), + "ExecutionRoleArn": PropertyType(False, IS_STR), + "EnvironmentVariables": PropertyType(False, IS_DICT), + "EntryPoint": PassThroughProperty(False), + "MaxConcurrencyPerEnvironment": PassThroughProperty(False), + "Timeout": PropertyType(False, IS_INT), + "LoggingConfig": PassThroughProperty(False), + "RevisionWeights": PassThroughProperty(False), + "EndpointName": PassThroughProperty(False), + "EndpointType": PassThroughProperty(False), + "EndpointDescription": PassThroughProperty(False), + "ScalingConfig": PassThroughProperty(False), + "ThrottleConfig": PassThroughProperty(False), + "AutoDeploymentMode": PassThroughProperty(False), + } + + FunctionName: Intrinsicable[str] | None + Tags: dict[str, Any] | None + ReplicaRegions: Union[list[str], dict[str, list[str]]] | None + AuthType: str | None + Runtime: str | None + CodeUri: Any | None + InlineCode: Any | None + RevisionDescription: Intrinsicable[str] | None + KmsKeyArn: Intrinsicable[str] | None + ExecutionRoleArn: str | None + EnvironmentVariables: dict[str, Any] | None + EntryPoint: Intrinsicable[str] | None + MaxConcurrencyPerEnvironment: Intrinsicable[int] | None + Timeout: int | None + LoggingConfig: dict[str, Any] | None + RevisionWeights: dict[str, Any] | None + EndpointName: str | None + EndpointType: str | None + EndpointDescription: Intrinsicable[str] | None + ScalingConfig: dict[str, Any] | None + ThrottleConfig: dict[str, Any] | None + AutoDeploymentMode: str | None + + def to_cloudformation(self, **kwargs: Any) -> list[Any]: + """Transforms this SAM WebFunction into CloudFormation resources.""" + intrinsics_resolver: IntrinsicsResolver = kwargs["intrinsics_resolver"] + self._validate(intrinsics_resolver) + + resources: list[Any] = [] + + execution_role_arn = self.ExecutionRoleArn + if execution_role_arn is None: + execution_role = self._construct_role() + execution_role_arn = execution_role.get_runtime_attr("arn") + resources.append(execution_role) + + function = self._construct_lambda_webfunction() + resources.append(function) + + revision = self._construct_revision(execution_role_arn) + revision.depends_on = [function.logical_id] + resources.append(revision) + + endpoint = self._construct_endpoint(revision) + endpoint.depends_on = [function.logical_id, revision.logical_id] + resources.append(endpoint) + + return resources + + def _validate(self, intrinsics_resolver: IntrinsicsResolver) -> None: # noqa: PLR0912 + valid_auto_deploy_modes = ("LatestRevision", "Disabled") + + # AutoDeploymentMode is a SAM-only property enforced entirely at transform time and never passed + # through to CloudFormation. It is mutually exclusive with RevisionWeights: 'LatestRevision' forbids + # them (traffic auto-routes to the latest revision), while HomeRegion 'Disabled' requires them for + # manual routing (otherwise its output would be byte-identical to 'LatestRevision'). Regional endpoint + # types (MultiRegion/PerRegion) are a special case: they mandate 'Disabled' (never 'LatestRevision') but + # may omit RevisionWeights, in which case the endpoint pins 100% to the revision created in this same + # stack -- this enables a single-shot regional create and is not auto-deployment (it does not shift to + # future revisions). The value must be known now: resolve parameter refs, then reject anything that + # remains an unresolved intrinsic since CloudFormation would never see it to validate at deploy time. + auto_deploy = intrinsics_resolver.resolve_parameter_refs(self.AutoDeploymentMode) or "LatestRevision" + if is_intrinsic(auto_deploy): + raise InvalidResourceException( + self.logical_id, + f"'AutoDeploymentMode' must resolve to one of {list(valid_auto_deploy_modes)} at transform time; " + "unresolved intrinsic functions are not supported because it is not passed through to CloudFormation.", + ) + if auto_deploy not in valid_auto_deploy_modes: + raise InvalidResourceException( + self.logical_id, + f"'AutoDeploymentMode' must be one of {list(valid_auto_deploy_modes)}, got '{auto_deploy}'.", + ) + + # AuthType is required and has no service-side default. It is marked required in the generated JSON + # schema (via schema_source) so `sam validate --lint` flags a missing value up front. It is enforced + # here too -- rather than via a required property_type, which would raise a generic "Missing required + # property" message -- so the transform path gives the customer actionable guidance on which value to + # choose. + if not self.AuthType: + raise InvalidResourceException( + self.logical_id, + "'AuthType' is required. Specify 'IamAuth' (Lambda authorizes requests with SigV4/IAM) or " + "'ApplicationManaged' (the endpoint is public and your function handles authorization).", + ) + + # EndpointType is passed through to CloudFormation, so an unresolved intrinsic is legal; resolve it + # only to evaluate the regional-endpoint constraints below, and skip them if it stays intrinsic. + endpoint_type = intrinsics_resolver.resolve_parameter_refs(self.EndpointType) or "HomeRegion" + + # MultiRegion and PerRegion are both regional endpoint types: they serve the replica Regions (MultiRegion + # routes to the nearest; PerRegion exposes a separate endpoint per Region) and share the same deployment + # rules -- they mandate 'Disabled' and support single-shot create when RevisionWeights are omitted. + is_regional = isinstance(endpoint_type, str) and endpoint_type in ("MultiRegion", "PerRegion") + if auto_deploy == "LatestRevision" and self.RevisionWeights: + raise InvalidResourceException( + self.logical_id, + "'RevisionWeights' cannot be specified when 'AutoDeploymentMode' is 'LatestRevision'.", + ) + if is_regional and auto_deploy != "Disabled": + raise InvalidResourceException( + self.logical_id, + f"'AutoDeploymentMode' must be 'Disabled' when 'EndpointType' is '{endpoint_type}'.", + ) + # HomeRegion 'Disabled' means manual routing, so RevisionWeights is required; without it the emitted + # endpoint would be byte-identical to 'LatestRevision', making the mode a no-op. Regional endpoints + # (MultiRegion/PerRegion) are exempt: they mandate 'Disabled' and, when weights are omitted, pin 100% to + # the revision created in this stack (see _construct_endpoint), enabling a single-shot regional create. + if auto_deploy == "Disabled" and not is_regional and not self.RevisionWeights: + raise InvalidResourceException( + self.logical_id, + "'RevisionWeights' must be specified when 'AutoDeploymentMode' is 'Disabled'. Set " + "'AutoDeploymentMode' to 'LatestRevision' to route all traffic to the latest revision.", + ) + + # Validate Timeout (3-900 seconds); skip for intrinsic functions (dicts) + _MIN_TIMEOUT = 3 + _MAX_TIMEOUT = 900 + timeout = self.Timeout + if isinstance(timeout, int) and (timeout < _MIN_TIMEOUT or timeout > _MAX_TIMEOUT): + raise InvalidResourceException( + self.logical_id, + f"'Timeout' must be an integer between {_MIN_TIMEOUT} and {_MAX_TIMEOUT}.", + ) + + # Validate RevisionWeights (max 2 entries, sum to 100); skip for intrinsic values + _MAX_REVISION_ENTRIES = 2 + _REQUIRED_WEIGHT_SUM = 100 + if self.RevisionWeights: + if not isinstance(self.RevisionWeights, dict): + raise InvalidResourceException( + self.logical_id, + "'RevisionWeights' must be a map of revision IDs to weights (e.g., {\"rev-id\": 100}).", + ) + # A top-level intrinsic (e.g. Fn::If) cannot be supported: the SAM property is a map of revision + # IDs to weights, but the endpoint expects a list of {RevisionId, Weight} objects. Passing the + # intrinsic straight through would emit the map shape to CloudFormation and fail at deploy time. + # Reject it, the same way top-level intrinsics for IncludeRegions/ExcludeRegions are rejected. + if is_intrinsic(self.RevisionWeights): + raise InvalidResourceException( + self.logical_id, + "'RevisionWeights' does not support top-level intrinsic functions; provide a map of " + 'revision IDs to weights (e.g., {"rev-id": 100}). Intrinsic functions may be used for ' + "individual weight values.", + ) + _MIN_WEIGHT = 1 + _MAX_WEIGHT = 100 + weights = list(self.RevisionWeights.values()) + # Reject values that are neither int nor intrinsic, and literal ints outside the service's + # [1, 100] range (a 0 weight passes the sum check but violates the schema at stack creation). + for w in weights: + if not isinstance(w, int) and not is_intrinsic(w): + raise InvalidResourceException( + self.logical_id, + "'RevisionWeights' values must be integers or intrinsic functions.", + ) + if isinstance(w, int) and not (_MIN_WEIGHT <= w <= _MAX_WEIGHT): + raise InvalidResourceException( + self.logical_id, + f"'RevisionWeights' values must be between {_MIN_WEIGHT} and {_MAX_WEIGHT}, got {w}.", + ) + # Max entries applies regardless of value types + if len(self.RevisionWeights) > _MAX_REVISION_ENTRIES: + raise InvalidResourceException( + self.logical_id, + f"'RevisionWeights' supports a maximum of {_MAX_REVISION_ENTRIES} entries.", + ) + # Sum check only when all values are literal ints + if all(isinstance(w, int) for w in weights): + total_weight = sum(weights) + if total_weight != _REQUIRED_WEIGHT_SUM: + raise InvalidResourceException( + self.logical_id, + f"'RevisionWeights' values must sum to {_REQUIRED_WEIGHT_SUM}, got {total_weight}.", + ) + + # Validate EnvironmentVariables keys and values; skip if top-level intrinsic + _MAX_ENV_VALUE_LENGTH = 4096 + if self.EnvironmentVariables and not is_intrinsic(self.EnvironmentVariables): + for key, value in self.EnvironmentVariables.items(): + # Match the service key pattern exactly (leading letter required, no leading underscore) so + # invalid keys fail here with a clear message instead of at stack creation. + if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", key): + raise InvalidResourceException( + self.logical_id, + f"Environment variable key '{key}' must match pattern '^[a-zA-Z][a-zA-Z0-9_]*$'.", + ) + if isinstance(value, str) and not value: + raise InvalidResourceException( + self.logical_id, + f"Environment variable '{key}' value must not be empty.", + ) + if isinstance(value, str) and len(value) > _MAX_ENV_VALUE_LENGTH: + raise InvalidResourceException( + self.logical_id, + f"Environment variable '{key}' value exceeds maximum length of {_MAX_ENV_VALUE_LENGTH} characters.", + ) + + def _construct_function_name(self) -> Any: + """Return the function name shared by the WebFunction, its Revision, and its Endpoint. + + Ref on AWS::Lambda::WebFunction resolves to the function ARN, not the name, so the Revision and + Endpoint must reuse this exact value rather than Ref the function. Referencing it with Ref would set + their FunctionName to the ARN, which the service rejects (the endpoint/revision then cannot be + deleted: the stored ARN does not match the name parsed from it). + """ + if self.FunctionName: + return self.FunctionName + # Generate a name bounded by construction to the 64-char AWS::Lambda::WebFunction FunctionName + # limit (the standard Lambda function-name limit; 256 is the ARN limit, not the name limit). + # + # ${AWS::StackName} can be up to 128 chars and resolves only at deploy time, so it cannot be + # truncated with intrinsics and alone would exceed the limit -- it is therefore omitted. Uniqueness + # instead comes from the logical ID (unique within a stack) plus a fixed-width 8-char segment of the + # stack GUID (unique across stacks), mirroring how CloudFormation's own auto-naming appends a short + # uniquifier. Worst case: 55 (logical ID) + 1 (separator) + 8 (suffix) = 64. + _MAX_FUNCTION_NAME_LENGTH = 64 + _STACK_ID_SUFFIX_LENGTH = 8 + _SEPARATOR_LENGTH = 1 + logical_id_budget = _MAX_FUNCTION_NAME_LENGTH - _STACK_ID_SUFFIX_LENGTH - _SEPARATOR_LENGTH + bounded_logical_id = self.logical_id[:logical_id_budget] + return { + "Fn::Sub": [ + bounded_logical_id + "-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + {"Fn::Select": [2, {"Fn::Split": ["/", {"Ref": "AWS::StackId"}]}]}, + ] + }, + ] + }, + }, + ] + } + + def _construct_lambda_webfunction(self) -> LambdaWebFunction: + function = LambdaWebFunction(self.logical_id, depends_on=self.depends_on, attributes=self.resource_attributes) + function.FunctionName = self._construct_function_name() + function.Tags = self._construct_tag_list(self.Tags) + return function + + def _construct_revision(self, execution_role_arn: Any) -> LambdaWebFunctionRevision: + revision = LambdaWebFunctionRevision( + f"{self.logical_id}Revision", attributes=self.get_passthrough_resource_attributes() + ) + revision.FunctionName = self._construct_function_name() + revision.Description = self.RevisionDescription + revision.KmsKeyArn = self.KmsKeyArn + revision.BuildConfig = self._construct_build_config() + revision.ServiceConfig = self._construct_service_config(execution_role_arn) + return revision + + def _construct_endpoint(self, revision: LambdaWebFunctionRevision) -> LambdaWebFunctionEndpoint: + endpoint = LambdaWebFunctionEndpoint( + f"{self.logical_id}Endpoint", attributes=self.get_passthrough_resource_attributes() + ) + endpoint.FunctionName = self._construct_function_name() + endpoint.EndpointName = self.EndpointName or "prod" + endpoint.EndpointType = self.EndpointType or "HomeRegion" + # AuthType is required and has no default (validated in _validate); pass the customer's value through. + endpoint.AuthType = self.AuthType + + if self.RevisionWeights: + # _validate has already rejected a top-level intrinsic, so RevisionWeights is a literal map here + # (individual weight values may still be intrinsics, which pass through unchanged). + endpoint.RevisionWeights = [ + {"RevisionId": rev_id, "Weight": weight} for rev_id, weight in self.RevisionWeights.items() + ] + else: + # Reached for 'LatestRevision' and for regional (MultiRegion/PerRegion) 'Disabled' with no explicit + # weights: route 100% to the revision this deployment creates (via GetAtt, so it resolves on a + # single-shot create). (HomeRegion 'Disabled' without weights is rejected in _validate.) The service + # requires RevisionWeights to always be present on the endpoint. + endpoint.RevisionWeights = [{"RevisionId": revision.get_runtime_attr("revision_id"), "Weight": 100}] + + endpoint.Regions = self._construct_region_list() + endpoint.Description = self.EndpointDescription + endpoint.ScalingConfig = self.ScalingConfig + endpoint.ThrottleConfig = self.ThrottleConfig + return endpoint + + def _construct_build_config(self) -> dict[str, Any]: + build_config: dict[str, Any] = {"CodeConfig": {}} + build_config["CodeConfig"]["S3Object"] = self._construct_code_config() + if self.Runtime is None: + raise InvalidResourceException(self.logical_id, "'Runtime' is required.") + build_config["RuntimeConfig"] = {"Runtime": self.Runtime} + return build_config + + def _construct_code_config(self) -> dict[str, Any]: + if self.CodeUri is not None and self.InlineCode is not None: + raise InvalidResourceException(self.logical_id, "Only one of 'InlineCode' or 'CodeUri' can be set.") + if self.CodeUri: + return construct_webfunction_s3_location_object(self.CodeUri, self.logical_id, "CodeUri") + if self.InlineCode: + raise InvalidResourceException( + self.logical_id, + "'InlineCode' is not yet supported for 'AWS::Serverless::WebFunction'. Please use 'CodeUri' instead.", + ) + raise InvalidResourceException(self.logical_id, "Either 'InlineCode' or 'CodeUri' must be set.") + + def _construct_service_config(self, execution_role_arn: Any) -> dict[str, Any]: + service_config: dict[str, Any] = {"ExecutionRoleArn": execution_role_arn} + + if self.EnvironmentVariables and is_intrinsic(self.EnvironmentVariables): + if self.EntryPoint: + raise InvalidResourceException( + self.logical_id, + "'EntryPoint' cannot be used when 'EnvironmentVariables' is an intrinsic function.", + ) + service_config["EnvironmentVariables"] = self.EnvironmentVariables + else: + env_vars = dict(self.EnvironmentVariables or {}) + # Stringify scalar values (int, bool, float) — CFN requires string env var values + for k, v in env_vars.items(): + if isinstance(v, bool): + env_vars[k] = "true" if v else "false" + elif isinstance(v, (int, float)): + env_vars[k] = str(v) + if self.EntryPoint: + env_vars["AWS_LAMBDA_NODEJS_ENTRYPOINT"] = self.EntryPoint + if env_vars: + service_config["EnvironmentVariables"] = env_vars + + if self.LoggingConfig: + service_config.setdefault("TelemetryConfig", {})["LoggingConfig"] = self.LoggingConfig + + if self.MaxConcurrencyPerEnvironment is not None: + service_config["MaxConcurrencyPerEnvironment"] = self.MaxConcurrencyPerEnvironment + + service_config["TimeoutSeconds"] = self.Timeout or 30 + + return service_config + + def _construct_region_list(self) -> list[Intrinsicable[str]] | None: + replica = self.ReplicaRegions + if replica is None: + return None + # If the entire value is an intrinsic, pass through unchanged + if is_intrinsic(replica): + return cast(list[Intrinsicable[str]], replica) + if isinstance(replica, list): + # Expand string entries (region groups/codes), keep intrinsics as-is + string_entries = [r for r in replica if isinstance(r, str)] + intrinsic_entries = [r for r in replica if not isinstance(r, str)] + expanded = sorted(self._expand_regions(string_entries)) if string_entries else [] + return cast(list[Intrinsicable[str]], expanded + intrinsic_entries) + + include_regions = replica.get("IncludeRegions") or [] + exclude_regions = replica.get("ExcludeRegions") or [] + + # Reject unrecognized keys + valid_keys = {"IncludeRegions", "ExcludeRegions"} + unknown_keys = set(replica.keys()) - valid_keys + if unknown_keys: + raise InvalidResourceException( + self.logical_id, + f"'ReplicaRegions' contains unrecognized keys: {sorted(unknown_keys)}. Valid keys are: IncludeRegions, ExcludeRegions.", + ) + + # ExcludeRegions is only meaningful as a filter over an explicit inclusion set. Without IncludeRegions + # the result silently resolves to an empty region list (an endpoint with no replicas) instead of an + # error, so require IncludeRegions to be provided alongside ExcludeRegions. + if exclude_regions and not include_regions: + raise InvalidResourceException( + self.logical_id, + "'ExcludeRegions' requires 'IncludeRegions' to also be specified in 'ReplicaRegions'.", + ) + + # If either list is a top-level intrinsic, raise — can't meaningfully process include/exclude with intrinsics + if is_intrinsic(include_regions) or is_intrinsic(exclude_regions): + raise InvalidResourceException( + self.logical_id, + "'IncludeRegions' and 'ExcludeRegions' do not support top-level intrinsic functions.", + ) + + # Separate string entries from intrinsics in both lists + include_strings = [r for r in include_regions if isinstance(r, str)] + include_intrinsics = [r for r in include_regions if not isinstance(r, str)] + exclude_strings = [r for r in exclude_regions if isinstance(r, str)] + exclude_intrinsics = [r for r in exclude_regions if not isinstance(r, str)] + + # Intrinsics in ExcludeRegions can't be resolved at transform time — reject + if exclude_intrinsics: + raise InvalidResourceException( + self.logical_id, + "'ExcludeRegions' does not support intrinsic functions in entries.", + ) + + # Exclusions cannot be applied to intrinsic IncludeRegions entries: the intrinsic resolves only at + # deploy time and could resolve to an excluded region, silently defeating the exclusion. Reject the + # combination, consistent with rejecting intrinsic ExcludeRegions entries above. + if exclude_strings and include_intrinsics: + raise InvalidResourceException( + self.logical_id, + "'ExcludeRegions' cannot be used when 'IncludeRegions' contains intrinsic functions, because " + "the exclusion cannot be applied to an intrinsic value at transform time.", + ) + + included = set(self._expand_regions(include_strings)) if include_strings else set() + excluded = set(self._expand_regions(exclude_strings)) if exclude_strings else set() + + return cast(list[Intrinsicable[str]], sorted(included - excluded) + include_intrinsics) + + def _expand_regions(self, region_keys: list[str]) -> list[str]: + REGION_GROUPS: dict[str, tuple[str, ...]] = { + key: tuple(member.value) for key, member in RegionEnum.__members__.items() + } + + regions: set[str] = set() + stack: list[str] = [str(k) for k in region_keys] + + while stack: + key = stack.pop() + key_upper = key.upper() + + if key_upper == "ALL_STANDARD": + regions.update(RegionEnum.all_standard()) + continue + + if key_upper in REGION_GROUPS: + stack.extend(REGION_GROUPS[key_upper]) + continue + + if "-" in key: + regions.add(key) + continue + + raise InvalidResourceException(self.logical_id, f"Unknown region key or code: '{key}'.") + + return list(regions) + + def _construct_role(self) -> IAMRole: + assume_role_policy_document = IAMRolePolicies.lambda_assume_role_policy() + managed_policy_arns = [ArnGenerator.generate_aws_managed_policy_arn("service-role/AWSLambdaBasicExecutionRole")] + + return construct_role_for_resource( + resource_logical_id=self.logical_id, + attributes=self.get_passthrough_resource_attributes(), + managed_policy_map={}, + assume_role_policy_document=assume_role_policy_document, + resource_policies=ResourcePolicies({"Policies": None}, policy_template_processor=None), + managed_policy_arns=managed_policy_arns, + policy_documents=[], + role_path=None, + permissions_boundary=None, + tags=self._construct_tag_list(self.Tags), + get_managed_policy_map=None, + ) + + class SamApi(SamResourceMacro): """SAM rest API macro.""" diff --git a/samtranslator/schema/schema.json b/samtranslator/schema/schema.json index ebf3cceb8..3679e2f17 100644 --- a/samtranslator/schema/schema.json +++ b/samtranslator/schema/schema.json @@ -415248,6 +415248,236 @@ "title": "ScheduleV2EventProperties", "type": "object" }, + "samtranslator__internal__schema_source__aws_serverless_webfunction__Properties": { + "additionalProperties": false, + "properties": { + "AuthType": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Authtype" + }, + "AutoDeploymentMode": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Autodeploymentmode" + }, + "CodeUri": { + "title": "Codeuri" + }, + "EndpointDescription": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Endpointdescription" + }, + "EndpointName": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Endpointname" + }, + "EndpointType": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Endpointtype" + }, + "EntryPoint": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Entrypoint" + }, + "EnvironmentVariables": { + "title": "Environmentvariables", + "type": "object" + }, + "ExecutionRoleArn": { + "title": "Executionrolearn" + }, + "FunctionName": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Functionname" + }, + "InlineCode": { + "title": "Inlinecode", + "type": "string" + }, + "KmsKeyArn": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Kmskeyarn" + }, + "LoggingConfig": { + "title": "Loggingconfig", + "type": "object" + }, + "MaxConcurrencyPerEnvironment": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "integer" + } + ], + "title": "Maxconcurrencyperenvironment" + }, + "ReplicaRegions": { + "title": "Replicaregions" + }, + "RevisionDescription": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Revisiondescription" + }, + "RevisionWeights": { + "title": "Revisionweights", + "type": "object" + }, + "Runtime": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Runtime" + }, + "ScalingConfig": { + "title": "Scalingconfig", + "type": "object" + }, + "Tags": { + "title": "Tags", + "type": "object" + }, + "ThrottleConfig": { + "title": "Throttleconfig", + "type": "object" + }, + "Timeout": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "integer" + } + ], + "title": "Timeout" + } + }, + "required": [ + "Runtime", + "AuthType" + ], + "title": "Properties", + "type": "object" + }, + "samtranslator__internal__schema_source__aws_serverless_webfunction__Resource": { + "additionalProperties": false, + "properties": { + "Condition": { + "$ref": "#/definitions/PassThroughProp" + }, + "DeletionPolicy": { + "$ref": "#/definitions/PassThroughProp" + }, + "DependsOn": { + "$ref": "#/definitions/PassThroughProp" + }, + "IgnoreGlobals": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "title": "Ignoreglobals" + }, + "Metadata": { + "$ref": "#/definitions/PassThroughProp" + }, + "Properties": { + "$ref": "#/definitions/samtranslator__internal__schema_source__aws_serverless_webfunction__Properties" + }, + "Type": { + "enum": [ + "AWS::Serverless::WebFunction" + ], + "title": "Type", + "type": "string" + }, + "UpdateReplacePolicy": { + "$ref": "#/definitions/PassThroughProp" + } + }, + "required": [ + "Type", + "Properties" + ], + "title": "Resource", + "type": "object" + }, "samtranslator__internal__schema_source__aws_serverless_websocketapi__Domain": { "additionalProperties": false, "properties": { @@ -415770,6 +416000,9 @@ { "$ref": "#/definitions/samtranslator__internal__schema_source__aws_serverless_graphqlapi__Resource" }, + { + "$ref": "#/definitions/samtranslator__internal__schema_source__aws_serverless_webfunction__Resource" + }, { "$ref": "#/definitions/samtranslator__internal__schema_source__aws_serverless_capacity_provider__Resource" }, diff --git a/samtranslator/sdk/resource.py b/samtranslator/sdk/resource.py index d61f2a05a..c8f075246 100644 --- a/samtranslator/sdk/resource.py +++ b/samtranslator/sdk/resource.py @@ -64,6 +64,7 @@ class SamResourceType(Enum): Api = "AWS::Serverless::Api" Function = "AWS::Serverless::Function" + WebFunction = "AWS::Serverless::WebFunction" SimpleTable = "AWS::Serverless::SimpleTable" Application = "AWS::Serverless::Application" LambdaLayerVersion = "AWS::Serverless::LayerVersion" diff --git a/samtranslator/translator/translator.py b/samtranslator/translator/translator.py index 2aeadbe2c..9f2a4ad51 100644 --- a/samtranslator/translator/translator.py +++ b/samtranslator/translator/translator.py @@ -268,6 +268,7 @@ def _get_resources_to_iterate( """ functions = [] + webfunctions = [] statemachines = [] apis = [] others = [] @@ -282,6 +283,8 @@ def _get_resources_to_iterate( continue if resource["Type"] == "AWS::Serverless::Function": functions.append(data) + elif resource["Type"] == "AWS::Serverless::WebFunction": + webfunctions.append(data) elif resource["Type"] == "AWS::Serverless::StateMachine": statemachines.append(data) elif resource["Type"] in ( @@ -295,7 +298,7 @@ def _get_resources_to_iterate( else: others.append(data) - return functions + statemachines + apis + others + connectors + return functions + webfunctions + statemachines + apis + others + connectors @staticmethod def _update_resources(connectors_list: list[Resource]) -> dict[str, Any]: diff --git a/samtranslator/translator/verify_logical_id.py b/samtranslator/translator/verify_logical_id.py index 7da2c7911..751e5d08b 100644 --- a/samtranslator/translator/verify_logical_id.py +++ b/samtranslator/translator/verify_logical_id.py @@ -5,6 +5,7 @@ do_not_verify = { # type_after_transform: type_before_transform "AWS::Lambda::Function": "AWS::Serverless::Function", + "AWS::Lambda::WebFunction": "AWS::Serverless::WebFunction", "AWS::Lambda::LayerVersion": "AWS::Serverless::LayerVersion", "AWS::Lambda::CapacityProvider": "AWS::Serverless::CapacityProvider", "AWS::Lambda::MicrovmImage": "AWS::Serverless::MicrovmImage", diff --git a/samtranslator/validator/sam_schema/schema_new.json b/samtranslator/validator/sam_schema/schema_new.json index 65bca0b8b..64ec95fb9 100644 --- a/samtranslator/validator/sam_schema/schema_new.json +++ b/samtranslator/validator/sam_schema/schema_new.json @@ -106,6 +106,7 @@ "AWS::Serverless::Api", "AWS::Serverless::Application", "AWS::Serverless::Function", + "AWS::Serverless::WebFunction", "AWS::Serverless::HttpApi", "AWS::Serverless::WebSocketApi", "AWS::Serverless::LayerVersion", diff --git a/schema_source/sam.schema.json b/schema_source/sam.schema.json index cc1b29f7a..a892b9862 100644 --- a/schema_source/sam.schema.json +++ b/schema_source/sam.schema.json @@ -11210,6 +11210,236 @@ "title": "ScheduleV2EventProperties", "type": "object" }, + "samtranslator__internal__schema_source__aws_serverless_webfunction__Properties": { + "additionalProperties": false, + "properties": { + "AuthType": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Authtype" + }, + "AutoDeploymentMode": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Autodeploymentmode" + }, + "CodeUri": { + "title": "Codeuri" + }, + "EndpointDescription": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Endpointdescription" + }, + "EndpointName": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Endpointname" + }, + "EndpointType": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Endpointtype" + }, + "EntryPoint": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Entrypoint" + }, + "EnvironmentVariables": { + "title": "Environmentvariables", + "type": "object" + }, + "ExecutionRoleArn": { + "title": "Executionrolearn" + }, + "FunctionName": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Functionname" + }, + "InlineCode": { + "title": "Inlinecode", + "type": "string" + }, + "KmsKeyArn": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Kmskeyarn" + }, + "LoggingConfig": { + "title": "Loggingconfig", + "type": "object" + }, + "MaxConcurrencyPerEnvironment": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "integer" + } + ], + "title": "Maxconcurrencyperenvironment" + }, + "ReplicaRegions": { + "title": "Replicaregions" + }, + "RevisionDescription": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Revisiondescription" + }, + "RevisionWeights": { + "title": "Revisionweights", + "type": "object" + }, + "Runtime": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Runtime" + }, + "ScalingConfig": { + "title": "Scalingconfig", + "type": "object" + }, + "Tags": { + "title": "Tags", + "type": "object" + }, + "ThrottleConfig": { + "title": "Throttleconfig", + "type": "object" + }, + "Timeout": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "integer" + } + ], + "title": "Timeout" + } + }, + "required": [ + "Runtime", + "AuthType" + ], + "title": "Properties", + "type": "object" + }, + "samtranslator__internal__schema_source__aws_serverless_webfunction__Resource": { + "additionalProperties": false, + "properties": { + "Condition": { + "$ref": "#/definitions/PassThroughProp" + }, + "DeletionPolicy": { + "$ref": "#/definitions/PassThroughProp" + }, + "DependsOn": { + "$ref": "#/definitions/PassThroughProp" + }, + "IgnoreGlobals": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "title": "Ignoreglobals" + }, + "Metadata": { + "$ref": "#/definitions/PassThroughProp" + }, + "Properties": { + "$ref": "#/definitions/samtranslator__internal__schema_source__aws_serverless_webfunction__Properties" + }, + "Type": { + "enum": [ + "AWS::Serverless::WebFunction" + ], + "title": "Type", + "type": "string" + }, + "UpdateReplacePolicy": { + "$ref": "#/definitions/PassThroughProp" + } + }, + "required": [ + "Type", + "Properties" + ], + "title": "Resource", + "type": "object" + }, "samtranslator__internal__schema_source__aws_serverless_websocketapi__Domain": { "additionalProperties": false, "properties": { @@ -11679,6 +11909,9 @@ { "$ref": "#/definitions/samtranslator__internal__schema_source__aws_serverless_graphqlapi__Resource" }, + { + "$ref": "#/definitions/samtranslator__internal__schema_source__aws_serverless_webfunction__Resource" + }, { "$ref": "#/definitions/samtranslator__internal__schema_source__aws_serverless_capacity_provider__Resource" }, diff --git a/tests/model/test_sam_resources.py b/tests/model/test_sam_resources.py index a51d9c5f9..2e56451bf 100644 --- a/tests/model/test_sam_resources.py +++ b/tests/model/test_sam_resources.py @@ -8,7 +8,16 @@ from samtranslator.model.apigateway import ApiGatewayDeployment, ApiGatewayRestApi, ApiGatewayStage from samtranslator.model.apigatewayv2 import ApiGatewayV2HttpApi from samtranslator.model.iam import IAMRole -from samtranslator.model.lambda_ import LambdaFunction, LambdaLayerVersion, LambdaPermission, LambdaUrl, LambdaVersion +from samtranslator.model.lambda_ import ( + LambdaFunction, + LambdaLayerVersion, + LambdaPermission, + LambdaUrl, + LambdaVersion, + LambdaWebFunction, + LambdaWebFunctionEndpoint, + LambdaWebFunctionRevision, +) from samtranslator.model.packagetype import IMAGE, ZIP from samtranslator.model.sam_resources import ( SamApi, @@ -18,6 +27,7 @@ SamGraphQLApi, SamHttpApi, SamLayerVersion, + SamWebFunction, ) @@ -233,7 +243,7 @@ def test_with_autopublish_bad_hash(self): def test_with_autopublish_good_hash(self): function = SamFunction("foo") - function.Runtime = "foo" + function.Runtime = "foobar" function.Handler = "bar" function.CodeUri = "s3://foobar/foo.zip" function.AutoPublishAlias = "live" @@ -243,6 +253,753 @@ def test_with_autopublish_good_hash(self): function.to_cloudformation(**self.kwargs) +class TestWebFunctionBuildConfig(TestCase): + kwargs = { + "intrinsics_resolver": IntrinsicsResolver({}), + "event_resources": [], + "managed_policy_map": {"foo": "bar"}, + "resource_resolver": ResourceResolver({}), + } + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_with_code_uri(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + cfnResources = function.to_cloudformation(**self.kwargs) + revisions = [x for x in cfnResources if isinstance(x, LambdaWebFunctionRevision)] + self.assertEqual(len(revisions), 1) + self.assertEqual(revisions[0].BuildConfig["CodeConfig"]["S3Object"], {"Key": "foo.zip", "Bucket": "foobar"}) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_with_no_code_uri_or_no_inline(self): + function = SamWebFunction("foo") + function.Runtime = "foobar" + function.AuthType = "IamAuth" + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_with_both_code_uri_and_inline(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.InlineCode = "hello world" + function.AuthType = "IamAuth" + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_with_no_runtime(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.AuthType = "IamAuth" + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + +class TestWebFunctionRegions(TestCase): + kwargs = { + "intrinsics_resolver": IntrinsicsResolver({}), + "event_resources": [], + "managed_policy_map": {"foo": "bar"}, + "resource_resolver": ResourceResolver({}), + } + + def test_with_regions_flatlist(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = ["us-east-1", "us-east-1", "eu-west-1"] + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(len(endpoints), 1) + self.assertCountEqual(endpoints[0].Regions, ["us-east-1", "eu-west-1"]) + + def test_with_flatlist_enum(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = ["US"] + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(len(endpoints), 1) + self.assertCountEqual(endpoints[0].Regions, ["us-east-1", "us-east-2", "us-west-1", "us-west-2"]) + + def test_with_include_exclude_regions(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"IncludeRegions": ["US"], "ExcludeRegions": ["us-east-1"]} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(len(endpoints), 1) + self.assertCountEqual(endpoints[0].Regions, ["us-east-2", "us-west-1", "us-west-2"]) + + def test_with_all_standard(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = ["ALL_STANDARD"] + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(len(endpoints), 1) + self.assertCountEqual( + endpoints[0].Regions, + [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-south-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-southeast-1", + "ap-southeast-2", + "ca-central-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-central-1", + "eu-north-1", + "sa-east-1", + ], + ) + + def test_with_lowercase_enum(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"IncludeRegions": ["us"]} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(len(endpoints), 1) + self.assertCountEqual(endpoints[0].Regions, ["us-east-1", "us-east-2", "us-west-1", "us-west-2"]) + + def test_with_enum_and_individual(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"IncludeRegions": ["ALL_STANDARD", "af-south-1"]} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(len(endpoints), 1) + self.assertCountEqual( + endpoints[0].Regions, + [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-south-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-southeast-1", + "ap-southeast-2", + "ca-central-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-central-1", + "eu-north-1", + "sa-east-1", + "af-south-1", + ], + ) + + def test_with_invalid_region(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = ["USA"] + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + def test_with_only_exclude_regions_raises(self): + # ExcludeRegions without IncludeRegions would silently yield an empty region list; require both. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"ExcludeRegions": ["US"]} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + def test_replica_regions_top_level_intrinsic_passthrough(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"Ref": "RegionList"} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(endpoints[0].Regions, {"Ref": "RegionList"}) + + def test_flatlist_with_intrinsic_entry(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = ["us-east-1", {"Ref": "ExtraRegion"}] + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(endpoints[0].Regions, ["us-east-1", {"Ref": "ExtraRegion"}]) + + def test_include_regions_with_intrinsic_entry(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"IncludeRegions": ["us-east-1", {"Ref": "ExtraRegion"}]} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(endpoints[0].Regions, ["us-east-1", {"Ref": "ExtraRegion"}]) + + def test_include_regions_top_level_intrinsic_raises(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"IncludeRegions": {"Ref": "Regions"}} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + def test_exclude_regions_intrinsic_entry_raises(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"IncludeRegions": ["US"], "ExcludeRegions": [{"Ref": "Skip"}]} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + def test_exclude_with_only_intrinsic_include_raises(self): + # Exclusions cannot be applied to an intrinsic include at transform time. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"IncludeRegions": [{"Ref": "RegionParam"}], "ExcludeRegions": ["us-west-2"]} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + def test_exclude_with_mixed_string_and_intrinsic_include_raises(self): + # Even with a string include present, the intrinsic include portion cannot honor the exclusion. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = { + "IncludeRegions": ["US", {"Ref": "RegionParam"}], + "ExcludeRegions": ["us-west-2"], + } + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + def test_replica_regions_unknown_key_raises(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ReplicaRegions = {"IncludeRegionz": ["US"]} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + +class TestWebFunctionConfigs(TestCase): + kwargs = { + "intrinsics_resolver": IntrinsicsResolver({}), + "event_resources": [], + "managed_policy_map": {"foo": "bar"}, + "resource_resolver": ResourceResolver({}), + } + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_produces_four_resources(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + cfnResources = function.to_cloudformation(**self.kwargs) + self.assertEqual(len([x for x in cfnResources if isinstance(x, IAMRole)]), 1) + self.assertEqual(len([x for x in cfnResources if isinstance(x, LambdaWebFunction)]), 1) + self.assertEqual(len([x for x in cfnResources if isinstance(x, LambdaWebFunctionRevision)]), 1) + self.assertEqual(len([x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)]), 1) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_generated_function_name_uses_logical_id_and_stack_guid(self): + function = SamWebFunction("MyWebFunction") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + cfnResources = function.to_cloudformation(**self.kwargs) + fn = next(x for x in cfnResources if isinstance(x, LambdaWebFunction)) + self.assertEqual(fn.FunctionName["Fn::Sub"][0], "MyWebFunction-${StackIdSuffix}") + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_generated_function_name_bounds_long_logical_id(self): + # FunctionName limit is 64; the 8-char stack GUID suffix plus a separator leave a 55-char budget for + # the logical ID, so the resolved name can never exceed the limit (55 + 1 + 8 = 64). + logical_id = "A" * 200 + function = SamWebFunction(logical_id) + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + cfnResources = function.to_cloudformation(**self.kwargs) + fn = next(x for x in cfnResources if isinstance(x, LambdaWebFunction)) + template = fn.FunctionName["Fn::Sub"][0] + self.assertEqual(template, "A" * 55 + "-${StackIdSuffix}") + # Worst-case resolved length stays within the 64-char FunctionName limit + self.assertLessEqual(55 + 1 + 8, 64) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_generated_function_name_shared_by_all_resources(self): + # Ref on AWS::Lambda::WebFunction returns the ARN, so the revision and endpoint must reuse the same + # generated name value as the function rather than Ref it. + function = SamWebFunction("MyWebFunction") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + cfnResources = function.to_cloudformation(**self.kwargs) + fn = next(x for x in cfnResources if isinstance(x, LambdaWebFunction)) + revision = next(x for x in cfnResources if isinstance(x, LambdaWebFunctionRevision)) + endpoint = next(x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)) + self.assertEqual(revision.FunctionName, fn.FunctionName) + self.assertEqual(endpoint.FunctionName, fn.FunctionName) + # And specifically NOT a bare Ref (which resolves to the ARN) + self.assertNotEqual(revision.FunctionName, {"Ref": "MyWebFunction"}) + self.assertNotEqual(endpoint.FunctionName, {"Ref": "MyWebFunction"}) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_webfunction_name_runtime_attr_is_not_ref(self): + # Ref resolves to the ARN, so get_runtime_attr("name") must return the FunctionName value, not a Ref. + function = SamWebFunction("MyWebFunction") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + cfnResources = function.to_cloudformation(**self.kwargs) + fn = next(x for x in cfnResources if isinstance(x, LambdaWebFunction)) + self.assertEqual(fn.get_runtime_attr("name"), fn.FunctionName) + self.assertNotEqual(fn.get_runtime_attr("name"), {"Ref": "MyWebFunction"}) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_explicit_function_name_shared_by_all_resources(self): + function = SamWebFunction("MyWebFunction") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.FunctionName = "my-explicit-name" + cfnResources = function.to_cloudformation(**self.kwargs) + fn = next(x for x in cfnResources if isinstance(x, LambdaWebFunction)) + revision = next(x for x in cfnResources if isinstance(x, LambdaWebFunctionRevision)) + endpoint = next(x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)) + self.assertEqual(fn.FunctionName, "my-explicit-name") + self.assertEqual(revision.FunctionName, "my-explicit-name") + self.assertEqual(endpoint.FunctionName, "my-explicit-name") + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_default_service_config(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + cfnResources = function.to_cloudformation(**self.kwargs) + revisions = [x for x in cfnResources if isinstance(x, LambdaWebFunctionRevision)] + self.assertEqual(revisions[0].ServiceConfig["TimeoutSeconds"], 30) + self.assertIn("ExecutionRoleArn", revisions[0].ServiceConfig) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_with_custom_timeout(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.Timeout = 120 + cfnResources = function.to_cloudformation(**self.kwargs) + revisions = [x for x in cfnResources if isinstance(x, LambdaWebFunctionRevision)] + self.assertEqual(revisions[0].ServiceConfig["TimeoutSeconds"], 120) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_with_service_config(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.ExecutionRoleArn = "arn:aws:iam::123456789012:role/execution_role" + function.EntryPoint = "app.js" + cfnResources = function.to_cloudformation(**self.kwargs) + revisions = [x for x in cfnResources if isinstance(x, LambdaWebFunctionRevision)] + self.assertEqual(revisions[0].ServiceConfig["EnvironmentVariables"], {"AWS_LAMBDA_NODEJS_ENTRYPOINT": "app.js"}) + self.assertEqual( + revisions[0].ServiceConfig["ExecutionRoleArn"], "arn:aws:iam::123456789012:role/execution_role" + ) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_max_concurrency_per_environment_passthrough(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.MaxConcurrencyPerEnvironment = 100 + cfnResources = function.to_cloudformation(**self.kwargs) + revisions = [x for x in cfnResources if isinstance(x, LambdaWebFunctionRevision)] + self.assertEqual(revisions[0].ServiceConfig["MaxConcurrencyPerEnvironment"], 100) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_endpoint_scaling_throttle_and_description_passthrough(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EndpointDescription = "prod endpoint" + function.ScalingConfig = {"MaxEnvironments": 50} + function.ThrottleConfig = {"RateLimit": 5000} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(endpoints[0].Description, "prod endpoint") + self.assertEqual(endpoints[0].ScalingConfig, {"MaxEnvironments": 50}) + self.assertEqual(endpoints[0].ThrottleConfig, {"RateLimit": 5000}) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_endpoint_defaults(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(endpoints[0].EndpointName, "prod") + self.assertEqual(endpoints[0].EndpointType, "HomeRegion") + self.assertEqual(endpoints[0].AuthType, "IamAuth") + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_endpoint_custom_values(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.EndpointName = "staging" + function.EndpointType = "MultiRegion" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = "Disabled" + function.RevisionWeights = {"rev-abc": 100} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(endpoints[0].EndpointName, "staging") + self.assertEqual(endpoints[0].EndpointType, "MultiRegion") + self.assertEqual(endpoints[0].AuthType, "IamAuth") + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_validation_latest_revision_with_routing(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = "LatestRevision" + function.RevisionWeights = {"rev-123": 100} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_validation_multiregion_with_latest_revision(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EndpointType = "MultiRegion" + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_validation_disabled_without_revision_weights(self): + # HomeRegion 'Disabled' with no RevisionWeights would be identical to 'LatestRevision' — reject it. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = "Disabled" + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_multiregion_disabled_without_revision_weights_uses_initial_revision(self): + # MultiRegion mandates 'Disabled' but may omit RevisionWeights; the endpoint then pins 100% to the + # revision created in this same stack (via GetAtt), enabling a single-shot MultiRegion create. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EndpointType = "MultiRegion" + function.AutoDeploymentMode = "Disabled" + cfnResources = function.to_cloudformation(**self.kwargs) + endpoint = next(x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)) + self.assertEqual(endpoint.EndpointType, "MultiRegion") + self.assertEqual( + endpoint.RevisionWeights, + [{"RevisionId": {"Fn::GetAtt": ["fooRevision", "RevisionId"]}, "Weight": 100}], + ) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_validation_perregion_with_latest_revision(self): + # PerRegion shares MultiRegion's rules: it mandates 'Disabled', so 'LatestRevision' (the default) fails. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EndpointType = "PerRegion" + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_perregion_disabled_without_revision_weights_uses_initial_revision(self): + # PerRegion mandates 'Disabled' but may omit RevisionWeights; like MultiRegion the endpoint then pins + # 100% to the revision created in this same stack and emits the replica Regions as per-Region endpoints. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EndpointType = "PerRegion" + function.AutoDeploymentMode = "Disabled" + function.ReplicaRegions = {"IncludeRegions": ["us-west-2", "us-east-1"]} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoint = next(x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)) + self.assertEqual(endpoint.EndpointType, "PerRegion") + self.assertEqual( + endpoint.RevisionWeights, + [{"RevisionId": {"Fn::GetAtt": ["fooRevision", "RevisionId"]}, "Weight": 100}], + ) + self.assertEqual(endpoint.Regions, ["us-east-1", "us-west-2"]) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_manual_revision_routing(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = "Disabled" + function.RevisionWeights = {"rev-abc": 80, "rev-def": 20} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertCountEqual( + endpoints[0].RevisionWeights, + [{"RevisionId": "rev-abc", "Weight": 80}, {"RevisionId": "rev-def", "Weight": 20}], + ) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_revision_weights_with_intrinsic_value(self): + # Individual weight values may be intrinsics; the map is still converted to the list shape. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = "Disabled" + function.RevisionWeights = {"rev-abc": {"Ref": "WeightA"}, "rev-def": {"Ref": "WeightB"}} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertCountEqual( + endpoints[0].RevisionWeights, + [ + {"RevisionId": "rev-abc", "Weight": {"Ref": "WeightA"}}, + {"RevisionId": "rev-def", "Weight": {"Ref": "WeightB"}}, + ], + ) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_revision_weights_top_level_intrinsic_raises(self): + # A top-level intrinsic map (e.g. Fn::If) cannot be converted to the endpoint's list shape. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = "Disabled" + function.RevisionWeights = {"Fn::If": ["Cond", {"rev-a": 100}, {"rev-b": 100}]} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_revision_weight_zero_raises(self): + # A 0 weight sums to 100 but violates the service's [1, 100] range; reject at transform time. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = "Disabled" + function.RevisionWeights = {"rev-a": 0, "rev-b": 100} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_revision_weight_out_of_range_raises(self): + # Sums to 100 but individual weights fall outside [1, 100]. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = "Disabled" + function.RevisionWeights = {"rev-a": 120, "rev-b": -20} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_auto_deployment_mode_resolved_intrinsic(self): + # A Ref that resolves to a concrete mode is honored: "Disabled" allows RevisionWeights. + kwargs = dict(self.kwargs) + kwargs["intrinsics_resolver"] = IntrinsicsResolver({"Mode": "Disabled"}) + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = {"Ref": "Mode"} + function.RevisionWeights = {"rev-abc": 100} + cfnResources = function.to_cloudformation(**kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(endpoints[0].RevisionWeights, [{"RevisionId": "rev-abc", "Weight": 100}]) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_auto_deployment_mode_resolved_intrinsic_conflicts_with_weights(self): + # Resolves to "LatestRevision", which forbids RevisionWeights. + kwargs = dict(self.kwargs) + kwargs["intrinsics_resolver"] = IntrinsicsResolver({"Mode": "LatestRevision"}) + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = {"Ref": "Mode"} + function.RevisionWeights = {"rev-abc": 100} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_auto_deployment_mode_unresolved_intrinsic_raises(self): + # AutoDeploymentMode is SAM-only and never reaches CFN; an unresolved Ref must be rejected. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = {"Ref": "Unknown"} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_auto_deployment_mode_resolved_to_invalid_raises(self): + kwargs = dict(self.kwargs) + kwargs["intrinsics_resolver"] = IntrinsicsResolver({"Mode": "Nonsense"}) + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.AutoDeploymentMode = {"Ref": "Mode"} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_endpoint_type_unresolved_intrinsic_passthrough(self): + # EndpointType IS a CFN property, so an unresolved intrinsic is passed through and the + # MultiRegion constraint is not enforced. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EndpointType = {"Ref": "EpType"} + cfnResources = function.to_cloudformation(**self.kwargs) + endpoints = [x for x in cfnResources if isinstance(x, LambdaWebFunctionEndpoint)] + self.assertEqual(endpoints[0].EndpointType, {"Ref": "EpType"}) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_endpoint_type_resolved_multiregion_requires_disabled(self): + kwargs = dict(self.kwargs) + kwargs["intrinsics_resolver"] = IntrinsicsResolver({"EpType": "MultiRegion"}) + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EndpointType = {"Ref": "EpType"} + # AutoDeploymentMode defaults to LatestRevision, which is invalid for MultiRegion. + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_timeout_below_minimum_raises(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.Timeout = 1 + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_timeout_above_maximum_raises(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.Timeout = 901 + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_invalid_env_var_key_raises(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EnvironmentVariables = {"1BAD": "value"} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_leading_underscore_env_var_key_raises(self): + # The service key pattern requires a leading letter (no leading underscore); reject it here so it + # fails at transform time rather than at stack creation. + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EnvironmentVariables = {"_FOO": "value"} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_empty_env_var_value_raises(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EnvironmentVariables = {"MY_VAR": ""} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_env_var_value_too_long_raises(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EnvironmentVariables = {"MY_VAR": "x" * 4097} + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_intrinsic_env_vars_with_entrypoint_raises(self): + function = SamWebFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "bar" + function.AuthType = "IamAuth" + function.EnvironmentVariables = {"Fn::If": ["Cond", {"A": "1"}, {"B": "2"}]} + function.EntryPoint = "app.js" + with pytest.raises(InvalidResourceException): + function.to_cloudformation(**self.kwargs) + + class TestOpenApi(TestCase): kwargs = { "intrinsics_resolver": IntrinsicsResolver({}), diff --git a/tests/translator/input/error_webfunction_missing_authtype.yaml b/tests/translator/input/error_webfunction_missing_authtype.yaml new file mode 100644 index 000000000..deeb29f3c --- /dev/null +++ b/tests/translator/input/error_webfunction_missing_authtype.yaml @@ -0,0 +1,12 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: WebFunction without the required AuthType property + +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: s3://sam-demo-bucket/webfunc.zip +Metadata: + SamTransformTest: true diff --git a/tests/translator/input/webfunction_basic.yaml b/tests/translator/input/webfunction_basic.yaml new file mode 100644 index 000000000..4529b058c --- /dev/null +++ b/tests/translator/input/webfunction_basic.yaml @@ -0,0 +1,7 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + AuthType: IamAuth + CodeUri: s3://sam-demo-bucket/webfunc.zip diff --git a/tests/translator/input/webfunction_multiregion.yaml b/tests/translator/input/webfunction_multiregion.yaml new file mode 100644 index 000000000..8f9f9b7bb --- /dev/null +++ b/tests/translator/input/webfunction_multiregion.yaml @@ -0,0 +1,17 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + AuthType: IamAuth + CodeUri: s3://sam-demo-bucket/webfunc.zip + EndpointType: MultiRegion + AutoDeploymentMode: Disabled + ReplicaRegions: + IncludeRegions: + - US + ExcludeRegions: + - us-east-2 + - us-west-1 +Metadata: + SamTransformTest: true diff --git a/tests/translator/input/webfunction_with_all_properties.yaml b/tests/translator/input/webfunction_with_all_properties.yaml new file mode 100644 index 000000000..8f6a2a767 --- /dev/null +++ b/tests/translator/input/webfunction_with_all_properties.yaml @@ -0,0 +1,31 @@ +Resources: + MyWebFunction: + Type: AWS::Serverless::WebFunction + Properties: + Runtime: nodejs24.x + CodeUri: s3://sam-demo-bucket/webfunc.zip + Timeout: 120 + EntryPoint: app.js + MaxConcurrencyPerEnvironment: 100 + EnvironmentVariables: + MY_VAR: my_value + NUMERIC_VAR: 42 + BOOL_VAR: true + EndpointName: staging + EndpointType: MultiRegion + EndpointDescription: prod endpoint + ScalingConfig: + MaxEnvironments: 50 + ThrottleConfig: + RateLimit: 5000 + AuthType: IamAuth + AutoDeploymentMode: Disabled + RevisionWeights: + rev-abc: 80 + rev-def: 20 + ReplicaRegions: + - US + KmsKeyArn: arn:aws:kms:us-east-1:123456789012:key/abcd1234 + RevisionDescription: initial revision + Tags: + Department: Engineering diff --git a/tests/translator/output/aws-cn/webfunction_basic.json b/tests/translator/output/aws-cn/webfunction_basic.json new file mode 100644 index 000000000..29e9c7ebc --- /dev/null +++ b/tests/translator/output/aws-cn/webfunction_basic.json @@ -0,0 +1,186 @@ +{ + "Resources": { + "MyWebFunction": { + "Properties": { + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::WebFunction" + }, + "MyWebFunctionEndpoint": { + "DependsOn": [ + "MyWebFunction", + "MyWebFunctionRevision" + ], + "Properties": { + "AuthType": "IamAuth", + "EndpointName": "prod", + "EndpointType": "HomeRegion", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "RevisionWeights": [ + { + "RevisionId": { + "Fn::GetAtt": [ + "MyWebFunctionRevision", + "RevisionId" + ] + }, + "Weight": 100 + } + ] + }, + "Type": "AWS::Lambda::WebFunctionEndpoint" + }, + "MyWebFunctionRevision": { + "DependsOn": [ + "MyWebFunction" + ], + "Properties": { + "BuildConfig": { + "CodeConfig": { + "S3Object": { + "Bucket": "sam-demo-bucket", + "Key": "webfunc.zip" + } + }, + "RuntimeConfig": { + "Runtime": "nodejs24.x" + } + }, + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "ServiceConfig": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyWebFunctionRole", + "Arn" + ] + }, + "TimeoutSeconds": 30 + } + }, + "Type": "AWS::Lambda::WebFunctionRevision" + }, + "MyWebFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/aws-cn/webfunction_multiregion.json b/tests/translator/output/aws-cn/webfunction_multiregion.json new file mode 100644 index 000000000..a5c780c87 --- /dev/null +++ b/tests/translator/output/aws-cn/webfunction_multiregion.json @@ -0,0 +1,193 @@ +{ + "Metadata": { + "SamTransformTest": true + }, + "Resources": { + "MyWebFunction": { + "Properties": { + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::WebFunction" + }, + "MyWebFunctionEndpoint": { + "DependsOn": [ + "MyWebFunction", + "MyWebFunctionRevision" + ], + "Properties": { + "AuthType": "IamAuth", + "EndpointName": "prod", + "EndpointType": "MultiRegion", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Regions": [ + "us-east-1", + "us-west-2" + ], + "RevisionWeights": [ + { + "RevisionId": { + "Fn::GetAtt": [ + "MyWebFunctionRevision", + "RevisionId" + ] + }, + "Weight": 100 + } + ] + }, + "Type": "AWS::Lambda::WebFunctionEndpoint" + }, + "MyWebFunctionRevision": { + "DependsOn": [ + "MyWebFunction" + ], + "Properties": { + "BuildConfig": { + "CodeConfig": { + "S3Object": { + "Bucket": "sam-demo-bucket", + "Key": "webfunc.zip" + } + }, + "RuntimeConfig": { + "Runtime": "nodejs24.x" + } + }, + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "ServiceConfig": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyWebFunctionRole", + "Arn" + ] + }, + "TimeoutSeconds": 30 + } + }, + "Type": "AWS::Lambda::WebFunctionRevision" + }, + "MyWebFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/aws-cn/webfunction_with_all_properties.json b/tests/translator/output/aws-cn/webfunction_with_all_properties.json new file mode 100644 index 000000000..cf89e28e4 --- /dev/null +++ b/tests/translator/output/aws-cn/webfunction_with_all_properties.json @@ -0,0 +1,215 @@ +{ + "Resources": { + "MyWebFunction": { + "Properties": { + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + }, + { + "Key": "Department", + "Value": "Engineering" + } + ] + }, + "Type": "AWS::Lambda::WebFunction" + }, + "MyWebFunctionEndpoint": { + "DependsOn": [ + "MyWebFunction", + "MyWebFunctionRevision" + ], + "Properties": { + "AuthType": "IamAuth", + "Description": "prod endpoint", + "EndpointName": "staging", + "EndpointType": "MultiRegion", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Regions": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2" + ], + "RevisionWeights": [ + { + "RevisionId": "rev-abc", + "Weight": 80 + }, + { + "RevisionId": "rev-def", + "Weight": 20 + } + ], + "ScalingConfig": { + "MaxEnvironments": 50 + }, + "ThrottleConfig": { + "RateLimit": 5000 + } + }, + "Type": "AWS::Lambda::WebFunctionEndpoint" + }, + "MyWebFunctionRevision": { + "DependsOn": [ + "MyWebFunction" + ], + "Properties": { + "BuildConfig": { + "CodeConfig": { + "S3Object": { + "Bucket": "sam-demo-bucket", + "Key": "webfunc.zip" + } + }, + "RuntimeConfig": { + "Runtime": "nodejs24.x" + } + }, + "Description": "initial revision", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "KmsKeyArn": "arn:aws:kms:us-east-1:123456789012:key/abcd1234", + "ServiceConfig": { + "EnvironmentVariables": { + "AWS_LAMBDA_NODEJS_ENTRYPOINT": "app.js", + "BOOL_VAR": "true", + "MY_VAR": "my_value", + "NUMERIC_VAR": "42" + }, + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyWebFunctionRole", + "Arn" + ] + }, + "MaxConcurrencyPerEnvironment": 100, + "TimeoutSeconds": 120 + } + }, + "Type": "AWS::Lambda::WebFunctionRevision" + }, + "MyWebFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + }, + { + "Key": "Department", + "Value": "Engineering" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/aws-us-gov/webfunction_basic.json b/tests/translator/output/aws-us-gov/webfunction_basic.json new file mode 100644 index 000000000..4bf11194d --- /dev/null +++ b/tests/translator/output/aws-us-gov/webfunction_basic.json @@ -0,0 +1,186 @@ +{ + "Resources": { + "MyWebFunction": { + "Properties": { + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::WebFunction" + }, + "MyWebFunctionEndpoint": { + "DependsOn": [ + "MyWebFunction", + "MyWebFunctionRevision" + ], + "Properties": { + "AuthType": "IamAuth", + "EndpointName": "prod", + "EndpointType": "HomeRegion", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "RevisionWeights": [ + { + "RevisionId": { + "Fn::GetAtt": [ + "MyWebFunctionRevision", + "RevisionId" + ] + }, + "Weight": 100 + } + ] + }, + "Type": "AWS::Lambda::WebFunctionEndpoint" + }, + "MyWebFunctionRevision": { + "DependsOn": [ + "MyWebFunction" + ], + "Properties": { + "BuildConfig": { + "CodeConfig": { + "S3Object": { + "Bucket": "sam-demo-bucket", + "Key": "webfunc.zip" + } + }, + "RuntimeConfig": { + "Runtime": "nodejs24.x" + } + }, + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "ServiceConfig": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyWebFunctionRole", + "Arn" + ] + }, + "TimeoutSeconds": 30 + } + }, + "Type": "AWS::Lambda::WebFunctionRevision" + }, + "MyWebFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/aws-us-gov/webfunction_multiregion.json b/tests/translator/output/aws-us-gov/webfunction_multiregion.json new file mode 100644 index 000000000..a9018e53c --- /dev/null +++ b/tests/translator/output/aws-us-gov/webfunction_multiregion.json @@ -0,0 +1,193 @@ +{ + "Metadata": { + "SamTransformTest": true + }, + "Resources": { + "MyWebFunction": { + "Properties": { + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::WebFunction" + }, + "MyWebFunctionEndpoint": { + "DependsOn": [ + "MyWebFunction", + "MyWebFunctionRevision" + ], + "Properties": { + "AuthType": "IamAuth", + "EndpointName": "prod", + "EndpointType": "MultiRegion", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Regions": [ + "us-east-1", + "us-west-2" + ], + "RevisionWeights": [ + { + "RevisionId": { + "Fn::GetAtt": [ + "MyWebFunctionRevision", + "RevisionId" + ] + }, + "Weight": 100 + } + ] + }, + "Type": "AWS::Lambda::WebFunctionEndpoint" + }, + "MyWebFunctionRevision": { + "DependsOn": [ + "MyWebFunction" + ], + "Properties": { + "BuildConfig": { + "CodeConfig": { + "S3Object": { + "Bucket": "sam-demo-bucket", + "Key": "webfunc.zip" + } + }, + "RuntimeConfig": { + "Runtime": "nodejs24.x" + } + }, + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "ServiceConfig": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyWebFunctionRole", + "Arn" + ] + }, + "TimeoutSeconds": 30 + } + }, + "Type": "AWS::Lambda::WebFunctionRevision" + }, + "MyWebFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/aws-us-gov/webfunction_with_all_properties.json b/tests/translator/output/aws-us-gov/webfunction_with_all_properties.json new file mode 100644 index 000000000..40a2b2742 --- /dev/null +++ b/tests/translator/output/aws-us-gov/webfunction_with_all_properties.json @@ -0,0 +1,215 @@ +{ + "Resources": { + "MyWebFunction": { + "Properties": { + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + }, + { + "Key": "Department", + "Value": "Engineering" + } + ] + }, + "Type": "AWS::Lambda::WebFunction" + }, + "MyWebFunctionEndpoint": { + "DependsOn": [ + "MyWebFunction", + "MyWebFunctionRevision" + ], + "Properties": { + "AuthType": "IamAuth", + "Description": "prod endpoint", + "EndpointName": "staging", + "EndpointType": "MultiRegion", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Regions": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2" + ], + "RevisionWeights": [ + { + "RevisionId": "rev-abc", + "Weight": 80 + }, + { + "RevisionId": "rev-def", + "Weight": 20 + } + ], + "ScalingConfig": { + "MaxEnvironments": 50 + }, + "ThrottleConfig": { + "RateLimit": 5000 + } + }, + "Type": "AWS::Lambda::WebFunctionEndpoint" + }, + "MyWebFunctionRevision": { + "DependsOn": [ + "MyWebFunction" + ], + "Properties": { + "BuildConfig": { + "CodeConfig": { + "S3Object": { + "Bucket": "sam-demo-bucket", + "Key": "webfunc.zip" + } + }, + "RuntimeConfig": { + "Runtime": "nodejs24.x" + } + }, + "Description": "initial revision", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "KmsKeyArn": "arn:aws:kms:us-east-1:123456789012:key/abcd1234", + "ServiceConfig": { + "EnvironmentVariables": { + "AWS_LAMBDA_NODEJS_ENTRYPOINT": "app.js", + "BOOL_VAR": "true", + "MY_VAR": "my_value", + "NUMERIC_VAR": "42" + }, + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyWebFunctionRole", + "Arn" + ] + }, + "MaxConcurrencyPerEnvironment": 100, + "TimeoutSeconds": 120 + } + }, + "Type": "AWS::Lambda::WebFunctionRevision" + }, + "MyWebFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + }, + { + "Key": "Department", + "Value": "Engineering" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/error_webfunction_missing_authtype.json b/tests/translator/output/error_webfunction_missing_authtype.json new file mode 100644 index 000000000..80c0d7e54 --- /dev/null +++ b/tests/translator/output/error_webfunction_missing_authtype.json @@ -0,0 +1,15 @@ +{ + "_autoGeneratedBreakdownErrorMessage": [ + "Invalid Serverless Application Specification document. ", + "Number of errors found: 1. ", + "Resource with id [MyWebFunction] is invalid. ", + "'AuthType' is required. ", + "Specify 'IamAuth' (Lambda authorizes requests with SigV4/IAM) or 'ApplicationManaged' (the endpoint is public and your function handles authorization)." + ], + "errorMessage": "Invalid Serverless Application Specification document. Number of errors found: 1. Resource with id [MyWebFunction] is invalid. 'AuthType' is required. Specify 'IamAuth' (Lambda authorizes requests with SigV4/IAM) or 'ApplicationManaged' (the endpoint is public and your function handles authorization).", + "errors": [ + { + "errorMessage": "Resource with id [MyWebFunction] is invalid. 'AuthType' is required. Specify 'IamAuth' (Lambda authorizes requests with SigV4/IAM) or 'ApplicationManaged' (the endpoint is public and your function handles authorization)." + } + ] +} diff --git a/tests/translator/output/webfunction_basic.json b/tests/translator/output/webfunction_basic.json new file mode 100644 index 000000000..80f5f3f89 --- /dev/null +++ b/tests/translator/output/webfunction_basic.json @@ -0,0 +1,186 @@ +{ + "Resources": { + "MyWebFunction": { + "Properties": { + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::WebFunction" + }, + "MyWebFunctionEndpoint": { + "DependsOn": [ + "MyWebFunction", + "MyWebFunctionRevision" + ], + "Properties": { + "AuthType": "IamAuth", + "EndpointName": "prod", + "EndpointType": "HomeRegion", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "RevisionWeights": [ + { + "RevisionId": { + "Fn::GetAtt": [ + "MyWebFunctionRevision", + "RevisionId" + ] + }, + "Weight": 100 + } + ] + }, + "Type": "AWS::Lambda::WebFunctionEndpoint" + }, + "MyWebFunctionRevision": { + "DependsOn": [ + "MyWebFunction" + ], + "Properties": { + "BuildConfig": { + "CodeConfig": { + "S3Object": { + "Bucket": "sam-demo-bucket", + "Key": "webfunc.zip" + } + }, + "RuntimeConfig": { + "Runtime": "nodejs24.x" + } + }, + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "ServiceConfig": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyWebFunctionRole", + "Arn" + ] + }, + "TimeoutSeconds": 30 + } + }, + "Type": "AWS::Lambda::WebFunctionRevision" + }, + "MyWebFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/webfunction_multiregion.json b/tests/translator/output/webfunction_multiregion.json new file mode 100644 index 000000000..9b9a57464 --- /dev/null +++ b/tests/translator/output/webfunction_multiregion.json @@ -0,0 +1,193 @@ +{ + "Metadata": { + "SamTransformTest": true + }, + "Resources": { + "MyWebFunction": { + "Properties": { + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::WebFunction" + }, + "MyWebFunctionEndpoint": { + "DependsOn": [ + "MyWebFunction", + "MyWebFunctionRevision" + ], + "Properties": { + "AuthType": "IamAuth", + "EndpointName": "prod", + "EndpointType": "MultiRegion", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Regions": [ + "us-east-1", + "us-west-2" + ], + "RevisionWeights": [ + { + "RevisionId": { + "Fn::GetAtt": [ + "MyWebFunctionRevision", + "RevisionId" + ] + }, + "Weight": 100 + } + ] + }, + "Type": "AWS::Lambda::WebFunctionEndpoint" + }, + "MyWebFunctionRevision": { + "DependsOn": [ + "MyWebFunction" + ], + "Properties": { + "BuildConfig": { + "CodeConfig": { + "S3Object": { + "Bucket": "sam-demo-bucket", + "Key": "webfunc.zip" + } + }, + "RuntimeConfig": { + "Runtime": "nodejs24.x" + } + }, + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "ServiceConfig": { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyWebFunctionRole", + "Arn" + ] + }, + "TimeoutSeconds": 30 + } + }, + "Type": "AWS::Lambda::WebFunctionRevision" + }, + "MyWebFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/webfunction_with_all_properties.json b/tests/translator/output/webfunction_with_all_properties.json new file mode 100644 index 000000000..94bef8208 --- /dev/null +++ b/tests/translator/output/webfunction_with_all_properties.json @@ -0,0 +1,215 @@ +{ + "Resources": { + "MyWebFunction": { + "Properties": { + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + }, + { + "Key": "Department", + "Value": "Engineering" + } + ] + }, + "Type": "AWS::Lambda::WebFunction" + }, + "MyWebFunctionEndpoint": { + "DependsOn": [ + "MyWebFunction", + "MyWebFunctionRevision" + ], + "Properties": { + "AuthType": "IamAuth", + "Description": "prod endpoint", + "EndpointName": "staging", + "EndpointType": "MultiRegion", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "Regions": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2" + ], + "RevisionWeights": [ + { + "RevisionId": "rev-abc", + "Weight": 80 + }, + { + "RevisionId": "rev-def", + "Weight": 20 + } + ], + "ScalingConfig": { + "MaxEnvironments": 50 + }, + "ThrottleConfig": { + "RateLimit": 5000 + } + }, + "Type": "AWS::Lambda::WebFunctionEndpoint" + }, + "MyWebFunctionRevision": { + "DependsOn": [ + "MyWebFunction" + ], + "Properties": { + "BuildConfig": { + "CodeConfig": { + "S3Object": { + "Bucket": "sam-demo-bucket", + "Key": "webfunc.zip" + } + }, + "RuntimeConfig": { + "Runtime": "nodejs24.x" + } + }, + "Description": "initial revision", + "FunctionName": { + "Fn::Sub": [ + "MyWebFunction-${StackIdSuffix}", + { + "StackIdSuffix": { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + "KmsKeyArn": "arn:aws:kms:us-east-1:123456789012:key/abcd1234", + "ServiceConfig": { + "EnvironmentVariables": { + "AWS_LAMBDA_NODEJS_ENTRYPOINT": "app.js", + "BOOL_VAR": "true", + "MY_VAR": "my_value", + "NUMERIC_VAR": "42" + }, + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyWebFunctionRole", + "Arn" + ] + }, + "MaxConcurrencyPerEnvironment": 100, + "TimeoutSeconds": 120 + } + }, + "Type": "AWS::Lambda::WebFunctionRevision" + }, + "MyWebFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + }, + { + "Key": "Department", + "Value": "Engineering" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} From 362d01e4874084c555879a9e228c9680ba0fdf4a Mon Sep 17 00:00:00 2001 From: aws-sam-cli-bot <46753707+aws-sam-cli-bot@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:51:13 +0000 Subject: [PATCH 4/5] chore: bump version to 1.114.0 --- samtranslator/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samtranslator/__init__.py b/samtranslator/__init__.py index 22c47ece6..9f97f8dea 100644 --- a/samtranslator/__init__.py +++ b/samtranslator/__init__.py @@ -1 +1 @@ -__version__ = "1.113.0" +__version__ = "1.114.0" From 3d55ff43609bc74610d69154f6b0dfa78bb45337 Mon Sep 17 00:00:00 2001 From: Vandita Patidar Date: Thu, 1 Oct 2026 14:57:15 -0700 Subject: [PATCH 5/5] Update __init__.py --- samtranslator/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/samtranslator/__init__.py b/samtranslator/__init__.py index ea1cabc65..9f97f8dea 100644 --- a/samtranslator/__init__.py +++ b/samtranslator/__init__.py @@ -1,2 +1 @@ __version__ = "1.114.0" -