From 301e15dfa221fde6ea0e8190da38b8f3fb5d234d Mon Sep 17 00:00:00 2001 From: Damian Fuentes Date: Wed, 22 Jul 2026 16:11:10 -0700 Subject: [PATCH 1/2] full snapstart and provisioned concurrency support --- README.md | 6 +- tests/test_core.py | 404 ++++++++++++++++++++++++++++++++++++++- tests/test_settings.yaml | 25 +++ tests/test_websocket.py | 42 ++++ zappa/cli.py | 48 ++++- zappa/core.py | 237 +++++++++++++++++++---- 6 files changed, 721 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index ca4015f75..86e7690f3 100644 --- a/README.md +++ b/README.md @@ -1180,6 +1180,7 @@ to change Zappa's behavior. Use these at your own risk! // If you are adding a Function URL manually (e.g. outside Zappa) and seeing 403s with NONE // auth, this two-statement shape is the missing piece. "apigateway_version": "v1", // optional, API Gateway version to use. Can be "v1" or "v2". Default "v1". + "apigateway_lambda_qualifier": "live", // optional, Lambda version or alias for API Gateway integrations. Default null uses the unqualified function ARN, except when `snap_start` or `provisioned_concurrency` is enabled, in which case it defaults to Zappa's managed alias for that feature (see `snap_start`/`provisioned_concurrency` below). Set this only to point at your own alias instead. "architecture": "x86_64", // optional, Set Lambda Architecture, defaults to x86_64. For Graviton 2 use: arm64 "async_source": "sns", // Source of async tasks. Defaults to "lambda" "async_resources": true, // Create the SNS topic and DynamoDB table to use. Defaults to true. @@ -1263,7 +1264,7 @@ 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. If `provisioned_concurrency` is also set, it cannot exceed this value. "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. @@ -1276,6 +1277,7 @@ to change Zappa's behavior. Use these at your own risk! "prebuild_script": "your_module.your_function", // Function to execute before uploading code "profile_name": "your-profile-name", // AWS profile credentials to use. Default 'default'. Removing this setting will use the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables instead. "project_name": "MyProject", // The name of the project as it appears on AWS. Defaults to a slugified `pwd`. + "provisioned_concurrency": 5, // Number of pre-initialized execution environments to keep warm. Default null (disabled). Provisioned concurrency can only be configured on a published version or alias, so Zappa maintains a "provisioned-concurrency" alias automatically: each `zappa update`/`deploy` configures it on the new version, waits for it to become Ready, then repoints the alias (and cleans up the old version's config) — and points API Gateway at it via `apigateway_lambda_qualifier` unless you've overridden that setting. Cannot be combined with `snap_start` (AWS doesn't support SnapStart with provisioned concurrency), and cannot exceed `lambda_concurrency` if that's also set. "remote_env": "s3://my-project-config-files/filename.json", // optional file in s3 bucket containing a flat json object which will be used to set custom environment variables. "role_name": "MyLambdaRole", // Name of Zappa execution role. Default --ZappaExecutionRole. To use a different, pre-existing policy, you must also set manage_roles to false. "role_arn": "arn:aws:iam::12345:role/app-ZappaLambdaExecutionRole", // ARN of Zappa execution role. Default to None. To use a different, pre-existing policy, you must also set manage_roles to false. This overrides role_name. Use with temporary credentials via GetFederationToken. @@ -1283,7 +1285,7 @@ to change Zappa's behavior. Use these at your own risk! "runtime": "python3.14", // Python runtime to use on Lambda. Can be one of: "python3.9", "python3.10", "python3.11", "python3.12", "python3.13", or "python3.14". Defaults to whatever the current Python being used is. "s3_bucket": "dev-bucket", // Zappa zip bucket, "slim_handler": false, // Useful if project >50M. Set true to just upload a small handler to Lambda and load actual project from S3 at runtime. Default false. - "snap_start": "PublishedVersions", // Enable Lambda SnapStart for faster cold starts. Can be "PublishedVersions" or "None". Default "None". + "snap_start": "PublishedVersions", // Enable Lambda SnapStart for faster cold starts. Can be "PublishedVersions" or "None". Default "None". SnapStart requires invoking a published version or alias (never `$LATEST`), so Zappa maintains a "snapstart" alias automatically: each `zappa update`/`deploy` publishes a new version, waits for its snapshot to be Active, then repoints the alias — and points API Gateway at it via `apigateway_lambda_qualifier` unless you've overridden that setting. Cannot be combined with `provisioned_concurrency` (AWS doesn't support SnapStart with provisioned concurrency). "settings_file": "~/Projects/MyApp/settings/dev_settings.py", // Server side settings file location, "tags": { // Attach additional tags to AWS Resources "Key": "Value", // Example Key and value diff --git a/tests/test_core.py b/tests/test_core.py index 2d2dca8d7..aaf3371da 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -28,7 +28,14 @@ from packaging import version from zappa.cli import ZappaCLI, disable_click_colors, shamelessly_promote -from zappa.core import ALB_LAMBDA_ALIAS, ASSUME_POLICY, ATTACH_POLICY, Zappa +from zappa.core import ( + ALB_LAMBDA_ALIAS, + ASSUME_POLICY, + ATTACH_POLICY, + PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, + SNAPSTART_LAMBDA_ALIAS, + Zappa, +) from zappa.letsencrypt import ( create_chained_certificate, create_domain_csr, @@ -820,6 +827,57 @@ def test_create_api_gateway_v2_with_cors(self): self.assertEqual(["Content-Type"], cors_config["AllowHeaders"]) self.assertEqual(3600, cors_config["MaxAge"]) + def test_create_api_gateway_with_lambda_qualifier(self): + """Test API Gateway integrations target a qualified Lambda ARN when configured.""" + z = Zappa() + z.parameter_depth = 1 + z.integration_response_codes = [200] + z.method_response_codes = [200] + z.http_methods = ["GET"] + z.credentials_arn = "arn:aws:iam::12345:role/ZappaLambdaExecution" + lambda_arn = "arn:aws:lambda:us-east-1:12345:function:helloworld" + qualified_lambda_arn = lambda_arn + ":live" + + # v1 integration URI should include the qualifier. + z.create_stack_template( + lambda_arn, + "helloworld", + api_key_required=False, + iam_authorization=False, + authorizer=None, + lambda_qualifier="live", + ) + parsable_template = json.loads(z.cf_template.to_json()) + expected_v1_uri = ( + "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/" + + qualified_lambda_arn + + "/invocations" + ) + self.assertEqual( + expected_v1_uri, + parsable_template["Resources"]["GET0"]["Properties"]["Integration"]["Uri"], + ) + + # v2 integration URI and permission target should include the qualifier. + z.create_stack_template( + lambda_arn, + "helloworld", + api_key_required=False, + iam_authorization=False, + authorizer=None, + apigateway_version="v2", + lambda_qualifier="live", + ) + parsable_template = json.loads(z.cf_template.to_json()) + self.assertEqual( + qualified_lambda_arn, + parsable_template["Resources"]["IntegrationV2"]["Properties"]["IntegrationUri"], + ) + self.assertEqual( + qualified_lambda_arn, + parsable_template["Resources"]["ApiInvokePermissionV2"]["Properties"]["FunctionName"], + ) + def test_policy_json(self): # ensure the policy docs are valid JSON json.loads(ASSUME_POLICY) @@ -902,6 +960,34 @@ def test_snap_start_configuration(self): zappa_cli.load_settings("tests/test_settings.yaml") self.assertEqual("None", zappa_cli.snap_start) + def test_apigateway_lambda_qualifier_configuration(self): + """Test that API Gateway Lambda qualifier is loaded from settings.""" + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "apigateway_lambda_qualifier_enabled" + zappa_cli.load_settings("tests/test_settings.yaml") + self.assertEqual("live", zappa_cli.apigateway_lambda_qualifier) + + def test_apigateway_lambda_qualifier_defaults_to_snapstart_alias(self): + """ + Test that, when SnapStart is enabled and no explicit qualifier is + set, API Gateway is automatically pointed at the Zappa-managed + SnapStart alias rather than requiring manual configuration. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "snap_start_enabled_no_qualifier" + zappa_cli.load_settings("tests/test_settings.yaml") + self.assertEqual(SNAPSTART_LAMBDA_ALIAS, zappa_cli.apigateway_lambda_qualifier) + + def test_apigateway_lambda_qualifier_explicit_overrides_snapstart_default(self): + """ + Test that an explicit apigateway_lambda_qualifier setting is not + overridden by the SnapStart alias default. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "snap_start_enabled_explicit_qualifier" + zappa_cli.load_settings("tests/test_settings.yaml") + self.assertEqual("custom-alias", zappa_cli.apigateway_lambda_qualifier) + @mock.patch("botocore.client") def test_snap_start_passed_to_create_lambda_function(self, client): """ @@ -923,6 +1009,56 @@ def test_snap_start_passed_to_create_lambda_function(self, client): create_call_kwargs = zappa_core.lambda_client.create_function.call_args[1] self.assertEqual(create_call_kwargs["SnapStart"], {"ApplyOn": "PublishedVersions"}) + def test_snap_start_creates_alias_after_version_is_active(self): + """ + Test that create_lambda_function waits for the newly published + version's SnapStart snapshot to become active before creating the + SnapStart alias that points at it. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + mock_client.create_function.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + "Version": "1", + } + + z.create_lambda_function( + function_name="test", + handler="handler.lambda_handler", + snap_start="PublishedVersions", + ) + + wait_call = mock.call.get_waiter("published_version_active").wait(FunctionName="test", Qualifier="1") + create_alias_call = mock.call.create_alias( + FunctionName="arn:aws:lambda:us-east-1:123:function:test", + FunctionVersion="1", + Name=SNAPSTART_LAMBDA_ALIAS, + ) + calls = mock_client.mock_calls + self.assertIn(wait_call, calls) + self.assertIn(create_alias_call, calls) + self.assertLess(calls.index(wait_call), calls.index(create_alias_call)) + + def test_snap_start_disabled_does_not_create_alias(self): + """ + Test that no SnapStart alias is created when SnapStart is disabled. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + mock_client.create_function.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + "Version": "1", + } + + z.create_lambda_function(function_name="test", handler="handler.lambda_handler", snap_start=None) + + for call in mock_client.create_alias.call_args_list: + self.assertNotEqual(call.kwargs.get("Name"), SNAPSTART_LAMBDA_ALIAS) + def test_snap_start_publishes_version_after_config_update(self): """ Test that update_lambda_configuration publishes a new version when @@ -941,7 +1077,7 @@ def test_snap_start_publishes_version_after_config_update(self): "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test:2", "Version": "2", } - # ALB alias does not exist + # Neither the ALB nor the SnapStart alias exists yet mock_client.get_alias.side_effect = botocore.exceptions.ClientError( {"Error": {"Code": "ResourceNotFoundException", "Message": ""}}, "GetAlias", @@ -956,6 +1092,19 @@ def test_snap_start_publishes_version_after_config_update(self): mock_client.publish_version.assert_called_once_with(FunctionName="test") + # The version must be confirmed active before it's published anywhere... + wait_call = mock.call.get_waiter("published_version_active").wait(FunctionName="test", Qualifier="2") + # ...and since the SnapStart alias doesn't exist yet, it's created. + create_alias_call = mock.call.create_alias( + FunctionName="test", + FunctionVersion="2", + Name=SNAPSTART_LAMBDA_ALIAS, + ) + calls = mock_client.mock_calls + self.assertIn(wait_call, calls) + self.assertIn(create_alias_call, calls) + self.assertLess(calls.index(wait_call), calls.index(create_alias_call)) + def test_snap_start_disabled_does_not_publish_extra_version(self): """ Test that update_lambda_configuration does NOT publish an extra version @@ -982,7 +1131,8 @@ def test_snap_start_disabled_does_not_publish_extra_version(self): def test_snap_start_updates_alb_alias_after_publish(self): """ Test that when snap_start publishes a new version, the ALB alias - is updated to point to the new version. + and the SnapStart alias are both updated to point to the new + version, only after its snapshot is confirmed active. """ z = Zappa() z.credentials_arn = object() @@ -996,7 +1146,7 @@ def test_snap_start_updates_alb_alias_after_publish(self): "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test:3", "Version": "3", } - # ALB alias exists + # Both the ALB alias and the SnapStart alias already exist mock_client.get_alias.return_value = { "AliasArn": "arn:aws:lambda:us-east-1:123:function:test:current-alb-version", "Name": "current-alb-version", @@ -1010,11 +1160,255 @@ def test_snap_start_updates_alb_alias_after_publish(self): snap_start="PublishedVersions", ) - mock_client.update_alias.assert_called_once_with( + self.assertEqual(mock_client.update_alias.call_count, 2) + mock_client.update_alias.assert_any_call( FunctionName="test", FunctionVersion="3", Name="current-alb-version", ) + mock_client.update_alias.assert_any_call( + FunctionName="test", + FunctionVersion="3", + Name=SNAPSTART_LAMBDA_ALIAS, + ) + + wait_call = mock.call.get_waiter("published_version_active").wait(FunctionName="test", Qualifier="3") + calls = mock_client.mock_calls + self.assertIn(wait_call, calls) + self.assertLess( + calls.index(wait_call), + calls.index(mock.call.update_alias(FunctionName="test", FunctionVersion="3", Name="current-alb-version")), + ) + + def test_apigateway_lambda_qualifier_defaults_to_provisioned_concurrency_alias(self): + """ + Test that, when provisioned concurrency is enabled and no explicit + qualifier is set, API Gateway is automatically pointed at the + Zappa-managed provisioned-concurrency alias. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "provisioned_concurrency_enabled_no_qualifier" + zappa_cli.load_settings("tests/test_settings.yaml") + self.assertEqual(PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, zappa_cli.apigateway_lambda_qualifier) + + def test_apigateway_lambda_qualifier_explicit_overrides_provisioned_concurrency_default(self): + """ + Test that an explicit apigateway_lambda_qualifier setting is not + overridden by the provisioned-concurrency alias default. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "provisioned_concurrency_enabled_explicit_qualifier" + zappa_cli.load_settings("tests/test_settings.yaml") + self.assertEqual("custom-alias", zappa_cli.apigateway_lambda_qualifier) + + def test_snap_start_and_provisioned_concurrency_mutually_exclusive_raises(self): + """ + Test that enabling both snap_start and provisioned_concurrency + raises at settings-load time, since AWS Lambda doesn't support + SnapStart together with provisioned concurrency. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "provisioned_concurrency_and_snap_start_conflict" + with self.assertRaises(ClickException): + zappa_cli.load_settings("tests/test_settings.yaml") + + def test_provisioned_concurrency_exceeding_reserved_concurrency_raises(self): + """ + Test that provisioned_concurrency > lambda_concurrency (reserved + concurrency) raises at settings-load time rather than failing later + as an AWS API error. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "provisioned_concurrency_exceeds_reserved" + with self.assertRaises(ClickException): + zappa_cli.load_settings("tests/test_settings.yaml") + + def test_wait_until_provisioned_concurrency_is_ready_polls_until_ready(self): + """ + Test that the provisioned-concurrency wait helper polls + get_provisioned_concurrency_config until Status becomes READY. + """ + z = Zappa() + + with mock.patch.object(z, "lambda_client") as mock_client: + mock_client.get_provisioned_concurrency_config.side_effect = [ + {"Status": "IN_PROGRESS"}, + {"Status": "IN_PROGRESS"}, + {"Status": "READY"}, + ] + sleeps = [] + + z.wait_until_lambda_function_provisioned_concurrency_is_ready( + "test", "3", poll_interval=1, sleep_func=sleeps.append + ) + + self.assertEqual(mock_client.get_provisioned_concurrency_config.call_count, 3) + self.assertEqual(sleeps, [1, 1]) + + def test_wait_until_provisioned_concurrency_raises_on_failed_status(self): + """ + Test that the provisioned-concurrency wait helper raises when AWS + reports the initialization failed, instead of polling forever. + """ + z = Zappa() + + with mock.patch.object(z, "lambda_client") as mock_client: + mock_client.get_provisioned_concurrency_config.return_value = { + "Status": "FAILED", + "StatusReason": "Insufficient capacity", + } + + with self.assertRaises(RuntimeError): + z.wait_until_lambda_function_provisioned_concurrency_is_ready("test", "3", sleep_func=lambda s: None) + + def test_provisioned_concurrency_creates_alias_after_ready(self): + """ + Test that create_lambda_function waits for the version to be active + and its provisioned concurrency to be ready before creating the + provisioned-concurrency alias that points at it. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + mock_client.create_function.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + "Version": "1", + } + mock_client.get_provisioned_concurrency_config.return_value = {"Status": "READY"} + + z.create_lambda_function( + function_name="test", + handler="handler.lambda_handler", + provisioned_concurrency=5, + ) + + mock_client.put_provisioned_concurrency_config.assert_called_once_with( + FunctionName="arn:aws:lambda:us-east-1:123:function:test", + Qualifier="1", + ProvisionedConcurrentExecutions=5, + ) + + ready_call = mock.call.get_provisioned_concurrency_config(FunctionName="test", Qualifier="1") + create_alias_call = mock.call.create_alias( + FunctionName="arn:aws:lambda:us-east-1:123:function:test", + FunctionVersion="1", + Name=PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, + ) + calls = mock_client.mock_calls + self.assertIn(ready_call, calls) + self.assertIn(create_alias_call, calls) + self.assertLess(calls.index(ready_call), calls.index(create_alias_call)) + + def test_provisioned_concurrency_migrates_alias_after_update(self): + """ + Test that update_lambda_function waits for the newly published + version to be active and its provisioned concurrency to be ready + before migrating the provisioned-concurrency alias to it. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + mock_client.update_function_code.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + "Version": "4", + } + # No alias exists yet (ALB or provisioned-concurrency) + mock_client.get_alias.side_effect = botocore.exceptions.ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": ""}}, + "GetAlias", + ) + mock_client.get_provisioned_concurrency_config.return_value = {"Status": "READY"} + + z.update_lambda_function( + bucket="test-bucket", + function_name="test", + s3_key="test.zip", + provisioned_concurrency=5, + ) + + mock_client.put_provisioned_concurrency_config.assert_called_once_with( + FunctionName="test", + Qualifier="4", + ProvisionedConcurrentExecutions=5, + ) + mock_client.create_alias.assert_any_call( + FunctionName="test", + FunctionVersion="4", + Name=PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, + ) + + ready_call = mock.call.get_provisioned_concurrency_config(FunctionName="test", Qualifier="4") + alias_call = mock.call.create_alias( + FunctionName="test", FunctionVersion="4", Name=PROVISIONED_CONCURRENCY_LAMBDA_ALIAS + ) + calls = mock_client.mock_calls + self.assertIn(ready_call, calls) + self.assertIn(alias_call, calls) + self.assertLess(calls.index(ready_call), calls.index(alias_call)) + + def test_provisioned_concurrency_skips_cleanup_on_first_deploy(self): + """ + Test that no old-version provisioned-concurrency cleanup happens + when the alias doesn't already exist (e.g. PC was just turned on). + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + mock_client.update_function_code.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + "Version": "1", + } + mock_client.get_alias.side_effect = botocore.exceptions.ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": ""}}, + "GetAlias", + ) + mock_client.get_provisioned_concurrency_config.return_value = {"Status": "READY"} + + z.update_lambda_function( + bucket="test-bucket", + function_name="test", + s3_key="test.zip", + provisioned_concurrency=5, + ) + + mock_client.delete_provisioned_concurrency_config.assert_not_called() + + def test_provisioned_concurrency_cleans_up_old_version(self): + """ + Test that update_lambda_function deletes the previous version's + provisioned-concurrency config after migrating the alias to the new + version, so nobody keeps paying for capacity nothing routes to. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + mock_client.update_function_code.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + "Version": "4", + } + # The provisioned-concurrency alias already exists, pointed at version 2 + mock_client.get_alias.return_value = { + "AliasArn": "arn:aws:lambda:us-east-1:123:function:test:provisioned-concurrency", + "Name": PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, + "FunctionVersion": "2", + } + mock_client.get_provisioned_concurrency_config.return_value = {"Status": "READY"} + + z.update_lambda_function( + bucket="test-bucket", + function_name="test", + s3_key="test.zip", + provisioned_concurrency=5, + ) + + mock_client.delete_provisioned_concurrency_config.assert_called_once_with( + FunctionName="test", + Qualifier="2", + ) def test_update_empty_aws_env_hash(self): z = Zappa() diff --git a/tests/test_settings.yaml b/tests/test_settings.yaml index 618341168..7b6c7fe70 100644 --- a/tests/test_settings.yaml +++ b/tests/test_settings.yaml @@ -60,3 +60,28 @@ snap_start_enabled: snap_start_disabled: extends: ttt888 snap_start: None +apigateway_lambda_qualifier_enabled: + extends: ttt888 + apigateway_lambda_qualifier: live +snap_start_enabled_no_qualifier: + extends: ttt888 + snap_start: PublishedVersions +snap_start_enabled_explicit_qualifier: + extends: ttt888 + snap_start: PublishedVersions + apigateway_lambda_qualifier: custom-alias +provisioned_concurrency_enabled_no_qualifier: + extends: ttt888 + provisioned_concurrency: 5 +provisioned_concurrency_enabled_explicit_qualifier: + extends: ttt888 + provisioned_concurrency: 5 + apigateway_lambda_qualifier: custom-alias +provisioned_concurrency_and_snap_start_conflict: + extends: ttt888 + provisioned_concurrency: 5 + snap_start: PublishedVersions +provisioned_concurrency_exceeds_reserved: + extends: ttt888 + provisioned_concurrency: 10 + lambda_concurrency: 5 diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 70f968341..56033eecd 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -313,6 +313,48 @@ def test_no_websocket_resources_when_disabled(self): ]: self.assertNotIn(name, resources, f"Unexpected WS resource: {name}") + def test_websocket_lambda_qualifier_is_applied(self): + from zappa.core import Zappa + + z = Zappa.__new__(Zappa) + z.boto_session = MagicMock() + z.boto_session.region_name = "us-east-1" + z.cf_api_resources = [] + z.cf_parameters = {} + + qualified_lambda_arn = "arn:aws:lambda:us-east-1:123456789:function:my-func:live" + template = z.create_stack_template( + lambda_arn="arn:aws:lambda:us-east-1:123456789:function:my-func", + lambda_name="my-func", + api_key_required=False, + iam_authorization=False, + authorizer=None, + apigateway_version="v2", + websocket=True, + websocket_stage_name="production", + lambda_qualifier="live", + ) + + resources = template.to_dict()["Resources"] + integration_uri = resources["WsIntegration"]["Properties"]["IntegrationUri"] + self.assertIn("Fn::Join", integration_uri) + self.assertEqual( + "", + integration_uri["Fn::Join"][0], + ) + self.assertEqual( + qualified_lambda_arn, + integration_uri["Fn::Join"][1][3], + ) + self.assertEqual( + "/invocations", + integration_uri["Fn::Join"][1][4], + ) + self.assertEqual( + qualified_lambda_arn, + resources["WsInvokePermission"]["Properties"]["FunctionName"], + ) + class TestWebSocketHandlerDispatch(unittest.TestCase): """Test WebSocket event dispatch through LambdaHandler.""" diff --git a/zappa/cli.py b/zappa/cli.py index edd57faf4..06512dd24 100755 --- a/zappa/cli.py +++ b/zappa/cli.py @@ -37,7 +37,13 @@ from dateutil import parser from . import __version__ -from .core import API_GATEWAY_REGIONS, DEFAULT_AWS_REGION, Zappa +from .core import ( + API_GATEWAY_REGIONS, + DEFAULT_AWS_REGION, + PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, + SNAPSTART_LAMBDA_ALIAS, + Zappa, +) from .utilities import ( DEFAULT_EFS_MOUNT_POINT, check_new_version_available, @@ -107,6 +113,7 @@ class ZappaCLI: lambda_name = None lambda_description = None lambda_concurrency = None + provisioned_concurrency = None s3_bucket_name = None settings_file = None zip_path = None @@ -116,6 +123,7 @@ class ZappaCLI: memory_size = None ephemeral_storage = None use_apigateway = None + apigateway_lambda_qualifier = None lambda_handler = None django_settings = None manage_roles = True @@ -797,6 +805,7 @@ def template(self, lambda_arn, role_arn, output=None, json=False): endpoint_configuration=self.endpoint_configuration, apigateway_version=self.apigateway_version, stage_name=self.api_stage, + lambda_qualifier=self.apigateway_lambda_qualifier, ) if not output: @@ -945,6 +954,7 @@ def deploy(self, source_zip=None, docker_image_uri=None): use_alb=self.use_alb, layers=self.layers, concurrency=self.lambda_concurrency, + provisioned_concurrency=self.provisioned_concurrency, ) kwargs["function_name"] = self.lambda_name if docker_image_uri: @@ -998,6 +1008,7 @@ def deploy(self, source_zip=None, docker_image_uri=None): endpoint_configuration=self.endpoint_configuration, apigateway_version=self.apigateway_version, stage_name=self.api_stage, + lambda_qualifier=self.apigateway_lambda_qualifier, websocket=self.use_websocket, ) @@ -1189,6 +1200,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, + provisioned_concurrency=self.provisioned_concurrency, ) if docker_image_uri: kwargs["docker_image_uri"] = docker_image_uri @@ -1254,6 +1266,7 @@ def update(self, source_zip=None, no_upload=False, docker_image_uri=None): endpoint_configuration=self.endpoint_configuration, apigateway_version=self.apigateway_version, stage_name=self.api_stage, + lambda_qualifier=self.apigateway_lambda_qualifier, websocket=self.use_websocket, ) self.zappa.update_stack( @@ -2725,6 +2738,12 @@ def load_settings(self, settings_file=None, session=None): self.use_apigateway = self.stage_config.get("apigateway_enabled", True) self.apigateway_description = self.stage_config.get("apigateway_description", None) self.apigateway_version = self.stage_config.get("apigateway_version", "v1") + self.apigateway_lambda_qualifier = self.stage_config.get("apigateway_lambda_qualifier", None) + if self.apigateway_lambda_qualifier is not None and not isinstance(self.apigateway_lambda_qualifier, str): + raise ClickException( + "The 'apigateway_lambda_qualifier' setting must be a string " + "(Lambda alias or version), or null." + ) self.lambda_handler = self.stage_config.get("lambda_handler", "handler.lambda_handler") # DEPRECATED. https://github.com/Miserlou/Zappa/issues/456 @@ -2742,6 +2761,19 @@ def load_settings(self, settings_file=None, session=None): self.cors = self.stage_config.get("cors", False) self.lambda_description = self.stage_config.get("lambda_description", "Zappa Deployment") self.lambda_concurrency = self.stage_config.get("lambda_concurrency", None) + self.provisioned_concurrency = self.stage_config.get("provisioned_concurrency", None) + if self.provisioned_concurrency is not None: + if ( + not isinstance(self.provisioned_concurrency, int) + or isinstance(self.provisioned_concurrency, bool) + or self.provisioned_concurrency < 1 + ): + raise ClickException("The 'provisioned_concurrency' setting must be a positive integer, or null.") + if self.lambda_concurrency is not None and self.provisioned_concurrency > self.lambda_concurrency: + raise ClickException( + "The 'provisioned_concurrency' setting cannot exceed 'lambda_concurrency' " + "(reserved concurrency)." + ) self.environment_variables = self.stage_config.get("environment_variables", {}) self.aws_environment_variables = self.stage_config.get("aws_environment_variables", {}) self.check_environment(self.environment_variables) @@ -2749,6 +2781,20 @@ 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") + if self.provisioned_concurrency is not None and self.snap_start and self.snap_start != "None": + raise ClickException( + "'snap_start' and 'provisioned_concurrency' cannot both be enabled on the same " + "function (AWS Lambda does not support SnapStart with provisioned concurrency)." + ) + # SnapStart and provisioned concurrency both require invoking a + # version/alias rather than $LATEST. Zappa manages a dedicated alias + # for whichever one is enabled (see SNAPSTART_LAMBDA_ALIAS / + # PROVISIONED_CONCURRENCY_LAMBDA_ALIAS in zappa/core.py); default API + # Gateway to it unless the user opted into their own qualifier. + if self.apigateway_lambda_qualifier is None and self.snap_start and self.snap_start != "None": + self.apigateway_lambda_qualifier = SNAPSTART_LAMBDA_ALIAS + elif self.apigateway_lambda_qualifier is None and self.provisioned_concurrency is not None: + self.apigateway_lambda_qualifier = PROVISIONED_CONCURRENCY_LAMBDA_ALIAS 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 aed32c8ca..fce738c6a 100644 --- a/zappa/core.py +++ b/zappa/core.py @@ -126,6 +126,15 @@ # the Lambda. # See: https://github.com/Miserlou/Zappa/pull/1730 ALB_LAMBDA_ALIAS = "current-alb-version" +# SnapStart snapshots are only usable when a function is invoked via a +# published version or alias (never $LATEST), so Zappa maintains this alias +# and repoints it to each new version once its snapshot is confirmed ready. +SNAPSTART_LAMBDA_ALIAS = "snapstart" +# Provisioned concurrency likewise can only be configured on a published +# version or alias, and initializing it takes time. Zappa maintains this +# alias, repointing it to each new version only once its provisioned +# concurrency is confirmed ready. +PROVISIONED_CONCURRENCY_LAMBDA_ALIAS = "provisioned-concurrency" X86_ARCHITECTURE = "x86_64" ARM_ARCHITECTURE = "arm64" VALID_ARCHITECTURES = (X86_ARCHITECTURE, ARM_ARCHITECTURE) @@ -1206,6 +1215,7 @@ def create_lambda_function( use_alb=False, layers=None, concurrency=None, + provisioned_concurrency=None, docker_image_uri=None, ): """ @@ -1267,6 +1277,13 @@ def create_lambda_function( resource_arn = response["FunctionArn"] version = response["Version"] + # SnapStart snapshots are created asynchronously after a version is + # published, and provisioned concurrency can't be configured on a + # version until it's active. Any alias we create below must not + # point at this version until it's confirmed ready. + if (snap_start and snap_start != "None") or provisioned_concurrency is not None: + self.wait_until_lambda_function_version_is_active(function_name, version) + # If we're using an ALB, let's create an alias mapped to the newly # created function. This allows clean, no downtime association when # using application load balancers as an event source. @@ -1279,6 +1296,32 @@ def create_lambda_function( Name=ALB_LAMBDA_ALIAS, ) + # Maintain a dedicated alias for SnapStart-invoking callers (e.g. API + # Gateway), so they never target $LATEST, which SnapStart can't + # snapshot. + if snap_start and snap_start != "None": + self.lambda_client.create_alias( + FunctionName=resource_arn, + FunctionVersion=version, + Name=SNAPSTART_LAMBDA_ALIAS, + ) + + # Maintain a dedicated alias for provisioned-concurrency-invoking + # callers, only pointed at this version once its provisioned + # concurrency has finished initializing. + if provisioned_concurrency is not None: + self.lambda_client.put_provisioned_concurrency_config( + FunctionName=resource_arn, + Qualifier=version, + ProvisionedConcurrentExecutions=provisioned_concurrency, + ) + self.wait_until_lambda_function_provisioned_concurrency_is_ready(function_name, version) + self.lambda_client.create_alias( + FunctionName=resource_arn, + FunctionVersion=version, + Name=PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, + ) + if self.tags: self.lambda_client.tag_resource(Resource=resource_arn, Tags=self.tags) @@ -1302,6 +1345,7 @@ def update_lambda_function( local_zip=None, num_revisions=None, concurrency=None, + provisioned_concurrency=None, docker_image_uri=None, ): """ @@ -1325,28 +1369,38 @@ def update_lambda_function( version = response["Version"] # If the lambda has an ALB alias, let's update the alias - # to point to the newest version of the function. We have to use a GET - # here, as there's no HEAD-esque call to retrieve metadata about a - # function alias. + # to point to the newest version of the function. # Related: https://github.com/Miserlou/Zappa/pull/1730 # https://github.com/Miserlou/Zappa/issues/1823 - try: - response = self.lambda_client.get_alias( + self.migrate_lambda_alias(function_name, ALB_LAMBDA_ALIAS, version, create_if_missing=False) + + if provisioned_concurrency is not None: + # Provisioned concurrency can only be configured on a published + # version, and initializing it takes time. Configure it on the + # version just published here (no need for SnapStart's "extra + # publish after config update" dance, since PC has no such + # before-publish ordering requirement), wait for it to become + # ready, then migrate the alias so callers never hit a cold, + # unprovisioned version. + self.wait_until_lambda_function_version_is_active(function_name, version) + self.lambda_client.put_provisioned_concurrency_config( FunctionName=function_name, - Name=ALB_LAMBDA_ALIAS, + Qualifier=version, + ProvisionedConcurrentExecutions=provisioned_concurrency, ) - alias_exists = True - except botocore.exceptions.ClientError as e: # pragma: no cover - if "ResourceNotFoundException" not in e.response["Error"]["Code"]: - raise e - alias_exists = False - - if alias_exists: - self.lambda_client.update_alias( - FunctionName=function_name, - FunctionVersion=version, - Name=ALB_LAMBDA_ALIAS, + self.wait_until_lambda_function_provisioned_concurrency_is_ready(function_name, version) + old_version = self.migrate_lambda_alias( + function_name, PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, version, create_if_missing=True ) + if old_version is not None and old_version != version: + try: + self.lambda_client.delete_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=old_version, + ) + except botocore.exceptions.ClientError as e: + if "ResourceNotFoundException" not in e.response["Error"]["Code"]: + raise e if concurrency is not None: self.lambda_client.put_function_concurrency( @@ -1477,17 +1531,19 @@ def update_lambda_configuration( publish_response = self.lambda_client.publish_version(FunctionName=function_name) version = publish_response["Version"] + # SnapStart snapshots are created asynchronously after a version + # is published. Wait for this version to become active before + # repointing any alias at it, so callers never hit a version + # whose snapshot isn't ready yet. + self.wait_until_lambda_function_version_is_active(function_name, version) + # Update ALB alias to point to the new version if it exists - try: - self.lambda_client.get_alias(FunctionName=function_name, Name=ALB_LAMBDA_ALIAS) - self.lambda_client.update_alias( - FunctionName=function_name, - FunctionVersion=version, - Name=ALB_LAMBDA_ALIAS, - ) - except botocore.exceptions.ClientError as e: - if "ResourceNotFoundException" not in e.response["Error"]["Code"]: - raise e + self.migrate_lambda_alias(function_name, ALB_LAMBDA_ALIAS, version, create_if_missing=False) + + # Migrate the SnapStart alias to the new version, creating it + # first if it doesn't exist yet (e.g. SnapStart was just enabled + # on a function Zappa had already deployed). + self.migrate_lambda_alias(function_name, SNAPSTART_LAMBDA_ALIAS, version, create_if_missing=True) return resource_arn @@ -1573,6 +1629,89 @@ 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_until_lambda_function_version_is_active(self, function_name, version): + """ + Wait until the given published Lambda version's State=Active. + + SnapStart snapshots are created asynchronously after a version is + published; invoking the version (directly, or via an alias) before + its snapshot is ready will fail. Provisioned concurrency can't be + configured on a version until it's active either. This must be + called before migrating any alias to a newly published version when + SnapStart or provisioned concurrency is enabled. + """ + # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/lambda.html#waiters + waiter = self.lambda_client.get_waiter("published_version_active") + logger.info(f"Waiting for lambda function [{function_name}] version [{version}] to become active...") + waiter.wait(FunctionName=function_name, Qualifier=version) + + def wait_until_lambda_function_provisioned_concurrency_is_ready( + self, function_name, qualifier, poll_interval=5, timeout=600, sleep_func=time.sleep + ): + """ + Wait until the given version/alias's provisioned concurrency has + finished initializing (Status=READY). + + boto3 has no waiter for this, so it's polled manually. Invoking a + version/alias before its provisioned concurrency is ready falls back + to an on-demand cold start, defeating the point of enabling it, so + this must be called before migrating any alias to a newly + provisioned version. + """ + elapsed = 0 + while True: + response = self.lambda_client.get_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=qualifier, + ) + status = response["Status"] + if status == "READY": + return + if status == "FAILED": + raise RuntimeError( + f"Provisioned concurrency failed for lambda function [{function_name}] " + f"qualifier [{qualifier}]: {response.get('StatusReason')}" + ) + if elapsed >= timeout: + raise RuntimeError( + f"Timed out waiting for provisioned concurrency on lambda function " + f"[{function_name}] qualifier [{qualifier}]" + ) + logger.info( + f"Waiting for provisioned concurrency on lambda function [{function_name}] " + f"qualifier [{qualifier}] to become ready..." + ) + sleep_func(poll_interval) + elapsed += poll_interval + + def migrate_lambda_alias(self, function_name, alias_name, version, create_if_missing=True): + """ + Repoint `alias_name` to `version`. If the alias doesn't exist yet and + create_if_missing is True, create it instead of updating it. + + Returns the alias's previous FunctionVersion, or None if it didn't + already exist. + """ + try: + existing_alias = self.lambda_client.get_alias(FunctionName=function_name, Name=alias_name) + except botocore.exceptions.ClientError as e: + if "ResourceNotFoundException" not in e.response["Error"]["Code"]: + raise e + if create_if_missing: + self.lambda_client.create_alias( + FunctionName=function_name, + FunctionVersion=version, + Name=alias_name, + ) + return None + + self.lambda_client.update_alias( + FunctionName=function_name, + FunctionVersion=version, + Name=alias_name, + ) + return existing_alias["FunctionVersion"] + def get_lambda_function(self, function_name): """ Returns the lambda function ARN, given a name @@ -2040,9 +2179,30 @@ def undeploy_lambda_alb(self, lambda_name): # API Gateway ## + @staticmethod + def _get_qualified_lambda_arn(lambda_arn: str, lambda_qualifier: Optional[str] = None) -> str: + """ + Return a Lambda function ARN qualified with a version or alias. + """ + if not lambda_qualifier: + return lambda_arn + + qualifier = lambda_qualifier.strip() + if not qualifier: + return lambda_arn + + parts = lambda_arn.split(":") + # Function ARN without qualifier has 7 parts: + # arn:partition:lambda:region:account:function:function-name + if len(parts) >= 8 and parts[5] == "function": + return ":".join(parts[:7] + [qualifier]) + + return f"{lambda_arn}:{qualifier}" + def create_api_gateway_v2_routes( # type: ignore[no-untyped-def] self, lambda_arn: str, + lambda_qualifier: Optional[str] = None, api_name: Optional[str] = None, api_key_required: bool = False, authorization_type: str = "NONE", @@ -2057,6 +2217,8 @@ def create_api_gateway_v2_routes( # type: ignore[no-untyped-def] """ import troposphere.apigatewayv2 as apigwv2 + qualified_lambda_arn = self._get_qualified_lambda_arn(lambda_arn, lambda_qualifier) + # Create the HTTP API http_api = apigwv2.Api("ApiV2") http_api.Name = api_name or lambda_arn.split(":")[-1] @@ -2084,7 +2246,7 @@ def create_api_gateway_v2_routes( # type: ignore[no-untyped-def] integration = apigwv2.Integration("IntegrationV2") integration.ApiId = troposphere.Ref(http_api) integration.IntegrationType = "AWS_PROXY" - integration.IntegrationUri = lambda_arn + integration.IntegrationUri = qualified_lambda_arn integration.PayloadFormatVersion = "2.0" self.cf_template.add_resource(integration) @@ -2112,7 +2274,7 @@ def create_api_gateway_v2_routes( # type: ignore[no-untyped-def] # Add Lambda permission for API Gateway v2 to invoke the function permission = troposphere.awslambda.Permission("ApiInvokePermissionV2") - permission.FunctionName = lambda_arn + permission.FunctionName = qualified_lambda_arn permission.Action = "lambda:InvokeFunction" permission.Principal = "apigateway.amazonaws.com" permission.SourceArn = troposphere.Join( @@ -2134,6 +2296,7 @@ def create_api_gateway_v2_routes( # type: ignore[no-untyped-def] def create_websocket_api( self, lambda_arn: str, + lambda_qualifier: Optional[str] = None, api_name: Optional[str] = None, stage_name: str = "production", ): @@ -2143,6 +2306,8 @@ def create_websocket_api( """ import troposphere.apigatewayv2 as apigwv2 + qualified_lambda_arn = self._get_qualified_lambda_arn(lambda_arn, lambda_qualifier) + ws_api = apigwv2.Api("WsApi") ws_api.Name = (api_name or lambda_arn.split(":")[-1]) + "-ws" ws_api.ProtocolType = "WEBSOCKET" @@ -2159,7 +2324,7 @@ def create_websocket_api( "arn:aws:apigateway:", troposphere.Ref("AWS::Region"), ":lambda:path/2015-03-31/functions/", - lambda_arn, + qualified_lambda_arn, "/invocations", ], ) @@ -2182,7 +2347,7 @@ def create_websocket_api( # Lambda invoke permission permission = troposphere.awslambda.Permission("WsInvokePermission") - permission.FunctionName = lambda_arn + permission.FunctionName = qualified_lambda_arn permission.Action = "lambda:InvokeFunction" permission.Principal = "apigateway.amazonaws.com" permission.SourceArn = troposphere.Join( @@ -2204,6 +2369,7 @@ def create_websocket_api( def create_api_gateway_routes( # type: ignore[no-untyped-def] self, lambda_arn: str, + lambda_qualifier: Optional[str] = None, api_name: Optional[str] = None, api_key_required: bool = False, authorization_type: str = "NONE", @@ -2222,6 +2388,7 @@ def create_api_gateway_routes( # type: ignore[no-untyped-def] if apigateway_version == "v2": return self.create_api_gateway_v2_routes( lambda_arn=lambda_arn, + lambda_qualifier=lambda_qualifier, api_name=api_name, api_key_required=api_key_required, authorization_type=authorization_type, @@ -2252,13 +2419,14 @@ def create_api_gateway_routes( # type: ignore[no-untyped-def] root_id = troposphere.GetAtt(restapi, "RootResourceId") invocation_prefix = "aws" if self.boto_session.region_name != "us-gov-west-1" else "aws-us-gov" + qualified_lambda_arn = self._get_qualified_lambda_arn(lambda_arn, lambda_qualifier) invocations_uri = ( "arn:" + invocation_prefix + ":apigateway:" + self.boto_session.region_name + ":lambda:path/2015-03-31/functions/" - + lambda_arn + + qualified_lambda_arn + "/invocations" ) @@ -2267,7 +2435,7 @@ def create_api_gateway_routes( # type: ignore[no-untyped-def] ## authorizer_resource = None if authorizer: - authorizer_lambda_arn = authorizer.get("arn", lambda_arn) + authorizer_lambda_arn = authorizer.get("arn", qualified_lambda_arn) lambda_uri = ( f"arn:{invocation_prefix}:apigateway:{self.boto_session.region_name}:" f"lambda:path/2015-03-31/functions/{authorizer_lambda_arn}/invocations" @@ -2784,6 +2952,7 @@ def create_stack_template( endpoint_configuration=None, apigateway_version=DEFAULT_APIGATEWAY_VERSION, stage_name=None, + lambda_qualifier=None, websocket=False, websocket_stage_name=None, ): @@ -2813,6 +2982,7 @@ def create_stack_template( self.create_api_gateway_routes( lambda_arn, + lambda_qualifier=lambda_qualifier, api_name=lambda_name, api_key_required=api_key_required, authorization_type=auth_type, @@ -2827,6 +2997,7 @@ def create_stack_template( if websocket: self.create_websocket_api( lambda_arn=lambda_arn, + lambda_qualifier=lambda_qualifier, api_name=lambda_name, stage_name=websocket_stage_name or stage_name or "production", ) From 877d4976c8b654643d770c9b85144d3aceeb8126 Mon Sep 17 00:00:00 2001 From: Damian Fuentes Date: Wed, 29 Jul 2026 19:04:20 -0700 Subject: [PATCH 2/2] addressed the 3 concerns and the lint blocker --- README.md | 2 +- tests/test_core.py | 267 ++++++++++++++++++++++++++++++++++++--- tests/test_settings.yaml | 15 +++ zappa/cli.py | 31 ++++- zappa/core.py | 122 ++++++++++++------ 5 files changed, 368 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 86e7690f3..ccadbe469 100644 --- a/README.md +++ b/README.md @@ -1271,7 +1271,7 @@ to change Zappa's behavior. Use these at your own risk! "memory_size": 512, // Lambda function memory in MB. Default 512. "ephemeral_storage": { "Size": 512 }, // Lambda function ephemeral_storage size in MB, Default 512, Max 10240 "efs_config": [{ "Arn": "arn:aws:elasticfilesystem:...:access-point/fsap-...", "LocalMountPath": "/mnt/data" }], // Optional EFS configuration. See EFS section for details. - "num_retained_versions":5, // Number of published Lambda versions to retain. Default 5. Older versions are deleted on `zappa update` to bound code-storage and (when SnapStart is enabled) snapshot-cache cost. Set to `null` to keep all versions. + "num_retained_versions":5, // Number of published Lambda versions to retain. Default 5. Older versions are deleted on `zappa update` to bound code-storage and (when SnapStart is enabled) snapshot-cache cost. Set to `null` to keep all versions. Must be `null` or at least 2 when `snap_start` or `provisioned_concurrency` is enabled, otherwise pruning could try to delete the version Zappa's managed alias still points to. "payload_compression": true, // Whether or not to enable API gateway payload compression (default: true) "payload_minimum_compression_size": 0, // The threshold size (in bytes) below which payload compression will not be applied (default: 0) "prebuild_script": "your_module.your_function", // Function to execute before uploading code diff --git a/tests/test_core.py b/tests/test_core.py index aaf3371da..87df34558 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -849,9 +849,7 @@ def test_create_api_gateway_with_lambda_qualifier(self): ) parsable_template = json.loads(z.cf_template.to_json()) expected_v1_uri = ( - "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/" - + qualified_lambda_arn - + "/invocations" + "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/" + qualified_lambda_arn + "/invocations" ) self.assertEqual( expected_v1_uri, @@ -1223,6 +1221,48 @@ def test_provisioned_concurrency_exceeding_reserved_concurrency_raises(self): with self.assertRaises(ClickException): zappa_cli.load_settings("tests/test_settings.yaml") + def test_snap_start_with_low_num_retained_versions_raises(self): + """ + Test that num_retained_versions < 2 raises when snap_start is + enabled, since pruning could otherwise delete the version the + SnapStart alias still points to. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "snap_start_enabled_num_retained_versions_too_low" + with self.assertRaises(ClickException): + zappa_cli.load_settings("tests/test_settings.yaml") + + def test_provisioned_concurrency_with_low_num_retained_versions_raises(self): + """ + Test that num_retained_versions < 2 raises when provisioned + concurrency is enabled, for the same pruning-conflict reason. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "provisioned_concurrency_enabled_num_retained_versions_too_low" + with self.assertRaises(ClickException): + zappa_cli.load_settings("tests/test_settings.yaml") + + def test_snap_start_with_num_retained_versions_of_two_is_allowed(self): + """ + Test that num_retained_versions == 2 is the minimum accepted value + when snap_start is enabled. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "snap_start_enabled_num_retained_versions_ok" + zappa_cli.load_settings("tests/test_settings.yaml") + self.assertEqual(2, zappa_cli.num_retained_versions) + + def test_low_num_retained_versions_allowed_without_managed_alias(self): + """ + Test that num_retained_versions == 1 is still fine when neither + snap_start nor provisioned_concurrency is enabled, since the + pruning-conflict scenario doesn't apply. + """ + zappa_cli = ZappaCLI() + zappa_cli.api_stage = "num_retained_versions_one_without_managed_alias" + zappa_cli.load_settings("tests/test_settings.yaml") + self.assertEqual(1, zappa_cli.num_retained_versions) + def test_wait_until_provisioned_concurrency_is_ready_polls_until_ready(self): """ Test that the provisioned-concurrency wait helper polls @@ -1300,9 +1340,51 @@ def test_provisioned_concurrency_creates_alias_after_ready(self): self.assertIn(create_alias_call, calls) self.assertLess(calls.index(ready_call), calls.index(create_alias_call)) + def test_provisioned_concurrency_publishes_version_after_config_update(self): + """ + Test that update_lambda_configuration publishes the provisioned- + concurrency version AFTER the config update lands, so alias-routed + traffic sees the new config (env vars, memory, timeout, etc.) + instead of being frozen at the pre-update state. + update_lambda_function's code-only version can't guarantee this, + since UpdateFunctionConfiguration has no Publish parameter of its + own. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + mock_client.get_function_configuration.return_value = {"PackageType": "Zip"} + mock_client.update_function_configuration.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + } + mock_client.publish_version.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test:4", + "Version": "4", + } + mock_client.get_alias.side_effect = botocore.exceptions.ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": ""}}, + "GetAlias", + ) + mock_client.get_provisioned_concurrency_config.return_value = {"Status": "READY"} + + z.update_lambda_configuration( + "arn:aws:lambda:us-east-1:123:function:test", + "test", + "handler.lambda_handler", + provisioned_concurrency=5, + ) + + config_call = mock.call.update_function_configuration(**mock_client.update_function_configuration.call_args[1]) + publish_call = mock.call.publish_version(FunctionName="test") + calls = mock_client.mock_calls + self.assertIn(config_call, calls) + self.assertIn(publish_call, calls) + self.assertLess(calls.index(config_call), calls.index(publish_call)) + def test_provisioned_concurrency_migrates_alias_after_update(self): """ - Test that update_lambda_function waits for the newly published + Test that update_lambda_configuration waits for the newly published version to be active and its provisioned concurrency to be ready before migrating the provisioned-concurrency alias to it. """ @@ -1310,8 +1392,12 @@ def test_provisioned_concurrency_migrates_alias_after_update(self): z.credentials_arn = object() with mock.patch.object(z, "lambda_client") as mock_client: - mock_client.update_function_code.return_value = { + mock_client.get_function_configuration.return_value = {"PackageType": "Zip"} + mock_client.update_function_configuration.return_value = { "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + } + mock_client.publish_version.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test:4", "Version": "4", } # No alias exists yet (ALB or provisioned-concurrency) @@ -1321,10 +1407,10 @@ def test_provisioned_concurrency_migrates_alias_after_update(self): ) mock_client.get_provisioned_concurrency_config.return_value = {"Status": "READY"} - z.update_lambda_function( - bucket="test-bucket", - function_name="test", - s3_key="test.zip", + z.update_lambda_configuration( + "arn:aws:lambda:us-east-1:123:function:test", + "test", + "handler.lambda_handler", provisioned_concurrency=5, ) @@ -1357,8 +1443,12 @@ def test_provisioned_concurrency_skips_cleanup_on_first_deploy(self): z.credentials_arn = object() with mock.patch.object(z, "lambda_client") as mock_client: - mock_client.update_function_code.return_value = { + mock_client.get_function_configuration.return_value = {"PackageType": "Zip"} + mock_client.update_function_configuration.return_value = { "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + } + mock_client.publish_version.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test:1", "Version": "1", } mock_client.get_alias.side_effect = botocore.exceptions.ClientError( @@ -1367,10 +1457,10 @@ def test_provisioned_concurrency_skips_cleanup_on_first_deploy(self): ) mock_client.get_provisioned_concurrency_config.return_value = {"Status": "READY"} - z.update_lambda_function( - bucket="test-bucket", - function_name="test", - s3_key="test.zip", + z.update_lambda_configuration( + "arn:aws:lambda:us-east-1:123:function:test", + "test", + "handler.lambda_handler", provisioned_concurrency=5, ) @@ -1378,7 +1468,7 @@ def test_provisioned_concurrency_skips_cleanup_on_first_deploy(self): def test_provisioned_concurrency_cleans_up_old_version(self): """ - Test that update_lambda_function deletes the previous version's + Test that update_lambda_configuration deletes the previous version's provisioned-concurrency config after migrating the alias to the new version, so nobody keeps paying for capacity nothing routes to. """ @@ -1386,8 +1476,12 @@ def test_provisioned_concurrency_cleans_up_old_version(self): z.credentials_arn = object() with mock.patch.object(z, "lambda_client") as mock_client: - mock_client.update_function_code.return_value = { + mock_client.get_function_configuration.return_value = {"PackageType": "Zip"} + mock_client.update_function_configuration.return_value = { "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + } + mock_client.publish_version.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test:4", "Version": "4", } # The provisioned-concurrency alias already exists, pointed at version 2 @@ -1398,10 +1492,10 @@ def test_provisioned_concurrency_cleans_up_old_version(self): } mock_client.get_provisioned_concurrency_config.return_value = {"Status": "READY"} - z.update_lambda_function( - bucket="test-bucket", - function_name="test", - s3_key="test.zip", + z.update_lambda_configuration( + "arn:aws:lambda:us-east-1:123:function:test", + "test", + "handler.lambda_handler", provisioned_concurrency=5, ) @@ -1410,6 +1504,139 @@ def test_provisioned_concurrency_cleans_up_old_version(self): Qualifier="2", ) + def _mock_rollback_prerequisites(self, mock_client, requests_get_mock, new_version="5"): + """ + Configure the lambda_client/requests mocks shared by every + rollback_lambda_function_version test: three published versions + (plus $LATEST) exist, and rolling back one revision republishes the + code as `new_version`. + """ + mock_client.list_versions_by_function.return_value = { + "Versions": [ + {"Version": "1"}, + {"Version": "2"}, + {"Version": "3"}, + {"Version": "$LATEST"}, + ] + } + mock_client.get_function.return_value = {"Code": {"Location": "https://example.com/code.zip"}} + requests_get_mock.return_value = mock.Mock(status_code=200, content=b"zip-bytes") + mock_client.update_function_code.return_value = { + "FunctionArn": "arn:aws:lambda:us-east-1:123:function:test", + "Version": new_version, + } + + @mock.patch("zappa.core.requests.get") + def test_rollback_migrates_alb_alias(self, requests_get_mock): + """ + Test that rolling back republishes the old code and migrates the + ALB alias to the newly republished version, so ALB-routed traffic + actually reflects the rollback. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + self._mock_rollback_prerequisites(mock_client, requests_get_mock) + mock_client.get_alias.return_value = { + "AliasArn": "arn:aws:lambda:us-east-1:123:function:test:current-alb-version", + "Name": ALB_LAMBDA_ALIAS, + "FunctionVersion": "3", + } + + z.rollback_lambda_function_version("test", versions_back=1) + + mock_client.update_alias.assert_any_call( + FunctionName="test", + FunctionVersion="5", + Name=ALB_LAMBDA_ALIAS, + ) + + @mock.patch("zappa.core.requests.get") + def test_rollback_waits_then_migrates_snap_start_alias(self, requests_get_mock): + """ + Test that rolling back with snap_start enabled waits for the + republished version to be active before migrating the SnapStart + alias to it. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + self._mock_rollback_prerequisites(mock_client, requests_get_mock) + mock_client.get_alias.side_effect = botocore.exceptions.ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": ""}}, + "GetAlias", + ) + + z.rollback_lambda_function_version("test", versions_back=1, snap_start="PublishedVersions") + + wait_call = mock.call.get_waiter("published_version_active").wait(FunctionName="test", Qualifier="5") + alias_call = mock.call.create_alias(FunctionName="test", FunctionVersion="5", Name=SNAPSTART_LAMBDA_ALIAS) + calls = mock_client.mock_calls + self.assertIn(wait_call, calls) + self.assertIn(alias_call, calls) + self.assertLess(calls.index(wait_call), calls.index(alias_call)) + + @mock.patch("zappa.core.requests.get") + def test_rollback_configures_provisioned_concurrency_then_migrates_alias(self, requests_get_mock): + """ + Test that rolling back with provisioned concurrency enabled + configures PC on the republished version, waits for it to become + ready, migrates the provisioned-concurrency alias to it, and cleans + up the old version's PC config. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + self._mock_rollback_prerequisites(mock_client, requests_get_mock) + mock_client.get_alias.return_value = { + "AliasArn": "arn:aws:lambda:us-east-1:123:function:test:provisioned-concurrency", + "Name": PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, + "FunctionVersion": "3", + } + mock_client.get_provisioned_concurrency_config.return_value = {"Status": "READY"} + + z.rollback_lambda_function_version("test", versions_back=1, provisioned_concurrency=5) + + mock_client.put_provisioned_concurrency_config.assert_called_once_with( + FunctionName="test", + Qualifier="5", + ProvisionedConcurrentExecutions=5, + ) + mock_client.update_alias.assert_any_call( + FunctionName="test", + FunctionVersion="5", + Name=PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, + ) + mock_client.delete_provisioned_concurrency_config.assert_called_once_with( + FunctionName="test", + Qualifier="3", + ) + + @mock.patch("zappa.core.requests.get") + def test_rollback_with_publish_false_skips_alias_migration(self, requests_get_mock): + """ + Test that rolling back with publish=False doesn't touch any alias, + wait, or provisioned-concurrency config, since there's no new + version for them to point at. + """ + z = Zappa() + z.credentials_arn = object() + + with mock.patch.object(z, "lambda_client") as mock_client: + self._mock_rollback_prerequisites(mock_client, requests_get_mock) + + z.rollback_lambda_function_version( + "test", versions_back=1, publish=False, snap_start="PublishedVersions", provisioned_concurrency=5 + ) + + mock_client.get_alias.assert_not_called() + mock_client.update_alias.assert_not_called() + mock_client.create_alias.assert_not_called() + mock_client.put_provisioned_concurrency_config.assert_not_called() + def test_update_empty_aws_env_hash(self): z = Zappa() z.credentials_arn = object() diff --git a/tests/test_settings.yaml b/tests/test_settings.yaml index 7b6c7fe70..f4d916a8b 100644 --- a/tests/test_settings.yaml +++ b/tests/test_settings.yaml @@ -85,3 +85,18 @@ provisioned_concurrency_exceeds_reserved: extends: ttt888 provisioned_concurrency: 10 lambda_concurrency: 5 +snap_start_enabled_num_retained_versions_too_low: + extends: ttt888 + snap_start: PublishedVersions + num_retained_versions: 1 +provisioned_concurrency_enabled_num_retained_versions_too_low: + extends: ttt888 + provisioned_concurrency: 5 + num_retained_versions: 1 +snap_start_enabled_num_retained_versions_ok: + extends: ttt888 + snap_start: PublishedVersions + num_retained_versions: 2 +num_retained_versions_one_without_managed_alias: + extends: ttt888 + num_retained_versions: 1 diff --git a/zappa/cli.py b/zappa/cli.py index 06512dd24..f927250a5 100755 --- a/zappa/cli.py +++ b/zappa/cli.py @@ -1200,7 +1200,6 @@ 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, - provisioned_concurrency=self.provisioned_concurrency, ) if docker_image_uri: kwargs["docker_image_uri"] = docker_image_uri @@ -1246,6 +1245,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, + provisioned_concurrency=self.provisioned_concurrency, wait=False, ) @@ -1366,7 +1366,12 @@ def rollback(self, revision): print("Rolling back..") - self.zappa.rollback_lambda_function_version(self.lambda_name, versions_back=revision) + self.zappa.rollback_lambda_function_version( + self.lambda_name, + versions_back=revision, + snap_start=self.snap_start, + provisioned_concurrency=self.provisioned_concurrency, + ) print("Done!") def tail( @@ -2741,8 +2746,7 @@ def load_settings(self, settings_file=None, session=None): self.apigateway_lambda_qualifier = self.stage_config.get("apigateway_lambda_qualifier", None) if self.apigateway_lambda_qualifier is not None and not isinstance(self.apigateway_lambda_qualifier, str): raise ClickException( - "The 'apigateway_lambda_qualifier' setting must be a string " - "(Lambda alias or version), or null." + "The 'apigateway_lambda_qualifier' setting must be a string " "(Lambda alias or version), or null." ) self.lambda_handler = self.stage_config.get("lambda_handler", "handler.lambda_handler") @@ -2771,8 +2775,7 @@ def load_settings(self, settings_file=None, session=None): raise ClickException("The 'provisioned_concurrency' setting must be a positive integer, or null.") if self.lambda_concurrency is not None and self.provisioned_concurrency > self.lambda_concurrency: raise ClickException( - "The 'provisioned_concurrency' setting cannot exceed 'lambda_concurrency' " - "(reserved concurrency)." + "The 'provisioned_concurrency' setting cannot exceed 'lambda_concurrency' " "(reserved concurrency)." ) self.environment_variables = self.stage_config.get("environment_variables", {}) self.aws_environment_variables = self.stage_config.get("aws_environment_variables", {}) @@ -2795,6 +2798,22 @@ def load_settings(self, settings_file=None, session=None): self.apigateway_lambda_qualifier = SNAPSTART_LAMBDA_ALIAS elif self.apigateway_lambda_qualifier is None and self.provisioned_concurrency is not None: self.apigateway_lambda_qualifier = PROVISIONED_CONCURRENCY_LAMBDA_ALIAS + # Each deploy publishes an extra version to capture SnapStart/PC's + # config, one cycle behind the code-only version; the managed alias + # still points at the *previous* cycle's extra version when this + # cycle's pruning runs. Retaining fewer than 2 versions can delete + # the version that alias still references, causing Lambda to reject + # the deletion with a ResourceConflictException. + if ( + self.num_retained_versions is not None + and self.num_retained_versions < 2 + and ((self.snap_start and self.snap_start != "None") or self.provisioned_concurrency is not None) + ): + raise ClickException( + "'num_retained_versions' must be null or at least 2 when 'snap_start' or " + "'provisioned_concurrency' is enabled — otherwise version pruning can delete " + "the version Zappa's managed alias still points to." + ) 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 fce738c6a..6b5e75301 100644 --- a/zappa/core.py +++ b/zappa/core.py @@ -1345,7 +1345,6 @@ def update_lambda_function( local_zip=None, num_revisions=None, concurrency=None, - provisioned_concurrency=None, docker_image_uri=None, ): """ @@ -1374,34 +1373,6 @@ def update_lambda_function( # https://github.com/Miserlou/Zappa/issues/1823 self.migrate_lambda_alias(function_name, ALB_LAMBDA_ALIAS, version, create_if_missing=False) - if provisioned_concurrency is not None: - # Provisioned concurrency can only be configured on a published - # version, and initializing it takes time. Configure it on the - # version just published here (no need for SnapStart's "extra - # publish after config update" dance, since PC has no such - # before-publish ordering requirement), wait for it to become - # ready, then migrate the alias so callers never hit a cold, - # unprovisioned version. - self.wait_until_lambda_function_version_is_active(function_name, version) - self.lambda_client.put_provisioned_concurrency_config( - FunctionName=function_name, - Qualifier=version, - ProvisionedConcurrentExecutions=provisioned_concurrency, - ) - self.wait_until_lambda_function_provisioned_concurrency_is_ready(function_name, version) - old_version = self.migrate_lambda_alias( - function_name, PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, version, create_if_missing=True - ) - if old_version is not None and old_version != version: - try: - self.lambda_client.delete_provisioned_concurrency_config( - FunctionName=function_name, - Qualifier=old_version, - ) - except botocore.exceptions.ClientError as e: - if "ResourceNotFoundException" not in e.response["Error"]["Code"]: - raise e - if concurrency is not None: self.lambda_client.put_function_concurrency( FunctionName=function_name, @@ -1457,6 +1428,7 @@ def update_lambda_configuration( aws_kms_key_arn=None, layers=None, snap_start=None, + provisioned_concurrency=None, wait=True, ): """ @@ -1523,27 +1495,56 @@ def update_lambda_configuration( self.lambda_client.tag_resource(Resource=resource_arn, Tags=self.tags) # SnapStart only creates snapshots for versions published AFTER it's - # enabled. During updates, the code is published before the config is - # updated, so we must publish an additional version here. - if snap_start and snap_start != "None": + # enabled, and provisioned concurrency can only be configured on a + # published version. Either way, the code is published before the + # config is updated, so we must publish an additional version here + # to capture the new config (UpdateFunctionConfiguration has no + # Publish parameter of its own). + needs_extra_publish = (snap_start and snap_start != "None") or provisioned_concurrency is not None + if needs_extra_publish: self.wait_until_lambda_function_is_updated(function_name) - logger.info("Publishing new version for SnapStart snapshot creation..") + logger.info("Publishing new version to capture updated configuration..") publish_response = self.lambda_client.publish_version(FunctionName=function_name) version = publish_response["Version"] - # SnapStart snapshots are created asynchronously after a version - # is published. Wait for this version to become active before - # repointing any alias at it, so callers never hit a version - # whose snapshot isn't ready yet. + # SnapStart snapshots/provisioned concurrency are both configured + # asynchronously after a version is published. Wait for this + # version to become active before repointing any alias at it, so + # callers never hit a version that isn't ready yet. self.wait_until_lambda_function_version_is_active(function_name, version) # Update ALB alias to point to the new version if it exists self.migrate_lambda_alias(function_name, ALB_LAMBDA_ALIAS, version, create_if_missing=False) - # Migrate the SnapStart alias to the new version, creating it - # first if it doesn't exist yet (e.g. SnapStart was just enabled - # on a function Zappa had already deployed). - self.migrate_lambda_alias(function_name, SNAPSTART_LAMBDA_ALIAS, version, create_if_missing=True) + if snap_start and snap_start != "None": + # Migrate the SnapStart alias to the new version, creating it + # first if it doesn't exist yet (e.g. SnapStart was just + # enabled on a function Zappa had already deployed). + self.migrate_lambda_alias(function_name, SNAPSTART_LAMBDA_ALIAS, version, create_if_missing=True) + + if provisioned_concurrency is not None: + self.lambda_client.put_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=version, + ProvisionedConcurrentExecutions=provisioned_concurrency, + ) + self.wait_until_lambda_function_provisioned_concurrency_is_ready(function_name, version) + # Migrate the provisioned-concurrency alias to the new + # version, creating it first if it doesn't exist yet, then + # clean up the old version's provisioned concurrency so + # nobody keeps paying for capacity nothing routes to. + old_version = self.migrate_lambda_alias( + function_name, PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, version, create_if_missing=True + ) + if old_version is not None and old_version != version: + try: + self.lambda_client.delete_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=old_version, + ) + except botocore.exceptions.ClientError as e: + if "ResourceNotFoundException" not in e.response["Error"]["Code"]: + raise e return resource_arn @@ -1573,7 +1574,9 @@ def invoke_lambda_function( return self.lambda_client.invoke(**invoke_kwargs) - def rollback_lambda_function_version(self, function_name, versions_back=1, publish=True): + def rollback_lambda_function_version( + self, function_name, versions_back=1, publish=True, snap_start=None, provisioned_concurrency=None + ): """ Rollback the lambda function code 'versions_back' number of revisions. Returns the Function ARN. @@ -1609,6 +1612,41 @@ def rollback_lambda_function_version(self, function_name, versions_back=1, publi FunctionName=function_name, ZipFile=response.content, Publish=publish ) # pragma: no cover + if publish: + version = response["Version"] + if (snap_start and snap_start != "None") or provisioned_concurrency is not None: + self.wait_until_lambda_function_version_is_active(function_name, version) + + # Migrate Zappa-managed aliases so alias-routed traffic (ALB, + # SnapStart, provisioned concurrency, and API Gateway via + # apigateway_lambda_qualifier) actually reflects the rollback, + # instead of continuing to serve whatever version they last + # pointed to. + self.migrate_lambda_alias(function_name, ALB_LAMBDA_ALIAS, version, create_if_missing=False) + + if snap_start and snap_start != "None": + self.migrate_lambda_alias(function_name, SNAPSTART_LAMBDA_ALIAS, version, create_if_missing=True) + + if provisioned_concurrency is not None: + self.lambda_client.put_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=version, + ProvisionedConcurrentExecutions=provisioned_concurrency, + ) + self.wait_until_lambda_function_provisioned_concurrency_is_ready(function_name, version) + old_version = self.migrate_lambda_alias( + function_name, PROVISIONED_CONCURRENCY_LAMBDA_ALIAS, version, create_if_missing=True + ) + if old_version is not None and old_version != version: + try: + self.lambda_client.delete_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=old_version, + ) + except botocore.exceptions.ClientError as e: + if "ResourceNotFoundException" not in e.response["Error"]["Code"]: + raise e + return response["FunctionArn"] def wait_until_lambda_function_is_active(self, function_name):