From f5ca87ca161a85087878eb00b3d79d9bdbd20478 Mon Sep 17 00:00:00 2001 From: Samyak Kumar Date: Mon, 18 Aug 2025 16:52:59 -0700 Subject: [PATCH] Allow out-of-band tags for stackset to remain during updates --- .../stackset/UpdateHandler.java | 7 +-- .../translator/RequestTranslator.java | 39 +++++++++----- .../stackset/UpdateHandlerTest.java | 53 +++++++++++++++++++ .../stackset/util/TestUtils.java | 19 ++++++- 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/aws-cloudformation-stackset/src/main/java/software/amazon/cloudformation/stackset/UpdateHandler.java b/aws-cloudformation-stackset/src/main/java/software/amazon/cloudformation/stackset/UpdateHandler.java index 3488118..9d1fc33 100644 --- a/aws-cloudformation-stackset/src/main/java/software/amazon/cloudformation/stackset/UpdateHandler.java +++ b/aws-cloudformation-stackset/src/main/java/software/amazon/cloudformation/stackset/UpdateHandler.java @@ -40,7 +40,7 @@ protected ProgressEvent handleRequest( // ManagedExecution update should be separated due to its limitations .then(progress -> updateManagedExecution(proxy, proxyClient, progress, previousModel, stackSet)) .then(progress -> deleteStackInstances(proxy, proxyClient, progress, placeHolder.getDeleteStackInstances(), logger)) - .then(progress -> updateStackSet(proxy, proxyClient, request, progress, previousModel)) + .then(progress -> updateStackSet(proxy, proxyClient, request, progress, previousModel, stackSet)) .then(progress -> createStackInstances(proxy, proxyClient, progress, placeHolder.getCreateStackInstances(), logger)) .then(progress -> updateStackInstances(proxy, proxyClient, progress, placeHolder.getUpdateStackInstances(), logger)) .then(progress -> ProgressEvent.defaultSuccessHandler(model)); @@ -62,7 +62,8 @@ private ProgressEvent updateStackSet( final ProxyClient client, final ResourceHandlerRequest handlerRequest, final ProgressEvent progress, - final ResourceModel previousModel) { + final ResourceModel previousModel, + final StackSet currentStackSet) { final ResourceModel desiredModel = progress.getResourceModel(); final CallbackContext callbackContext = progress.getCallbackContext(); @@ -70,7 +71,7 @@ private ProgressEvent updateStackSet( return ProgressEvent.progress(desiredModel, callbackContext); } return proxy.initiate("AWS-CloudFormation-StackSet::UpdateStackSet", client, desiredModel, callbackContext) - .translateToServiceRequest(modelRequest -> updateStackSetRequest(modelRequest, handlerRequest.getDesiredResourceTags())) + .translateToServiceRequest(modelRequest -> updateStackSetRequest(modelRequest, handlerRequest.getPreviousResourceTags(), handlerRequest.getDesiredResourceTags(), currentStackSet.tags())) .backoffDelay(MULTIPLE_OF) .makeServiceCall((modelRequest, proxyInvocation) -> { logger.log(String.format("%s [%s] UpdateStackSet request: [%s]", diff --git a/aws-cloudformation-stackset/src/main/java/software/amazon/cloudformation/stackset/translator/RequestTranslator.java b/aws-cloudformation-stackset/src/main/java/software/amazon/cloudformation/stackset/translator/RequestTranslator.java index fc11925..bc694d9 100644 --- a/aws-cloudformation-stackset/src/main/java/software/amazon/cloudformation/stackset/translator/RequestTranslator.java +++ b/aws-cloudformation-stackset/src/main/java/software/amazon/cloudformation/stackset/translator/RequestTranslator.java @@ -11,13 +11,18 @@ import software.amazon.awssdk.services.cloudformation.model.ListStackInstancesRequest; import software.amazon.awssdk.services.cloudformation.model.ListStackSetOperationResultsRequest; import software.amazon.awssdk.services.cloudformation.model.ListStackSetsRequest; +import software.amazon.awssdk.services.cloudformation.model.Tag; import software.amazon.awssdk.services.cloudformation.model.UpdateStackInstancesRequest; import software.amazon.awssdk.services.cloudformation.model.UpdateStackSetRequest; import software.amazon.cloudformation.stackset.OperationPreferences; import software.amazon.cloudformation.stackset.ResourceModel; import software.amazon.cloudformation.stackset.StackInstances; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import static software.amazon.awssdk.services.cloudformation.model.StackSetStatus.ACTIVE; import static software.amazon.cloudformation.stackset.translator.PropertyTranslator.translateToSdkAutoDeployment; @@ -106,7 +111,17 @@ public static DeleteStackInstancesRequest deleteStackInstancesRequest( public static UpdateStackSetRequest updateStackSetRequest( final ResourceModel model, - final Map tags) { + final Map previousTags, + final Map tags, + final List currentTags) { + // Aggregate all current tags + Set allTags = new HashSet<>(currentTags); + Set currentTagSet = translateToSdkTags(tags); + allTags.addAll(currentTagSet); + // Remove the one's that were in the previousTags but not in tags + Set previousTagSet = translateToSdkTags(previousTags); + previousTagSet.removeAll(currentTagSet); + allTags.removeAll(previousTagSet); return UpdateStackSetRequest.builder() .stackSetName(model.getStackSetId()) .administrationRoleARN(model.getAdministrationRoleARN()) @@ -118,7 +133,7 @@ public static UpdateStackSetRequest updateStackSetRequest( .parameters(translateToSdkParameters(model.getParameters())) .templateURL(model.getTemplateURL()) .templateBody(model.getTemplateBody()) - .tags(translateToSdkTags(tags)) + .tags(allTags) .callAs(model.getCallAs()) .build(); } @@ -200,16 +215,16 @@ public static GetTemplateSummaryRequest getTemplateSummaryRequest( } public static ListStackSetOperationResultsRequest listStackSetOperationResultsRequest( - final String nextToken, - final String stackSetName, - final String operationId, - final String callAs) { + final String nextToken, + final String stackSetName, + final String operationId, + final String callAs) { return ListStackSetOperationResultsRequest.builder() - .maxResults(LIST_MAX_ITEMS) - .nextToken(nextToken) - .stackSetName(stackSetName) - .operationId(operationId) - .callAs(callAs) - .build(); + .maxResults(LIST_MAX_ITEMS) + .nextToken(nextToken) + .stackSetName(stackSetName) + .operationId(operationId) + .callAs(callAs) + .build(); } } diff --git a/aws-cloudformation-stackset/src/test/java/software/amazon/cloudformation/stackset/UpdateHandlerTest.java b/aws-cloudformation-stackset/src/test/java/software/amazon/cloudformation/stackset/UpdateHandlerTest.java index 18b6861..e499272 100644 --- a/aws-cloudformation-stackset/src/test/java/software/amazon/cloudformation/stackset/UpdateHandlerTest.java +++ b/aws-cloudformation-stackset/src/test/java/software/amazon/cloudformation/stackset/UpdateHandlerTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import software.amazon.awssdk.awscore.exception.AwsErrorDetails; import software.amazon.awssdk.awscore.exception.AwsServiceException; @@ -50,10 +52,13 @@ import static software.amazon.cloudformation.stackset.util.TestUtils.DELEGATED_ADMIN_SERVICE_MANAGED_MODEL; import static software.amazon.cloudformation.stackset.util.TestUtils.DELETE_STACK_INSTANCES_RESPONSE; import static software.amazon.cloudformation.stackset.util.TestUtils.DESCRIBE_SELF_MANAGED_STACK_SET_ME_DISABLED_RESPONSE; +import static software.amazon.cloudformation.stackset.util.TestUtils.DESCRIBE_SELF_MANAGED_STACK_SET_OUT_OF_BAND_TAGS_RESPONSE; import static software.amazon.cloudformation.stackset.util.TestUtils.DESCRIBE_SELF_MANAGED_STACK_SET_RESPONSE; import static software.amazon.cloudformation.stackset.util.TestUtils.DESCRIBE_SERVICE_MANAGED_STACK_SET_RESPONSE; import static software.amazon.cloudformation.stackset.util.TestUtils.DESIRED_RESOURCE_TAGS; +import static software.amazon.cloudformation.stackset.util.TestUtils.EXPECTED_TAGS; import static software.amazon.cloudformation.stackset.util.TestUtils.LOGICAL_ID; +import static software.amazon.cloudformation.stackset.util.TestUtils.NEW_RESOURCE_TAGS; import static software.amazon.cloudformation.stackset.util.TestUtils.OPERATION_SUCCEED_RESPONSE; import static software.amazon.cloudformation.stackset.util.TestUtils.PREVIOUS_RESOURCE_TAGS; import static software.amazon.cloudformation.stackset.util.TestUtils.REQUEST_TOKEN; @@ -73,6 +78,7 @@ @ExtendWith(MockitoExtension.class) public class UpdateHandlerTest extends AbstractMockTestBase { + @Spy private UpdateHandler handler; private CloudFormationClient client; private ResourceHandlerRequest request; @@ -182,6 +188,53 @@ public void handleRequest_SelfManagedSS_SimpleSuccess() { verify(client, times(4)).describeStackSetOperation(any(DescribeStackSetOperationRequest.class)); } + @Test + public void handleRequest_UpdateSelfManagedSS_RespectOutOfBandTags() { + ArgumentCaptor updateStackSetRequestArgumentCaptor = ArgumentCaptor.forClass(UpdateStackSetRequest.class); + request = ResourceHandlerRequest.builder() + .previousResourceState(SELF_MANAGED_MODEL) + .desiredResourceState(UPDATED_SELF_MANAGED_MODEL) + .previousResourceTags(PREVIOUS_RESOURCE_TAGS) + .desiredResourceTags(NEW_RESOURCE_TAGS) + .build(); + + when(client.describeStackSet(any(DescribeStackSetRequest.class))) + .thenReturn(DESCRIBE_SELF_MANAGED_STACK_SET_OUT_OF_BAND_TAGS_RESPONSE); + when(client.getTemplateSummary(any(GetTemplateSummaryRequest.class))) + .thenReturn(VALID_TEMPLATE_SUMMARY_RESPONSE); + when(client.updateStackSet(any(UpdateStackSetRequest.class))) + .thenReturn(UPDATE_STACK_SET_RESPONSE); + when(client.createStackInstances(any(CreateStackInstancesRequest.class))) + .thenReturn(CREATE_STACK_INSTANCES_RESPONSE); + when(client.deleteStackInstances(any(DeleteStackInstancesRequest.class))) + .thenReturn(DELETE_STACK_INSTANCES_RESPONSE); + when(client.updateStackInstances(any(UpdateStackInstancesRequest.class))) + .thenReturn(UPDATE_STACK_INSTANCES_RESPONSE); + when(client.describeStackSetOperation(any(DescribeStackSetOperationRequest.class))) + .thenReturn(OPERATION_SUCCEED_RESPONSE); + + final ProgressEvent response + = handler.handleRequest(proxy, request, null, loggerProxy); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(UPDATED_SELF_MANAGED_MODEL); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + + + verify(client).getTemplateSummary(any(GetTemplateSummaryRequest.class)); + verify(client).updateStackSet(updateStackSetRequestArgumentCaptor.capture()); + assertThat(updateStackSetRequestArgumentCaptor.getValue().tags()).hasSameElementsAs(EXPECTED_TAGS); + verify(client).createStackInstances(any(CreateStackInstancesRequest.class)); + verify(client).updateStackInstances(any(UpdateStackInstancesRequest.class)); + verify(client).deleteStackInstances(any(DeleteStackInstancesRequest.class)); + verify(client, times(4)).describeStackSetOperation(any(DescribeStackSetOperationRequest.class)); + } + @Test public void handleRequest_ServiceManagedSS_WithCallAs_SimpleSuccess() { diff --git a/aws-cloudformation-stackset/src/test/java/software/amazon/cloudformation/stackset/util/TestUtils.java b/aws-cloudformation-stackset/src/test/java/software/amazon/cloudformation/stackset/util/TestUtils.java index f62cc7b..7ab4548 100644 --- a/aws-cloudformation-stackset/src/test/java/software/amazon/cloudformation/stackset/util/TestUtils.java +++ b/aws-cloudformation-stackset/src/test/java/software/amazon/cloudformation/stackset/util/TestUtils.java @@ -33,6 +33,7 @@ import software.amazon.cloudformation.stackset.ResourceModel; import software.amazon.cloudformation.stackset.StackInstances; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; @@ -157,8 +158,7 @@ public class TestUtils { "key1", "val1", "key2", "val2", "key3", "val3"); public final static Map PREVIOUS_RESOURCE_TAGS = ImmutableMap.of( "key-1", "val1", "key-2", "val2", "key-3", "val3"); - public final static Map NEW_RESOURCE_TAGS = ImmutableMap.of( - "key1", "val1", "key2updated", "val2updated", "key3", "val3"); + public final static Map NEW_RESOURCE_TAGS = ImmutableMap.of("key2updated", "val2updated"); public final static Set REGIONS_1 = new HashSet<>(Arrays.asList(US_WEST_1, US_EAST_1)); public final static Set UPDATED_REGIONS_1 = new HashSet<>(Arrays.asList(US_WEST_1, US_EAST_2)); @@ -242,6 +242,11 @@ public class TestUtils { Tag.builder().key("key1").value("val1").build(), Tag.builder().key("key2").value("val2").build(), Tag.builder().key("key3").value("val3").build())); + public final static Set OUT_OF_BAND_TAGS = new HashSet<>(Arrays.asList( + Tag.builder().key("outOfBandTags").value("outOfBandTagValue").build())); + public final static List EXPECTED_TAGS = new ArrayList<>(Arrays.asList( + Tag.builder().key("outOfBandTags").value("outOfBandTagValue").build(), + Tag.builder().key("key2updated").value("val2updated").build())); public final static AutoDeployment AUTO_DEPLOYMENT_ENABLED = AutoDeployment.builder() .enabled(true) @@ -431,6 +436,11 @@ public class TestUtils { .managedExecution(MANAGED_EXECUTION_DISABLED_SDK) .build(); + public final static StackSet SELF_MANAGED_STACK_SET_WITH_OUT_OF_BAND_TAGS = SELF_MANAGED_STACK_SET.toBuilder() + .tags(OUT_OF_BAND_TAGS) + .managedExecution(MANAGED_EXECUTION_DISABLED_SDK) + .build(); + public final static StackSet NULL_PERMISSION_MODEL_STACK_SET = StackSet.builder() .stackSetId(STACK_SET_ID) .stackSetName(STACK_SET_NAME) @@ -734,6 +744,11 @@ public class TestUtils { .stackSet(SELF_MANAGED_STACK_SET_ME_DISABLED) .build(); + public final static DescribeStackSetResponse DESCRIBE_SELF_MANAGED_STACK_SET_OUT_OF_BAND_TAGS_RESPONSE = + DescribeStackSetResponse.builder() + .stackSet(SELF_MANAGED_STACK_SET_WITH_OUT_OF_BAND_TAGS) + .build(); + public final static DescribeStackSetResponse DESCRIBE_SERVICE_MANAGED_STACK_SET_RESPONSE = DescribeStackSetResponse.builder() .stackSet(SERVICE_MANAGED_STACK_SET)