Skip to content

Commit c344d89

Browse files
committed
add exclusions
1 parent e69e257 commit c344d89

2 files changed

Lines changed: 161 additions & 18 deletions

File tree

packages/deploymentUtils/src/changesets/checkDestructiveChanges.ts

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
1-
import {CloudFormationClient, DescribeChangeSetCommand} from "@aws-sdk/client-cloudformation"
1+
import {
2+
CloudFormationClient,
3+
DescribeChangeSetCommand,
4+
DescribeChangeSetCommandOutput
5+
} from "@aws-sdk/client-cloudformation"
26

37
export type ChangeRequiringAttention = {
4-
logicalId: string;
5-
physicalId: string;
6-
resourceType: string;
7-
reason: string;
8+
logicalId: string;
9+
physicalId: string;
10+
resourceType: string;
11+
reason: string;
12+
}
13+
14+
export type AllowedDestructiveChange = {
15+
LogicalResourceId: string;
16+
PhysicalResourceId: string;
17+
ResourceType: string;
18+
ExpiryDate: string | Date;
19+
AllowedReason: string;
820
}
921

1022
type RawChange = {
11-
ResourceChange?: {
12-
LogicalResourceId?: string;
13-
PhysicalResourceId?: string;
14-
ResourceType?: string;
15-
Replacement?: unknown;
16-
Action?: string;
17-
} | null;
23+
ResourceChange?: {
24+
LogicalResourceId?: string;
25+
PhysicalResourceId?: string;
26+
ResourceType?: string;
27+
Replacement?: unknown;
28+
Action?: string;
29+
} | null;
1830
}
1931

2032
const requiresReplacement = (replacement: unknown): boolean => {
@@ -27,7 +39,16 @@ const requiresReplacement = (replacement: unknown): boolean => {
2739
}
2840

2941
type ChangeSet = {
30-
Changes?: unknown;
42+
Changes?: unknown;
43+
}
44+
45+
const toDate = (value: Date | string | number | undefined | null): Date | undefined => {
46+
if (value === undefined || value === null) {
47+
return undefined
48+
}
49+
50+
const date = value instanceof Date ? value : new Date(value)
51+
return Number.isNaN(date.getTime()) ? undefined : date
3152
}
3253

3354
export function checkDestructiveChanges(changeSet: ChangeSet | undefined | null): Array<ChangeRequiringAttention> {
@@ -68,7 +89,8 @@ export function checkDestructiveChanges(changeSet: ChangeSet | undefined | null)
6889
export async function checkDestructiveChangeSet(
6990
changeSetName: string,
7091
stackName: string,
71-
region: string): Promise<void> {
92+
region: string,
93+
allowedChanges: Array<AllowedDestructiveChange> = []): Promise<void> {
7294
if (!changeSetName || !stackName || !region) {
7395
throw new Error("Change set name, stack name, and region are required")
7496
}
@@ -79,16 +101,48 @@ export async function checkDestructiveChangeSet(
79101
StackName: stackName
80102
})
81103

82-
const response = await client.send(command)
104+
const response: DescribeChangeSetCommandOutput = await client.send(command)
83105
const destructiveChanges = checkDestructiveChanges(response)
106+
const creationTime = toDate(response.CreationTime)
107+
108+
const remainingChanges = destructiveChanges.filter(change => {
109+
const waiver = allowedChanges.find(allowed =>
110+
allowed.LogicalResourceId === change.logicalId &&
111+
allowed.PhysicalResourceId === change.physicalId &&
112+
allowed.ResourceType === change.resourceType
113+
)
114+
115+
if (!waiver || !creationTime) {
116+
return true
117+
}
118+
119+
const expiryDate = toDate(waiver.ExpiryDate)
120+
if (!expiryDate) {
121+
return true
122+
}
123+
124+
if (expiryDate.getTime() > creationTime.getTime()) {
125+
126+
console.log(
127+
// eslint-disable-next-line max-len
128+
`Allowing destructive change ${change.logicalId} (${change.resourceType}) until ${expiryDate.toISOString()} - ${waiver.AllowedReason}`
129+
)
130+
return false
131+
}
132+
133+
console.error(
134+
`Waiver for ${change.logicalId} (${change.resourceType}) expired on ${expiryDate.toISOString()}`
135+
)
136+
return true
137+
})
84138

85-
if (destructiveChanges.length === 0) {
139+
if (remainingChanges.length === 0) {
86140
console.log(`Change set ${changeSetName} for stack ${stackName} has no destructive changes.`)
87141
return
88142
}
89143

90144
console.error("Resources that require attention:")
91-
destructiveChanges.forEach(({logicalId, physicalId, resourceType, reason}) => {
145+
remainingChanges.forEach(({logicalId, physicalId, resourceType, reason}) => {
92146
console.error(`- LogicalId: ${logicalId}, PhysicalId: ${physicalId}, Type: ${resourceType}, Reason: ${reason}`)
93147
})
94148
throw new Error(`Change set ${changeSetName} contains destructive changes`)

packages/deploymentUtils/tests/changesets/checkDestructiveChanges.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
test,
99
vi
1010
} from "vitest"
11-
import {checkDestructiveChanges, checkDestructiveChangeSet} from "../../src/changesets/checkDestructiveChanges"
11+
import {
12+
checkDestructiveChanges,
13+
checkDestructiveChangeSet,
14+
AllowedDestructiveChange
15+
} from "../../src/changesets/checkDestructiveChanges"
1216

1317
const mockCloudFormationSend = vi.fn()
1418

@@ -136,4 +140,89 @@ describe("checkDestructiveChangeSet", () => {
136140
errorSpy.mockRestore()
137141
}
138142
})
143+
144+
test("allows matching destructive changes when waiver is active", async () => {
145+
const changeSet = {
146+
CreationTime: "2026-02-20T11:54:17.083Z",
147+
Changes: [
148+
{
149+
ResourceChange: {
150+
LogicalResourceId: "ResourceToRemove",
151+
PhysicalResourceId: "physical-id",
152+
ResourceType: "AWS::S3::Bucket",
153+
Action: "Remove"
154+
}
155+
}
156+
]
157+
}
158+
mockCloudFormationSend.mockResolvedValueOnce(changeSet)
159+
160+
const allowedChanges: Array<AllowedDestructiveChange> = [
161+
{
162+
LogicalResourceId: "ResourceToRemove",
163+
PhysicalResourceId: "physical-id",
164+
ResourceType: "AWS::S3::Bucket",
165+
ExpiryDate: "2026-03-01T00:00:00Z",
166+
AllowedReason: "Pending migration"
167+
}
168+
]
169+
170+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined)
171+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)
172+
173+
try {
174+
await expect(checkDestructiveChangeSet("cs", "stack", "eu-west-2", allowedChanges))
175+
.resolves.toBeUndefined()
176+
177+
expect(mockCloudFormationSend).toHaveBeenCalledTimes(1)
178+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Allowing destructive change ResourceToRemove"))
179+
expect(logSpy).toHaveBeenCalledWith("Change set cs for stack stack has no destructive changes.")
180+
expect(errorSpy).not.toHaveBeenCalled()
181+
} finally {
182+
logSpy.mockRestore()
183+
errorSpy.mockRestore()
184+
}
185+
})
186+
187+
test("throws when waiver expired before change set creation", async () => {
188+
const changeSet = {
189+
CreationTime: "2026-02-20T11:54:17.083Z",
190+
Changes: [
191+
{
192+
ResourceChange: {
193+
LogicalResourceId: "ResourceToRemove",
194+
PhysicalResourceId: "physical-id",
195+
ResourceType: "AWS::S3::Bucket",
196+
Action: "Remove"
197+
}
198+
}
199+
]
200+
}
201+
mockCloudFormationSend.mockResolvedValueOnce(changeSet)
202+
203+
const allowedChanges: Array<AllowedDestructiveChange> = [
204+
{
205+
LogicalResourceId: "ResourceToRemove",
206+
PhysicalResourceId: "physical-id",
207+
ResourceType: "AWS::S3::Bucket",
208+
ExpiryDate: "2026-02-01T00:00:00Z",
209+
AllowedReason: "Expired waiver"
210+
}
211+
]
212+
213+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined)
214+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)
215+
216+
try {
217+
await expect(checkDestructiveChangeSet("cs", "stack", "eu-west-2", allowedChanges))
218+
.rejects.toThrow("Change set cs contains destructive changes")
219+
220+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Waiver for ResourceToRemove"))
221+
expect(errorSpy).toHaveBeenCalledWith("Resources that require attention:")
222+
expect(logSpy).not.toHaveBeenCalledWith("Change set cs for stack stack has no destructive changes.")
223+
} finally {
224+
logSpy.mockRestore()
225+
errorSpy.mockRestore()
226+
}
227+
})
139228
})

0 commit comments

Comments
 (0)