diff --git a/README.md b/README.md index 5abe8e114..519426449 100644 --- a/README.md +++ b/README.md @@ -1086,6 +1086,7 @@ to change Zappa's behavior. Use these at your own risk! "maxAge": 0 // The maximum amount of time, in seconds, that web browsers can cache results of a preflight request. default 0. } }, + "function_url_domains": ["yourapp.yourdomain.com"], // optional, list of custom domains to associate with the function URL. Requires certificate_arn to be set. // NOTE: Function URLs do NOT include stage names in their paths. Unlike API Gateway v1/v2 which include // the stage name in the URL (e.g., /dev/mypath), Function URLs route directly to your app (e.g., /mypath). // This means SCRIPT_NAME will be empty for Function URL requests, and PATH_INFO will contain the full path. @@ -1173,7 +1174,8 @@ to change Zappa's behavior. Use these at your own risk! "lambda_description": "Your Description", // However you want to describe your project for the AWS console. Default "Zappa Deployment". "lambda_handler": "your_custom_handler", // The name of Lambda handler. Default: handler.lambda_handler "layers": ["arn:aws:lambda:::layer::"], // optional lambda layers - "lambda_concurrency": 10, // Sets the maximum number of simultaneous executions for a function, and reserves capacity for that concurrency level. Default is None. + "lambda_concurrency": 10, // Sets the maximum number of simultaneous executions for a function, and reserves capacity for that concurrency level. Default is None. Not supported when using capacity providers. + "capacity_provider_config": { "LambdaManagedInstancesCapacityProviderConfig": { "CapacityProviderArn": "arn:aws:lambda:::capacity-provider/", "PerExecutionEnvironmentMaxConcurrency": 10, "ExecutionEnvironmentMemoryGiBPerVCpu": 2.0 } }, // Configure the Lambda capacity provider used for your function. Optional. Not compatible with VPC configurations. "lets_encrypt_key": "s3://your-bucket/account.key", // Let's Encrypt account key path. Can either be an S3 path or a local file path. "log_level": "DEBUG", // Set the Zappa log level. Can be one of CRITICAL, ERROR, WARNING, INFO and DEBUG. Default: DEBUG "manage_roles": true, // Have Zappa automatically create and define IAM execution roles and policies. Default true. If false, you must define your own IAM Role and role_name setting. diff --git a/tests/placebo/TestZappa.test_cli_aws/cloudfront.DeleteDistribution_1.json b/tests/placebo/TestZappa.test_cli_aws/cloudfront.DeleteDistribution_1.json new file mode 100644 index 000000000..ffbb4ac71 --- /dev/null +++ b/tests/placebo/TestZappa.test_cli_aws/cloudfront.DeleteDistribution_1.json @@ -0,0 +1,12 @@ +{ + "ResponseMetadata": { + "RequestId": "7f720a6c-9e5b-4080-b289-df07fe33c7fc", + "HTTPStatusCode": 204, + "HTTPHeaders": { + "x-amzn-requestid": "7f720a6c-9e5b-4080-b289-df07fe33c7fc", + "content-type": "text/xml", + "date": "Wed, 26 Oct 2022 06:16:00 GMT" + }, + "RetryAttempts": 0 + } +} \ No newline at end of file diff --git a/tests/placebo/TestZappa.test_cli_aws/cloudfront.UpdateDistribution_1.json b/tests/placebo/TestZappa.test_cli_aws/cloudfront.UpdateDistribution_1.json new file mode 100644 index 000000000..32ff81426 --- /dev/null +++ b/tests/placebo/TestZappa.test_cli_aws/cloudfront.UpdateDistribution_1.json @@ -0,0 +1,162 @@ +{ + "ResponseMetadata": { + "RequestId": "e9a681a2-2a82-4e8c-bab0-133e6d1fd04d", + "HTTPStatusCode": 200, + "HTTPHeaders": { + "x-amzn-requestid": "e9a681a2-2a82-4e8c-bab0-133e6d1fd04d", + "etag": "EQZU30ULEIL60", + "content-type": "text/xml", + "content-length": "4148", + "date": "Wed, 26 Oct 2022 03:45:13 GMT" + }, + "RetryAttempts": 0 + }, + "ETag": "EQZU30ULEIL60", + "Distribution": { + "Id": "E1YIU775JNY3JV", + "ARN": "arn:aws:cloudfront::123456789:distribution/E1YIU775JNY3JV", + "Status": "InProgress", + "LastModifiedTime": "", + "InProgressInvalidationBatches": 0, + "DomainName": "dolayrplf7f1.cloudfront.net", + "ActiveTrustedSigners": { + "Enabled": false, + "Quantity": 0 + }, + "ActiveTrustedKeyGroups": { + "Enabled": false, + "Quantity": 0 + }, + "DistributionConfig": { + "CallerReference": "zappa-create-function-url-custom-domain", + "Aliases": { + "Quantity": 1, + "Items": [ + "test-lambda-function-url.example.com" + ] + }, + "DefaultRootObject": "", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "LambdaFunctionURL", + "DomainName": "wwvjk2tpuvrr457k3xt4kuryby0qmmzs.lambda-url.ap-southeast-1.on.aws", + "OriginPath": "", + "CustomHeaders": { + "Quantity": 1, + "Items": [ + { + "HeaderName": "CloudFront", + "HeaderValue": "CloudFront" + } + ] + }, + "CustomOriginConfig": { + "HTTPPort": 80, + "HTTPSPort": 443, + "OriginProtocolPolicy": "https-only", + "OriginSslProtocols": { + "Quantity": 1, + "Items": [ + "TLSv1" + ] + }, + "OriginReadTimeout": 60, + "OriginKeepaliveTimeout": 60 + }, + "ConnectionAttempts": 3, + "ConnectionTimeout": 10, + "OriginShield": { + "Enabled": false + }, + "OriginAccessControlId": "" + } + ] + }, + "OriginGroups": { + "Quantity": 0 + }, + "DefaultCacheBehavior": { + "TargetOriginId": "LambdaFunctionURL", + "TrustedSigners": { + "Enabled": false, + "Quantity": 0 + }, + "TrustedKeyGroups": { + "Enabled": false, + "Quantity": 0 + }, + "ViewerProtocolPolicy": "redirect-to-https", + "AllowedMethods": { + "Quantity": 7, + "Items": [ + "HEAD", + "DELETE", + "POST", + "GET", + "OPTIONS", + "PUT", + "PATCH" + ], + "CachedMethods": { + "Quantity": 3, + "Items": [ + "HEAD", + "GET", + "OPTIONS" + ] + } + }, + "SmoothStreaming": true, + "Compress": true, + "LambdaFunctionAssociations": { + "Quantity": 0 + }, + "FunctionAssociations": { + "Quantity": 0 + }, + "FieldLevelEncryptionId": "", + "CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" + }, + "CacheBehaviors": { + "Quantity": 0 + }, + "CustomErrorResponses": { + "Quantity": 0 + }, + "Comment": "Lambda FunctionURL zappa-function-url-test-dev", + "Logging": { + "Enabled": false, + "IncludeCookies": false, + "Bucket": "", + "Prefix": "" + }, + "PriceClass": "PriceClass_100", + "Enabled": true, + "ViewerCertificate": { + "CloudFrontDefaultCertificate": false, + "ACMCertificateArn": "arn:aws:acm:us-east-1:123456789:certificate/77bff5cb-03c7-4b11-ba8e-312e6f49a31f", + "SSLSupportMethod": "sni-only", + "MinimumProtocolVersion": "TLSv1.2_2021", + "Certificate": "arn:aws:acm:us-east-1:123456789:certificate/77bff5cb-03c7-4b11-ba8e-312e6f49a31f", + "CertificateSource": "acm" + }, + "Restrictions": { + "GeoRestriction": { + "RestrictionType": "none", + "Quantity": 0 + } + }, + "WebACLId": "", + "HttpVersion": "http2", + "IsIPV6Enabled": true + }, + "AliasICPRecordals": [ + { + "CNAME": "test-lambda-function-url.example.com", + "ICPRecordalStatus": "APPROVED" + } + ] + } +} \ No newline at end of file diff --git a/tests/placebo/TestZappa.test_cli_aws/route53.ChangeResourceRecordSets_1.json b/tests/placebo/TestZappa.test_cli_aws/route53.ChangeResourceRecordSets_1.json new file mode 100644 index 000000000..ff367525f --- /dev/null +++ b/tests/placebo/TestZappa.test_cli_aws/route53.ChangeResourceRecordSets_1.json @@ -0,0 +1 @@ +{'ResponseMetadata': {'RequestId': 'ec2fc714-a609-43ac-977e-2a8612d131a3', 'HTTPStatusCode': 200, 'HTTPHeaders': {'x-amzn-requestid': 'ec2fc714-a609-43ac-977e-2a8612d131a3', 'content-type': 'text/xml', 'content-length': '282', 'date': 'Wed, 26 Oct 2022 04:02:46 GMT'}, 'RetryAttempts': 0}, 'ChangeInfo': {'Id': '/change/C06780612UZZBG9OMVN6', 'Status': 'PENDING', 'SubmittedAt': datetime.datetime(2022, 10, 26, 4, 2, 47, 57000, tzinfo=tzutc())}} \ No newline at end of file diff --git a/tests/test_core.py b/tests/test_core.py index 0f2c1ae7c..63650b7c6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -868,6 +868,60 @@ def test_update_layers(self): z.update_lambda_configuration("test", "test", "test") self.assertEqual(mock_client.update_function_configuration.call_args[1]["Layers"], []) + def test_update_capacity_provider_configuration(self): + z = Zappa(load_credentials=False) + z.credentials_arn = object() + z.lambda_client = mock.MagicMock() + z.lambda_client.get_function_configuration.return_value = {"PackageType": "Zip"} + capacity_provider_arn = "arn:aws:lambda:us-east-1:123456789012:capacity-provider/zappa-test" + z.lambda_client.list_function_versions_by_capacity_provider.return_value = { + "CapacityProviderArn": capacity_provider_arn, + "FunctionVersions": [ + {"FunctionArn": "test", "State": "Active"}, + ], + "NextMarker": "", + } + z.lambda_client.publish_version.return_value = { + "FunctionArn": "test", + } + z.wait_until_lambda_function_is_updated = mock.MagicMock() + capacity_provider_config = { + "LambdaManagedInstancesCapacityProviderConfig": { + "CapacityProviderArn": capacity_provider_arn, + "PerExecutionEnvironmentMaxConcurrency": 10, + "ExecutionEnvironmentMemoryGiBPerVCpu": 4.0, + } + } + + z.update_lambda_configuration( + "test", + "test", + "test", + capacity_provider_config=capacity_provider_config, + ) + + self.assertEqual( + z.lambda_client.update_function_configuration.call_args[1]["CapacityProviderConfig"], + capacity_provider_config, + ) + + def test_update_capacity_provider_rejects_vpc(self): + z = Zappa(load_credentials=False) + z.credentials_arn = object() + z.lambda_client = mock.MagicMock() + + with self.assertRaises(ValueError): + z.update_lambda_configuration( + "arn", + "name", + "handler", + vpc_config={"SubnetIds": ["subnet-1"], "SecurityGroupIds": ["sg-1"]}, + capacity_provider_config={ + "LambdaManagedInstancesCapacityProviderConfig": {"CapacityProviderArn": "arn:aws:lambda:::capacity"} + }, + wait=False, + ) + def test_snap_start_configuration(self): """ Test that SnapStart configuration is correctly set in Lambda configuration. @@ -884,6 +938,22 @@ def test_snap_start_configuration(self): zappa_cli.load_settings("tests/test_settings.yaml") self.assertEqual("None", zappa_cli.snap_start) + def test_capacity_provider_configuration(self): + """ + Test that capacity provider configuration is loaded from settings. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "capacity_provider_enabled" + zappa_cli.load_settings("tests/test_settings.yaml") + expected_config = { + "LambdaManagedInstancesCapacityProviderConfig": { + "CapacityProviderArn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider/zappa-test", + "PerExecutionEnvironmentMaxConcurrency": 5, + "ExecutionEnvironmentMemoryGiBPerVCpu": 2.0, + } + } + self.assertEqual(expected_config, zappa_cli.capacity_provider_config) + def test_update_empty_aws_env_hash(self): z = Zappa() z.credentials_arn = object() @@ -3583,6 +3653,168 @@ def test_zappa_core_undeploy_lambda_alb(self): elbv2_stubber.activate() zappa_core.undeploy_lambda_alb(**kwargs) + def test_create_lambda_capacity_provider_config(self): + zappa_core = Zappa(load_credentials=False) + zappa_core.credentials_arn = "arn:aws:iam::123:role/zappa" + zappa_core.lambda_client = mock.MagicMock() + zappa_core.wait_until_lambda_function_is_active = mock.MagicMock() + zappa_core.lambda_client.create_function.return_value = { + "FunctionArn": "abc", + "Version": 1, + } + capacity_provider_config = { + "LambdaManagedInstancesCapacityProviderConfig": { + "CapacityProviderArn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider/zappa-test", + "PerExecutionEnvironmentMaxConcurrency": 3, + "ExecutionEnvironmentMemoryGiBPerVCpu": 2.0, + } + } + + zappa_core.create_lambda_function( + function_name="abc", + handler="handler.lambda_handler", + docker_image_uri="123456789012.dkr.ecr.us-east-1.amazonaws.com/repo:latest", + capacity_provider_config=capacity_provider_config, + ) + + self.assertEqual( + zappa_core.lambda_client.create_function.call_args[1]["CapacityProviderConfig"], + capacity_provider_config, + ) + + def test_wait_for_capacity_provider_response(self): + zappa_core = Zappa(load_credentials=False) + zappa_core.lambda_client = mock.MagicMock() + expected = {"FunctionVersions": []} + zappa_core.lambda_client.list_function_versions_by_capacity_provider.return_value = expected + + result = zappa_core.wait_for_capacity_provider_response( + function_arn=None, + capacity_provider_name="provider", + ) + + zappa_core.lambda_client.list_function_versions_by_capacity_provider.assert_called_with( + CapacityProviderName="provider", + ) + self.assertEqual(result, expected) + + def test_wait_for_capacity_provider_response_waits_until_active(self): + zappa_core = Zappa(load_credentials=False) + zappa_core.lambda_client = mock.MagicMock() + function_arn = "arn:aws:lambda:us-east-1:123:function:test:1" + pending = {"FunctionVersions": [{"FunctionArn": function_arn, "State": "Pending"}]} + active = {"FunctionVersions": [{"FunctionArn": function_arn, "State": "Active"}]} + zappa_core.lambda_client.list_function_versions_by_capacity_provider.side_effect = [pending, active] + + result = zappa_core.wait_for_capacity_provider_response( + capacity_provider_name="provider", + function_arn=function_arn, + function_state="Active", + max_attempts=2, + delay_seconds=0, + ) + + self.assertEqual(result, active) + self.assertEqual(zappa_core.lambda_client.list_function_versions_by_capacity_provider.call_count, 2) + + def test_wait_for_capacity_provider_response_waits_until_empty(self): + zappa_core = Zappa(load_credentials=False) + zappa_core.lambda_client = mock.MagicMock() + function_arn = "arn:aws:lambda:us-east-1:123:function:test:1" + present = {"FunctionVersions": [{"FunctionArn": function_arn, "State": "Pending"}]} + gone = {"FunctionVersions": []} + zappa_core.lambda_client.list_function_versions_by_capacity_provider.side_effect = [present, gone] + + result = zappa_core.wait_for_capacity_provider_response( + capacity_provider_name="provider", + function_arn=function_arn, + function_state="Empty", + max_attempts=2, + delay_seconds=0, + ) + + self.assertEqual(result, gone) + self.assertEqual(zappa_core.lambda_client.list_function_versions_by_capacity_provider.call_count, 2) + + def test_wait_for_capacity_provider_response_exits_on_failed(self): + zappa_core = Zappa(load_credentials=False) + zappa_core.lambda_client = mock.MagicMock() + function_arn = "arn:aws:lambda:us-east-1:123:function:test:1" + failed = {"FunctionVersions": [{"FunctionArn": function_arn, "State": "Failed"}]} + zappa_core.lambda_client.list_function_versions_by_capacity_provider.return_value = failed + + with self.assertRaises(RuntimeError): + zappa_core.wait_for_capacity_provider_response( + capacity_provider_name="provider", + function_arn=function_arn, + function_state="Active", + max_attempts=1, + delay_seconds=0, + ) + + with self.assertRaises(RuntimeError): + zappa_core.wait_for_capacity_provider_response( + capacity_provider_name="provider", + function_arn=function_arn, + function_state="Empty", + max_attempts=1, + delay_seconds=0, + ) + + def test_wait_for_capacity_provider_response_passes_marker(self): + zappa_core = Zappa(load_credentials=False) + zappa_core.lambda_client = mock.MagicMock() + expected = {"FunctionVersions": []} + zappa_core.lambda_client.list_function_versions_by_capacity_provider.return_value = expected + + result = zappa_core.wait_for_capacity_provider_response( + function_arn=None, + capacity_provider_name="provider", + marker="m", + ) + + zappa_core.lambda_client.list_function_versions_by_capacity_provider.assert_called_with( + CapacityProviderName="provider", + Marker="m", + ) + self.assertEqual(result, expected) + + def test_wait_for_capacity_provider_response_retries(self): + zappa_core = Zappa(load_credentials=False) + zappa_core.lambda_client = mock.MagicMock() + error = botocore.exceptions.ClientError( + {"Error": {"Code": "ThrottlingException", "Message": "slow down"}}, "ListFunctionVersionsByCapacityProvider" + ) + success = {"FunctionVersions": [{"FunctionVersion": "1"}]} + zappa_core.lambda_client.list_function_versions_by_capacity_provider.side_effect = [error, success] + + result = zappa_core.wait_for_capacity_provider_response( + function_arn=None, + capacity_provider_name="provider", + max_attempts=2, + delay_seconds=0, + ) + + self.assertEqual(result, success) + self.assertEqual(zappa_core.lambda_client.list_function_versions_by_capacity_provider.call_count, 2) + + def test_create_lambda_capacity_provider_rejects_vpc(self): + zappa_core = Zappa(load_credentials=False) + zappa_core.credentials_arn = "arn:aws:iam::123:role/zappa" + zappa_core.lambda_client = mock.MagicMock() + + with self.assertRaises(ValueError): + zappa_core.create_lambda_function( + function_name="abc", + handler="handler.lambda_handler", + bucket="bucket", + s3_key="key", + capacity_provider_config={ + "LambdaManagedInstancesCapacityProviderConfig": {"CapacityProviderArn": "arn:aws:lambda:::capacity"} + }, + vpc_config={"SubnetIds": ["subnet-1"], "SecurityGroupIds": ["sg-1"]}, + ) + @mock.patch("botocore.client") def test_set_lambda_concurrency(self, client): boto_mock = mock.MagicMock() @@ -3613,6 +3845,7 @@ def test_update_lambda_concurrency(self, client): aws_region="test", load_credentials=True, ) + zappa_core.lambda_client.get_function_configuration.return_value = {} zappa_core.lambda_client.create_function.return_value = { "FunctionArn": "abc", "Version": 1, @@ -3628,6 +3861,52 @@ def test_update_lambda_concurrency(self, client): ) boto_mock.client().delete_function_concurrency.assert_not_called() + @mock.patch("botocore.client") + def test_update_lambda_concurrency_skipped_for_capacity_provider(self, client): + boto_mock = mock.MagicMock() + zappa_core = Zappa( + boto_session=boto_mock, + profile_name="test", + aws_region="test", + load_credentials=True, + ) + zappa_core.lambda_client.create_function.return_value = { + "FunctionArn": "abc", + "Version": 1, + } + zappa_core.lambda_client.get_function_configuration.return_value = {"CapacityProviderConfig": {"foo": "bar"}} + + zappa_core.update_lambda_function(bucket="test", function_name="abc") + + boto_mock.client().put_function_concurrency.assert_not_called() + boto_mock.client().delete_function_concurrency.assert_not_called() + + def test_create_lambda_capacity_provider_skips_concurrency(self): + zappa_core = Zappa(load_credentials=False) + zappa_core.credentials_arn = "arn:aws:iam::123:role/zappa" + zappa_core.lambda_client = mock.MagicMock() + zappa_core.wait_until_lambda_function_is_active = mock.MagicMock() + zappa_core.lambda_client.create_function.return_value = { + "FunctionArn": "abc", + "Version": 1, + } + capacity_provider_config = { + "LambdaManagedInstancesCapacityProviderConfig": { + "CapacityProviderArn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider/zappa-test", + } + } + + zappa_core.create_lambda_function( + function_name="abc", + handler="handler.lambda_handler", + bucket="bucket", + s3_key="key", + capacity_provider_config=capacity_provider_config, + concurrency=5, + ) + + zappa_core.lambda_client.put_function_concurrency.assert_not_called() + @mock.patch("botocore.client") def test_delete_lambda_concurrency(self, client): boto_mock = mock.MagicMock() @@ -3637,6 +3916,7 @@ def test_delete_lambda_concurrency(self, client): aws_region="test", load_credentials=True, ) + zappa_core.lambda_client.get_function_configuration.return_value = {} zappa_core.lambda_client.create_function.return_value = { "FunctionArn": "abc", "Version": 1, diff --git a/tests/test_settings.yaml b/tests/test_settings.yaml index 618341168..c5e91dede 100644 --- a/tests/test_settings.yaml +++ b/tests/test_settings.yaml @@ -60,3 +60,10 @@ snap_start_enabled: snap_start_disabled: extends: ttt888 snap_start: None +capacity_provider_enabled: + extends: ttt888 + capacity_provider_config: + LambdaManagedInstancesCapacityProviderConfig: + CapacityProviderArn: arn:aws:lambda:us-east-1:123456789012:capacity-provider/zappa-test + PerExecutionEnvironmentMaxConcurrency: 5 + ExecutionEnvironmentMemoryGiBPerVCpu: 2.0 diff --git a/tests/test_wsgi_script_name_settings.py b/tests/test_wsgi_script_name_settings.py index d2ec45cd9..4174cf73f 100644 --- a/tests/test_wsgi_script_name_settings.py +++ b/tests/test_wsgi_script_name_settings.py @@ -10,3 +10,4 @@ LOG_LEVEL = "DEBUG" PROJECT_NAME = "wsgi_script_name_settings" COGNITO_TRIGGER_MAPPING = {} +EXCEPTION_HANDLER = None diff --git a/zappa/cli.py b/zappa/cli.py index 70bc78511..a56d7bc05 100755 --- a/zappa/cli.py +++ b/zappa/cli.py @@ -124,6 +124,7 @@ class ZappaCLI: xray_tracing = False aws_kms_key_arn = "" snap_start = None + capacity_provider_config = None context_header_mappings = None additional_text_mimetypes = None tags = [] # type: ignore[var-annotated] @@ -917,6 +918,7 @@ def deploy(self, source_zip=None, docker_image_uri=None): runtime=self.runtime, aws_environment_variables=self.aws_environment_variables, aws_kms_key_arn=self.aws_kms_key_arn, + capacity_provider_config=self.capacity_provider_config, use_alb=self.use_alb, layers=self.layers, concurrency=self.lambda_concurrency, @@ -958,7 +960,8 @@ def deploy(self, source_zip=None, docker_image_uri=None): function_name=self.lambda_arn, function_url_config=self.function_url_config, ) - self.zappa.deploy_lambda_function_url(**kwargs) + endpoint_url = self.zappa.deploy_lambda_function_url(**kwargs) + self.zappa.wait_until_lambda_function_is_updated(function_name=self.lambda_name) if self.use_apigateway: # Create and configure the API Gateway @@ -1013,9 +1016,8 @@ def deploy(self, source_zip=None, docker_image_uri=None): else: self.zappa.add_api_stage_to_api_key(api_key=self.api_key, api_id=api_id, stage_name=self.api_stage) - if self.stage_config.get("touch", True): - self.zappa.wait_until_lambda_function_is_updated(function_name=self.lambda_name) - self.touch_endpoint(endpoint_url) + if self.stage_config.get("touch", True): + self.touch_endpoint(endpoint_url) # Finally, delete the local copy our zip package if not source_zip and not docker_image_uri: @@ -1161,6 +1163,7 @@ def update(self, source_zip=None, no_upload=False, docker_image_uri=None): function_name=self.lambda_name, num_revisions=self.num_retained_versions, concurrency=self.lambda_concurrency, + capacity_provider_config=self.capacity_provider_config, ) if docker_image_uri: kwargs["docker_image_uri"] = docker_image_uri @@ -1206,6 +1209,7 @@ def update(self, source_zip=None, no_upload=False, docker_image_uri=None): aws_kms_key_arn=self.aws_kms_key_arn, layers=self.layers, snap_start=self.snap_start, + capacity_provider_config=self.capacity_provider_config, wait=False, ) @@ -1275,7 +1279,7 @@ def update(self, source_zip=None, no_upload=False, docker_image_uri=None): function_name=self.lambda_arn, function_url_config=self.function_url_config, ) - self.zappa.update_lambda_function_url(**kwargs) + endpoint_url = self.zappa.update_lambda_function_url(**kwargs)[:-1] else: self.zappa.delete_lambda_function_url(self.lambda_arn) @@ -1294,6 +1298,7 @@ def update(self, source_zip=None, no_upload=False, docker_image_uri=None): endpoint_url += "/" + self.base_path deployed_string = "Your updated Zappa deployment is " + click.style("live", fg="green", bold=True) + "!" + touch_url = endpoint_url if self.use_apigateway: deployed_string = deployed_string + ": " + click.style("{}".format(endpoint_url), bold=True) @@ -1303,13 +1308,11 @@ def update(self, source_zip=None, no_upload=False, docker_image_uri=None): if endpoint_url != api_url: deployed_string = deployed_string + " (" + api_url + ")" - - if self.stage_config.get("touch", True): - self.zappa.wait_until_lambda_function_is_updated(function_name=self.lambda_name) if api_url: - self.touch_endpoint(api_url) - elif endpoint_url: - self.touch_endpoint(endpoint_url) + touch_url = api_url + + if self.stage_config.get("touch", True): + self.touch_endpoint(touch_url) click.echo(deployed_string) @@ -1398,6 +1401,11 @@ def undeploy(self, no_confirm=False, remove_logs=False): self.zappa.undeploy_api_gateway(self.lambda_name, domain_name=domain_name, base_path=base_path) + if self.use_function_url: + if self.function_url_domains: + self.zappa.undeploy_function_url_custom_domain(self.lambda_name) + self.zappa.delete_lambda_function_url(self.lambda_arn) + self.unschedule() # removes event triggers, including warm up event. self.zappa.delete_lambda_function(self.lambda_name) @@ -1515,7 +1523,7 @@ def schedule(self): def unschedule(self): """ - Given a a list of scheduled functions, + Given a list of scheduled functions, tear down their regular execution. """ @@ -1587,6 +1595,7 @@ def invoke( invocation_type="RequestResponse", client_context=client_context, qualifier=qualifier, + log_type="Tail" if not self.capacity_provider_config else "None", ) print(self.format_lambda_response(response, not no_color)) @@ -2333,7 +2342,7 @@ def certify(self, no_confirm=True, manual=False): Register or update a domain certificate for this env. """ - if not self.domain: + if not (self.domain or self.function_url_domains): raise ClickException( "Can't certify a domain without " + click.style("domain", fg="red", bold=True) + " configured!" ) @@ -2414,19 +2423,19 @@ def certify(self, no_confirm=True, manual=False): certificate_chain = f.read() click.echo("Certifying domain " + click.style(self.domain, fg="green", bold=True) + "..") + route53 = self.stage_config.get("route53_enabled", True) - # Get cert and update domain. + # Get cert and update domain for api_gateway + if self.use_apigateway: + # Let's Encrypt + if not cert_location and not cert_arn: + from .letsencrypt import get_cert_and_update_domain - # Let's Encrypt - if not cert_location and not cert_arn: - from .letsencrypt import get_cert_and_update_domain + cert_success = get_cert_and_update_domain(self.zappa, self.lambda_name, self.api_stage, self.domain, manual) - cert_success = get_cert_and_update_domain(self.zappa, self.lambda_name, self.api_stage, self.domain, manual) + # Custom SSL / ACM + else: - # Custom SSL / ACM - else: - route53 = self.stage_config.get("route53_enabled", True) - if self.use_apigateway: if not self.zappa.get_domain_name(self.domain, route53=route53): dns_name = self.zappa.create_domain_name( domain_name=self.domain, @@ -2461,28 +2470,29 @@ def certify(self, no_confirm=True, manual=False): ) cert_success = True + if cert_success: + click.echo("Certificate " + click.style("updated", fg="green", bold=True) + "!") + else: + click.echo(click.style("Failed", fg="red", bold=True) + " to generate or install certificate! :(") + click.echo("\n==============\n") + shamelessly_promote() - if self.use_function_url: - self.lambda_arn = self.zappa.get_lambda_function(function_name=self.lambda_name) - dns_name = self.zappa.update_lambda_function_url_domains( - self.lambda_arn, self.function_url_domains, cert_arn, self.function_url_cloudfront_config - ) - if route53: - for domain in self.function_url_domains: - self.zappa.update_route53_records(domain, dns_name) - print( - "Created a new domain name with supplied certificate. " - "Please note that it can take up to 40 minutes for this domain to be " - "created and propagated through AWS, but it requires no further work on your part." - ) - cert_success = True - - if cert_success: - click.echo("Certificate " + click.style("updated", fg="green", bold=True) + "!") - else: - click.echo(click.style("Failed", fg="red", bold=True) + " to generate or install certificate! :(") - click.echo("\n==============\n") - shamelessly_promote() + if self.use_function_url: + self.lambda_arn = self.zappa.get_lambda_function(function_name=self.lambda_name) + dns_name = self.zappa.update_lambda_function_url_domains( + self.lambda_arn, + self.function_url_domains, + cert_arn, + self.function_url_cloudfront_config, + ) + if route53: + for domain in self.function_url_domains: + self.zappa.update_route53_records(domain, dns_name) + print( + "Created a new domain name with supplied certificate. " + "Please note that it can take up to 40 minutes for this domain to be " + "created and propagated through AWS, but it requires no further work on your part." + ) ## # Shell @@ -2713,6 +2723,7 @@ def load_settings(self, settings_file=None, session=None): self.runtime = self.stage_config.get("runtime", get_runtime_from_python_version()) self.aws_kms_key_arn = self.stage_config.get("aws_kms_key_arn", "") self.snap_start = self.stage_config.get("snap_start", "None") + self.capacity_provider_config = self.stage_config.get("capacity_provider_config", None) self.context_header_mappings = self.stage_config.get("context_header_mappings", {}) self.xray_tracing = self.stage_config.get("xray_tracing", False) self.desired_role_arn = self.stage_config.get("role_arn") diff --git a/zappa/core.py b/zappa/core.py index e2fdce92e..9a6213223 100644 --- a/zappa/core.py +++ b/zappa/core.py @@ -16,6 +16,7 @@ import tarfile import tempfile import time +import urllib import uuid import zipfile from io import open @@ -295,6 +296,7 @@ def __init__( self.cognito_client = self.boto_client("cognito-idp") self.sts_client = self.boto_client("sts") self.efs_client = self.boto_client("efs") + self.cloudfront_client = self.boto_client("cloudfront") self.tags = tags self.cf_template = troposphere.Template() @@ -1185,6 +1187,7 @@ def create_lambda_function( aws_environment_variables=None, aws_kms_key_arn=None, snap_start=None, + capacity_provider_config=None, xray_tracing=False, local_zip=None, use_alb=False, @@ -1211,6 +1214,14 @@ def create_lambda_function( if not layers: layers = [] + uses_capacity_provider = bool(capacity_provider_config) + uses_vpc = bool(vpc_config and (vpc_config.get("SubnetIds") or vpc_config.get("SecurityGroupIds"))) + if uses_capacity_provider and uses_vpc: + raise ValueError( + "Lambda capacity providers cannot be used with VPC configurations. " + "Remove VPC settings or disable the capacity provider." + ) + kwargs = dict( FunctionName=function_name, Role=self.credentials_arn, @@ -1230,6 +1241,9 @@ def create_lambda_function( # zappa currently only supports a single architecture, and uses a str value internally Architectures=[self.architecture], ) + if capacity_provider_config: + kwargs["CapacityProviderConfig"] = capacity_provider_config + kwargs.pop("VpcConfig") if not docker_image_uri: kwargs["Runtime"] = runtime kwargs["Handler"] = handler @@ -1266,7 +1280,12 @@ def create_lambda_function( if self.tags: self.lambda_client.tag_resource(Resource=resource_arn, Tags=self.tags) - if concurrency is not None: + if uses_capacity_provider: + if concurrency is not None: + logger.warning( + "Reserved concurrency is not supported with Lambda capacity providers; skipping reserved concurrency." + ) + elif concurrency is not None: self.lambda_client.put_function_concurrency( FunctionName=resource_arn, ReservedConcurrentExecutions=concurrency, @@ -1286,6 +1305,7 @@ def update_lambda_function( local_zip=None, num_revisions=None, concurrency=None, + capacity_provider_config=None, docker_image_uri=None, ): """ @@ -1295,7 +1315,7 @@ def update_lambda_function( """ logger.info("Updating Lambda function code..") - kwargs = dict(FunctionName=function_name, Publish=publish) + kwargs = dict(FunctionName=function_name, Publish=publish, Architectures=[self.architecture]) if docker_image_uri: kwargs["ImageUri"] = docker_image_uri elif local_zip: @@ -1332,28 +1352,36 @@ def update_lambda_function( Name=ALB_LAMBDA_ALIAS, ) - if concurrency is not None: - self.lambda_client.put_function_concurrency( - FunctionName=function_name, - ReservedConcurrentExecutions=concurrency, - ) + uses_capacity_provider = bool(capacity_provider_config) + if not uses_capacity_provider: + try: + function_configuration = self.lambda_client.get_function_configuration(FunctionName=function_name) + uses_capacity_provider = bool(function_configuration.get("CapacityProviderConfig")) + except botocore.exceptions.ClientError: + uses_capacity_provider = False + + if uses_capacity_provider: + if concurrency is not None: + logger.warning( + "Reserved concurrency is not supported with Lambda capacity providers; skipping reserved concurrency." + ) else: - self.lambda_client.delete_function_concurrency(FunctionName=function_name) + if concurrency is not None: + self.lambda_client.put_function_concurrency( + FunctionName=function_name, + ReservedConcurrentExecutions=concurrency, + ) + else: + self.lambda_client.delete_function_concurrency(FunctionName=function_name) if num_revisions: # Find the existing revision IDs for the given function # Related: https://github.com/Miserlou/Zappa/issues/1402 - versions_in_lambda = [] - versions = self.lambda_client.list_versions_by_function(FunctionName=function_name) - for version in versions["Versions"]: - versions_in_lambda.append(version["Version"]) - while "NextMarker" in versions: - versions = self.lambda_client.list_versions_by_function( - FunctionName=function_name, Marker=versions["NextMarker"] - ) - for version in versions["Versions"]: - versions_in_lambda.append(version["Version"]) + versions_in_lambda = self.list_lambda_function_versions(function_name) versions_in_lambda.remove("$LATEST") + + if "$LATEST.PUBLISHED" in versions_in_lambda: + versions_in_lambda.remove("$LATEST.PUBLISHED") # Delete older revisions if their number exceeds the specified limit for version in versions_in_lambda[::-1][num_revisions:]: self.lambda_client.delete_function(FunctionName=function_name, Qualifier=version) @@ -1362,6 +1390,17 @@ def update_lambda_function( return resource_arn + def list_lambda_function_versions(self, function_name): + versions_in_lambda = [] + versions = self.lambda_client.list_versions_by_function(FunctionName=function_name) + for version in versions["Versions"]: + versions_in_lambda.append(version["Version"]) + while "NextMarker" in versions: + versions = self.lambda_client.list_versions_by_function(FunctionName=function_name, Marker=versions["NextMarker"]) + for version in versions["Versions"]: + versions_in_lambda.append(version["Version"]) + return versions_in_lambda + def update_lambda_configuration( self, lambda_arn, @@ -1379,6 +1418,7 @@ def update_lambda_configuration( aws_kms_key_arn=None, layers=None, snap_start=None, + capacity_provider_config=None, wait=True, ): """ @@ -1399,6 +1439,14 @@ def update_lambda_configuration( if not layers: layers = [] + uses_capacity_provider = bool(capacity_provider_config) + uses_vpc = bool(vpc_config and (vpc_config.get("SubnetIds") or vpc_config.get("SecurityGroupIds"))) + if uses_capacity_provider and uses_vpc: + raise ValueError( + "Lambda capacity providers cannot be used with VPC configurations. " + "Remove VPC settings or disable the capacity provider." + ) + if wait: # Wait until function is ready, otherwise expected keys will be missing from 'lambda_aws_config'. self.wait_until_lambda_function_is_updated(function_name) @@ -1428,6 +1476,10 @@ def update_lambda_configuration( "SnapStart": {"ApplyOn": snap_start if snap_start else "None"}, } + if capacity_provider_config: + kwargs["CapacityProviderConfig"] = capacity_provider_config + kwargs.pop("VpcConfig") + if lambda_aws_config.get("PackageType", None) != "Image": kwargs.update( { @@ -1440,6 +1492,41 @@ def update_lambda_configuration( response = self.lambda_client.update_function_configuration(**kwargs) resource_arn = response["FunctionArn"] + if capacity_provider_config: + + capacity_provider_arn = capacity_provider_config["LambdaManagedInstancesCapacityProviderConfig"][ + "CapacityProviderArn" + ] + capacity_provider_name_part = capacity_provider_arn + if "capacity-provider/" in capacity_provider_name_part: + capacity_provider_name_part = capacity_provider_name_part.split("capacity-provider/", 1)[1] + elif "capacity-provider:" in capacity_provider_name_part: + capacity_provider_name_part = capacity_provider_name_part.split("capacity-provider:", 1)[1] + capacity_provider_name = capacity_provider_name_part.rsplit("/", 1)[-1] + + versions_in_lambda = self.list_lambda_function_versions(function_name=function_name) + + if versions_in_lambda: + # wait for latest version + latest_version = max(int(v) for v in versions_in_lambda if v.isdigit()) + + if latest_version: + self.wait_for_capacity_provider_response( + capacity_provider_name=capacity_provider_name, + function_arn=f"{response["FunctionArn"]}:{latest_version}", + function_state="Active", + ) + + # publish to latest + response = self.lambda_client.publish_version(FunctionName=function_name, PublishTo="LATEST_PUBLISHED") + logger.info(f"Publish to {response['FunctionArn']}") + + time.sleep(10) + self.wait_for_capacity_provider_response( + capacity_provider_name=capacity_provider_name, + function_arn=response["FunctionArn"], + function_state="Active", + ) if self.tags: self.lambda_client.tag_resource(Resource=resource_arn, Tags=self.tags) @@ -1528,6 +1615,93 @@ def wait_until_lambda_function_is_updated(self, function_name): logger.info(f"Waiting for lambda function [{function_name}] to be updated...") waiter.wait(FunctionName=function_name) + def wait_for_capacity_provider_response( + self, + function_arn: str, + capacity_provider_name: str, + function_state: str = "Active", + marker: str | None = None, + max_attempts: int = 60, + delay_seconds: float = 5, + ): + """ + Call list_function_versions_by_capacity_provider with retry. + + If function_arn is provided: + - function_state="Active": poll until the matching FunctionVersion has State=Active + - function_state="Empty": poll until the matching FunctionVersion record is no longer returned + In both modes, if the matching record is ever seen in State=Failed, raises RuntimeError. + Returns the last API response seen. + """ + if not capacity_provider_name: + raise ValueError("capacity_provider_name is required.") + + normalized_state = (function_state or "").strip().lower() + if normalized_state not in ("active", "empty"): + raise ValueError("function_state must be 'Active' or 'Empty'.") + + list_kwargs = {"CapacityProviderName": capacity_provider_name} + if marker: + list_kwargs["Marker"] = marker + + logger.info(f"Wait for {function_arn} to be {function_state} ...") + last_response = None + for attempt in range(1, max_attempts + 1): + try: + response = self.lambda_client.list_function_versions_by_capacity_provider(**list_kwargs) + last_response = response + except botocore.exceptions.ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + if error_code in ("ThrottlingException", "Throttling", "TooManyRequestsException") and attempt < max_attempts: + time.sleep(delay_seconds) + continue + raise + + if not function_arn: + return response + + matched_item = None + page = response or {} + while True: + for item in page.get("FunctionVersions") or []: + arn = item.get("FunctionArn") or "" + if function_arn in arn: + matched_item = item + break + if matched_item: + break + + next_marker = page.get("NextMarker") + if not next_marker: + break + + page = self.lambda_client.list_function_versions_by_capacity_provider( + CapacityProviderName=capacity_provider_name, + Marker=next_marker, + ) + last_response = page + + if matched_item: + state = matched_item.get("State") + logger.info(f"{attempt}/{max_attempts} attempts: current state {state}, expect {function_state}") + if state == "Failed": + raise RuntimeError( + f"Function version [{matched_item.get('FunctionArn')}] entered Failed state under " + f"capacity provider [{capacity_provider_name}]." + ) + if normalized_state == "active" and state == "Active": + return last_response + else: + if normalized_state == "empty": + return last_response + + time.sleep(delay_seconds) + + raise TimeoutError( + f"Timed out waiting for function [{function_arn}] to become {function_state} under capacity provider " + f"[{capacity_provider_name}] after {max_attempts} attempts." + ) + def get_lambda_function(self, function_name): """ Returns the lambda function ARN, given a name @@ -1624,7 +1798,7 @@ def deploy_lambda_function_url(self, function_name, function_url_config): response = self.lambda_client.create_function_url_config( FunctionName=function_name, AuthType=function_url_config["authorizer"] ) - print("function URL address: {}".format(response["FunctionUrl"])) + logger.info("function URL address: {}".format(response["FunctionUrl"])) self.update_function_url_policy(function_name, function_url_config) return response @@ -1647,12 +1821,14 @@ def update_lambda_function_url(self, function_name, function_url_config): ) else: response = self.lambda_client.update_function_url_config( - FunctionName=function_name, AuthType=function_url_config["authorizer"] + FunctionName=function_name, + AuthType=function_url_config["authorizer"], ) - print("function URL address: {}".format(response["FunctionUrl"])) + logger.info("function URL address: {}".format(response["FunctionUrl"])) self.update_function_url_policy(config["FunctionArn"], function_url_config) + return response["FunctionUrl"] else: - self.deploy_lambda_function_url(function_name, function_url_config) + return self.deploy_lambda_function_url(function_name, function_url_config) def delete_lambda_function_url(self, function_name): try: @@ -1666,7 +1842,7 @@ def delete_lambda_function_url(self, function_name): for config in response.get("FunctionUrlConfigs", []): resp = self.lambda_client.delete_function_url_config(FunctionName=config["FunctionArn"]) if resp["ResponseMetadata"]["HTTPStatusCode"] == 204: - print("function URL deleted: {}".format(config["FunctionUrl"])) + logger.info("function URL deleted: {}".format(config["FunctionUrl"])) self.delete_function_url_policy(config["FunctionArn"]) ## @@ -1675,9 +1851,8 @@ def delete_lambda_function_url(self, function_name): def update_lambda_function_url_domains(self, function_name, function_url_domains, certificate_arn, cloudfront_config): response = self.lambda_client.list_function_url_configs(FunctionName=function_name, MaxItems=50) if not response.get("FunctionUrlConfigs", []): - print("no function url configured on lambda, skip setting custom domains") + logger.info("no function url configured on lambda, skip setting custom domains") url = response["FunctionUrlConfigs"][0]["FunctionUrl"] - import urllib url = urllib.parse.urlparse(url) @@ -1754,7 +1929,7 @@ def update_lambda_function_url_domains(self, function_name, function_url_domains if not distributions: response = self.cloudfront_client.create_distribution(DistributionConfig=config) if response["ResponseMetadata"]["HTTPStatusCode"] == 201: - print( + logger.info( "created cloudfront distribution for {}. It will take a while for the change to be deployed.".format( function_url_domains ) @@ -1764,13 +1939,15 @@ def update_lambda_function_url_domains(self, function_name, function_url_domains id = distributions[0]["Id"] distribution = self.cloudfront_client.get_distribution(Id=id) new_config = distribution["Distribution"]["DistributionConfig"] - new_config.update(config) + updates = config.copy() + updates.pop("CallerReference") + new_config.update(updates) response = self.cloudfront_client.update_distribution( DistributionConfig=new_config, Id=id, IfMatch=distribution["ETag"] ) if response["ResponseMetadata"]["HTTPStatusCode"] == 200: - print( + logger.info( "update cloudfront distribution for {}. It will take a while for the change to be deployed.".format( function_url_domains ) @@ -2599,6 +2776,41 @@ def get_rest_apis(self, project_name): continue yield api + def undeploy_function_url_custom_domain(self, lambda_name): + + response = self.lambda_client.list_function_url_configs(FunctionName=lambda_name, MaxItems=50) + if not response.get("FunctionUrlConfigs", []): + logger.info("no function url configured on lambda. skip delete custom domains") + url = response["FunctionUrlConfigs"][0]["FunctionUrl"] + url = urllib.parse.urlparse(url) + distributions = self.cloudfront_client.list_distributions() + distributions = [ + item + for item in distributions["DistributionList"]["Items"] + if url.hostname in [origin["DomainName"] for origin in item["Origins"]["Items"]] + ] + + for distribution in distributions: + id = distribution["Id"] + distribution = self.cloudfront_client.get_distribution(Id=id) + new_config = distribution["Distribution"]["DistributionConfig"] + new_config["Enabled"] = False + + response = self.cloudfront_client.update_distribution( + DistributionConfig=new_config, Id=id, IfMatch=distribution["ETag"] + ) + response + logger.info("wait for distribution to be disabled.") + waiter = self.cloudfront_client.get_waiter("distribution_deployed") + waiter.wait(Id=id, WaiterConfig={"Delay": 20, "MaxAttempts": 20}) + delete_response = self.cloudfront_client.delete_distribution(Id=id, IfMatch=response["ETag"]) + if delete_response["ResponseMetadata"]["HTTPStatusCode"] == 204: + logger.info("cloudfront distribution deleted") + + # attempt remove 53 record + for domain in distribution["Distribution"]["DistributionConfig"]["Aliases"].get("Items", []): + self.delete_route53_records(domain, distribution["Distribution"]["DomainName"]) + def undeploy_api_gateway(self, lambda_name, domain_name=None, base_path=None): """ Delete a deployed REST API Gateway. @@ -3046,6 +3258,41 @@ def update_route53_records(self, domain_name, dns_name): return response + def delete_route53_records(self, domain_name, dns_name): + """ + Updates Route53 Records following GW domain creation + """ + zone_id = self.get_hosted_zone_id_for_domain(domain_name) + + is_apex = self.route53.get_hosted_zone(Id=zone_id)["HostedZone"]["Name"][:-1] == domain_name + if is_apex: + record_set = { + "Name": domain_name, + "Type": "A", + "AliasTarget": { + "HostedZoneId": "Z2FDTNDATAQYW2", # This is a magic value that means "CloudFront" + "DNSName": dns_name, + "EvaluateTargetHealth": False, + }, + } + else: + record_set = { + "Name": domain_name, + "Type": "CNAME", + "ResourceRecords": [{"Value": dns_name}], + "TTL": 60, + } + + response = self.route53.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={"Changes": [{"Action": "DELETE", "ResourceRecordSet": record_set}]}, + ) + + if response["ResponseMetadata"]["HTTPStatusCode"] == 200: + logger.info("removed route 53 record for {}".format(domain_name)) + + return response + def update_domain_name( self, domain_name, @@ -3084,19 +3331,26 @@ def update_domain_name( ) certificate_arn = acm_certificate["CertificateArn"] - self.update_domain_base_path_mapping(domain_name, lambda_name, stage, base_path) + if self.apigateway: + self.update_domain_base_path_mapping(domain_name, lambda_name, stage, base_path) - return self.apigateway_client.update_domain_name( - domainName=domain_name, - patchOperations=[ - { - "op": "replace", - "path": "/certificateName", - "value": certificate_name, - }, - {"op": "replace", "path": "/certificateArn", "value": certificate_arn}, - ], - ) + res = self.apigateway_client.update_domain_name( + domainName=domain_name, + patchOperations=[ + { + "op": "replace", + "path": "/certificateName", + "value": certificate_name, + }, + { + "op": "replace", + "path": "/certificateArn", + "value": certificate_arn, + }, + ], + ) + + return res def update_domain_base_path_mapping(self, domain_name, lambda_name, stage, base_path): """ @@ -3244,12 +3498,11 @@ def _clear_policy(self, lambda_name): if policy_response["ResponseMetadata"]["HTTPStatusCode"] == 200: statement = json.loads(policy_response["Policy"])["Statement"] for s in statement: - # Only remove CloudWatch Events permissions (created by schedule_events) - principal = s.get("Principal", {}) - if isinstance(principal, dict) and principal.get("Service") == "events.amazonaws.com": - delete_response = self.lambda_client.remove_permission(FunctionName=lambda_name, StatementId=s["Sid"]) - if delete_response["ResponseMetadata"]["HTTPStatusCode"] != 204: - logger.error("Failed to delete an obsolete policy statement: {}".format(policy_response)) + if s["Sid"] in ["FunctionURLAllowPublicAccess"]: + continue + delete_response = self.lambda_client.remove_permission(FunctionName=lambda_name, StatementId=s["Sid"]) + if delete_response["ResponseMetadata"]["HTTPStatusCode"] != 204: + logger.error("Failed to delete an obsolete policy statement: {}".format(policy_response)) else: logger.debug("Failed to load Lambda function policy: {}".format(policy_response)) except ClientError as e: