From bf3c9768b2421404c0e330ac33f35cafcb468fd5 Mon Sep 17 00:00:00 2001 From: Oliver Bell Date: Sat, 1 Jul 2023 11:32:33 +0100 Subject: [PATCH] Lambda: create list_aliases functionality (#6455) --- moto/awslambda/exceptions.py | 7 ++ moto/awslambda/models.py | 111 ++++++++++++---------- moto/awslambda/responses.py | 15 +++ tests/test_awslambda/test_lambda.py | 101 ++++++++++++++++++++ tests/test_awslambda/test_lambda_alias.py | 93 +++++++++++++++++- 5 files changed, 276 insertions(+), 51 deletions(-) diff --git a/moto/awslambda/exceptions.py b/moto/awslambda/exceptions.py index 15b0e0ccd..9107e1ac5 100644 --- a/moto/awslambda/exceptions.py +++ b/moto/awslambda/exceptions.py @@ -34,6 +34,13 @@ class PreconditionFailedException(JsonRESTError): super().__init__("PreconditionFailedException", message) +class ConflictException(LambdaClientError): + code = 409 + + def __init__(self, message: str): + super().__init__("ConflictException", message) + + class UnknownAliasException(LambdaClientError): code = 404 diff --git a/moto/awslambda/models.py b/moto/awslambda/models.py index 04af5c3a5..2aa35c9d0 100644 --- a/moto/awslambda/models.py +++ b/moto/awslambda/models.py @@ -36,6 +36,7 @@ from moto.ecr.models import ecr_backends from moto.s3.exceptions import MissingBucket, MissingKey from moto import settings from .exceptions import ( + ConflictException, CrossAccountNotAllowed, FunctionUrlConfigNotFound, InvalidRoleFormat, @@ -514,8 +515,6 @@ class LambdaFunction(CloudFormationModel, DockerModel): self.tags = spec.get("Tags") or dict() - self._aliases: Dict[str, LambdaAlias] = dict() - def __getstate__(self) -> Dict[str, Any]: return { k: v @@ -954,43 +953,6 @@ class LambdaFunction(CloudFormationModel, DockerModel): def delete(self, account_id: str, region: str) -> None: lambda_backends[account_id][region].delete_function(self.function_name) - def delete_alias(self, name: str) -> None: - self._aliases.pop(name, None) - - def get_alias(self, name: str) -> LambdaAlias: - if name in self._aliases: - return self._aliases[name] - arn = f"arn:aws:lambda:{self.region}:{self.account_id}:function:{self.function_name}:{name}" - raise UnknownAliasException(arn) - - def has_alias(self, alias_name: str) -> bool: - try: - return self.get_alias(alias_name) is not None - except UnknownAliasException: - return False - - def put_alias( - self, name: str, description: str, function_version: str, routing_config: str - ) -> LambdaAlias: - alias = LambdaAlias( - account_id=self.account_id, - region=self.region, - name=name, - function_name=self.function_name, - function_version=function_version, - description=description, - routing_config=routing_config, - ) - self._aliases[name] = alias - return alias - - def update_alias( - self, name: str, description: str, function_version: str, routing_config: str - ) -> LambdaAlias: - alias = self.get_alias(name) - alias.update(description, function_version, routing_config) - return alias - def create_url_config(self, config: Dict[str, Any]) -> "FunctionUrlConfig": self.url_config = FunctionUrlConfig(function=self, config=config) return self.url_config # type: ignore[return-value] @@ -1205,22 +1167,34 @@ class LambdaStorage(object): self.region_name = region_name self.account_id = account_id + # function-arn -> alias -> LambdaAlias + self._aliases: Dict[str, Dict[str, LambdaAlias]] = defaultdict(lambda: {}) + def _get_latest(self, name: str) -> LambdaFunction: return self._functions[name]["latest"] def _get_version(self, name: str, version: str) -> Optional[LambdaFunction]: for config in self._functions[name]["versions"]: - if str(config.version) == version or config.has_alias(version): + if str(config.version) == version: return config + return None - def delete_alias(self, name: str, function_name: str) -> None: + def _get_function_aliases(self, function_name: str) -> Dict[str, LambdaAlias]: fn = self.get_function_by_name_or_arn(function_name) - return fn.delete_alias(name) + return self._aliases[fn.function_arn] + + def delete_alias(self, name: str, function_name: str) -> None: + aliases = self._get_function_aliases(function_name) + aliases.pop(name, None) def get_alias(self, name: str, function_name: str) -> LambdaAlias: - fn = self.get_function_by_name_or_arn(function_name) - return fn.get_alias(name) + aliases = self._get_function_aliases(function_name) + if name in aliases: + return aliases[name] + + arn = f"arn:aws:lambda:{self.region_name}:{self.account_id}:function:{function_name}:{name}" + raise UnknownAliasException(arn) def put_alias( self, @@ -1230,8 +1204,23 @@ class LambdaStorage(object): description: str, routing_config: str, ) -> LambdaAlias: - fn = self.get_function_by_name_or_arn(function_name) - return fn.put_alias(name, description, function_version, routing_config) + fn = self.get_function_by_name_or_arn(function_name, function_version) + aliases = self._get_function_aliases(function_name) + if name in aliases: + arn = f"arn:aws:lambda:{self.region_name}:{self.account_id}:function:{function_name}:{name}" + raise ConflictException(f"Alias already exists: {arn}") + + alias = LambdaAlias( + account_id=self.account_id, + region=self.region_name, + name=name, + function_name=fn.function_name, + function_version=function_version, + description=description, + routing_config=routing_config, + ) + aliases[name] = alias + return alias def update_alias( self, @@ -1241,8 +1230,13 @@ class LambdaStorage(object): description: str, routing_config: str, ) -> LambdaAlias: - fn = self.get_function_by_name_or_arn(function_name) - return fn.update_alias(name, description, function_version, routing_config) + alias = self.get_alias(name, function_name) + + # errors if new function version doesn't exist + self.get_function_by_name_or_arn(function_name, function_version) + + alias.update(description, function_version, routing_config) + return alias def get_function_by_name( self, name: str, qualifier: Optional[str] = None @@ -1256,7 +1250,17 @@ class LambdaStorage(object): if qualifier.lower() == "$latest": return self._functions[name]["latest"] - return self._get_version(name, qualifier) + found_version = self._get_version(name, qualifier) + if found_version: + return found_version + + aliases = self._get_function_aliases(name) + + if qualifier in aliases: + alias = aliases[qualifier] + return self._get_version(name, alias.function_version) + + return None def list_versions_by_function(self, name: str) -> Iterable[LambdaFunction]: if name not in self._functions: @@ -1266,6 +1270,10 @@ class LambdaStorage(object): latest.function_arn += ":$LATEST" return [latest] + self._functions[name]["versions"] + def list_aliases(self, function_name: str) -> Iterable[LambdaAlias]: + aliases = self._get_function_aliases(function_name) + return sorted(aliases.values(), key=lambda alias: alias.name) + def get_arn(self, arn: str) -> Optional[LambdaFunction]: # Function ARN may contain an alias # arn:aws:lambda:region:account_id:function:: @@ -1375,6 +1383,8 @@ class LambdaStorage(object): ): del self._functions[name] + self._aliases[function.function_arn] = {} + def all(self) -> Iterable[LambdaFunction]: result = [] @@ -1706,6 +1716,9 @@ class LambdaBackend(BaseBackend): def list_versions_by_function(self, function_name: str) -> Iterable[LambdaFunction]: return self._lambdas.list_versions_by_function(function_name) + def list_aliases(self, function_name: str) -> Iterable[LambdaAlias]: + return self._lambdas.list_aliases(function_name) + def get_event_source_mapping(self, uuid: str) -> Optional[EventSourceMapping]: return self._event_source_mappings.get(uuid) diff --git a/moto/awslambda/responses.py b/moto/awslambda/responses.py index 5810935c4..0df05b90e 100644 --- a/moto/awslambda/responses.py +++ b/moto/awslambda/responses.py @@ -46,8 +46,13 @@ class LambdaResponse(BaseResponse): def aliases(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE: # type: ignore[return] self.setup_class(request, full_url, headers) + if request.method == "POST": return self._create_alias() + elif request.method == "GET": + path = request.path if hasattr(request, "path") else path_url(request.url) + function_name = path.split("/")[-2] + return self._list_aliases(function_name) def alias(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE: # type: ignore[return] self.setup_class(request, full_url, headers) @@ -296,6 +301,16 @@ class LambdaResponse(BaseResponse): return 200, {}, json.dumps(result) + def _list_aliases(self, function_name: str) -> TYPE_RESPONSE: + result: Dict[str, Any] = {"Aliases": []} + + aliases = self.backend.list_aliases(function_name) + for alias in aliases: + json_data = alias.to_json() + result["Aliases"].append(json_data) + + return 200, {}, json.dumps(result) + def _create_function(self) -> TYPE_RESPONSE: fn = self.backend.create_function(self.json_body) config = fn.get_configuration(on_create=True) diff --git a/tests/test_awslambda/test_lambda.py b/tests/test_awslambda/test_lambda.py index 1d567ef09..0a52ebafc 100644 --- a/tests/test_awslambda/test_lambda.py +++ b/tests/test_awslambda/test_lambda.py @@ -958,6 +958,107 @@ def test_list_versions_by_function(): ) +@mock_lambda +@mock_s3 +def test_list_aliases(): + bucket_name = str(uuid4()) + s3_conn = boto3.client("s3", _lambda_region) + s3_conn.create_bucket( + Bucket=bucket_name, + CreateBucketConfiguration={"LocationConstraint": _lambda_region}, + ) + + zip_content = get_test_zip_file2() + s3_conn.put_object(Bucket=bucket_name, Key="test.zip", Body=zip_content) + conn = boto3.client("lambda", _lambda_region) + function_name = str(uuid4())[0:6] + function_name2 = str(uuid4())[0:6] + + conn.create_function( + FunctionName=function_name, + Runtime="python2.7", + Role=get_role_name(), + Handler="lambda_function.lambda_handler", + Code={"S3Bucket": bucket_name, "S3Key": "test.zip"}, + Description="test lambda function", + Timeout=3, + MemorySize=128, + Publish=True, + ) + + conn.create_function( + FunctionName=function_name2, + Runtime="python2.7", + Role=get_role_name(), + Handler="lambda_function.lambda_handler", + Code={"S3Bucket": bucket_name, "S3Key": "test.zip"}, + Description="test lambda function", + Timeout=3, + MemorySize=128, + Publish=True, + ) + + first_version = conn.publish_version(FunctionName=function_name)["Version"] + + conn.create_alias( + FunctionName=function_name, + Name="alias1", + FunctionVersion=first_version, + ) + + conn.update_function_code(FunctionName=function_name, ZipFile=get_test_zip_file1()) + second_version = conn.publish_version(FunctionName=function_name)["Version"] + + conn.create_alias( + FunctionName=function_name, + Name="alias2", + FunctionVersion=second_version, + ) + + conn.create_alias( + FunctionName=function_name, + Name="alias0", + FunctionVersion=second_version, + ) + + aliases = conn.list_aliases(FunctionName=function_name) + assert len(aliases["Aliases"]) == 3 + + # should be ordered by their alias name (as per SDK response) + assert ( + aliases["Aliases"][0]["AliasArn"] + == f"arn:aws:lambda:us-west-2:{ACCOUNT_ID}:function:{function_name}:alias0" + ) + assert aliases["Aliases"][0]["FunctionVersion"] == second_version + + assert ( + aliases["Aliases"][1]["AliasArn"] + == f"arn:aws:lambda:us-west-2:{ACCOUNT_ID}:function:{function_name}:alias1" + ) + assert aliases["Aliases"][1]["FunctionVersion"] == first_version + + assert ( + aliases["Aliases"][2]["AliasArn"] + == f"arn:aws:lambda:us-west-2:{ACCOUNT_ID}:function:{function_name}:alias2" + ) + assert aliases["Aliases"][2]["FunctionVersion"] == second_version + + res = conn.publish_version(FunctionName=function_name2) + conn.create_alias( + FunctionName=function_name2, + Name="alias1", + FunctionVersion=res["Version"], + ) + + aliases = conn.list_aliases(FunctionName=function_name2) + + assert len(aliases["Aliases"]) == 1 + assert ( + aliases["Aliases"][0]["AliasArn"] + == f"arn:aws:lambda:us-west-2:{ACCOUNT_ID}:function:{function_name2}:alias1" + ) + + @mock_lambda @mock_s3 def test_create_function_with_already_exists(): diff --git a/tests/test_awslambda/test_lambda_alias.py b/tests/test_awslambda/test_lambda_alias.py index a0fd33150..00ade02f7 100644 --- a/tests/test_awslambda/test_lambda_alias.py +++ b/tests/test_awslambda/test_lambda_alias.py @@ -10,6 +10,7 @@ from uuid import uuid4 from .utilities import ( get_role_name, get_test_zip_file1, + get_test_zip_file2, ) # See our Development Tips on writing tests for hints on how to write good tests: @@ -150,6 +151,56 @@ def test_get_alias(): assert "RevisionId" in resp +@mock_lambda +def test_aliases_are_unique_per_function(): + client = boto3.client("lambda", region_name="us-west-1") + function_name = str(uuid4())[0:6] + function_name2 = str(uuid4())[0:6] + + client.create_function( + FunctionName=function_name, + Runtime="python3.7", + Role=get_role_name(), + Handler="lambda_function.lambda_handler", + Code={"ZipFile": get_test_zip_file1()}, + ) + client.create_function( + FunctionName=function_name2, + Runtime="python3.7", + Role=get_role_name(), + Handler="lambda_function.lambda_handler", + Code={"ZipFile": get_test_zip_file1()}, + ) + + client.create_alias( + FunctionName=function_name, Name="alias1", FunctionVersion="$LATEST" + ) + client.create_alias( + FunctionName=function_name2, Name="alias1", FunctionVersion="$LATEST" + ) + + client.update_function_code( + FunctionName=function_name, ZipFile=get_test_zip_file2() + ) + client.update_function_code( + FunctionName=function_name2, ZipFile=get_test_zip_file2() + ) + + res = client.publish_version(FunctionName=function_name) + + with pytest.raises(ClientError) as exc: + client.create_alias( + FunctionName=function_name, Name="alias1", FunctionVersion=res["Version"] + ) + + err = exc.value.response["Error"] + assert err["Code"] == "ConflictException" + assert ( + err["Message"] + == f"Alias already exists: arn:aws:lambda:us-west-1:{ACCOUNT_ID}:function:{function_name}:alias1" + ) + + @mock_lambda def test_get_alias_using_function_arn(): client = boto3.client("lambda", region_name="us-west-1") @@ -250,10 +301,16 @@ def test_update_alias(): FunctionName=function_name, Name="alias1", FunctionVersion="$LATEST" ) + client.update_function_code( + FunctionName=function_name, ZipFile=get_test_zip_file2() + ) + + new_version = client.publish_version(FunctionName=function_name)["Version"] + resp = client.update_alias( FunctionName=function_name, Name="alias1", - FunctionVersion="1", + FunctionVersion=new_version, Description="updated desc", ) @@ -262,11 +319,43 @@ def test_update_alias(): == f"arn:aws:lambda:us-east-2:{ACCOUNT_ID}:function:{function_name}:alias1" ) assert resp["Name"] == "alias1" - assert resp["FunctionVersion"] == "1" + assert resp["FunctionVersion"] == new_version assert resp["Description"] == "updated desc" assert "RevisionId" in resp +@mock_lambda +def test_update_alias_errors_if_version_doesnt_exist(): + client = boto3.client("lambda", region_name="us-east-2") + function_name = str(uuid4())[0:6] + + client.create_function( + FunctionName=function_name, + Runtime="python3.7", + Role=get_role_name(), + Handler="lambda_function.lambda_handler", + Code={"ZipFile": get_test_zip_file1()}, + ) + + client.create_alias( + FunctionName=function_name, Name="alias1", FunctionVersion="$LATEST" + ) + + with pytest.raises(ClientError) as exc: + client.update_alias( + FunctionName=function_name, + Name="alias1", + FunctionVersion="1", + Description="updated desc", + ) + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert ( + err["Message"] + == f"Function not found: arn:aws:lambda:us-east-2:{ACCOUNT_ID}:function:{function_name}:1" + ) + + @mock_lambda def test_update_alias_routingconfig(): client = boto3.client("lambda", region_name="us-east-2")