diff --git a/aws-logs-loggroup/aws-logs-loggroup.json b/aws-logs-loggroup/aws-logs-loggroup.json index 646543b..36f4da0 100644 --- a/aws-logs-loggroup/aws-logs-loggroup.json +++ b/aws-logs-loggroup/aws-logs-loggroup.json @@ -2,6 +2,30 @@ "typeName": "AWS::Logs::LogGroup", "description": "Resource schema for AWS::Logs::LogGroup", "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-logs.git", + "definitions": { + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., :, /, =, +, - and @.", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., :, /, =, +, - and @.", + "minLength": 0, + "maxLength": 256 + } + }, + "required": [ + "Key", + "Value" + ] + } + }, "properties": { "LogGroupName": { "description": "The name of the log group. If you don't specify a name, AWS CloudFormation generates a unique ID for the log group.", @@ -39,6 +63,15 @@ 3653 ] }, + "Tags": { + "description": "An array of key-value pairs to apply to this resource.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, "Arn": { "description": "The CloudWatch log group ARN.", "type": "string" @@ -49,12 +82,14 @@ "permissions": [ "logs:DescribeLogGroups", "logs:CreateLogGroup", - "logs:PutRetentionPolicy" + "logs:PutRetentionPolicy", + "logs:TagLogGroup" ] }, "read": { "permissions": [ - "logs:DescribeLogGroups" + "logs:DescribeLogGroups", + "logs:ListTagsLogGroup" ] }, "update": { @@ -63,7 +98,9 @@ "logs:AssociateKmsKey", "logs:DisassociateKmsKey", "logs:PutRetentionPolicy", - "logs:DeleteRetentionPolicy" + "logs:DeleteRetentionPolicy", + "logs:TagLogGroup", + "logs:UntagLogGroup" ] }, "delete": { @@ -74,7 +111,8 @@ }, "list": { "permissions": [ - "logs:DescribeLogGroups" + "logs:DescribeLogGroups", + "logs:ListTagsLogGroup" ] } }, diff --git a/aws-logs-loggroup/pom.xml b/aws-logs-loggroup/pom.xml index 5a75c6f..c1705b4 100644 --- a/aws-logs-loggroup/pom.xml +++ b/aws-logs-loggroup/pom.xml @@ -16,6 +16,7 @@ 1.8 UTF-8 UTF-8 + 2.15.19 @@ -43,7 +44,7 @@ software.amazon.awssdk cloudwatchlogs - 2.13.18 + ${awssdk.version} diff --git a/aws-logs-loggroup/resource-role.yaml b/aws-logs-loggroup/resource-role.yaml index 57a3f2d..4e97078 100644 --- a/aws-logs-loggroup/resource-role.yaml +++ b/aws-logs-loggroup/resource-role.yaml @@ -29,7 +29,10 @@ Resources: - "logs:DeleteRetentionPolicy" - "logs:DescribeLogGroups" - "logs:DisassociateKmsKey" + - "logs:ListTagsLogGroup" - "logs:PutRetentionPolicy" + - "logs:TagLogGroup" + - "logs:UntagLogGroup" Resource: "*" Outputs: ExecutionRoleArn: diff --git a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/Configuration.java b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/Configuration.java index 2c95be4..6f9900c 100644 --- a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/Configuration.java +++ b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/Configuration.java @@ -2,8 +2,10 @@ import org.json.JSONObject; import org.json.JSONTokener; +import software.amazon.awssdk.utils.CollectionUtils; import java.util.Map; +import java.util.stream.Collectors; class Configuration extends BaseConfiguration { @@ -16,6 +18,12 @@ public JSONObject resourceSchemaJSONObject() { } public Map resourceDefinedTags(final ResourceModel resourceModel) { - return null; + if (CollectionUtils.isNullOrEmpty(resourceModel.getTags())) { + return null; + } + + return resourceModel.getTags() + .stream() + .collect(Collectors.toMap(Tag::getKey, Tag::getValue, (value1, value2) -> value2)); } } diff --git a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/CreateHandler.java b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/CreateHandler.java index 5fcbc94..79206ab 100644 --- a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/CreateHandler.java +++ b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/CreateHandler.java @@ -26,7 +26,7 @@ public ProgressEvent handleRequest( final ResourceModel model = request.getDesiredResourceState(); try { - proxy.injectCredentialsAndInvokeV2(Translator.translateToCreateRequest(model), + proxy.injectCredentialsAndInvokeV2(Translator.translateToCreateRequest(model, request.getDesiredResourceTags()), ClientBuilder.getClient()::createLogGroup); } catch (final ResourceAlreadyExistsException e) { throw new CfnAlreadyExistsException(ResourceModel.TYPE_NAME, diff --git a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/ListHandler.java b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/ListHandler.java index 24033b6..442b965 100644 --- a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/ListHandler.java +++ b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/ListHandler.java @@ -1,5 +1,7 @@ package software.amazon.logs.loggroup; +import software.amazon.awssdk.services.cloudwatchlogs.model.ListTagsLogGroupResponse; +import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.OperationStatus; @@ -7,6 +9,9 @@ import software.amazon.cloudformation.proxy.ResourceHandlerRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse; +import java.util.Map; +import java.util.stream.Collectors; + public class ListHandler extends BaseHandler { @Override @@ -19,9 +24,17 @@ public ProgressEvent handleRequest( final DescribeLogGroupsResponse response = proxy.injectCredentialsAndInvokeV2(Translator.translateToListRequest(request.getNextToken()), ClientBuilder.getClient()::describeLogGroups); + + final Map tagResponses = Translator.streamOfOrEmpty(response.logGroups()) + .collect(Collectors.toMap( + LogGroup::logGroupName, + logGroup -> proxy.injectCredentialsAndInvokeV2(Translator.translateToListTagsLogGroupRequest(logGroup.logGroupName()), + ClientBuilder.getClient()::listTagsLogGroup)) + ); + return ProgressEvent.builder() .status(OperationStatus.SUCCESS) - .resourceModels(Translator.translateForList(response)) + .resourceModels(Translator.translateForList(response, tagResponses)) .nextToken(response.nextToken()) .build(); } diff --git a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/ReadHandler.java b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/ReadHandler.java index 5a52b11..0ff0ff6 100644 --- a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/ReadHandler.java +++ b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/ReadHandler.java @@ -1,5 +1,7 @@ package software.amazon.logs.loggroup; +import software.amazon.awssdk.services.cloudwatchlogs.model.CloudWatchLogsException; +import software.amazon.awssdk.services.cloudwatchlogs.model.ListTagsLogGroupResponse; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.ProgressEvent; @@ -25,14 +27,26 @@ public ProgressEvent handleRequest( } DescribeLogGroupsResponse response = null; + ListTagsLogGroupResponse tagsResponse = null; try { response = proxy.injectCredentialsAndInvokeV2(Translator.translateToReadRequest(model), - ClientBuilder.getClient()::describeLogGroups); + ClientBuilder.getClient()::describeLogGroups); + try { + tagsResponse = proxy.injectCredentialsAndInvokeV2(Translator.translateToListTagsLogGroupRequest(model.getLogGroupName()), + ClientBuilder.getClient()::listTagsLogGroup); + } catch (final CloudWatchLogsException e) { + if (Translator.ACCESS_DENIED_ERROR_CODE.equals(e.awsErrorDetails().errorCode())) { + // fail silently, if there is no permission to list tags + logger.log(e.getMessage()); + } else { + throw e; + } + } } catch (final ResourceNotFoundException e) { throwNotFoundException(model); } - final ResourceModel modelFromReadResult = Translator.translateForRead(response); + final ResourceModel modelFromReadResult = Translator.translateForRead(response, tagsResponse); if (modelFromReadResult.getLogGroupName() == null) { throwNotFoundException(model); } diff --git a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/Translator.java b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/Translator.java index 66d333d..9eb9e33 100644 --- a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/Translator.java +++ b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/Translator.java @@ -5,18 +5,29 @@ import software.amazon.awssdk.services.cloudwatchlogs.model.DeleteRetentionPolicyRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse; +import software.amazon.awssdk.services.cloudwatchlogs.model.ListTagsLogGroupRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.ListTagsLogGroupResponse; import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.DisassociateKmsKeyRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.AssociateKmsKeyRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.TagLogGroupRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.UntagLogGroupRequest; +import software.amazon.awssdk.utils.CollectionUtils; import java.util.Collection; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; final class Translator { + + static final String ACCESS_DENIED_ERROR_CODE = "AccessDeniedException"; + private Translator() {} static DescribeLogGroupsRequest translateToReadRequest(final ResourceModel model) { @@ -38,10 +49,11 @@ static DeleteLogGroupRequest translateToDeleteRequest(final ResourceModel model) .build(); } - static CreateLogGroupRequest translateToCreateRequest(final ResourceModel model) { + static CreateLogGroupRequest translateToCreateRequest(final ResourceModel model, final Map tags) { return CreateLogGroupRequest.builder() .logGroupName(model.getLogGroupName()) .kmsKeyId(model.getKmsKeyId()) + .tags(tags) .build(); } @@ -71,7 +83,27 @@ static AssociateKmsKeyRequest translateToAssociateKmsKeyRequest(final ResourceMo .build(); } - static ResourceModel translateForRead(final DescribeLogGroupsResponse response) { + static ListTagsLogGroupRequest translateToListTagsLogGroupRequest(final String logGroupName) { + return ListTagsLogGroupRequest.builder() + .logGroupName(logGroupName) + .build(); + } + + static TagLogGroupRequest translateToTagLogGroupRequest(final String logGroupName, final Map tags) { + return TagLogGroupRequest.builder() + .logGroupName(logGroupName) + .tags(tags) + .build(); + } + + static UntagLogGroupRequest translateToUntagLogGroupRequest(final String logGroupName, final List tagKeys) { + return UntagLogGroupRequest.builder() + .logGroupName(logGroupName) + .tags(tagKeys) + .build(); + } + + static ResourceModel translateForRead(final DescribeLogGroupsResponse response, final ListTagsLogGroupResponse tagsResponse) { final String logGroupName = streamOfOrEmpty(response.logGroups()) .map(software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup::logGroupName) .filter(Objects::nonNull) @@ -92,26 +124,31 @@ static ResourceModel translateForRead(final DescribeLogGroupsResponse response) .filter(Objects::nonNull) .findAny() .orElse(null); + final Set tags = translateSdkToTags(Optional.ofNullable(tagsResponse) + .map(ListTagsLogGroupResponse::tags) + .orElse(null)); return ResourceModel.builder() .arn(logGroupArn) .logGroupName(logGroupName) .retentionInDays(retentionInDays) .kmsKeyId(kmsKeyId) + .tags(tags) .build(); } - static List translateForList(final DescribeLogGroupsResponse response) { + static List translateForList(final DescribeLogGroupsResponse response, final Map tagResponses) { return streamOfOrEmpty(response.logGroups()) .map(logGroup -> ResourceModel.builder() .arn(logGroup.arn()) .logGroupName(logGroup.logGroupName()) .retentionInDays(logGroup.retentionInDays()) .kmsKeyId(logGroup.kmsKeyId()) + .tags(translateSdkToTags(tagResponses.get(logGroup.logGroupName()).tags())) .build()) .collect(Collectors.toList()); } - private static Stream streamOfOrEmpty(final Collection collection) { + static Stream streamOfOrEmpty(final Collection collection) { return Optional.ofNullable(collection) .map(Collection::stream) .orElseGet(Stream::empty); @@ -128,4 +165,19 @@ static String buildResourceDoesNotExistErrorMessage(final String resourceIdentif ResourceModel.TYPE_NAME, resourceIdentifier); } + + static Map translateTagsToSdk(final Set tags) { + if (CollectionUtils.isNullOrEmpty(tags)) { + return null; + } + return tags.stream().collect(Collectors.toMap(Tag::getKey, Tag::getValue)); + } + + static Set translateSdkToTags(final Map tags) { + if (CollectionUtils.isNullOrEmpty(tags)) { + return null; + } + return tags.entrySet().stream().map(tag -> new Tag(tag.getKey(), tag.getValue())) + .collect(Collectors.toSet()); + } } diff --git a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/UpdateHandler.java b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/UpdateHandler.java index d95e015..3feccd6 100644 --- a/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/UpdateHandler.java +++ b/aws-logs-loggroup/src/main/java/software/amazon/logs/loggroup/UpdateHandler.java @@ -1,5 +1,7 @@ package software.amazon.logs.loggroup; +import com.google.common.collect.MapDifference; +import com.google.common.collect.Maps; import software.amazon.cloudformation.exceptions.CfnInternalFailureException; import software.amazon.cloudformation.exceptions.CfnResourceConflictException; import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException; @@ -7,6 +9,7 @@ import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.ProgressEvent; import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.CloudWatchLogsException; import software.amazon.awssdk.services.cloudwatchlogs.model.DeleteRetentionPolicyRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.DisassociateKmsKeyRequest; @@ -16,7 +19,14 @@ import software.amazon.awssdk.services.cloudwatchlogs.model.ResourceNotFoundException; import software.amazon.awssdk.services.cloudwatchlogs.model.ServiceUnavailableException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class UpdateHandler extends BaseHandler { @@ -27,11 +37,14 @@ public ProgressEvent handleRequest( final CallbackContext callbackContext, final Logger logger) { - // Everything except RetentionPolicyInDays and KmsKeyId is createOnly final ResourceModel model = request.getDesiredResourceState(); final ResourceModel previousModel = request.getPreviousResourceState(); + final Map tags = request.getDesiredResourceTags(); + final Map previousTags = request.getPreviousResourceTags(); + final boolean retentionChanged = ! retentionUnchanged(previousModel, model); final boolean kmsKeyChanged = ! kmsKeyUnchanged(previousModel, model); + final boolean tagsChanged = ! tagsUnchanged(previousTags, tags); if (retentionChanged && model.getRetentionInDays() == null) { deleteRetentionPolicy(proxy, request, logger); } else if (retentionChanged){ @@ -47,6 +60,10 @@ public ProgressEvent handleRequest( associateKmsKey(proxy, request, logger); } + if (tagsChanged) { + updateTags(proxy, model, previousTags, tags, logger); + } + return ProgressEvent.defaultSuccessHandler(model); } @@ -148,6 +165,53 @@ private void associateKmsKey(final AmazonWebServicesClientProxy proxy, logger.log(kmsKeyMessage); } + private void updateTags(final AmazonWebServicesClientProxy proxy, + final ResourceModel model, + final Map previousTags, + final Map tags, + final Logger logger) { + MapDifference tagsDifference = Maps.difference(Optional.ofNullable(previousTags).orElse(new HashMap<>()), + Optional.ofNullable(tags).orElse(new HashMap<>())); + final Map tagsToRemove = tagsDifference.entriesOnlyOnLeft(); + final Map tagsToAdd = tagsDifference.entriesOnlyOnRight(); + final Map tagsToDiffer = tagsDifference.entriesDiffering().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, tag -> tag.getValue().rightValue())); + final Map tagsToUpdate = Stream.concat(tagsToAdd.entrySet().stream(), tagsToDiffer.entrySet().stream()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + try { + if (!tagsToRemove.isEmpty()) { + final List tagKeys = new ArrayList<>(tagsToRemove.keySet()); + proxy.injectCredentialsAndInvokeV2(Translator.translateToUntagLogGroupRequest(model.getLogGroupName(), tagKeys), + ClientBuilder.getClient()::untagLogGroup); + + final String message = + String.format("%s [%s] successfully removed tags: [%s]", + ResourceModel.TYPE_NAME, model.getLogGroupName(), tagKeys); + logger.log(message); + } + if(!tagsToUpdate.isEmpty()) { + proxy.injectCredentialsAndInvokeV2(Translator.translateToTagLogGroupRequest(model.getLogGroupName(), tagsToUpdate), + ClientBuilder.getClient()::tagLogGroup); + + final String message = + String.format("%s [%s] successfully added tags: [%s]", + ResourceModel.TYPE_NAME, model.getLogGroupName(), tagsToUpdate); + logger.log(message); + } + } catch (final ResourceNotFoundException e) { + throwNotFoundException(model); + } catch (final InvalidParameterException e) { + throw new CfnInternalFailureException(e); + } catch (final CloudWatchLogsException e) { + if (Translator.ACCESS_DENIED_ERROR_CODE.equals(e.awsErrorDetails().errorCode())) { + // fail silently, if there is no permission to list tags + logger.log(e.getMessage()); + } else { + throw e; + } + } + } + private void throwNotFoundException(final ResourceModel model) { throw new software.amazon.cloudformation.exceptions.ResourceNotFoundException(ResourceModel.TYPE_NAME, Objects.toString(model.getPrimaryIdentifier())); @@ -161,4 +225,11 @@ private static boolean retentionUnchanged(final ResourceModel previousModel, fin private static boolean kmsKeyUnchanged(final ResourceModel previousModel, final ResourceModel model) { return (previousModel != null && Objects.equals(model.getKmsKeyId(), previousModel.getKmsKeyId())); } + + private static boolean tagsUnchanged(final Map previousTags, final Map tags) { + if (previousTags == null && tags == null) { + return true; + } + return (previousTags != null && Objects.equals(previousTags, tags)); + } } diff --git a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ConfigurationTest.java b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ConfigurationTest.java new file mode 100644 index 0000000..c4fe0ed --- /dev/null +++ b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ConfigurationTest.java @@ -0,0 +1,28 @@ +package software.amazon.logs.loggroup; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConfigurationTest { + + @Test + public void testResourceDefinedTags_MergeDuplicateKeys() { + final Set tags = new HashSet<>(Arrays.asList( + Tag.builder().key("key-1").value("value-1").build(), + Tag.builder().key("key-1").value("value-2").build() + )); + final ResourceModel model = ResourceModel.builder() + .tags(tags) + .build(); + + final Configuration configuration = new Configuration(); + + assertThat(configuration.resourceDefinedTags(model)).isEqualTo(Collections.singletonMap("key-1", "value-2")); + } +} diff --git a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/CreateHandlerTest.java b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/CreateHandlerTest.java index b334e70..eb4c942 100644 --- a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/CreateHandlerTest.java +++ b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/CreateHandlerTest.java @@ -1,5 +1,9 @@ package software.amazon.logs.loggroup; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.services.cloudwatchlogs.model.CloudWatchLogsRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.CreateLogGroupRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyRequest; import software.amazon.cloudformation.exceptions.CfnAlreadyExistsException; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.Logger; @@ -55,6 +59,10 @@ public void handleRequest_Success() { .retentionInDays(1) .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") .build(); + final Map tags = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; doReturn(describeResponseInitial, createLogGroupResponse, putRetentionPolicyResponse) .when(proxy) @@ -70,17 +78,30 @@ public void handleRequest_Success() { .build(); final ResourceHandlerRequest request = ResourceHandlerRequest.builder() - .desiredResourceState(model) - .build(); + .desiredResourceState(model) + .desiredResourceTags(tags) + .build(); final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); + ArgumentCaptor requests = ArgumentCaptor.forClass(CloudWatchLogsRequest.class); + verify(proxy, times(2)).injectCredentialsAndInvokeV2(requests.capture(), any()); + assertThat(requests.getAllValues().get(0)).isEqualTo(CreateLogGroupRequest.builder() + .logGroupName("LogGroup") + .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .tags(tags) + .build()); + assertThat(requests.getAllValues().get(1)).isEqualTo(PutRetentionPolicyRequest.builder() + .logGroupName("LogGroup") + .retentionInDays(1) + .build()); + assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModels()).isNull(); - assertThat(response.getResourceModel()).isEqualToComparingFieldByField(logGroup); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } diff --git a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ListHandlerTest.java b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ListHandlerTest.java index 22256ee..b06311e 100644 --- a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ListHandlerTest.java +++ b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ListHandlerTest.java @@ -1,5 +1,6 @@ package software.amazon.logs.loggroup; +import software.amazon.awssdk.services.cloudwatchlogs.model.ListTagsLogGroupResponse; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.OperationStatus; @@ -15,6 +16,8 @@ import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup; import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.doReturn; @@ -44,15 +47,29 @@ public void handleRequest_Success() { .retentionInDays(1) .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") .build(); + final Set tags = new HashSet<>(Arrays.asList( + Tag.builder().key("key-1").value("value-1").build(), + Tag.builder().key("key-2").value("value-2").build() + )); final LogGroup logGroup2 = LogGroup.builder() .logGroupName("LogGroup2") .retentionInDays(2) .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") .build(); + final Set tags2 = new HashSet<>(Arrays.asList( + Tag.builder().key("key-3").value("value-3").build(), + Tag.builder().key("key-4").value("value-4").build() + )); final DescribeLogGroupsResponse describeResponse = DescribeLogGroupsResponse.builder() .logGroups(Arrays.asList(logGroup, logGroup2)) .nextToken("token2") .build(); + final ListTagsLogGroupResponse tagsResponse = ListTagsLogGroupResponse.builder() + .tags(Translator.translateTagsToSdk(tags)) + .build(); + final ListTagsLogGroupResponse tagsResponse2 = ListTagsLogGroupResponse.builder() + .tags(Translator.translateTagsToSdk(tags2)) + .build(); doReturn(describeResponse) .when(proxy) @@ -60,17 +77,31 @@ public void handleRequest_Success() { ArgumentMatchers.any(), ArgumentMatchers.any() ); + doReturn(tagsResponse) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.eq(Translator.translateToListTagsLogGroupRequest(logGroup.logGroupName())), + ArgumentMatchers.any() + ); + doReturn(tagsResponse2) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.eq(Translator.translateToListTagsLogGroupRequest(logGroup2.logGroupName())), + ArgumentMatchers.any() + ); final ResourceModel model1 = ResourceModel.builder() .logGroupName("LogGroup") .retentionInDays(1) .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .tags(tags) .build(); final ResourceModel model2 = ResourceModel.builder() .logGroupName("LogGroup2") .retentionInDays(2) .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + .tags(tags2) .build(); final ResourceHandlerRequest request = ResourceHandlerRequest.builder() diff --git a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ReadHandlerTest.java b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ReadHandlerTest.java index 93453e6..99e4b0e 100644 --- a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ReadHandlerTest.java +++ b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/ReadHandlerTest.java @@ -1,5 +1,11 @@ package software.amazon.logs.loggroup; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.cloudwatchlogs.model.CloudWatchLogsException; +import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.ListTagsLogGroupRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.ListTagsLogGroupResponse; import software.amazon.cloudformation.exceptions.ResourceNotFoundException; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.Logger; @@ -15,7 +21,10 @@ import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse; import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -47,11 +56,18 @@ public void handleRequest_Success() { .retentionInDays(1) .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") .build(); + final Set tags = new HashSet<>(Arrays.asList( + Tag.builder().key("key-1").value("value-1").build(), + Tag.builder().key("key-2").value("value-2").build() + )); final DescribeLogGroupsResponse describeResponse = DescribeLogGroupsResponse.builder() .logGroups(Collections.singletonList(logGroup)) .build(); + final ListTagsLogGroupResponse tagsResponse = ListTagsLogGroupResponse.builder() + .tags(Translator.translateTagsToSdk(tags)) + .build(); - doReturn(describeResponse) + doReturn(describeResponse, tagsResponse) .when(proxy) .injectCredentialsAndInvokeV2( ArgumentMatchers.any(), @@ -62,6 +78,7 @@ public void handleRequest_Success() { .logGroupName("LogGroup") .retentionInDays(1) .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .tags(tags) .build(); final ResourceHandlerRequest request = ResourceHandlerRequest.builder() @@ -75,7 +92,8 @@ public void handleRequest_Success() { assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModels()).isNull(); - assertThat(response.getResourceModel()).isEqualToComparingFieldByField(logGroup); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); + assertThat(response.getResourceModel().getTags()).isEqualTo(tags); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @@ -85,8 +103,11 @@ public void handleRequest_FailureNotFound_EmptyLogGroupResponse() { final DescribeLogGroupsResponse describeResponse = DescribeLogGroupsResponse.builder() .logGroups(Collections.emptyList()) .build(); + final ListTagsLogGroupResponse tagsResponse = ListTagsLogGroupResponse.builder() + .tags(Collections.emptyMap()) + .build(); - doReturn(describeResponse) + doReturn(describeResponse, tagsResponse) .when(proxy) .injectCredentialsAndInvokeV2( ArgumentMatchers.any(), @@ -151,4 +172,99 @@ public void handleRequest_FailureNotFound_NullModel() { assertThrows(ResourceNotFoundException.class, () -> handler.handleRequest(proxy, request, null, logger)); } + + @Test + public void handleRequest_FailureListTagsAccessDenied_NoException() { + final LogGroup logGroup = LogGroup.builder() + .logGroupName("LogGroup") + .retentionInDays(1) + .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .build(); + final DescribeLogGroupsResponse describeResponse = DescribeLogGroupsResponse.builder() + .logGroups(Collections.singletonList(logGroup)) + .build(); + + doReturn(describeResponse) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(DescribeLogGroupsRequest.class), + ArgumentMatchers.any() + ); + final AwsServiceException exception = CloudWatchLogsException.builder() + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("AccessDeniedException") + .build()) + .build(); + doThrow(exception) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(ListTagsLogGroupRequest.class), + ArgumentMatchers.any() + ); + + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .retentionInDays(1) + .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); + assertThat(response.getResourceModel().getTags()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_FailureListTags_WithException() { + final LogGroup logGroup = LogGroup.builder() + .logGroupName("LogGroup") + .retentionInDays(1) + .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .build(); + final DescribeLogGroupsResponse describeResponse = DescribeLogGroupsResponse.builder() + .logGroups(Collections.singletonList(logGroup)) + .build(); + + doReturn(describeResponse) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(DescribeLogGroupsRequest.class), + ArgumentMatchers.any() + ); + final AwsServiceException exception = CloudWatchLogsException.builder() + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("InternalFailure") + .build()) + .build(); + doThrow(exception) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(ListTagsLogGroupRequest.class), + ArgumentMatchers.any() + ); + + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .retentionInDays(1) + .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + assertThrows(AwsServiceException.class, + () -> handler.handleRequest(proxy, request, null, logger)); + } } diff --git a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/TranslatorTest.java b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/TranslatorTest.java index 63f4083..c76184c 100644 --- a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/TranslatorTest.java +++ b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/TranslatorTest.java @@ -8,22 +8,44 @@ import software.amazon.awssdk.services.cloudwatchlogs.model.DeleteRetentionPolicyRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse; +import software.amazon.awssdk.services.cloudwatchlogs.model.ListTagsLogGroupRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.ListTagsLogGroupResponse; import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup; import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.DisassociateKmsKeyRequest; import software.amazon.awssdk.services.cloudwatchlogs.model.AssociateKmsKeyRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.TagLogGroupRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.UntagLogGroupRequest; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @ExtendWith(MockitoExtension.class) public class TranslatorTest { + private static final Set SET_TAGS = new HashSet<>(Arrays.asList( + Tag.builder().key("key-1").value("value-1").build(), + Tag.builder().key("key-2").value("value-2").build() + )); + + private static final Map MAP_TAGS = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; + private static final ResourceModel RESOURCE_MODEL = ResourceModel.builder() - .retentionInDays(1) - .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - .logGroupName("LogGroup") - .build(); + .retentionInDays(1) + .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .logGroupName("LogGroup") + .tags(SET_TAGS) + .build(); @Test public void testTranslateToRead() { @@ -53,10 +75,11 @@ public void testTranslateToDelete() { @Test public void testTranslateToCreate() { final CreateLogGroupRequest request = CreateLogGroupRequest.builder() - .logGroupName(RESOURCE_MODEL.getLogGroupName()) - .kmsKeyId(RESOURCE_MODEL.getKmsKeyId()) - .build(); - assertThat(Translator.translateToCreateRequest(RESOURCE_MODEL)).isEqualToComparingFieldByField(request); + .logGroupName(RESOURCE_MODEL.getLogGroupName()) + .kmsKeyId(RESOURCE_MODEL.getKmsKeyId()) + .tags(MAP_TAGS) + .build(); + assertThat(Translator.translateToCreateRequest(RESOURCE_MODEL, MAP_TAGS)).isEqualToComparingFieldByField(request); } @Test @@ -101,6 +124,39 @@ public void testTranslateToAssociateKmsKeyRequest() { .isEqualToComparingFieldByField(request); } + @Test + public void testTranslateToListTagsLogGroupRequest() { + final ListTagsLogGroupRequest request = ListTagsLogGroupRequest.builder() + .logGroupName(RESOURCE_MODEL.getLogGroupName()) + .build(); + + assertThat(Translator.translateToListTagsLogGroupRequest(RESOURCE_MODEL.getLogGroupName())) + .isEqualToComparingFieldByField(request); + } + + @Test + public void testTranslateToTagLogGroupRequest() { + final TagLogGroupRequest request = TagLogGroupRequest.builder() + .logGroupName(RESOURCE_MODEL.getLogGroupName()) + .tags(MAP_TAGS) + .build(); + + assertThat(Translator.translateToTagLogGroupRequest(RESOURCE_MODEL.getLogGroupName(), MAP_TAGS)) + .isEqualToComparingFieldByField(request); + } + + @Test + public void testTranslateToUntagLogGroupRequest() { + final List tagKeys = SET_TAGS.stream().map(Tag::getKey).collect(Collectors.toList()); + final UntagLogGroupRequest request = UntagLogGroupRequest.builder() + .logGroupName(RESOURCE_MODEL.getLogGroupName()) + .tags(tagKeys) + .build(); + + assertThat(Translator.translateToUntagLogGroupRequest(RESOURCE_MODEL.getLogGroupName(), tagKeys)) + .isEqualToComparingFieldByField(request); + } + @Test public void testTranslateForRead() { final LogGroup logGroup = LogGroup.builder() @@ -112,7 +168,10 @@ public void testTranslateForRead() { final DescribeLogGroupsResponse response = DescribeLogGroupsResponse.builder() .logGroups(Collections.singletonList(logGroup)) .build(); - assertThat(Translator.translateForRead(response)).isEqualToComparingFieldByField(RESOURCE_MODEL); + final ListTagsLogGroupResponse tagsResponse = ListTagsLogGroupResponse.builder() + .tags(MAP_TAGS) + .build(); + assertThat(Translator.translateForRead(response, tagsResponse)).isEqualToComparingFieldByField(RESOURCE_MODEL); } @Test @@ -120,11 +179,15 @@ public void testTranslateForRead_logGroupEmpty() { final DescribeLogGroupsResponse response = DescribeLogGroupsResponse.builder() .logGroups(Collections.emptyList()) .build(); + final ListTagsLogGroupResponse tagsResponse = ListTagsLogGroupResponse.builder() + .tags(Collections.emptyMap()) + .build(); final ResourceModel emptyModel = ResourceModel.builder() .retentionInDays(null) .logGroupName(null) + .tags(null) .build(); - assertThat(Translator.translateForRead(response)).isEqualToComparingFieldByField(emptyModel); + assertThat(Translator.translateForRead(response, tagsResponse)).isEqualToComparingFieldByField(emptyModel); } @Test @@ -132,11 +195,15 @@ public void testTranslateForRead_LogGroupHasNullMembers() { final DescribeLogGroupsResponse response = DescribeLogGroupsResponse.builder() .logGroups(Collections.singletonList(LogGroup.builder().build())) .build(); + final ListTagsLogGroupResponse tagsResponse = ListTagsLogGroupResponse.builder() + .tags(Collections.emptyMap()) + .build(); final ResourceModel emptyModel = ResourceModel.builder() - .retentionInDays(null) - .logGroupName(null) - .build(); - assertThat(Translator.translateForRead(response)).isEqualToComparingFieldByField(emptyModel); + .retentionInDays(null) + .logGroupName(null) + .tags(null) + .build(); + assertThat(Translator.translateForRead(response, tagsResponse)).isEqualToComparingFieldByField(emptyModel); } @Test @@ -150,4 +217,26 @@ public void buildResourceDoesNotExistErrorMessage() { final String expected = "Resource of type 'AWS::Logs::LogGroup' with identifier 'ID' was not found."; assertThat(Translator.buildResourceDoesNotExistErrorMessage("ID")).isEqualTo(expected); } + + @Test + public void translateTagsToSdk() { + assertThat(Translator.translateTagsToSdk(SET_TAGS)).isEqualTo(MAP_TAGS); + } + + @Test + public void translateTagsToSdk_TagsEmpty() { + assertThat(Translator.translateTagsToSdk(null)).isNull(); + assertThat(Translator.translateTagsToSdk(Collections.emptySet())).isNull(); + } + + @Test + public void translateSdkToTags() { + assertThat(Translator.translateSdkToTags(MAP_TAGS)).isEqualTo(SET_TAGS); + } + + @Test + public void translateSdkToTags_TagsEmpty() { + assertThat(Translator.translateSdkToTags(null)).isNull(); + assertThat(Translator.translateSdkToTags(Collections.emptyMap())).isNull(); + } } diff --git a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/UpdateHandlerTest.java b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/UpdateHandlerTest.java index a63a3cf..28d8c48 100644 --- a/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/UpdateHandlerTest.java +++ b/aws-logs-loggroup/src/test/java/software/amazon/logs/loggroup/UpdateHandlerTest.java @@ -1,6 +1,21 @@ package software.amazon.logs.loggroup; +import com.google.common.collect.Maps; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.cloudwatchlogs.model.CloudWatchLogsException; import software.amazon.awssdk.services.cloudwatchlogs.model.CloudWatchLogsRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.DeleteRetentionPolicyResponse; +import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse; +import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup; +import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyResponse; +import software.amazon.awssdk.services.cloudwatchlogs.model.DisassociateKmsKeyResponse; +import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.DeleteRetentionPolicyRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.AssociateKmsKeyRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.DisassociateKmsKeyRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.TagLogGroupRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.UntagLogGroupRequest; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.OperationStatus; @@ -13,17 +28,14 @@ import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import software.amazon.awssdk.services.cloudwatchlogs.model.DeleteRetentionPolicyResponse; -import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse; -import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup; -import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyResponse; -import software.amazon.awssdk.services.cloudwatchlogs.model.DisassociateKmsKeyResponse; -import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyRequest; -import software.amazon.awssdk.services.cloudwatchlogs.model.DeleteRetentionPolicyRequest; -import software.amazon.awssdk.services.cloudwatchlogs.model.AssociateKmsKeyRequest; -import software.amazon.awssdk.services.cloudwatchlogs.model.DisassociateKmsKeyRequest; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -59,6 +71,10 @@ public void handleRequest_Success() { .retentionInDays(1) .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") .build(); + final Map tags = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; final DescribeLogGroupsResponse describeResponse = DescribeLogGroupsResponse.builder() .logGroups(Collections.singletonList(logGroup)) .build(); @@ -78,6 +94,7 @@ public void handleRequest_Success() { final ResourceHandlerRequest request = ResourceHandlerRequest.builder() .desiredResourceState(model) + .desiredResourceTags(tags) .build(); final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); @@ -87,7 +104,7 @@ public void handleRequest_Success() { assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModels()).isNull(); - assertThat(response.getResourceModel()).isEqualToComparingFieldByField(logGroup); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @@ -126,7 +143,7 @@ public void handleRequest_Success_RetentionPolicyDeleted() { assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModels()).isNull(); - assertThat(response.getResourceModel()).isEqualToComparingFieldByField(logGroup); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @@ -162,7 +179,7 @@ public void handleRequest_Success_KmsKeyIdDeleted() { assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModels()).isNull(); - assertThat(response.getResourceModel()).isEqualToComparingFieldByField(logGroup); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @@ -210,7 +227,7 @@ public void handleRequest_SuccessNoChange() { assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModels()).isNull(); - assertThat(response.getResourceModel()).isEqualToComparingFieldByField(logGroup); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @@ -222,6 +239,10 @@ public void handleRequest_SuccessNoChange_NoAction_WithPreviousModel() { .retentionInDays(1) .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") .build(); + final Map tags = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; final ResourceModel previousModel = ResourceModel.builder() .logGroupName("LogGroup") .retentionInDays(1) @@ -237,22 +258,27 @@ public void handleRequest_SuccessNoChange_NoAction_WithPreviousModel() { final ResourceHandlerRequest request = ResourceHandlerRequest.builder() .previousResourceState(previousModel) .desiredResourceState(model) + .previousResourceTags(tags) + .desiredResourceTags(tags) .build(); final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); + ArgumentCaptor requests = ArgumentCaptor.forClass(CloudWatchLogsRequest.class); + verify(proxy, times(0)).injectCredentialsAndInvokeV2(requests.capture(), any()); + assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModels()).isNull(); - assertThat(response.getResourceModel()).isEqualToComparingFieldByField(logGroup); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @Test - public void handleRequest_Success_UpdateWith_RetentionAndKms() { + public void handleRequest_Success_UpdateWith_RetentionAndKmsAndTags() { final LogGroup logGroup = LogGroup.builder() .logGroupName("LogGroup") .retentionInDays(2) @@ -262,21 +288,26 @@ public void handleRequest_Success_UpdateWith_RetentionAndKms() { .logGroupName("LogGroup") .build(); + final Map tags = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; final ResourceModel model = ResourceModel.builder() - .logGroupName("LogGroup") - .retentionInDays(2) - .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - .build(); + .logGroupName("LogGroup") + .retentionInDays(2) + .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .build(); final ResourceHandlerRequest request = ResourceHandlerRequest.builder() - .previousResourceState(previousModel) - .desiredResourceState(model) - .build(); + .previousResourceState(previousModel) + .desiredResourceState(model) + .desiredResourceTags(tags) + .build(); final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); ArgumentCaptor requests = ArgumentCaptor.forClass(CloudWatchLogsRequest.class); - verify(proxy, times(2)).injectCredentialsAndInvokeV2(requests.capture(), any()); + verify(proxy, times(3)).injectCredentialsAndInvokeV2(requests.capture(), any()); assertThat(requests.getAllValues().get(0)).isEqualTo(PutRetentionPolicyRequest.builder() .logGroupName("LogGroup") .retentionInDays(2) @@ -287,12 +318,161 @@ public void handleRequest_Success_UpdateWith_RetentionAndKms() { .kmsKeyId("arn:aws:kms:us-east-1:$123456789012:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") .build()); + assertThat(requests.getAllValues().get(2)).isEqualTo(TagLogGroupRequest.builder() + .logGroupName("LogGroup") + .tags(tags) + .build()); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_Success_AddTags() { + final LogGroup logGroup = LogGroup.builder() + .logGroupName("LogGroup") + .build(); + final Map previousTags = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final Map newTags = new HashMap() {{ + put("key-3", "value-3"); + put("key-4", "value-4"); + }}; + final Map tags = Stream.concat(previousTags.entrySet().stream(), newTags.entrySet().stream()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .previousResourceTags(previousTags) + .desiredResourceTags(tags) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); + + ArgumentCaptor requests = ArgumentCaptor.forClass(CloudWatchLogsRequest.class); + verify(proxy, times(1)).injectCredentialsAndInvokeV2(requests.capture(), any()); + assertThat(requests.getAllValues().get(0)).isEqualTo(TagLogGroupRequest.builder() + .logGroupName("LogGroup") + .tags(newTags) + .build()); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_Success_UpdateTags() { + final LogGroup logGroup = LogGroup.builder() + .logGroupName("LogGroup") + .build(); + final Map previousTags = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final Map tags = new HashMap() {{ + put("key-2", "value-2-new"); + put("key-3", "value-3"); + }}; + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .previousResourceTags(previousTags) + .desiredResourceTags(tags) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); + + ArgumentCaptor requests = ArgumentCaptor.forClass(CloudWatchLogsRequest.class); + verify(proxy, times(2)).injectCredentialsAndInvokeV2(requests.capture(), any()); + final List removedTagKeys = new ArrayList<>(Maps.difference(previousTags, tags).entriesOnlyOnLeft().keySet()); + assertThat(requests.getAllValues().get(0)).isEqualTo(UntagLogGroupRequest.builder() + .logGroupName("LogGroup") + .tags(removedTagKeys) + .build()); + assertThat(requests.getAllValues().get(1)).isEqualTo(TagLogGroupRequest.builder() + .logGroupName("LogGroup") + .tags(tags) + .build()); + assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModels()).isNull(); - assertThat(response.getResourceModel()).isEqualToComparingFieldByField(logGroup); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_Success_RemoveTags() { + final LogGroup logGroup = LogGroup.builder() + .logGroupName("LogGroup") + .build(); + final Map previousTags = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .previousResourceTags(previousTags) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); + + ArgumentCaptor requests = ArgumentCaptor.forClass(CloudWatchLogsRequest.class); + verify(proxy, times(1)).injectCredentialsAndInvokeV2(requests.capture(), any()); + final List removedTagKeys = new ArrayList<>(previousTags.keySet()); + assertThat(requests.getAllValues().get(0)).isEqualTo(UntagLogGroupRequest.builder() + .logGroupName("LogGroup") + .tags(removedTagKeys) + .build()); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(logGroup); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @@ -599,4 +779,268 @@ public void handleRequest_DisassociateKms_ServiceUnavailable_ServiceException() assertThrows(software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException.class, () -> handler.handleRequest(proxy, request, null, logger)); } + + @Test + public void handleRequest_AddTags_FailureNotFound_ServiceException() { + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + doThrow(software.amazon.awssdk.services.cloudwatchlogs.model.ResourceNotFoundException.class) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(TagLogGroupRequest.class), + any() + ); + + final Map tags = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .desiredResourceTags(tags) + .build(); + + assertThrows(software.amazon.cloudformation.exceptions.ResourceNotFoundException.class, + () -> handler.handleRequest(proxy, request, null, logger)); + } + + @Test + public void handleRequest_AddTags_InvalidParameter_ServiceException() { + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + doThrow(software.amazon.awssdk.services.cloudwatchlogs.model.InvalidParameterException.class) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(TagLogGroupRequest.class), + any() + ); + + final Map tags = Collections.singletonMap("key-1", "value-1"); + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .desiredResourceTags(tags) + .build(); + + assertThrows(software.amazon.cloudformation.exceptions.CfnInternalFailureException.class, + () -> handler.handleRequest(proxy, request, null, logger)); + } + + @Test + public void handleRequest_AddTags_AccessDenied_NoException() { + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final AwsServiceException exception = CloudWatchLogsException.builder() + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("AccessDeniedException") + .build()) + .build(); + doThrow(exception) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(TagLogGroupRequest.class), + any() + ); + + final Map tags = Collections.singletonMap("key-1", "value-1"); + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .desiredResourceTags(tags) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(model); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_AddTags_InternalFailure_WithException() { + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final AwsServiceException exception = CloudWatchLogsException.builder() + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("InternalFailure") + .build()) + .build(); + doThrow(exception) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(TagLogGroupRequest.class), + any() + ); + + final Map tags = Collections.singletonMap("key-1", "value-1"); + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .desiredResourceTags(tags) + .build(); + + assertThrows(AwsServiceException.class, + () -> handler.handleRequest(proxy, request, null, logger)); + } + + @Test + public void handleRequest_RemoveTags_FailureNotFound_ServiceException() { + final Map previousTags = new HashMap() {{ + put("key-1", "value-1"); + put("key-2", "value-2"); + }}; + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + doThrow(software.amazon.awssdk.services.cloudwatchlogs.model.ResourceNotFoundException.class) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(UntagLogGroupRequest.class), + any() + ); + + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .previousResourceTags(previousTags) + .build(); + + assertThrows(software.amazon.cloudformation.exceptions.ResourceNotFoundException.class, + () -> handler.handleRequest(proxy, request, null, logger)); + } + + + @Test + public void handleRequest_RemoveTags_InvalidParameter_ServiceException() { + final Map previousTags = Collections.singletonMap("key-1", "value-1"); + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + doThrow(software.amazon.awssdk.services.cloudwatchlogs.model.InvalidParameterException.class) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(UntagLogGroupRequest.class), + any() + ); + + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .previousResourceTags(previousTags) + .build(); + + assertThrows(software.amazon.cloudformation.exceptions.CfnInternalFailureException.class, + () -> handler.handleRequest(proxy, request, null, logger)); + } + + @Test + public void handleRequest_RemoveTags_AccessDenied_NoException() { + final Map previousTags = Collections.singletonMap("key-1", "value-1"); + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final AwsServiceException exception = CloudWatchLogsException.builder() + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("AccessDeniedException") + .build()) + .build(); + doThrow(exception) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(UntagLogGroupRequest.class), + any() + ); + + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .previousResourceTags(previousTags) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getResourceModel()).isEqualToComparingOnlyGivenFields(model); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_RemoveTags_InternalFailure_WithException() { + final Map previousTags = Collections.singletonMap("key-1", "value-1"); + final ResourceModel previousModel = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final AwsServiceException exception = CloudWatchLogsException.builder() + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("InternalFailure") + .build()) + .build(); + doThrow(exception) + .when(proxy) + .injectCredentialsAndInvokeV2( + ArgumentMatchers.isA(UntagLogGroupRequest.class), + any() + ); + + final Map tags = Collections.singletonMap("key-1", "value-1"); + final ResourceModel model = ResourceModel.builder() + .logGroupName("LogGroup") + .build(); + + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(model) + .previousResourceTags(previousTags) + .build(); + + assertThrows(AwsServiceException.class, + () -> handler.handleRequest(proxy, request, null, logger)); + } } diff --git a/aws-logs-loggroup/template.yml b/aws-logs-loggroup/template.yml index a439697..438be3d 100644 --- a/aws-logs-loggroup/template.yml +++ b/aws-logs-loggroup/template.yml @@ -5,6 +5,7 @@ Description: AWS SAM template for the AWS::Logs::LogGroup resource type Globals: Function: Timeout: 60 # docker start-up times can be long for SAM CLI + MemorySize: 256 Resources: TypeFunction: