forked from pifantastic/grunt-s3
-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.js
412 lines (354 loc) · 12.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
402
403
404
405
406
407
408
409
410
411
/*jshint esnext:true*/
/*globals module:true, require:true, process:true*/
/*
* Grunt Task File
* ---------------
*
* Task: S3
* Description: Move files to and from s3
* Dependencies: knox, async, underscore.deferred
*
*/
module.exports = function (grunt) {
/**
* Module dependencies.
*/
// Core.
const util = require('util');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const url = require('url');
const zlib = require('zlib');
// Npm.
const knox = require('knox');
const mime = require('mime');
const async = require('async');
const _ = require('underscore');
const deferred = require('underscore.deferred');
_.mixin(deferred);
const existsSync = ('existsSync' in fs) ? fs.existsSync : path.existsSync;
/**
* Grunt aliases.
*/
var log = grunt.log;
/**
* Success/error messages.
*/
const MSG_UPLOAD_SUCCESS = '↗'.blue + ' Uploaded: %s (%s)';
const MSG_DOWNLOAD_SUCCESS = '↙'.yellow + ' Downloaded: %s (%s)';
const MSG_DELETE_SUCCESS = '✗'.red + ' Deleted: %s';
const MSG_COPY_SUCCESS = '→'.cyan + ' Copied: %s to %s';
const MSG_ERR_NOT_FOUND = '¯\\_(ツ)_/¯ File not found: %s';
const MSG_ERR_UPLOAD = 'Upload error: %s (%s)';
const MSG_ERR_DOWNLOAD = 'Download error: %s (%s)';
const MSG_ERR_DELETE = 'Delete error: %s (%s)';
const MSG_ERR_COPY = 'Copy error: %s to %s';
const MSG_ERR_CHECKSUM = 'Expected hash: %s but found %s for %s';
/**
* Create an Error object based off of a formatted message. Arguments
* are identical to those of util.format.
*
* @param {String} Format.
* @param {...string|number} Values to insert into Format.
* @returns {Error}
*/
function makeError () {
var msg = util.format.apply(util, _.toArray(arguments));
return new Error(msg);
}
/**
* Get the grunt s3 configuration options, filling in options from
* environment variables if present.
*
* @returns {Object} The s3 configuration.
*/
function getConfig () {
return _.defaults(grunt.config('s3') || {}, {
key : process.env.AWS_ACCESS_KEY_ID,
secret : process.env.AWS_SECRET_ACCESS_KEY
});
}
/**
* Transfer files to/from s3.
*
* Uses global s3 grunt config.
*/
grunt.registerTask('s3', 'Publishes files to s3.', function () {
var done = this.async();
var config = _.defaults(getConfig(), {
upload: [],
download: [],
del: [],
copy: []
});
var transfers = [];
config.upload.forEach(function(upload) {
// Expand list of files to upload.
var files = grunt.file.expandFiles(upload.src);
files.forEach(function(file) {
// If there is only 1 file and it matches the original file wildcard,
// we know this is a single file transfer. Otherwise, we need to build
// the destination.
var dest = (files.length === 1 && file === upload.src) ?
upload.dest :
path.join(upload.dest, path.basename(file));
transfers.push(grunt.helper('s3.put', file, dest, upload));
});
});
config.download.forEach(function(download) {
transfers.push(grunt.helper('s3.pull', download.src, download.dest, download));
});
config.del.forEach(function(del) {
transfers.push(grunt.helper('s3.delete', del.src, del));
});
config.copy.forEach(function(copy) {
transfers.push(grunt.helper('s3.copy', copy.src, copy.dest, copy));
});
var total = transfers.length;
var errors = 0;
// Keep a running total of errors/completions as the transfers complete.
transfers.forEach(function(transfer) {
transfer.done(function(msg) {
log.ok(msg);
});
transfer.fail(function(msg) {
log.error(msg);
++errors;
});
transfer.always(function() {
// If this was the last transfer to complete, we're all done.
if (--total === 0) {
done(!errors);
}
});
});
});
/**
* Publishes the local file at src to the s3 dest.
*
* Verifies that the upload was successful by comparing an md5 checksum of
* the local and remote versions.
*
* @param {String} src The local path to the file to upload.
* @param {String} dest The s3 path, relative to the bucket, to which the src
* is uploaded.
* @param {Object} [options] An object containing options which override any
* option declared in the global s3 config.
*/
grunt.registerHelper('s3.put', function (src, dest, options) {
var dfd = new _.Deferred();
// Make sure the local file exists.
if (!existsSync(src)) {
return dfd.reject(makeError(MSG_ERR_NOT_FOUND, src));
}
var config = _.defaults(options || {}, getConfig());
var headers = options.headers || {};
if (options.access) {
headers['x-amz-acl'] = options.access;
}
// Pick out the configuration options we need for the client.
var client = knox.createClient(_(config).pick([
'endpoint', 'port', 'key', 'secret', 'access', 'bucket'
]));
// Encapsulate this logic to make it easier to gzip the file first if
// necesssary.
function upload(cb) {
cb = cb || function () {};
// Upload the file to s3.
client.putFile(src, dest, headers, function (err, res) {
// If there was an upload error or any status other than a 200, we
// can assume something went wrong.
if (err || res.statusCode !== 200) {
cb(makeError(MSG_ERR_UPLOAD, src, err || res.statusCode));
}
else {
// Read the local file so we can get its md5 hash.
fs.readFile(src, function (err, data) {
if (err) {
cb(makeError(MSG_ERR_UPLOAD, src, err));
}
else {
// The etag head in the response from s3 has double quotes around
// it. Strip them out.
var remoteHash = res.headers.etag.replace(/"/g, '');
// Get an md5 of the local file so we can verify the upload.
var localHash = crypto.createHash('md5').update(data).digest('hex');
if (remoteHash === localHash) {
var msg = util.format(MSG_UPLOAD_SUCCESS, src, localHash);
cb(null, msg);
}
else {
cb(makeError(MSG_ERR_CHECKSUM, localHash, remoteHash, src));
}
}
});
}
});
}
// If gzip is enabled, gzip the file into a temp file and then perform the
// upload.
if (options.gzip) {
headers['Content-Encoding'] = 'gzip';
headers['Content-Type'] = mime.lookup(src);
// Determine a unique temp file name.
var tmp = src + '.gz';
var incr = 0;
while (existsSync(tmp)) {
tmp = src + '.' + (incr++) + '.gz';
}
var input = fs.createReadStream(src);
var output = fs.createWriteStream(tmp);
// Gzip the file and upload when done.
input.pipe(zlib.createGzip()).pipe(output)
.on('error', function (err) {
dfd.reject(makeError(MSG_ERR_UPLOAD, src, err));
})
.on('close', function () {
// Update the src to point to the newly created .gz file.
src = tmp;
upload(function (err, msg) {
// Clean up the temp file.
fs.unlinkSync(tmp);
if (err) {
dfd.reject(err);
}
else {
dfd.resolve(msg);
}
});
});
}
else {
// No need to gzip so go ahead and upload the file.
upload(function (err, msg) {
if (err) {
dfd.reject(err);
}
else {
dfd.resolve(msg);
}
});
}
return dfd;
});
/**
* Download a file from s3.
*
* Verifies that the download was successful by downloading the file and
* comparing an md5 checksum of the local and remote versions.
*
* @param {String} src The s3 path, relative to the bucket, of the file being
* downloaded.
* @param {String} dest The local path where the download will be saved.
* @param {Object} [options] An object containing options which override any
* option declared in the global s3 config.
*/
grunt.registerHelper('s3.pull', function (src, dest, options) {
var dfd = new _.Deferred();
var config = _.defaults(options || {}, getConfig());
// Create a local stream we can write the downloaded file to.
var file = fs.createWriteStream(dest);
// Pick out the configuration options we need for the client.
var client = knox.createClient(_(config).pick([
'endpoint', 'port', 'key', 'secret', 'access', 'bucket'
]));
// Upload the file to s3.
client.getFile(src, function (err, res) {
// If there was an upload error or any status other than a 200, we
// can assume something went wrong.
if (err || res.statusCode !== 200) {
return dfd.reject(makeError(MSG_ERR_DOWNLOAD, src, err || res.statusCode));
}
res
.on('data', function (chunk) {
file.write(chunk);
})
.on('error', function (err) {
return dfd.reject(makeError(MSG_ERR_DOWNLOAD, src, err));
})
.on('end', function () {
file.end();
// Read the local file so we can get its md5 hash.
fs.readFile(dest, function (err, data) {
if (err) {
return dfd.reject(makeError(MSG_ERR_DOWNLOAD, src, err));
}
else {
// The etag head in the response from s3 has double quotes around
// it. Strip them out.
var remoteHash = res.headers.etag.replace(/"/g, '');
// Get an md5 of the local file so we can verify the download.
var localHash = crypto.createHash('md5').update(data).digest('hex');
if (remoteHash === localHash) {
var msg = util.format(MSG_DOWNLOAD_SUCCESS, src, localHash);
dfd.resolve(msg);
}
else {
dfd.reject(makeError(MSG_ERR_CHECKSUM, localHash, remoteHash, src));
}
}
});
});
});
return dfd;
});
/**
* Copy a file from s3 to s3.
*
* @param {String} src The s3 path, including the bucket, to the file to
* copy.
* @param {String} dest The s3 path, relative to the bucket, to the file to
* create.
* @param {Object} [options] An object containing options which override any
* option declared in the global s3 config.
*/
grunt.registerHelper('s3.copy', function (src, dest, options) {
var dfd = new _.Deferred();
var config = _.defaults(options || {}, getConfig());
// Pick out the configuration options we need for the client.
var client = knox.createClient(_(config).pick([
'endpoint', 'port', 'key', 'secret', 'access', 'bucket'
]));
// Copy the src file to dest.
var req = client.put(dest, {
'Content-Length': 0,
'x-amz-copy-source' : src
});
req.on('response', function (res) {
if (res.statusCode !== 200) {
dfd.reject(makeError(MSG_ERR_COPY, src, dest));
}
else {
dfd.resolve(util.format(MSG_COPY_SUCCESS, src, dest));
}
});
return dfd;
});
/**
* Delete a file from s3.
*
* @param {String} src The s3 path, relative to the bucket, to the file to
* delete.
* @param {Object} [options] An object containing options which override any
* option declared in the global s3 config.
*/
grunt.registerHelper('s3.delete', function (src, options) {
var dfd = new _.Deferred();
var config = _.defaults(options || {}, getConfig());
// Pick out the configuration options we need for the client.
var client = knox.createClient(_(config).pick([
'endpoint', 'port', 'key', 'secret', 'access', 'bucket'
]));
// Upload the file to this endpoint.
client.deleteFile(src, function (err, res) {
if (err || res.statusCode !== 204) {
dfd.reject(makeError(MSG_ERR_DELETE, src, err || res.statusCode));
}
else {
dfd.resolve(util.format(MSG_DELETE_SUCCESS, src));
}
});
return dfd;
});
};