diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.py index 9ac335a2b892f..adc04756a59c5 100644 --- a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.py +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.py @@ -66,6 +66,14 @@ def create( response = lambda_client.publish_version(**params) model["Version"] = response["Version"] model["Id"] = response["FunctionArn"] + if model.get("ProvisionedConcurrencyConfig"): + lambda_client.put_provisioned_concurrency_config( + FunctionName=model["FunctionName"], + Qualifier=model["Version"], + ProvisionedConcurrentExecutions=model["ProvisionedConcurrencyConfig"][ + "ProvisionedConcurrentExecutions" + ], + ) ctx[REPEATED_INVOCATION] = True return ProgressEvent( status=OperationStatus.IN_PROGRESS, @@ -73,25 +81,50 @@ def create( custom_context=request.custom_context, ) - version = lambda_client.get_function(FunctionName=model["Id"]) - if version["Configuration"]["State"] == "Pending": - return ProgressEvent( - status=OperationStatus.IN_PROGRESS, - resource_model=model, - custom_context=request.custom_context, - ) - elif version["Configuration"]["State"] == "Active": - return ProgressEvent( - status=OperationStatus.SUCCESS, - resource_model=model, + if model.get("ProvisionedConcurrencyConfig"): + # Assumption: Ready provisioned concurrency implies the function version is ready + provisioned_concurrency_config = lambda_client.get_provisioned_concurrency_config( + FunctionName=model["FunctionName"], + Qualifier=model["Version"], ) + if provisioned_concurrency_config["Status"] == "IN_PROGRESS": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + elif provisioned_concurrency_config["Status"] == "READY": + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + else: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message="", + error_code="VersionStateFailure", # TODO: not parity tested + ) else: - return ProgressEvent( - status=OperationStatus.FAILED, - resource_model=model, - message="", - error_code="VersionStateFailure", # TODO: not parity tested - ) + version = lambda_client.get_function(FunctionName=model["Id"]) + if version["Configuration"]["State"] == "Pending": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + elif version["Configuration"]["State"] == "Active": + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + else: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message="", + error_code="VersionStateFailure", # TODO: not parity tested + ) def read( self, @@ -117,6 +150,7 @@ def delete( lambda_client = request.aws_client_factory.lambda_ # without qualifier entire function is deleted instead of just version + # provisioned concurrency is automatically deleted upon deleting a function or function version lambda_client.delete_function(FunctionName=model["Id"], Qualifier=model["Version"]) return ProgressEvent( diff --git a/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.py b/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.py index 224bcb0383f21..044eeed162845 100644 --- a/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.py +++ b/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.py @@ -84,7 +84,7 @@ def create( if model.get("ProvisionedConcurrencyConfig"): lambda_.put_provisioned_concurrency_config( FunctionName=model["FunctionName"], - Qualifier=model["Id"].split(":")[-1], + Qualifier=model["Name"], ProvisionedConcurrentExecutions=model["ProvisionedConcurrencyConfig"][ "ProvisionedConcurrentExecutions" ], @@ -100,13 +100,25 @@ def create( # get provisioned config status result = lambda_.get_provisioned_concurrency_config( FunctionName=model["FunctionName"], - Qualifier=model["Id"].split(":")[-1], + Qualifier=model["Name"], ) if result["Status"] == "IN_PROGRESS": return ProgressEvent( status=OperationStatus.IN_PROGRESS, resource_model=model, ) + elif result["Status"] == "READY": + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + else: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message="", + error_code="VersionStateFailure", # TODO: not parity tested + ) return ProgressEvent( status=OperationStatus.SUCCESS, @@ -137,6 +149,7 @@ def delete( lambda_ = request.aws_client_factory.lambda_ try: + # provisioned concurrency is automatically deleted upon deleting a function alias lambda_.delete_alias( FunctionName=model["FunctionName"], Name=model["Name"], diff --git a/tests/aws/services/cloudformation/resources/test_lambda.py b/tests/aws/services/cloudformation/resources/test_lambda.py index 532ea5a11436d..f40489799615b 100644 --- a/tests/aws/services/cloudformation/resources/test_lambda.py +++ b/tests/aws/services/cloudformation/resources/test_lambda.py @@ -239,6 +239,12 @@ def test_lambda_alias(deploy_cfn_template, snapshot, aws_client): parameters={"FunctionName": function_name, "AliasName": alias_name}, ) + invoke_result = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=alias_name, Payload=b"{}" + ) + assert "FunctionError" not in invoke_result + snapshot.match("invoke_result", invoke_result) + role_arn = aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"]["Role"] snapshot.add_transformer( snapshot.transform.regex(role_arn.partition("role/")[-1], ""), priority=-1 @@ -252,6 +258,12 @@ def test_lambda_alias(deploy_cfn_template, snapshot, aws_client): alias = aws_client.lambda_.get_alias(FunctionName=function_name, Name=alias_name) snapshot.match("Alias", alias) + provisioned_concurrency_config = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=alias_name, + ) + snapshot.match("provisioned_concurrency_config", provisioned_concurrency_config) + @markers.aws.validated def test_lambda_logging_config(deploy_cfn_template, snapshot, aws_client): @@ -351,21 +363,70 @@ def test_lambda_version(deploy_cfn_template, snapshot, aws_client): template_path=os.path.join( os.path.dirname(__file__), "../../../templates/cfn_lambda_version.yaml" ), - max_wait=240, + max_wait=180, ) + function_name = deployment.outputs["FunctionName"] + function_version = deployment.outputs["FunctionVersion"] invoke_result = aws_client.lambda_.invoke( - FunctionName=deployment.outputs["FunctionName"], Payload=b"{}" + FunctionName=function_name, Qualifier=function_version, Payload=b"{}" ) - assert 200 <= invoke_result["StatusCode"] < 300 + assert "FunctionError" not in invoke_result + snapshot.match("invoke_result", invoke_result) stack_resources = aws_client.cloudformation.describe_stack_resources( StackName=deployment.stack_id ) snapshot.match("stack_resources", stack_resources) + versions_by_fn = aws_client.lambda_.list_versions_by_function(FunctionName=function_name) + get_function_version = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier=function_version + ) + + snapshot.match("versions_by_fn", versions_by_fn) + snapshot.match("get_function_version", get_function_version) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # Lambda ZIP flaky in CI + "$..CodeSize", + ] +) +@markers.aws.validated +def test_lambda_version_provisioned_concurrency(deploy_cfn_template, snapshot, aws_client): + """Provisioned concurrency slows down the test case considerably (~2min 40s on AWS) + because CloudFormation waits until the provisioned Lambda functions are ready. + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]) + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_lambda_version_provisioned_concurrency.yaml", + ), + max_wait=240, + ) function_name = deployment.outputs["FunctionName"] function_version = deployment.outputs["FunctionVersion"] + + invoke_result = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=function_version, Payload=b"{}" + ) + assert "FunctionError" not in invoke_result + snapshot.match("invoke_result", invoke_result) + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + snapshot.match("stack_resources", stack_resources) + versions_by_fn = aws_client.lambda_.list_versions_by_function(FunctionName=function_name) get_function_version = aws_client.lambda_.get_function( FunctionName=function_name, Qualifier=function_version @@ -374,6 +435,12 @@ def test_lambda_version(deploy_cfn_template, snapshot, aws_client): snapshot.match("versions_by_fn", versions_by_fn) snapshot.match("get_function_version", get_function_version) + provisioned_concurrency_config = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=function_version, + ) + snapshot.match("provisioned_concurrency_config", provisioned_concurrency_config) + @markers.aws.validated def test_lambda_cfn_run(deploy_cfn_template, aws_client): diff --git a/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json b/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json index d3e39608a2b41..484f94d6b4898 100644 --- a/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json +++ b/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json @@ -68,8 +68,20 @@ } }, "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_alias": { - "recorded-date": "09-04-2024, 07:19:19", + "recorded-date": "07-05-2025, 15:39:26", "recorded-content": { + "invoke_result": { + "ExecutedVersion": "1", + "Payload": { + "function_version": "1", + "initialization_type": null + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, "stack_resource_descriptions": { "StackResources": [ { @@ -136,6 +148,17 @@ "HTTPHeaders": {}, "HTTPStatusCode": 200 } + }, + "provisioned_concurrency_config": { + "AllocatedProvisionedConcurrentExecutions": 1, + "AvailableProvisionedConcurrentExecutions": 1, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } } } }, @@ -655,8 +678,19 @@ } }, "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version": { - "recorded-date": "09-04-2024, 07:21:37", + "recorded-date": "07-05-2025, 13:19:10", "recorded-content": { + "invoke_result": { + "ExecutedVersion": "1", + "Payload": { + "function_version": "1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, "stack_resources": { "StackResources": [ { @@ -725,7 +759,7 @@ "PackageType": "Zip", "RevisionId": "", "Role": "arn::iam::111111111111:role/", - "Runtime": "python3.9", + "Runtime": "python3.12", "SnapStart": { "ApplyOn": "None", "OptimizationStatus": "Off" @@ -758,7 +792,7 @@ "PackageType": "Zip", "RevisionId": "", "Role": "arn::iam::111111111111:role/", - "Runtime": "python3.9", + "Runtime": "python3.12", "SnapStart": { "ApplyOn": "None", "OptimizationStatus": "Off" @@ -803,7 +837,7 @@ "PackageType": "Zip", "RevisionId": "", "Role": "arn::iam::111111111111:role/", - "Runtime": "python3.9", + "Runtime": "python3.12", "RuntimeVersionConfig": { "RuntimeVersionArn": "arn::lambda:::runtime:" }, @@ -1661,5 +1695,198 @@ "SystemLogLevel": "INFO" } } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version_provisioned_concurrency": { + "recorded-date": "07-05-2025, 13:23:25", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "1", + "Payload": { + "initialization_type": "provisioned-concurrency" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnVersion7BF8AE5A", + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Version", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "versions_by_fn": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_version": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "provisioned_concurrency_config": { + "AllocatedProvisionedConcurrentExecutions": 1, + "AvailableProvisionedConcurrentExecutions": 1, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } } } diff --git a/tests/aws/services/cloudformation/resources/test_lambda.validation.json b/tests/aws/services/cloudformation/resources/test_lambda.validation.json index 910fd07381eec..e603d1df5aa41 100644 --- a/tests/aws/services/cloudformation/resources/test_lambda.validation.json +++ b/tests/aws/services/cloudformation/resources/test_lambda.validation.json @@ -24,7 +24,7 @@ "last_validated_date": "2024-04-09T07:20:36+00:00" }, "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_alias": { - "last_validated_date": "2024-04-09T07:19:19+00:00" + "last_validated_date": "2025-05-07T15:39:26+00:00" }, "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_cfn_dead_letter_config_async_invocation": { "last_validated_date": "2024-04-09T07:39:50+00:00" @@ -45,7 +45,10 @@ "last_validated_date": "2025-04-08T12:12:01+00:00" }, "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version": { - "last_validated_date": "2024-04-09T07:21:37+00:00" + "last_validated_date": "2025-05-07T13:19:10+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version_provisioned_concurrency": { + "last_validated_date": "2025-05-07T13:23:25+00:00" }, "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_w_dynamodb_event_filter_update": { "last_validated_date": "2024-12-11T09:03:52+00:00" diff --git a/tests/aws/services/lambda_/test_lambda.py b/tests/aws/services/lambda_/test_lambda.py index 61827dbee334e..481cfa1b1006e 100644 --- a/tests/aws/services/lambda_/test_lambda.py +++ b/tests/aws/services/lambda_/test_lambda.py @@ -2621,7 +2621,7 @@ def test_lambda_provisioned_concurrency_scheduling( ) snapshot.match("get_provisioned_postwait", get_provisioned_postwait) - # Schedule Lambda to provisioned concurrency instead of launching a new on-demand instance + # Invoke should favor provisioned concurrency function over launching a new on-demand instance invoke_result = aws_client.lambda_.invoke( FunctionName=func_name, Qualifier=v1["Version"], diff --git a/tests/aws/templates/cfn_lambda_alias.yml b/tests/aws/templates/cfn_lambda_alias.yml index d8980e30afb49..05a831835c0e3 100644 --- a/tests/aws/templates/cfn_lambda_alias.yml +++ b/tests/aws/templates/cfn_lambda_alias.yml @@ -30,18 +30,20 @@ Resources: FunctionName: !Ref FunctionName Code: ZipFile: | - exports.handler = function(event) { - return { - statusCode: 200, - body: "Hello, World!" - }; - }; + import os + + def handler(event, context): + function_version = os.environ["AWS_LAMBDA_FUNCTION_VERSION"] + print(f"{function_version=}") + init_type = os.environ.get("_XRAY_SDK_LAMBDA_PLACEMENT_INIT_TYPE", None) + print(f"{init_type=}") + return {"function_version": function_version, "initialization_type": init_type} Role: Fn::GetAtt: - MyFnServiceRole - Arn Handler: index.handler - Runtime: nodejs18.x + Runtime: python3.12 DependsOn: - MyFnServiceRole diff --git a/tests/aws/templates/cfn_lambda_version.yaml b/tests/aws/templates/cfn_lambda_version.yaml index e71e1de8bbb03..be448001e1e14 100644 --- a/tests/aws/templates/cfn_lambda_version.yaml +++ b/tests/aws/templates/cfn_lambda_version.yaml @@ -20,16 +20,18 @@ Resources: Properties: Code: ZipFile: | + import os def handler(event, context): - print(event) - return "hello" + function_version = os.environ["AWS_LAMBDA_FUNCTION_VERSION"] + print(f"{function_version=}") + return {"function_version": function_version} Role: Fn::GetAtt: - fnServiceRole5D180AFD - Arn Handler: index.handler - Runtime: python3.9 + Runtime: python3.12 DependsOn: - fnServiceRole5D180AFD fnVersion7BF8AE5A: diff --git a/tests/aws/templates/cfn_lambda_version_provisioned_concurrency.yaml b/tests/aws/templates/cfn_lambda_version_provisioned_concurrency.yaml new file mode 100644 index 0000000000000..b6461d6f1df8d --- /dev/null +++ b/tests/aws/templates/cfn_lambda_version_provisioned_concurrency.yaml @@ -0,0 +1,54 @@ +Resources: + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + fn5FF616E3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + import os + + def handler(event, context): + init_type = os.environ["AWS_LAMBDA_INITIALIZATION_TYPE"] + print(f"{init_type=}") + return {"initialization_type": init_type} + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Handler: index.handler + Runtime: python3.12 + DependsOn: + - fnServiceRole5D180AFD + fnVersion7BF8AE5A: + Type: AWS::Lambda::Version + Properties: + FunctionName: + Ref: fn5FF616E3 + Description: test description + ProvisionedConcurrencyConfig: + ProvisionedConcurrentExecutions: 1 + +Outputs: + FunctionName: + Value: + Ref: fn5FF616E3 + FunctionVersion: + Value: + Fn::GetAtt: + - fnVersion7BF8AE5A + - Version