-
Notifications
You must be signed in to change notification settings - Fork 3.9k
/
index.ts
170 lines (159 loc) · 6.03 KB
/
index.ts
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
/*eslint-disable no-console*/
/* eslint-disable import/no-extraneous-dependencies */
import { SSM } from '@aws-sdk/client-ssm';
import { CrossRegionExports, ExportWriterCRProps } from '../types';
export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
const props: ExportWriterCRProps = event.ResourceProperties.WriterProps;
const exports = props.exports as CrossRegionExports;
const ssm = new SSM({ region: props.region });
try {
switch (event.RequestType) {
case 'Create':
console.info(`Creating new SSM Parameter exports in region ${props.region}`);
await throwIfAnyInUse(ssm, exports);
await putParameters(ssm, exports);
return;
case 'Update':
const oldProps: ExportWriterCRProps = event.OldResourceProperties.WriterProps;
const oldExports = oldProps.exports as CrossRegionExports;
const newExports = except(exports, oldExports);
// throw an error to fail the deployment if any export value is changing
const changedExports = changed(oldExports, exports);
if (changedExports.length > 0) {
throw new Error('Some exports have changed!\n'+ changedExports.join('\n'));
}
// if we are removing any exports that are in use, then throw an
// error to fail the deployment
const removedExports = except(oldExports, exports);
await throwIfAnyInUse(ssm, removedExports);
// if the ones we are removing are not in use then delete them
const removedExportsNames = Object.keys(removedExports);
// this method will skip if no export names are to be deleted
await deleteParameters(ssm, removedExportsNames);
// also throw an error if we are creating a new export that already exists for some reason
await throwIfAnyInUse(ssm, newExports);
console.info(`Creating new SSM Parameter exports in region ${props.region}`);
await putParameters(ssm, newExports);
return;
case 'Delete':
// if any of the exports are currently in use then throw an error to fail
// the stack deletion.
await throwIfAnyInUse(ssm, exports);
// if none are in use then delete all of them
await deleteParameters(ssm, Object.keys(exports));
return;
default:
return;
}
} catch (e) {
console.error('Error processing event: ', e);
throw e;
}
};
/**
* Create parameters for existing exports
*/
async function putParameters(ssm: SSM, parameters: CrossRegionExports): Promise<void> {
await Promise.all(Array.from(Object.entries(parameters), ([name, value]) => {
return ssm.putParameter({
Name: name,
Value: value,
Type: 'String',
});
}));
}
/**
* Delete parameters no longer in use.
* From https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_DeleteParameters.html there
* is a constraint on names. It must have size at least 1 and at most 10.
*/
async function deleteParameters(ssm: SSM, names: string[]) {
// max allowed by DeleteParameters api
const maxSize = 10;
// more testable if we delete in order
names.sort();
for (let chunkStartIdx = 0; chunkStartIdx < names.length; chunkStartIdx += maxSize) {
const chunkOfNames = names.slice(chunkStartIdx, chunkStartIdx + maxSize);
// also observe minimum size constraint: Names parameter must have size at least 1
if (chunkOfNames.length > 0) {
await ssm.deleteParameters({
Names: chunkOfNames,
});
}
}
}
/**
* Query for existing parameters that are in use
*/
async function throwIfAnyInUse(ssm: SSM, parameters: CrossRegionExports): Promise<void> {
const tagResults: Map<string, Set<string>> = new Map();
await Promise.all(Object.keys(parameters).map(async (name: string) => {
const result = await isInUse(ssm, name);
if (result.size > 0) {
tagResults.set(name, result);
}
}));
if (tagResults.size > 0) {
const message: string = Object.entries(tagResults)
.map((result: [string, string[]]) => `${result[0]} is in use by stack(s) ${result[1].join(' ')}`)
.join('\n');
throw new Error(`Exports cannot be updated: \n${message}`);
}
}
/**
* Check if a parameter is in use
*/
async function isInUse(ssm: SSM, parameterName: string): Promise<Set<string>> {
const tagResults: Set<string> = new Set();
try {
const result = await ssm.listTagsForResource({
ResourceId: parameterName,
ResourceType: 'Parameter',
});
result.TagList?.forEach(tag => {
const tagParts = tag.Key?.split(':') ?? [];
if (tagParts[0] === 'aws-cdk' && tagParts[1] === 'strong-ref') {
tagResults.add(tagParts[2]);
}
});
} catch (e: any) {
// an InvalidResourceId means that the parameter doesn't exist
// which we should ignore since that means it's not in use
if (e.name === 'InvalidResourceId') {
return new Set();
}
throw e;
}
return tagResults;
}
/**
* Return only the items from source that do not exist in the filter
*
* @param source the source object to perform the filter on
* @param filter filter out items that exist in this object
* @returns any exports that don't exist in the filter
*/
function except(source: CrossRegionExports, filter: CrossRegionExports): CrossRegionExports {
return Object.keys(source)
.filter(key => (!filter.hasOwnProperty(key)))
.reduce((acc: CrossRegionExports, curr: string) => {
acc[curr] = source[curr];
return acc;
}, {});
}
/**
* Return items that exist in both the the old parameters and the new parameters,
* but have different values
*
* @param oldParams the exports that existed previous to this execution
* @param newParams the exports for the current execution
* @returns any parameters that have different values
*/
function changed(oldParams: CrossRegionExports, newParams: CrossRegionExports): string[] {
return Object.keys(oldParams)
.filter(key => (newParams.hasOwnProperty(key) && oldParams[key] !== newParams[key]))
.reduce((acc: string[], curr: string) => {
acc.push(curr);
return acc;
}, []);
}