Skip to content

Commit

Permalink
feat(cfn): delete CFN changeset if empty upon request (#4101)
Browse files Browse the repository at this point in the history
* 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 <markvu@live.com>

* Missing parenthesis

* Rethrow exception so it can be properly handled by orca
  • Loading branch information
xavileon authored and mergify[bot] committed Oct 28, 2019
1 parent 64197ce commit 2aaab60
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 0 deletions.
@@ -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;
}
}
@@ -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;
}
@@ -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<Map> {

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();
}
}
@@ -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
}
}
@@ -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
}
}


}
Expand Up @@ -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";
}

0 comments on commit 2aaab60

Please sign in to comment.