-
Notifications
You must be signed in to change notification settings - Fork 3
/
index.js
401 lines (394 loc) · 15.1 KB
/
index.js
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
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
const { updater } = require('@architect/utils');
const update = updater('S3 Image Bucket', {});
const { join } = require('path');
const S3rver = require('s3rver');
let s3Instance = null;
const defaultLocalOptions = {
port: 4569,
address: 'localhost',
directory: './buckets', // TODO maybe use os.tmpdir and clean up on shutdown?
accessKeyId: 'S3RVER',
secretAccessKey: 'S3RVER',
allowMismatchedSignatures: true,
resetOnClose: true
};
module.exports = {
variables: function s3ImageBucketVars ({ arc, stage }) {
if (!arc['image-bucket']) return {};
const isLocal = stage === 'testing';
// expose the key and secret for above user in the service map
return {
accessKey: isLocal ? 'S3RVER' : { Ref: 'ImageBucketCreds' },
name: isLocal ? getBucketName(arc.app, stage) : { Ref: 'ImageBucket' },
secretKey: isLocal ? 'S3RVER' : { 'Fn::GetAtt': [ 'ImageBucketCreds', 'SecretAccessKey' ] }
};
},
package: function s3ImageBucketPackage ({ arc, cloudformation: cfn, inventory, createFunction, stage }) {
if (!arc['image-bucket']) return cfn;
let options = opts(arc['image-bucket']);
// we have to assign a name to the bucket otherwise we get a circular dependency between lambda, role and bucket.
// see https://aws.amazon.com/blogs/infrastructure-and-automation/handling-circular-dependency-errors-in-aws-cloudformation/
const bukkit = getBucketName(arc.app, stage);
cfn.Resources.ImageBucket = {
Type: 'AWS::S3::Bucket',
DependsOn: [],
Properties: {
BucketName: bukkit
}
};
// give the overarching arc app role access to the bucket
cfn.Resources.Role.Properties.Policies.push({
PolicyName: 'ImageBucketAccess',
PolicyDocument: {
Statement: [ {
Effect: 'Allow',
Action: [
's3:GetObject',
's3:PutObject',
's3:DeleteObject',
's3:PutObjectAcl',
's3:ListBucket'
],
Resource: [ {
'Fn::Join': [ '', [ 'arn:aws:s3:::', bukkit ] ]
}, {
'Fn::Join': [ '', [ 'arn:aws:s3:::', bukkit, '/*' ] ]
} ]
} ]
}
});
// create a minimal IAM user that clients will use to upload to the bucket
cfn.Resources.ImageBucketUploader = {
Type: 'AWS::IAM::User',
Properties: {}
};
// grant it minimal permissions to upload
cfn.Resources.UploadMinimalPolicy = {
Type: 'AWS::IAM::Policy',
DependsOn: 'ImageBucket',
Properties: {
PolicyName: 'UploadPolicy',
PolicyDocument: {
Statement: [ {
Effect: 'Allow',
Action: [
's3:PutObject',
's3:PutObjectAcl'
],
Resource: [ {
'Fn::Join': [ '', [ 'arn:aws:s3:::', bukkit ] ]
}, {
'Fn::Join': [ '', [ 'arn:aws:s3:::', bukkit, '/*' ] ]
} ]
} ]
},
Users: [ { Ref: 'ImageBucketUploader' } ],
}
};
// create a secret key that will be used by randos on the internet
cfn.Resources.ImageBucketCreds = {
Type: 'AWS::IAM::AccessKey',
DependsOn: 'ImageBucketUploader',
Properties: {
UserName: { Ref: 'ImageBucketUploader' }
}
};
// should the bucket be set up for static hosting?
if (options.StaticWebsite) {
cfn.Resources.ImageBucket.Properties.WebsiteConfiguration = {
IndexDocument: 'index.html'
};
// TODO: support optional referer conditions provided ?
// TODO: support exposing only particular sub-paths of the bucket?
cfn.Resources.ImageBucketPolicy = {
Type: 'AWS::S3::BucketPolicy',
DependsOn: 'ImageBucket',
Properties: {
Bucket: bukkit,
PolicyDocument: {
Statement: [ {
Action: [ 's3:GetObject' ],
Effect: 'Allow',
Resource: {
'Fn::Join': [
'',
[ 'arn:aws:s3:::', bukkit, '/*' ]
]
},
Principal: '*'
/*
Condition: {
StringLike: {
'aws:Referer': refs
}
}
*/
} ]
}
}
};
if (inventory.inv.http && options.StaticWebsite.Map) {
// wire the image bucket up to api gateway
const [ httpRoute, bucketRoute ] = options.StaticWebsite.Map;
cfn.Resources.HTTP.Properties.DefinitionBody.paths[httpRoute] = {
get: {
'x-amazon-apigateway-integration': {
payloadFormatVersion: '1.0',
type: 'http_proxy',
httpMethod: 'GET',
uri: {
'Fn::Join': [ '', [
'http://',
bukkit,
'.s3.',
{
'Fn::Sub': [ '${AWS::Region}.amazonaws.com${proxy}', {
proxy: bucketRoute
} ]
}
] ]
},
connectionType: 'INTERNET',
timeoutInMillis: 30000
}
}
};
}
}
// CORS access rules for the bucket
if (options.CORS) {
cfn.Resources.ImageBucket.Properties.CorsConfiguration = {
CorsRules: options.CORS
};
}
// set up lambda triggers
if (options.lambdas && options.lambdas.length) {
// drop a reference to ImageMagick binaries as a Lamda Layer via a nested stack
// App: https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-1:145266761615:applications~image-magick-lambda-layer
cfn.Resources.ImageMagick = {
Type: 'AWS::Serverless::Application',
Properties: {
Location: {
ApplicationId: 'arn:aws:serverlessrepo:us-east-1:145266761615:applications/image-magick-lambda-layer',
SemanticVersion: '1.0.0'
}
}
};
// iterate over each lambda and add the plethora of CFN resources
const cwd = inventory.inv._project.src;
const lambdaConfigs = [];
options.lambdas.forEach(lambda => {
// set up the lambdas themselves
let src = lambdaPath(cwd, lambda.name);
let [ functionName, functionDefn ] = createFunction({ inventory, src });
cfn.Resources[functionName] = functionDefn;
// customize some things about the lambdas
cfn.Resources[functionName].Properties.Runtime = 'nodejs10.x'; // the imagemagick layer requires node 10 :(
// We get the below layer, which contains Image Magick binaries, from
// the serverless 'application' we incorporated above
cfn.Resources[functionName].Properties.Layers = [ { 'Fn::GetAtt': [ 'ImageMagick', 'Outputs.LayerVersion' ] } ];
// set up the notification events from s3 to the lambdas
let events = Object.keys(lambda.events);
events.forEach(event => {
let cfg = {
Function: { 'Fn::GetAtt': [ functionName, 'Arn' ] },
Event: event
};
if (lambda.events[event] && lambda.events[event].length) {
cfg.Filter = { S3Key: { Rules: lambda.events[event].map(filterPair => ({ Name: filterPair[0], Value: filterPair[1] })) } };
}
lambdaConfigs.push(cfg);
});
// give the image bucket permission to invoke each lambda trigger
const invokePerm = `${functionName}InvokePermission`;
cfn.Resources[invokePerm] = {
Type: 'AWS::Lambda::Permission',
DependsOn: functionName,
Properties: {
FunctionName: { 'Fn::GetAtt': [ functionName, 'Arn' ] },
Action: 'lambda:InvokeFunction',
Principal: 's3.amazonaws.com',
SourceAccount: { Ref: 'AWS::AccountId' },
SourceArn: { 'Fn::Join': [ '', [
'arn:aws:s3:::',
bukkit
] ] }
}
};
cfn.Resources.ImageBucket.DependsOn.push(invokePerm);
});
// wire up s3 notification events for lambdas
cfn.Resources.ImageBucket.Properties.NotificationConfiguration = {
LambdaConfigurations: lambdaConfigs
};
}
// TODO: add the s3 bucket url to the cfn outputs. maybe take into account
// `StaticWebsite` option and reflect the url based on that?
// see outputs of
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/quickref-s3.html#scenario-s3-bucket-website for ideas
return cfn;
},
functions: function s3ImageBucketLambdas ({ arc, inventory }) {
if (!arc['image-bucket']) return [];
let options = opts(arc['image-bucket']);
if (!options.lambdas || (Array.isArray(options.lambdas) && options.lambdas.length === 0)) return [];
const cwd = inventory.inv._project.src;
return options.lambdas.map(lambda => {
return {
src: lambdaPath(cwd, lambda.name),
body: `exports.handler = async function (event) {
// remember this is nodev10 running in here!
console.log(event);
}`
};
});
},
sandbox: {
start: async function ({ arc, inventory, services, invokeFunction }) {
if (!arc['image-bucket']) return;
const bukkit = getBucketName(arc.app, 'testing');
let options = opts(arc['image-bucket']);
let s3rverOptions = { configureBuckets: [ { name: bukkit } ], ...defaultLocalOptions };
// TODO: static website proxy support
if (options.StaticWebsite && options.StaticWebsite.Map) {
// Configure s3rver for static hosting
s3rverOptions.configureBuckets[0].configs = [ '<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><IndexDocument><Suffix>index.html</Suffix></IndexDocument></WebsiteConfiguration>' ];
// convert API Gateway proxy syntax to Router path param syntax
let imgRequestPath = options.StaticWebsite.Map[0].replace('{proxy+}', ':proxy');
let bucketPath = options.StaticWebsite.Map[1];
update.status(`Mounting ${imgRequestPath} as proxy to local S3 Image Bucket`);
// Set up a proxy on the sandbox http server to our s3rver bucket
services.http.get(imgRequestPath, (req, res) => {
let object = req.params.proxy;
let pathOnBucket = bucketPath.replace('{proxy}', object);
res.statusCode = 301;
res.setHeader('Location', `http://${defaultLocalOptions.address}:${defaultLocalOptions.port}/${bukkit}${pathOnBucket}`);
res.end('\n');
});
}
s3Instance = new S3rver(s3rverOptions);
update.start('Starting up S3rver...');
await s3Instance.run();
update.done('S3rver for S3 Image Bucket started.');
if (options.lambdas && options.lambdas.length) {
const cwd = inventory.inv._project.src;
s3Instance.on('event', (e) => {
console.log('s3rver event', e);
const record = e.Records[0];
const { eventName } = record;
let triggerParts = eventName.split(':');
let triggerEvt = triggerParts[0]; // i.e. ObjectCreated or ObjectRemoved
let triggerApi = triggerParts[1]; // i.e. *, Put, Post, Copy
update.status(`S3 ${triggerEvt}:${triggerApi} event for key ${record.s3.object.key} received!`);
let lambdasToTrigger = [];
options.lambdas.forEach(l => {
Object.keys(l.events).forEach(e => {
let eventParts = e.split(':');
// TODO: prefix and suffix support
let evt = eventParts[1]; // i.e. ObjectCreated or ObjectRemoved
let api = eventParts[2]; // i.e. *, Put, Post, Copy
if (evt === triggerEvt && (api === '*' || triggerApi === api)) {
if (!lambdasToTrigger.includes(l)) lambdasToTrigger.push(l);
}
});
});
if (lambdasToTrigger.length) {
lambdasToTrigger.forEach(lambda => {
const src = join(cwd, 'src', 'image-bucket', lambda.name);
update.status(`Invoking lambda ${src}...`);
invokeFunction({ src, payload: e }, (err) => {
if (err) update.error(`Error invoking image-bucket S3 trigger at ${src}!`, err);
});
});
}
});
}
},
end: async function ({ arc }) {
if (!arc['image-bucket']) return;
update.start('Shutting down S3rver for Image Bucket...');
try {
await s3Instance.close();
update.done('S3rver gracefully shut down.');
} catch (e) {
update.error('Error closing down S3rver!', e);
}
}
},
opts
};
function opts (pragma) {
return pragma.reduce((obj, opt) => {
if (Array.isArray(opt)) {
if (opt.length > 2) {
obj[opt[0]] = opt.slice(1);
} else {
obj[opt[0]] = opt[1];
}
} else if (typeof opt === 'string') {
obj[opt] = true;
} else {
let key = Object.keys(opt)[0];
if (key.startsWith('CORS')) {
if (!obj.CORS) obj.CORS = [];
let corsRules = opt[key];
Object.keys(corsRules).forEach(k => {
// All CORS options must be arrays, even for singular items
if (typeof corsRules[k] === 'string') corsRules[k] = [ corsRules[k] ];
});
obj.CORS.push(opt[key]);
} else if (key.startsWith('Lambda')) {
if (!obj.lambdas) obj.lambdas = [];
let lambda = {
name: key.replace(/^Lambda/, ''),
events: {}
};
let props = opt[key];
let lambdaKeys = Object.keys(props);
lambdaKeys.forEach(eventName => {
let filterPairs = props[eventName];
lambda.events[eventName] = [];
for (let i = 0; i < filterPairs.length - 1; i++) {
if (i % 2 === 1) continue;
lambda.events[eventName].push([ filterPairs[i], filterPairs[i + 1] ]);
}
});
obj.lambdas.push(lambda);
} else {
obj[key] = opt[key];
}
}
return obj;
}, {});
}
function lambdaPath (cwd, name) {
return join(cwd, 'src', 'image-bucket', name.length ? name : 'lambda');
}
// use a global for bucket name so that the various plugin methods, when running in sandbox, generate a bucket name once and reuse that
let bukkit;
function getBucketName (appname, stage) {
if (bukkit) return bukkit;
bukkit = generateBucketName(appname[0], stage);
return bukkit;
}
function generateBucketName (app, stage) {
// this can be tricky as S3 Bucket names can have a max 63 character length
// so the math ends up like this:
// - ${stage} can have a max length of 10 (for "production") - tho even this
// is not exact as custom stage names can be provided and could be longer!
// - "-img-bucket-" is 12
// - account IDs are 12 digits
// = 34 characters
// that leaves 29 characters for the app name
// so lets cut it off a bit before that
let appLabel = app.substr(0, 24);
if (stage === 'testing') {
// In sandbox, we need to provide a simple string for the S3 mock server
return `${appLabel}-${stage}-img-bucket-123456789012`;
}
// For cloudformation, though, we need to use the Sub function to sub in the
// AWS account ID
return {
'Fn::Sub': `${appLabel}-${stage}-img-bucket-\${AWS::AccountId}`
};
}