forked from aws/aws-cdk-cli
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstack-refresh.ts
More file actions
264 lines (226 loc) · 8.56 KB
/
stack-refresh.ts
File metadata and controls
264 lines (226 loc) · 8.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
import type { ParameterDeclaration } from '@aws-sdk/client-cloudformation';
import { minimatch } from 'minimatch';
import { ToolkitError } from '../../toolkit/toolkit-error';
import type { ICloudFormationClient } from '../aws-auth/private';
import type { IoHelper } from '../io/private';
export class ActiveAssetCache {
private readonly stacks: Set<string> = new Set();
public rememberStack(stackTemplate: string) {
this.stacks.add(stackTemplate);
}
public contains(asset: string): boolean {
for (const stack of this.stacks) {
if (stack.includes(asset)) {
return true;
}
}
return false;
}
}
/**
* Check if a stack name matches any of the skip patterns using glob matching
*/
function shouldSkipStack(stackName: string, skipPatterns?: string[]): boolean {
if (!skipPatterns || skipPatterns.length === 0) {
return false;
}
// Extract stack name from ARN if entire path is passed
// fetchAllStackTemplates can return either stack name or id so we handle both
const extractedStackName = stackName.includes(':cloudformation:') && stackName.includes(':stack/')
? stackName.split('/')[1] || stackName
: stackName;
return skipPatterns.some(pattern => minimatch(extractedStackName, pattern));
}
async function paginateSdkCall(cb: (nextToken?: string) => Promise<string | undefined>) {
let finished = false;
let nextToken: string | undefined;
while (!finished) {
nextToken = await cb(nextToken);
if (nextToken === undefined) {
finished = true;
}
}
}
/**
* Fetches all relevant stack templates from CloudFormation. It ignores the following stacks:
* - stacks in DELETE_COMPLETE or DELETE_IN_PROGRESS stage
* - stacks that are using a different bootstrap qualifier
* - unauthorized stacks that match the skip patterns (when specified)
*/
async function fetchAllStackTemplates(
cfn: ICloudFormationClient,
ioHelper: IoHelper,
qualifier?: string,
skipUnauthorizedStacksWhenNonCdk?: string[],
) {
const stackNames: string[] = [];
await paginateSdkCall(async (nextToken) => {
const stacks = await cfn.listStacks({ NextToken: nextToken });
// We ignore stacks with these statuses because their assets are no longer live
const ignoredStatues = ['CREATE_FAILED', 'DELETE_COMPLETE', 'DELETE_IN_PROGRESS', 'DELETE_FAILED', 'REVIEW_IN_PROGRESS'];
stackNames.push(
...(stacks.StackSummaries ?? [])
.filter((s: any) => !ignoredStatues.includes(s.StackStatus))
.map((s: any) => s.StackId ?? s.StackName),
);
return stacks.NextToken;
});
await ioHelper.defaults.debug(`Parsing through ${stackNames.length} stacks`);
const templates: string[] = [];
for (const stack of stackNames) {
try {
let summary;
summary = await cfn.getTemplateSummary({
StackName: stack,
});
if (bootstrapFilter(summary.Parameters, qualifier)) {
// This stack is definitely bootstrapped to a different qualifier so we can safely ignore it
continue;
}
const template = await cfn.getTemplate({
StackName: stack,
});
templates.push((template.TemplateBody ?? '') + JSON.stringify(summary?.Parameters));
} catch (error: any) {
// Check if this is a CloudFormation access denied error
if (error.name === 'AccessDenied') {
if (shouldSkipStack(stack, skipUnauthorizedStacksWhenNonCdk)) {
await ioHelper.defaults.warn(
`Skipping unauthorized stack '${stack}' as specified in --skip-unauthorized-stacks-when-noncdk`,
);
continue;
}
throw new ToolkitError(
`Access denied when trying to access stack '${stack}'. ` +
'If this is a non-CDK stack that you want to skip, add it to --skip-unauthorized-stacks-when-noncdk.',
);
}
// Re-throw the error if it's not handled
throw error;
}
}
await ioHelper.defaults.debug('Done parsing through stacks');
return templates;
}
/**
* Filter out stacks that we KNOW are using a different bootstrap qualifier
* This is mostly necessary for the integration tests that can run the same app (with the same assets)
* under different qualifiers.
* This is necessary because a stack under a different bootstrap could coincidentally reference the same hash
* and cause a false negative (cause an asset to be preserved when its isolated)
* This is intentionally done in a way where we ONLY filter out stacks that are meant for a different qualifier
* because we are okay with false positives.
*/
function bootstrapFilter(parameters?: ParameterDeclaration[], qualifier?: string) {
const bootstrapVersion = parameters?.find((p) => p.ParameterKey === 'BootstrapVersion');
const splitBootstrapVersion = bootstrapVersion?.DefaultValue?.split('/');
// We find the qualifier in a specific part of the bootstrap version parameter
return (qualifier &&
splitBootstrapVersion &&
splitBootstrapVersion.length == 4 &&
splitBootstrapVersion[2] != qualifier);
}
export interface RefreshStacksProps {
readonly cfn: ICloudFormationClient;
readonly ioHelper: IoHelper;
readonly activeAssets: ActiveAssetCache;
readonly qualifier?: string;
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
}
export async function refreshStacks(props: RefreshStacksProps) {
try {
const stacks = await fetchAllStackTemplates(
props.cfn,
props.ioHelper,
props.qualifier,
props.skipUnauthorizedStacksWhenNonCdk,
);
for (const stack of stacks) {
props.activeAssets.rememberStack(stack);
}
} catch (err) {
throw new ToolkitError(`Error refreshing stacks: ${err}`);
}
}
/**
* Background Stack Refresh properties
*/
export interface BackgroundStackRefreshProps {
/**
* The CFN SDK handler
*/
readonly cfn: ICloudFormationClient;
/**
* Used to send messages.
*/
readonly ioHelper: IoHelper;
/**
* Active Asset storage
*/
readonly activeAssets: ActiveAssetCache;
/**
* Stack bootstrap qualifier
*/
readonly qualifier?: string;
/**
* Non-CDK stack names or glob patterns to skip when encountering unauthorized access errors
*/
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
}
/**
* Class that controls scheduling of the background stack refresh
*/
export class BackgroundStackRefresh {
private timeout?: NodeJS.Timeout;
private lastRefreshTime: number;
private queuedPromises: Array<(value: unknown) => void> = [];
constructor(private readonly props: BackgroundStackRefreshProps) {
this.lastRefreshTime = Date.now();
}
public start() {
// Since start is going to be called right after the first invocation of refreshStacks,
// lets wait some time before beginning the background refresh.
this.timeout = setTimeout(() => this.refresh(), 300_000); // 5 minutes
}
private async refresh() {
const startTime = Date.now();
await refreshStacks({
cfn: this.props.cfn,
ioHelper: this.props.ioHelper,
activeAssets: this.props.activeAssets,
qualifier: this.props.qualifier,
skipUnauthorizedStacksWhenNonCdk: this.props.skipUnauthorizedStacksWhenNonCdk,
});
this.justRefreshedStacks();
// If the last invocation of refreshStacks takes <5 minutes, the next invocation starts 5 minutes after the last one started.
// If the last invocation of refreshStacks takes >5 minutes, the next invocation starts immediately.
this.timeout = setTimeout(() => this.refresh(), Math.max(startTime + 300_000 - Date.now(), 0));
}
private justRefreshedStacks() {
this.lastRefreshTime = Date.now();
for (const p of this.queuedPromises.splice(0, this.queuedPromises.length)) {
p(undefined);
}
}
/**
* Checks if the last successful background refresh happened within the specified time frame.
* If the last refresh is older than the specified time frame, it returns a Promise that resolves
* when the next background refresh completes or rejects if the refresh takes too long.
*/
public noOlderThan(ms: number) {
const horizon = Date.now() - ms;
// The last refresh happened within the time frame
if (this.lastRefreshTime >= horizon) {
return Promise.resolve();
}
// The last refresh happened earlier than the time frame
// We will wait for the latest refresh to land or reject if it takes too long
return Promise.race([
new Promise(resolve => this.queuedPromises.push(resolve)),
new Promise((_, reject) => setTimeout(() => reject(new ToolkitError('refreshStacks took too long; the background thread likely threw an error')), ms)),
]);
}
public stop() {
clearTimeout(this.timeout);
}
}