From 3a637b172a1820972c304bade4295f8d1bd30ac2 Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Fri, 20 Dec 2024 11:26:32 -0700 Subject: [PATCH 1/3] updated scaffold and added test to validate attribute naming change --- .../aws_lambda_layerversion.py | 22 ++-- .../aws_lambda_layerversion.schema.json | 100 +++++++++++++----- .../cloudformation/resources/test_lambda.py | 27 +++++ .../resources/test_lambda.snapshot.json | 12 +++ .../resources/test_lambda.validation.json | 3 + tests/aws/templates/lambda_layer_version.yml | 71 +++++++++++++ 6 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 tests/aws/templates/lambda_layer_version.yml diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.py index abafe8306074b..861adbb3a939b 100644 --- a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.py +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.py @@ -19,8 +19,8 @@ class LambdaLayerVersionProperties(TypedDict): CompatibleArchitectures: Optional[list[str]] CompatibleRuntimes: Optional[list[str]] Description: Optional[str] - Id: Optional[str] LayerName: Optional[str] + LayerVersionArn: Optional[str] LicenseInfo: Optional[str] @@ -45,7 +45,7 @@ def create( Create a new resource. Primary identifier fields: - - /properties/Id + - /properties/LayerVersionArn Required properties: - Content @@ -59,9 +59,12 @@ def create( - /properties/Content Read-only properties: - - /properties/Id - + - /properties/LayerVersionArn + IAM permissions required: + - lambda:PublishLayerVersion + - s3:GetObject + - s3:GetObjectVersion """ model = request.desired_state @@ -69,7 +72,7 @@ def create( if not model.get("LayerName"): model["LayerName"] = f"layer-{short_uid()}" response = lambda_client.publish_layer_version(**model) - model["Id"] = response["LayerVersionArn"] + model["LayerVersionArn"] = response["LayerVersionArn"] return ProgressEvent( status=OperationStatus.SUCCESS, @@ -84,7 +87,8 @@ def read( """ Fetch resource information - + IAM permissions required: + - lambda:GetLayerVersion """ raise NotImplementedError @@ -95,11 +99,13 @@ def delete( """ Delete a resource - + IAM permissions required: + - lambda:GetLayerVersion + - lambda:DeleteLayerVersion """ model = request.desired_state lambda_client = request.aws_client_factory.lambda_ - version = int(model["Id"].split(":")[-1]) + version = int(model["LayerVersionArn"].split(":")[-1]) lambda_client.delete_layer_version(LayerName=model["LayerName"], VersionNumber=version) return ProgressEvent( diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.schema.json index c15e27516da9a..7bc8e494ecd93 100644 --- a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.schema.json +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.schema.json @@ -1,59 +1,71 @@ { "typeName": "AWS::Lambda::LayerVersion", "description": "Resource Type definition for AWS::Lambda::LayerVersion", - "additionalProperties": false, + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-lambda.git", + "definitions": { + "Content": { + "type": "object", + "additionalProperties": false, + "properties": { + "S3ObjectVersion": { + "description": "For versioned objects, the version of the layer archive object to use.", + "type": "string" + }, + "S3Bucket": { + "description": "The Amazon S3 bucket of the layer archive.", + "type": "string" + }, + "S3Key": { + "description": "The Amazon S3 key of the layer archive.", + "type": "string" + } + }, + "required": [ + "S3Bucket", + "S3Key" + ] + } + }, "properties": { "CompatibleRuntimes": { + "description": "A list of compatible function runtimes. Used for filtering with ListLayers and ListLayerVersions.", "type": "array", + "insertionOrder": false, "uniqueItems": false, "items": { "type": "string" } }, "LicenseInfo": { + "description": "The layer's software license.", "type": "string" }, "Description": { + "description": "The description of the version.", "type": "string" }, "LayerName": { + "description": "The name or Amazon Resource Name (ARN) of the layer.", "type": "string" }, "Content": { + "description": "The function layer archive.", "$ref": "#/definitions/Content" }, - "Id": { + "LayerVersionArn": { "type": "string" }, "CompatibleArchitectures": { + "description": "A list of compatible instruction set architectures.", "type": "array", + "insertionOrder": false, "uniqueItems": false, "items": { "type": "string" } } }, - "definitions": { - "Content": { - "type": "object", - "additionalProperties": false, - "properties": { - "S3ObjectVersion": { - "type": "string" - }, - "S3Bucket": { - "type": "string" - }, - "S3Key": { - "type": "string" - } - }, - "required": [ - "S3Bucket", - "S3Key" - ] - } - }, + "additionalProperties": false, "required": [ "Content" ], @@ -65,10 +77,44 @@ "/properties/Description", "/properties/Content" ], + "readOnlyProperties": [ + "/properties/LayerVersionArn" + ], + "writeOnlyProperties": [ + "/properties/Content" + ], "primaryIdentifier": [ - "/properties/Id" + "/properties/LayerVersionArn" ], - "readOnlyProperties": [ - "/properties/Id" - ] + "tagging": { + "taggable": false, + "tagOnCreate": false, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "handlers": { + "create": { + "permissions": [ + "lambda:PublishLayerVersion", + "s3:GetObject", + "s3:GetObjectVersion" + ] + }, + "read": { + "permissions": [ + "lambda:GetLayerVersion" + ] + }, + "delete": { + "permissions": [ + "lambda:GetLayerVersion", + "lambda:DeleteLayerVersion" + ] + }, + "list": { + "permissions": [ + "lambda:ListLayerVersions" + ] + } + } } diff --git a/tests/aws/services/cloudformation/resources/test_lambda.py b/tests/aws/services/cloudformation/resources/test_lambda.py index 5691c74bbadb8..527a3321540ba 100644 --- a/tests/aws/services/cloudformation/resources/test_lambda.py +++ b/tests/aws/services/cloudformation/resources/test_lambda.py @@ -1237,3 +1237,30 @@ def check_dlq_message(response: dict): retry(check_dlq_message, response=response, retries=5, sleep=2.5) snapshot.match("failed-async-lambda", response) + + +@markers.aws.validated +def test_lambda_layer_crud(deploy_cfn_template, aws_client, s3_bucket, snapshot): + snapshot.add_transformers_list( + [snapshot.transform.key_value("LambdaName"), snapshot.transform.key_value("layer-name")] + ) + + layer_name = f"layer-{short_uid()}" + snapshot.match("layer-name", layer_name) + + bucket_key = "layer.zip" + zip_file = create_lambda_archive( + "hello", + get_content=True, + runtime=Runtime.python3_12, + file_name="hello.txt", + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_layer_version.yml" + ), + parameters={"LayerBucket": s3_bucket, "LayerName": layer_name}, + ) + snapshot.match("cfn-output", deployment.outputs) diff --git a/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json b/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json index 355d747e5e7ab..c61888dca606a 100644 --- a/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json +++ b/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json @@ -1594,5 +1594,17 @@ } } } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_layer_crud": { + "recorded-date": "20-12-2024, 18:23:31", + "recorded-content": { + "layer-name": "", + "cfn-output": { + "LambdaArn": "arn::lambda::111111111111:function:", + "LambdaName": "", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "LayerVersionRef": "arn::lambda::111111111111:layer::1" + } + } } } diff --git a/tests/aws/services/cloudformation/resources/test_lambda.validation.json b/tests/aws/services/cloudformation/resources/test_lambda.validation.json index 308100eed7510..74611cffac904 100644 --- a/tests/aws/services/cloudformation/resources/test_lambda.validation.json +++ b/tests/aws/services/cloudformation/resources/test_lambda.validation.json @@ -38,6 +38,9 @@ "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_function_tags": { "last_validated_date": "2024-10-01T12:52:51+00:00" }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_layer_crud": { + "last_validated_date": "2024-12-20T18:23:31+00:00" + }, "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version": { "last_validated_date": "2024-04-09T07:21:37+00:00" }, diff --git a/tests/aws/templates/lambda_layer_version.yml b/tests/aws/templates/lambda_layer_version.yml new file mode 100644 index 0000000000000..6b346ce55bc87 --- /dev/null +++ b/tests/aws/templates/lambda_layer_version.yml @@ -0,0 +1,71 @@ +Parameters: + LayerBucket: + Type: String + LayerName: + Type: String +Resources: + FunctionServiceRole: + 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 + Layer: + Type: AWS::Lambda::LayerVersion + Properties: + LayerName: !Ref LayerName + CompatibleArchitectures: + - arm64 + CompatibleRuntimes: + - python3.11 + - python3.12 + Content: + S3Bucket: !Ref LayerBucket + S3Key: layer.zip + Description: "layer to test cfn" + Function: + Type: AWS::Lambda::Function + Properties: + Description: "function to test lambda layer" + Layers: + - !Ref Layer + Code: + ZipFile: | + def handler(event, *args, **kwargs): + return "CRUD test" + Role: + Fn::GetAtt: + - FunctionServiceRole + - Arn + Handler: index.handler + Runtime: python3.12 + + DependsOn: + - FunctionServiceRole +Outputs: + LambdaName: + Value: + Ref: Function + LambdaArn: + Value: + Fn::GetAtt: + - Function + - Arn + LayerVersionRef: + Value: + Ref: Layer + LayerVersionArn: + Value: + Fn::GetAtt: + - Layer + - LayerVersionArn From 84659dcbde4a59efe5ef4ab0a822a62507c793ba Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Fri, 20 Dec 2024 14:13:04 -0700 Subject: [PATCH 2/3] Fix issue where `get_layer_version` would fail if the layer did not exist --- localstack-core/localstack/services/lambda_/provider.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/localstack-core/localstack/services/lambda_/provider.py b/localstack-core/localstack/services/lambda_/provider.py index 9ad6bd930f4f3..66770ae9b7b26 100644 --- a/localstack-core/localstack/services/lambda_/provider.py +++ b/localstack-core/localstack/services/lambda_/provider.py @@ -3606,7 +3606,12 @@ def get_layer_version_by_arn( ) store = lambda_stores[account_id][region_name] - layer_version = store.layers.get(layer_name, {}).layer_versions.get(layer_version) + if not (layers := store.layers.get(layer_name)): + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + + layer_version = layers.layer_versions.get(layer_version) if not layer_version: raise ResourceNotFoundException( From 2592afa185b657b227573ef1c5457636735ecd76 Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Fri, 20 Dec 2024 14:18:14 -0700 Subject: [PATCH 3/3] implement get and list methods --- .../aws_lambda_layerversion.py | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.py index 861adbb3a939b..3e8e2ecb4811c 100644 --- a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.py +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.py @@ -1,6 +1,7 @@ # LocalStack Resource Provider Scaffolding v2 from __future__ import annotations +import logging from pathlib import Path from typing import Optional, TypedDict @@ -8,11 +9,15 @@ from localstack.services.cloudformation.resource_provider import ( OperationStatus, ProgressEvent, + Properties, ResourceProvider, ResourceRequest, ) +from localstack.services.lambda_.api_utils import parse_layer_arn from localstack.utils.strings import short_uid +LOG = logging.getLogger(__name__) + class LambdaLayerVersionProperties(TypedDict): Content: Optional[Content] @@ -90,7 +95,58 @@ def read( IAM permissions required: - lambda:GetLayerVersion """ - raise NotImplementedError + lambda_client = request.aws_client_factory.lambda_ + layer_version_arn = request.desired_state.get("LayerVersionArn") + + try: + _, _, layer_name, version = parse_layer_arn(layer_version_arn) + except AttributeError as e: + LOG.info( + "Invalid Arn: '%s', %s", + layer_version_arn, + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + return ProgressEvent( + status=OperationStatus.FAILED, + message="Caught unexpected syntax violation. Consider using ARN.fromString().", + error_code="InternalFailure", + ) + + if not version: + return ProgressEvent( + status=OperationStatus.FAILED, + message="Invalid request provided: Layer Version ARN contains invalid layer name or version", + error_code="InvalidRequest", + ) + + try: + response = lambda_client.get_layer_version_by_arn(Arn=layer_version_arn) + except lambda_client.exceptions.ResourceNotFoundException as e: + return ProgressEvent( + status=OperationStatus.FAILED, + message="The resource you requested does not exist. " + f"(Service: Lambda, Status Code: 404, Request ID: {e.response['ResponseMetadata']['RequestId']})", + error_code="NotFound", + ) + layer = util.select_attributes( + response, + [ + "CompatibleRuntimes", + "Description", + "LayerVersionArn", + "CompatibleArchitectures", + ], + ) + layer.setdefault("CompatibleRuntimes", []) + layer.setdefault("CompatibleArchitectures", []) + layer.setdefault("LayerName", layer_name) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=layer, + custom_context=request.custom_context, + ) def delete( self, @@ -124,3 +180,32 @@ def update( """ raise NotImplementedError + + def list(self, request: ResourceRequest[Properties]) -> ProgressEvent[Properties]: + """ + List resources + + IAM permissions required: + - lambda:ListLayerVersions + """ + + lambda_client = request.aws_client_factory.lambda_ + + lambda_layer = request.desired_state.get("LayerName") + if not lambda_layer: + return ProgressEvent( + status=OperationStatus.FAILED, + message="Layer Name cannot be empty", + error_code="InvalidRequest", + ) + + layer_versions = lambda_client.list_layer_versions(LayerName=lambda_layer) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + LambdaLayerVersionProperties(LayerVersionArn=layer_version["LayerVersionArn"]) + for layer_version in layer_versions["LayerVersions"] + ], + custom_context=request.custom_context, + )