From 2aaab601a1c3e0d1c1029645ddf4c1f88eaac1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavi=20Le=C3=B3n?= Date: Mon, 28 Oct 2019 20:53:23 +0100 Subject: [PATCH] feat(cfn): delete CFN changeset if empty upon request (#4101) * feat(cfn): delete CFN changeset if empty upon request This patch introduces the DeleteCloudFormationChangeSet async operation, so Orca can request to delete a CFN change set under some conditions (e.g. when it's empty, or after the change set has been executed). This will allow full control of the change set life cycle by Spinnaker. * Add some information about the error * Update clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/ops/DeleteCloudFormationChangeSetAtomicOperation.java Co-Authored-By: Mark Vulfson * Missing parenthesis * Rethrow exception so it can be properly handled by orca --- ...tionChangeSetAtomicOperationConverter.java | 43 +++++++++ ...eteCloudFormationChangeSetDescription.java | 28 ++++++ ...loudFormationChangeSetAtomicOperation.java | 71 ++++++++++++++ ...angeSetAtomicOperationConverterSpec.groovy | 65 +++++++++++++ ...rmationChangeSetAtomicOperationSpec.groovy | 96 +++++++++++++++++++ .../orchestration/AtomicOperations.java | 1 + 6 files changed, 304 insertions(+) create mode 100644 clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/converters/DeleteCloudFormationChangeSetAtomicOperationConverter.java create mode 100644 clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/description/DeleteCloudFormationChangeSetDescription.java create mode 100644 clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/ops/DeleteCloudFormationChangeSetAtomicOperation.java create mode 100644 clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/converters/DeleteCloudFormationChangeSetAtomicOperationConverterSpec.groovy create mode 100644 clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/ops/DeleteCloudFormationChangeSetAtomicOperationSpec.groovy diff --git a/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/converters/DeleteCloudFormationChangeSetAtomicOperationConverter.java b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/converters/DeleteCloudFormationChangeSetAtomicOperationConverter.java new file mode 100644 index 00000000000..56759be688a --- /dev/null +++ b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/converters/DeleteCloudFormationChangeSetAtomicOperationConverter.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2019 Adevinta + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spinnaker.clouddriver.aws.deploy.converters; + +import com.netflix.spinnaker.clouddriver.aws.AmazonOperation; +import com.netflix.spinnaker.clouddriver.aws.deploy.description.DeleteCloudFormationChangeSetDescription; +import com.netflix.spinnaker.clouddriver.aws.deploy.ops.DeleteCloudFormationChangeSetAtomicOperation; +import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperation; +import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperations; +import com.netflix.spinnaker.clouddriver.security.AbstractAtomicOperationsCredentialsSupport; +import java.util.Map; +import org.springframework.stereotype.Component; + +@AmazonOperation(AtomicOperations.DELETE_CLOUDFORMATION_CHANGESET) +@Component("deleteCloudFormationChangeSetDescription") +public class DeleteCloudFormationChangeSetAtomicOperationConverter + extends AbstractAtomicOperationsCredentialsSupport { + @Override + public AtomicOperation convertOperation(Map input) { + return new DeleteCloudFormationChangeSetAtomicOperation(convertDescription(input)); + } + + @Override + public DeleteCloudFormationChangeSetDescription convertDescription(Map input) { + DeleteCloudFormationChangeSetDescription converted = + getObjectMapper().convertValue(input, DeleteCloudFormationChangeSetDescription.class); + converted.setCredentials(getCredentialsObject((String) input.get("credentials"))); + return converted; + } +} diff --git a/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/description/DeleteCloudFormationChangeSetDescription.java b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/description/DeleteCloudFormationChangeSetDescription.java new file mode 100644 index 00000000000..b28595ce2e5 --- /dev/null +++ b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/description/DeleteCloudFormationChangeSetDescription.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2019 Adevinta + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spinnaker.clouddriver.aws.deploy.description; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode(callSuper = false) +@Data +public class DeleteCloudFormationChangeSetDescription extends AbstractAmazonCredentialsDescription { + + private String stackName; + private String changeSetName; + private String region; +} diff --git a/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/ops/DeleteCloudFormationChangeSetAtomicOperation.java b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/ops/DeleteCloudFormationChangeSetAtomicOperation.java new file mode 100644 index 00000000000..5e721c5ae31 --- /dev/null +++ b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/ops/DeleteCloudFormationChangeSetAtomicOperation.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2019 Adevinta + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spinnaker.clouddriver.aws.deploy.ops; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.cloudformation.AmazonCloudFormation; +import com.amazonaws.services.cloudformation.model.*; +import com.netflix.spinnaker.clouddriver.aws.deploy.description.DeleteCloudFormationChangeSetDescription; +import com.netflix.spinnaker.clouddriver.aws.security.AmazonClientProvider; +import com.netflix.spinnaker.clouddriver.data.task.Task; +import com.netflix.spinnaker.clouddriver.data.task.TaskRepository; +import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperation; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; + +@Slf4j +public class DeleteCloudFormationChangeSetAtomicOperation implements AtomicOperation { + + private static final String BASE_PHASE = "DELETE_CLOUDFORMATION_CHANGESET"; + + @Autowired AmazonClientProvider amazonClientProvider; + + private DeleteCloudFormationChangeSetDescription description; + + public DeleteCloudFormationChangeSetAtomicOperation( + DeleteCloudFormationChangeSetDescription deployCloudFormationDescription) { + this.description = deployCloudFormationDescription; + } + + @Override + public Map operate(List priorOutputs) { + Task task = TaskRepository.threadLocalTask.get(); + AmazonCloudFormation amazonCloudFormation = + amazonClientProvider.getAmazonCloudFormation( + description.getCredentials(), description.getRegion()); + + DeleteChangeSetRequest deleteChangeSetRequest = + new DeleteChangeSetRequest() + .withStackName(description.getStackName()) + .withChangeSetName(description.getChangeSetName()); + try { + task.updateStatus(BASE_PHASE, "Deleting CloudFormation ChangeSet"); + amazonCloudFormation.deleteChangeSet(deleteChangeSetRequest); + } catch (AmazonServiceException e) { + log.error( + "Error removing change set " + + description.getChangeSetName() + + " on stack " + + description.getStackName(), + e); + throw e; + } + return Collections.emptyMap(); + } +} diff --git a/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/converters/DeleteCloudFormationChangeSetAtomicOperationConverterSpec.groovy b/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/converters/DeleteCloudFormationChangeSetAtomicOperationConverterSpec.groovy new file mode 100644 index 00000000000..c58ba5adc1c --- /dev/null +++ b/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/converters/DeleteCloudFormationChangeSetAtomicOperationConverterSpec.groovy @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2019 Adevinta + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.clouddriver.aws.deploy.converters + +import com.fasterxml.jackson.databind.ObjectMapper +import com.netflix.spinnaker.clouddriver.aws.deploy.description.DeleteCloudFormationChangeSetDescription +import com.netflix.spinnaker.clouddriver.aws.deploy.ops.DeleteCloudFormationChangeSetAtomicOperation +import com.netflix.spinnaker.clouddriver.aws.security.NetflixAmazonCredentials +import com.netflix.spinnaker.clouddriver.security.AccountCredentialsProvider +import spock.lang.Shared +import spock.lang.Specification + +class DeleteCloudFormationChangeSetAtomicOperationConverterSpec extends Specification { + + @Shared + ObjectMapper mapper = new ObjectMapper() + + @Shared + DeleteCloudFormationChangeSetAtomicOperationConverter converter + + def setupSpec() { + this.converter = new DeleteCloudFormationChangeSetAtomicOperationConverter(objectMapper: mapper) + def accountCredentialsProvider = Mock(AccountCredentialsProvider) + def mockCredentials = Mock(NetflixAmazonCredentials) + accountCredentialsProvider.getCredentials(_) >> mockCredentials + converter.accountCredentialsProvider = accountCredentialsProvider + } + + void "DeleteCloudFormationChangeSetConverter returns DeleteCloudFormationChangeSetDescription"() { + setup: + def input = [stackName : "stack", + changeSetName : "changeset", + region : "eu-west-1", + credentials : "credentials"] + + when: + DeleteCloudFormationChangeSetDescription description = converter.convertDescription(input) + + then: + description instanceof DeleteCloudFormationChangeSetDescription + description.stackName == "stack" + description.changeSetName == "changeset" + description.region == "eu-west-1" + + when: + def operation = converter.convertOperation(input) + + then: + operation instanceof DeleteCloudFormationChangeSetAtomicOperation + } +} diff --git a/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/ops/DeleteCloudFormationChangeSetAtomicOperationSpec.groovy b/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/ops/DeleteCloudFormationChangeSetAtomicOperationSpec.groovy new file mode 100644 index 00000000000..7d94b659482 --- /dev/null +++ b/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/deploy/ops/DeleteCloudFormationChangeSetAtomicOperationSpec.groovy @@ -0,0 +1,96 @@ +/* + * Copyright 2019 Adevinta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.clouddriver.aws.deploy.ops + +import com.amazonaws.AmazonServiceException +import com.amazonaws.services.cloudformation.AmazonCloudFormation +import com.amazonaws.services.cloudformation.model.* +import com.netflix.spinnaker.clouddriver.aws.TestCredential +import com.netflix.spinnaker.clouddriver.aws.deploy.description.DeleteCloudFormationChangeSetDescription +import com.netflix.spinnaker.clouddriver.aws.security.AmazonClientProvider +import com.netflix.spinnaker.clouddriver.data.task.Task +import com.netflix.spinnaker.clouddriver.data.task.TaskRepository +import spock.lang.Specification + +class DeleteCloudFormationChangeSetAtomicOperationSpec extends Specification { + void setupSpec() { + TaskRepository.threadLocalTask.set(Mock(Task)) + } + + void "should build a DeleteChangeSetRequest and submit through aws client"() { + given: + def amazonClientProvider = Mock(AmazonClientProvider) + def amazonCloudFormation = Mock(AmazonCloudFormation) + def deleteChangeSetResult = Mock(DeleteChangeSetResult) + def op = new DeleteCloudFormationChangeSetAtomicOperation( + new DeleteCloudFormationChangeSetDescription( + [ + stackName: "stackTest", + changeSetName: "changeSetName", + region: "eu-west-1", + credentials: TestCredential.named("test") + ] + ) + ) + op.amazonClientProvider = amazonClientProvider + + when: + op.operate([]) + + then: + 1 * amazonClientProvider.getAmazonCloudFormation(_, _) >> amazonCloudFormation + 1 * amazonCloudFormation.deleteChangeSet(_) >> { DeleteChangeSetRequest request -> + assert request.getStackName() == "stackTest" + assert request.getChangeSetName() == "changeSetName" + deleteChangeSetResult + } + } + + void "should propagate exceptions when deleting the change set"() { + given: + def amazonClientProvider = Mock(AmazonClientProvider) + def amazonCloudFormation = Mock(AmazonCloudFormation) + def op = new DeleteCloudFormationChangeSetAtomicOperation( + new DeleteCloudFormationChangeSetDescription( + [ + stackName: "stackTest", + changeSetName: "changeSetName", + region: "eu-west-1", + credentials: TestCredential.named("test") + ] + ) + ) + op.amazonClientProvider = amazonClientProvider + def exception = new AmazonServiceException("error") + + when: + try { + op.operate([]) + } + catch (Exception e) { + e instanceof AmazonServiceException + } + + then: + 1 * amazonClientProvider.getAmazonCloudFormation(_, _) >> amazonCloudFormation + 1 * amazonCloudFormation.deleteChangeSet(_) >> { + throw exception + } + } + + +} diff --git a/clouddriver-core/src/main/groovy/com/netflix/spinnaker/clouddriver/orchestration/AtomicOperations.java b/clouddriver-core/src/main/groovy/com/netflix/spinnaker/clouddriver/orchestration/AtomicOperations.java index 73d609a69a7..b25a26e9c3c 100644 --- a/clouddriver-core/src/main/groovy/com/netflix/spinnaker/clouddriver/orchestration/AtomicOperations.java +++ b/clouddriver-core/src/main/groovy/com/netflix/spinnaker/clouddriver/orchestration/AtomicOperations.java @@ -107,4 +107,5 @@ public final class AtomicOperations { // CloudFormation operations public static final String DEPLOY_CLOUDFORMATION_STACK = "deployCloudFormation"; + public static final String DELETE_CLOUDFORMATION_CHANGESET = "deleteCloudFormationChangeSet"; }