Skip to content

Commit d5306ae

Browse files
pflugs30Matthew Pfluger
andauthored
ExecuteChangeSet handles empty change sets (#438)
ExecuteChangeSet can now optionally handle empty change sets in two ways: * Skip execution of the change set, so the ExecuteChangeSet step does not fail * All of the above, plus deletion of the empty change set Co-authored-by: Matthew Pfluger <matthew.pfluger@kcc.com>
1 parent 540676c commit d5306ae

File tree

7 files changed

+186
-65
lines changed

7 files changed

+186
-65
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "Skips execution of and then deletes an empty change set"
4+
}

src/lib/cloudformationutils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,23 @@ export async function testChangeSetExists(
148148

149149
return false
150150
}
151+
152+
// If there were no changes, a validation error is thrown which we want to suppress
153+
// (issue #28) instead of erroring out and failing the build. The only way to determine
154+
// this is to inspect the message in conjunction with the status code, and over time
155+
// there has been some variance in service behavior based on how we attempted to make the
156+
// change. So now detect either of the errors, and for either if the message indicates
157+
// a no-op.
158+
export function isNoWorkToDoValidationError(errCodeOrStatus?: string, errMessage?: string): boolean {
159+
const knownNoOpErrorMessages = [
160+
/^No updates are to be performed./,
161+
/^The submitted information didn't contain changes./
162+
]
163+
164+
errCodeOrStatus = errCodeOrStatus || ''
165+
const message = errMessage || ''
166+
return (
167+
(errCodeOrStatus.search(/ValidationError/) !== -1 || errCodeOrStatus.search(/FAILED/) !== -1) &&
168+
knownNoOpErrorMessages.some(element => message.search(element) !== -1)
169+
)
170+
}

src/tasks/CloudFormationCreateOrUpdateStack/TaskOperations.ts

Lines changed: 18 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
*/
55

66
import CloudFormation = require('aws-sdk/clients/cloudformation')
7+
import { AWSError } from 'aws-sdk/lib/error'
78
import S3 = require('aws-sdk/clients/s3')
89
import * as tl from 'azure-pipelines-task-lib/task'
910
import {
1011
captureStackOutputs,
12+
isNoWorkToDoValidationError,
1113
setWaiterParams,
1214
testChangeSetExists,
1315
testStackExists,
@@ -222,15 +224,20 @@ export class TaskOperations {
222224
}
223225

224226
try {
225-
const response: CloudFormation.UpdateStackOutput = await this.cloudFormationClient
226-
.updateStack(request)
227-
.promise()
227+
await this.cloudFormationClient.updateStack(request).promise()
228228
await waitForStackUpdate(this.cloudFormationClient, request.StackName)
229229
} catch (err) {
230-
if (!this.isNoWorkToDoValidationError(err.code, err.message)) {
231-
console.error(tl.loc('StackUpdateRequestFailed', (err as Error).message), err)
232-
throw err
230+
const e = <AWSError>err
231+
if (isNoWorkToDoValidationError(e.code, e.message)) {
232+
if (this.taskParameters.warnWhenNoWorkNeeded) {
233+
tl.warning(tl.loc('NoWorkToDo'))
234+
}
235+
236+
return
233237
}
238+
239+
console.error(tl.loc('StackUpdateRequestFailed', e.message), err)
240+
throw err
234241
}
235242
}
236243

@@ -523,40 +530,6 @@ export class TaskOperations {
523530
return templateParameters
524531
}
525532

526-
// If there were no changes, a validation error is thrown which we want to suppress
527-
// (issue #28) instead of erroring out and failing the build. The only way to determine
528-
// this is to inspect the message in conjunction with the status code, and over time
529-
// there has been some variance in service behavior based on how we attempted to make the
530-
// change. So now detect either of the errors, and for either if the message indicates
531-
// a no-op.
532-
private isNoWorkToDoValidationError(errCodeOrStatus: string, errMessage: string): boolean {
533-
let noWorkToDo = false
534-
const knownNoOpErrorMessages = [
535-
/^No updates are to be performed./,
536-
/^The submitted information didn't contain changes./
537-
]
538-
539-
try {
540-
if (errCodeOrStatus.search(/ValidationError/) !== -1 || errCodeOrStatus.search(/FAILED/) !== -1) {
541-
knownNoOpErrorMessages.forEach(element => {
542-
if (errMessage.search(element) !== -1) {
543-
noWorkToDo = true
544-
}
545-
})
546-
}
547-
if (noWorkToDo) {
548-
if (this.taskParameters.warnWhenNoWorkNeeded) {
549-
tl.warning(tl.loc('NoWorkToDo'))
550-
}
551-
552-
return true
553-
}
554-
// tslint:disable-next-line:no-empty
555-
} catch (err) {}
556-
557-
return false
558-
}
559-
560533
private async waitForChangeSetCreation(changeSetName: string, stackName: string): Promise<boolean> {
561534
console.log(tl.loc('WaitingForChangeSetValidation', changeSetName, stackName))
562535
try {
@@ -572,11 +545,11 @@ export class TaskOperations {
572545
const response = await this.cloudFormationClient
573546
.describeChangeSet({ ChangeSetName: changeSetName, StackName: stackName })
574547
.promise()
575-
if (
576-
response.Status &&
577-
response.StatusReason &&
578-
this.isNoWorkToDoValidationError(response.Status, response.StatusReason)
579-
) {
548+
if (isNoWorkToDoValidationError(response.Status, response.StatusReason)) {
549+
if (this.taskParameters.warnWhenNoWorkNeeded) {
550+
tl.warning(tl.loc('NoWorkToDo'))
551+
}
552+
580553
return false
581554
} else {
582555
throw new Error(tl.loc('ChangeSetValidationFailed', stackName, changeSetName, (err as Error).message))

src/tasks/CloudFormationExecuteChangeSet/TaskOperations.ts

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import CloudFormation = require('aws-sdk/clients/cloudformation')
77
import * as tl from 'azure-pipelines-task-lib/task'
88
import {
99
captureStackOutputs,
10+
isNoWorkToDoValidationError,
1011
testStackHasResources,
1112
waitForStackCreation,
1213
waitForStackUpdate
@@ -20,29 +21,50 @@ export class TaskOperations {
2021
) {}
2122

2223
public async execute(): Promise<void> {
23-
const stackId = await this.verifyResourcesExist(
24+
const changeSet = await this.verifyResourcesExist(
2425
this.taskParameters.changeSetName,
2526
this.taskParameters.stackName
2627
)
2728
let waitForUpdate = false
29+
const stackId = changeSet.StackId || ''
2830
if (stackId) {
2931
waitForUpdate = await testStackHasResources(this.cloudFormationClient, this.taskParameters.stackName)
3032
}
3133

32-
console.log(tl.loc('ExecutingChangeSet', this.taskParameters.changeSetName, this.taskParameters.stackName))
33-
3434
try {
35-
await this.cloudFormationClient
36-
.executeChangeSet({
37-
ChangeSetName: this.taskParameters.changeSetName,
38-
StackName: this.taskParameters.stackName
39-
})
40-
.promise()
35+
if (
36+
this.taskParameters.noFailOnEmptyChangeSet &&
37+
isNoWorkToDoValidationError(changeSet.Status, changeSet.StatusReason)
38+
) {
39+
console.log(tl.loc('ExecutionSkipped', this.taskParameters.changeSetName))
40+
41+
if (this.taskParameters.deleteEmptyChangeSet) {
42+
const request: CloudFormation.DeleteChangeSetInput = {
43+
ChangeSetName: this.taskParameters.changeSetName
44+
}
45+
if (this.taskParameters.stackName) {
46+
request.StackName = this.taskParameters.stackName
47+
}
4148

42-
if (waitForUpdate) {
43-
await waitForStackUpdate(this.cloudFormationClient, this.taskParameters.stackName)
49+
await this.cloudFormationClient.deleteChangeSet(request).promise()
50+
console.log(tl.loc('DeletingChangeSet', this.taskParameters.changeSetName))
51+
}
4452
} else {
45-
await waitForStackCreation(this.cloudFormationClient, this.taskParameters.stackName)
53+
console.log(
54+
tl.loc('ExecutingChangeSet', this.taskParameters.changeSetName, this.taskParameters.stackName)
55+
)
56+
await this.cloudFormationClient
57+
.executeChangeSet({
58+
ChangeSetName: this.taskParameters.changeSetName,
59+
StackName: this.taskParameters.stackName
60+
})
61+
.promise()
62+
63+
if (waitForUpdate) {
64+
await waitForStackUpdate(this.cloudFormationClient, this.taskParameters.stackName)
65+
} else {
66+
await waitForStackCreation(this.cloudFormationClient, this.taskParameters.stackName)
67+
}
4668
}
4769

4870
if (this.taskParameters.outputVariable) {
@@ -66,7 +88,10 @@ export class TaskOperations {
6688
}
6789
}
6890

69-
private async verifyResourcesExist(changeSetName: string, stackName: string): Promise<string> {
91+
private async verifyResourcesExist(
92+
changeSetName: string,
93+
stackName: string
94+
): Promise<CloudFormation.DescribeChangeSetOutput> {
7095
try {
7196
const request: CloudFormation.DescribeChangeSetInput = {
7297
ChangeSetName: changeSetName
@@ -75,13 +100,7 @@ export class TaskOperations {
75100
request.StackName = stackName
76101
}
77102

78-
const response = await this.cloudFormationClient.describeChangeSet(request).promise()
79-
80-
if (!response.StackId) {
81-
return ''
82-
}
83-
84-
return response.StackId
103+
return await this.cloudFormationClient.describeChangeSet(request).promise()
85104
} catch (err) {
86105
throw new Error(tl.loc('ChangeSetDoesNotExist', changeSetName))
87106
}

src/tasks/CloudFormationExecuteChangeSet/TaskParameters.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface TaskParameters {
1515
awsConnectionParameters: AWSConnectionParameters
1616
changeSetName: string
1717
stackName: string
18+
noFailOnEmptyChangeSet: boolean
19+
deleteEmptyChangeSet: boolean
1820
outputVariable: string
1921
captureStackOutputs: string
2022
captureAsSecuredVars: boolean
@@ -25,6 +27,8 @@ export function buildTaskParameters(): TaskParameters {
2527
awsConnectionParameters: buildConnectionParameters(),
2628
changeSetName: getInputRequired('changeSetName'),
2729
stackName: getInputRequired('stackName'),
30+
noFailOnEmptyChangeSet: getBoolInput('noFailOnEmptyChangeSet', false),
31+
deleteEmptyChangeSet: getBoolInput('deleteEmptyChangeSet', false),
2832
outputVariable: getInputOrEmpty('outputVariable'),
2933
captureStackOutputs: getInputOrEmpty('captureStackOutputs'),
3034
captureAsSecuredVars: getBoolInput('captureAsSecuredVars', false)

src/tasks/CloudFormationExecuteChangeSet/task.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,23 @@
6060
"required": false,
6161
"helpMarkDown": "The stack name or Amazon Resource Name (ARN) of the stack associated with the change set. This value is required if you specified the name of a change set to execute. If the ARN of the change set was specified this field is optional.\n\nThe name must be unique in the region in which you are creating the stack. A stack name can contain only alphanumeric characters (case-sensitive) and hyphens. It must start with an alphabetic character and cannot be longer than 128 characters."
6262
},
63+
{
64+
"name": "noFailOnEmptyChangeSet",
65+
"type": "boolean",
66+
"label": "Do Not Fail On Empty Change Set",
67+
"defaultValue": "false",
68+
"required": false,
69+
"helpMarkDown": "Causes the task to identify an empty change set and skip its execution without throwing an error."
70+
},
71+
{
72+
"name": "deleteEmptyChangeSet",
73+
"type": "boolean",
74+
"label": "Delete Empty Change Set",
75+
"defaultValue": "false",
76+
"required": false,
77+
"visibleRule": "noFailOnEmptyChangeSet = true",
78+
"helpMarkDown": "Automatically delete change set if empty."
79+
},
6380
{
6481
"name": "outputVariable",
6582
"type": "string",
@@ -120,6 +137,8 @@
120137
},
121138
"messages": {
122139
"ExecutingChangeSet": "Executing change set %s, associated with stack %s",
140+
"DeletingChangeSet": "Deleting change set %s since it has no pending changes",
141+
"ExecutionSkipped": "Skipping execution of change set %s since it has no pending changes",
123142
"ExecuteChangeSetFailed": "Request to execute change set failed with message: %s",
124143
"WaitingForStackUpdate": "Waiting for stack %s to reach update complete status",
125144
"WaitingForStackCreation": "Waiting for stack %s to reach create complete status",

tests/taskTests/cloudFormationExecuteChangeSet/cloudFormationExecuteChangeSet-test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const defaultTaskParameters: TaskParameters = {
1515
awsConnectionParameters: emptyConnectionParameters,
1616
changeSetName: '',
1717
stackName: '',
18+
noFailOnEmptyChangeSet: false,
19+
deleteEmptyChangeSet: false,
1820
outputVariable: '',
1921
captureStackOutputs: '',
2022
captureAsSecuredVars: false
@@ -32,6 +34,17 @@ const changeSetFound = {
3234
}
3335
}
3436

37+
const changeSetFoundWithNoChanges = {
38+
promise: () => {
39+
return {
40+
StackId: 'yes',
41+
Status: 'FAILED',
42+
StatusReason:
43+
"The submitted information didn't contain changes. Submit different information to create a change set."
44+
}
45+
}
46+
}
47+
3548
const cloudFormationHasResourcesSucceeds = {
3649
promise: function() {
3750
return {
@@ -84,6 +97,75 @@ describe('Cloud Formation Execute Change Set', () => {
8497
expect(cloudFormation.describeStackResources).toBeCalledTimes(1)
8598
})
8699

100+
test('Resource exists works, change set has no changes, fails on execute', async () => {
101+
expect.assertions(2)
102+
103+
const cloudFormation = new CloudFormation() as any
104+
cloudFormation.describeChangeSet = jest.fn(() => changeSetFoundWithNoChanges)
105+
cloudFormation.describeStackResources = jest.fn()
106+
cloudFormation.executeChangeSet = jest.fn(() => ({
107+
promise: () => {
108+
throw new Error('no')
109+
}
110+
}))
111+
const taskOperations = new TaskOperations(cloudFormation, defaultTaskParameters)
112+
await taskOperations.execute().catch(err => {
113+
expect(err).toStrictEqual(new Error('no'))
114+
})
115+
116+
expect(cloudFormation.executeChangeSet).toBeCalledTimes(1)
117+
})
118+
119+
test('Resource exists works, change set has no changes, skips execute, ignores stack output', async () => {
120+
expect.assertions(4)
121+
122+
const cloudFormation = new CloudFormation() as any
123+
cloudFormation.describeChangeSet = jest.fn(() => changeSetFoundWithNoChanges)
124+
cloudFormation.describeStackResources = jest.fn()
125+
cloudFormation.executeChangeSet = jest.fn()
126+
cloudFormation.deleteChangeSet = jest.fn()
127+
128+
const taskParameters = { ...defaultTaskParameters }
129+
taskParameters.captureStackOutputs = ignoreStackOutputs
130+
taskParameters.noFailOnEmptyChangeSet = true
131+
taskParameters.captureStackOutputs = ignoreStackOutputs
132+
133+
const taskOperations = new TaskOperations(cloudFormation, taskParameters)
134+
await taskOperations.execute()
135+
136+
expect(cloudFormation.describeChangeSet).toBeCalledTimes(1)
137+
expect(cloudFormation.describeStackResources).toBeCalledTimes(1)
138+
expect(cloudFormation.executeChangeSet).toBeCalledTimes(0)
139+
expect(cloudFormation.deleteChangeSet).toBeCalledTimes(0)
140+
})
141+
142+
test('Resource exists works, change set has no changes, deletes empty change set, ignores stack output', async () => {
143+
expect.assertions(4)
144+
145+
const cloudFormation = new CloudFormation() as any
146+
cloudFormation.describeChangeSet = jest.fn(() => changeSetFoundWithNoChanges)
147+
cloudFormation.describeStackResources = jest.fn()
148+
cloudFormation.executeChangeSet = jest.fn()
149+
150+
const deleteSucceeded = {
151+
promise: () => undefined
152+
}
153+
cloudFormation.deleteChangeSet = jest.fn(() => deleteSucceeded)
154+
155+
const taskParameters = { ...defaultTaskParameters }
156+
taskParameters.noFailOnEmptyChangeSet = true
157+
taskParameters.deleteEmptyChangeSet = true
158+
taskParameters.captureStackOutputs = ignoreStackOutputs
159+
160+
const taskOperations = new TaskOperations(cloudFormation, taskParameters)
161+
await taskOperations.execute()
162+
163+
expect(cloudFormation.describeChangeSet).toBeCalledTimes(1)
164+
expect(cloudFormation.describeStackResources).toBeCalledTimes(1)
165+
expect(cloudFormation.executeChangeSet).toBeCalledTimes(0)
166+
expect(cloudFormation.deleteChangeSet).toBeCalledTimes(1)
167+
})
168+
87169
test('Execute change set fails, fails task', async () => {
88170
expect.assertions(1)
89171
const cloudFormation = new CloudFormation() as any

0 commit comments

Comments
 (0)