UNPKG

serverless-s3-sync

Version:

A plugin to sync local directories and S3 prefixes for Serverless Framework.

609 lines (557 loc) 21.3 kB
'use strict'; const BbPromise = require('bluebird'); const s3 = require('@auth0/s3'); const minimatch = require('minimatch'); const path = require('path'); const fs = require('fs'); const resolveStackOutput = require('./resolveStackOutput') const getAwsOptions = require('./getAwsOptions') const mime = require('mime'); const child_process = require('child_process'); const toS3Path = (osPath) => osPath.replace(new RegExp(`\\${path.sep}`, 'g'), '/'); /* From @auth0/s3/lib/index.js - used when uploading the file in the first place - added the + character to the set that are escaped. Using is is needed to update the meta data of keys that contain spaces, +, etc... to avoid a Key not found exception. */ function encodeSpecialCharacters(filename) { // Note: these characters are valid in URIs, but S3 does not like them for // some reason. return encodeURI(filename).replace(/[+!'()* ]/g, function (char) { return '%' + char.charCodeAt(0).toString(16); }); } class ServerlessS3Sync { constructor(serverless, options, logging) { this.serverless = serverless; this.options = options || {}; this.log = logging.log; this.progress = logging.progress; this.servicePath = this.serverless.service.serverless.config.servicePath; this.offline = String(this.options.offline).toUpperCase() === 'TRUE'; this.commands = { s3sync: { usage: 'Sync directories and S3 prefixes', lifecycleEvents: [ 'sync', 'metadata', 'tags' ], commands: { bucket: { options: { bucket: { usage: 'Specify the bucket you want to deploy (e.g. "-b myBucket1")', required: true, shortcut: 'b', type: 'string' } }, lifecycleEvents: [ 'sync', 'metadata', 'tags' ] } } }, deploy: { options: { nos3sync: { type: 'boolean', usage: 'Disable sync to S3 during deploy' } } }, remove: { options: { nos3sync: { type: 'boolean', usage: 'Disable sync to S3 during remove' } } }, offline: { options: { nos3sync: { type: 'boolean', usage: 'Disable sync to S3 for serverless offline' } }, commands: { start: { options: { nos3sync: { type: 'boolean', usage: 'Disable sync to S3 for serverless offline start command' } } } } } }; const noSync = this.getNoSync(); const customHooks = this.getCustomHooks().reduce((acc, hook) => { acc[hook] = () => BbPromise.bind(this).then(this.sync).then(this.syncMetadata).then(this.syncBucketTags); return acc; }, {}); this.hooks = { 'after:deploy:deploy': () => noSync ? undefined : BbPromise.bind(this).then(this.sync).then(this.syncMetadata).then(this.syncBucketTags), 'after:offline:start:init': () => noSync ? undefined : BbPromise.bind(this).then(this.sync).then(this.syncMetadata).then(this.syncBucketTags), 'after:offline:start': () => noSync ? undefined : BbPromise.bind(this).then(this.sync).then(this.syncMetadata).then(this.syncBucketTags), 'before:offline:start': this.setOffline.bind(this), 'before:offline:start:init': this.setOffline.bind(this), 'before:remove:remove': () => noSync ? undefined : BbPromise.bind(this).then(this.clear), 's3sync:sync': () => BbPromise.bind(this).then(() => this.sync(true)), 's3sync:metadata': () => BbPromise.bind(this).then(() => this.syncMetadata(true)), 's3sync:tags': () => BbPromise.bind(this).then(() => this.syncBucketTags(true)), 's3sync:bucket:sync': () => BbPromise.bind(this).then(() => this.sync(true)), 's3sync:bucket:metadata': () => BbPromise.bind(this).then(() => this.syncMetadata(true)), 's3sync:bucket:tags': () => BbPromise.bind(this).then(() => this.syncBucketTags(true)), ...customHooks, }; } setOffline() { this.offline = true } isOffline() { return this.offline || process.env.IS_OFFLINE; } getEndpoint() { return this.serverless.service.custom.s3Sync.hasOwnProperty('endpoint') ? this.serverless.service.custom.s3Sync.endpoint : null; } getNoSync() { if (this.options.nos3sync) { return true; } const noSync = this.serverless.service.custom.s3Sync.hasOwnProperty('noSync') ? this.serverless.service.custom.s3Sync.noSync : false; return String(noSync).toUpperCase() === 'TRUE'; } getCustomHooks() { return this.serverless.service.custom.s3Sync.hasOwnProperty('hooks') ? this.serverless.service.custom.s3Sync.hooks : []; } client() { const provider = this.serverless.getProvider('aws'); const s3Options = getAwsOptions(provider) if(this.getEndpoint() && this.isOffline()) { s3Options.endpoint = new provider.sdk.Endpoint(this.serverless.service.custom.s3Sync.endpoint); s3Options.s3ForcePathStyle = true; } const s3Client = new provider.sdk.S3(s3Options); if(this.getEndpoint() && this.isOffline()) { //see: https://github.com/aws/aws-sdk-js/issues/1157 s3Client.shouldDisableBodySigning = () => true } return s3.createClient({ s3Client }); } sync(invokedAsCommand) { let s3Sync = this.serverless.service.custom.s3Sync; if(s3Sync.hasOwnProperty('buckets')) { s3Sync = s3Sync.buckets; } if (!Array.isArray(s3Sync)) { this.log.error('serverless-s3-sync requires at least one configuration entry in custom.s3Sync') return Promise.resolve(); } const taskProgress = this.progress.create({ message: this.options.bucket? `Syncing directory attached to S3 bucket ${this.options.bucket}` : 'Syncing directories to S3 buckets' }) const servicePath = this.servicePath; const promises = s3Sync.map((s) => { let bucketPrefix = ''; if (s.hasOwnProperty('bucketPrefix')) { bucketPrefix = s.bucketPrefix; } let acl = 'private'; if (s.hasOwnProperty('acl')) { acl = s.acl; } if (s.hasOwnProperty('enabled') && s.enabled === false) { return; } let followSymlinks = false; if (s.hasOwnProperty('followSymlinks')) { followSymlinks = s.followSymlinks; } let defaultContentType = undefined if (s.hasOwnProperty('defaultContentType')) { defaultContentType = s.defaultContentType; } if ((!s.bucketName && !s.bucketNameKey) || !s.localDir) { throw 'Invalid custom.s3Sync'; } let deleteRemoved = true; if (s.hasOwnProperty('deleteRemoved')) { deleteRemoved = s.deleteRemoved; } let preCommand = undefined if (s.hasOwnProperty('preCommand')) { preCommand = s.preCommand; } return this.getBucketName(s) .then(bucketName => { if (this.options.bucket && bucketName != this.options.bucket) { // if the bucket option is given, that means we're in the subcommand where we're // only syncing one bucket, so only continue if this bucket name matches return null; } return new Promise((resolve) => { const localDir = [servicePath, s.localDir].join('/'); // we're doing the upload in parallel for all buckets, so create one progress entry for each let percent = 0; const getProgressMessage = () => `${localDir}: sync with bucket ${bucketName} (${percent}%)`; const bucketProgress = this.progress.create({ message: getProgressMessage() }) if (typeof(preCommand) != 'undefined') { bucketProgress.update(`${localDir}: running pre-command...`); child_process.execSync(preCommand, { stdio: 'inherit' }); } const params = { maxAsyncS3: 5, localDir, deleteRemoved, followSymlinks: followSymlinks, getS3Params: (localFile, stat, cb) => { const s3Params = {}; let onlyForEnv; if(Array.isArray(s.params)) { s.params.forEach((param) => { const glob = Object.keys(param)[0]; if(minimatch(localFile, `${path.resolve(localDir)}/${glob}`)) { Object.assign(s3Params, this.extractMetaParams(param) || {}); onlyForEnv = s3Params['OnlyForEnv'] || onlyForEnv; } }); // to avoid parameter validation error delete s3Params['OnlyForEnv']; } if (onlyForEnv && onlyForEnv !== this.options.env) { cb(null, null); } else { cb(null, s3Params); } }, s3Params: { Bucket: bucketName, Prefix: bucketPrefix, ACL: acl } }; if (typeof(defaultContentType) != 'undefined') { Object.assign(params, {defaultContentType: defaultContentType}) } bucketProgress.update(getProgressMessage()); const uploader = this.client().uploadDir(params); uploader.on('error', (err) => { bucketProgress.remove(); throw err; }); uploader.on('progress', () => { if (uploader.progressTotal === 0) { return; } const current = Math.round((uploader.progressAmount / uploader.progressTotal) * 10) * 10; if (current > percent) { percent = current; bucketProgress.update(getProgressMessage()); } }); uploader.on('end', () => { bucketProgress.remove(); resolve('done'); }); }); }); }); return Promise.all(promises) .then(() => { if (invokedAsCommand) { this.log.success('Synced files to S3 buckets'); } else { this.log.verbose('Synced files to S3 buckets'); } }) .finally(() => { taskProgress.remove(); }); } clear() { let s3Sync = this.serverless.service.custom.s3Sync; if(s3Sync.hasOwnProperty('buckets')) { s3Sync = s3Sync.buckets; } if (!Array.isArray(s3Sync)) { this.log.notice(`No configuration found for serverless-s3-sync, skipping removal...`); return Promise.resolve(); } const taskProgress = this.progress.create({ message: 'Removing objects from S3 buckets' }); const promises = s3Sync.map((s) => { let bucketPrefix = ''; if (s.hasOwnProperty('bucketPrefix')) { bucketPrefix = s.bucketPrefix; } if (s.hasOwnProperty('enabled') && s.enabled === false) { return; } return this.getBucketName(s) .then(bucketName => { return new Promise((resolve) => { const params = { Bucket: bucketName, Prefix: bucketPrefix }; let percent = 0; let getProgressMessage = () => `${bucketName}: removing files with prefix ${bucketPrefix} (${percent}%)` const bucketProgress = this.progress.create({ message: getProgressMessage() }) const uploader = this.client().deleteDir(params); uploader.on('error', (err) => { bucketProgress.remove(); throw err; }); uploader.on('progress', () => { if (uploader.progressTotal === 0) { return; } const current = Math.round((uploader.progressAmount / uploader.progressTotal) * 10) * 10; if (current > percent) { percent = current; bucketProgress.update(getProgressMessage()); } }); uploader.on('end', () => { bucketProgress.remove(); resolve('done'); }); }); }); }); return Promise.all((promises)) .then(() => { this.log.verbose('Removed objects from S3 buckets'); }) .finally(() => { taskProgress.remove(); }); } syncMetadata(invokedAsCommand) { let s3Sync = this.serverless.service.custom.s3Sync; if(s3Sync.hasOwnProperty('buckets')) { s3Sync = s3Sync.buckets; } if (!Array.isArray(s3Sync)) { this.log.error('serverless-s3-sync requires at least one configuration entry in custom.s3Sync'); return Promise.resolve(); } const taskProgress = this.progress.create({ message: 'Syncing bucket metadata' }); const servicePath = this.servicePath; const promises = s3Sync.map( async (s) => { let bucketPrefix = ''; if (s.hasOwnProperty('bucketPrefix') && s.bucketPrefix.length > 0) { bucketPrefix = s.bucketPrefix.replace(/\/?$/, '').replace(/^\/?/, '/') } let acl = 'private'; if (s.hasOwnProperty('acl')) { acl = s.acl; } if ((!s.bucketName && !s.bucketNameKey) || !s.localDir) { throw 'Invalid custom.s3Sync'; } const localDir = path.join(servicePath, s.localDir); let filesToSync = []; let ignoreFiles = ['.DS_Store']; if(Array.isArray(s.params)) { s.params.forEach((param) => { const glob = Object.keys(param)[0]; let files = this.getLocalFiles(localDir, []); minimatch.match(files, `${path.resolve(localDir)}${path.sep}${glob}`, {matchBase: true}).forEach((match) => { const params = this.extractMetaParams(param); if (ignoreFiles.includes(match)) return; if (params['OnlyForEnv'] && params['OnlyForEnv'] !== this.options.env) { ignoreFiles.push(match); filesToSync = filesToSync.filter(e => e.name !== match); return; } // to avoid Unexpected Parameter error delete params['OnlyForEnv']; filesToSync = filesToSync.filter(e => e.name !== match); filesToSync.push({name: match, params}); }); }); } return this.getBucketName(s) .then(bucketName => { if (this.options && this.options.bucket && bucketName != this.options.bucket) { // if the bucket option is given, that means we're in the subcommand where we're // only syncing one bucket, so only continue if this bucket name matches return null; } const bucketDir = `${bucketName}${bucketPrefix == '' ? '' : bucketPrefix}/`; let percent = 0; const getProgressMessage = () => `${localDir}: sync bucket metadata to ${bucketDir} (${percent}%)` const bucketProgress = this.progress.create({ message: getProgressMessage() }) return Promise.all(filesToSync.map((file, index) => { return new Promise((resolve) => { let contentTypeObject = {}; let detectedContentType = mime.getType(file.name) if (detectedContentType !== null || s.hasOwnProperty('defaultContentType')) { contentTypeObject.ContentType = detectedContentType ? detectedContentType : s.defaultContentType; } let params = { ...contentTypeObject, ...file.params, ...{ CopySource: encodeSpecialCharacters(toS3Path(file.name.replace(path.resolve(localDir) + path.sep, bucketDir))), Key: encodeSpecialCharacters(toS3Path(file.name.replace(path.resolve(localDir) + path.sep, `${bucketPrefix ? bucketPrefix.replace(/^\//, '') + '/' : ''}`))), Bucket: bucketName, ACL: acl, MetadataDirective: 'REPLACE' } }; const uploader = this.client().copyObject(params); uploader.on('error', (err) => { throw err; }); uploader.on('end', () => { const current = Math.round((index / filesToSync.length) * 10) * 10; if (current > percent) { percent = current; bucketProgress.update(getProgressMessage()) } resolve('done'); }); }); })).finally(() => { bucketProgress.remove(); }); }); }); return Promise.all((promises)) .then(() => { if (invokedAsCommand) { this.log.success('Synced bucket metadata'); } else { this.log.verbose('Synced bucket metadata'); } }) .finally(() => { taskProgress.remove(); }); } syncBucketTags(invokedAsCommand) { let s3Sync = this.serverless.service.custom.s3Sync; if(s3Sync.hasOwnProperty('buckets')) { s3Sync = s3Sync.buckets; } if (!Array.isArray(s3Sync)) { this.log.error('serverless-s3-sync requires at least one configuration entry in custom.s3Sync'); return Promise.resolve(); } const taskProgress = this.progress.create({ message: 'Updating bucket tags' }); const promises = s3Sync.map( async (s) => { if (!s.bucketName && !s.bucketNameKey) { throw 'Invalid custom.s3Sync'; } if (!s.bucketTags) { // bucket tags not configured for this bucket, skip it // so we don't require additional s3:getBucketTagging permissions return null; } // convert the tag key/value pairs into a TagSet structure for the putBucketTagging command const tagsToUpdate = Object.keys(s.bucketTags).map(tagKey => ({ Key: tagKey, Value: s.bucketTags[tagKey] })); return this.getBucketName(s) .then(bucketName => { if (this.options && this.options.bucket && bucketName != this.options.bucket) { // if the bucket option is given, that means we're in the subcommand where we're // only syncing one bucket, so only continue if this bucket name matches return null; } const bucketProgress = this.progress.create({ message: `${bucketName}: sync bucket tags` }) // AWS.S3 does not have an option to append tags to a bucket, it can only rewrite the whole set of tags // To avoid removing system tags set by other tools, we read the existing tags, merge our tags in the list // and then write them all back return this.client().s3.getBucketTagging({ Bucket: bucketName }).promise() .then(data => data.TagSet) .then(existingTagSet => { this.mergeTags(existingTagSet, tagsToUpdate); const putParams = { Bucket: bucketName, Tagging: { TagSet: existingTagSet } }; return this.client().s3.putBucketTagging(putParams).promise(); }) .finally(() => { bucketProgress.remove(); }); }); }); return Promise.all((promises)) .then(() => { if (invokedAsCommand) { this.log.success('Updated bucket tags'); } else { this.log.verbose('Updated bucket tags'); } }) .finally(() => { taskProgress.remove(); }); } mergeTags(existingTagSet, tagsToMerge) { tagsToMerge.forEach(tag => { const existingTag = existingTagSet.find(et => et.Key === tag.Key); if (existingTag) { existingTag.Value = tag.Value; } else { existingTagSet.push(tag); } }); } getLocalFiles(dir, files) { try { fs.accessSync(dir, fs.constants.R_OK); } catch (e) { this.log.error(`The directory ${dir} does not exist.`); return files; } fs.readdirSync(dir).forEach(file => { let fullPath = path.join(dir, file); try { fs.accessSync(fullPath, fs.constants.R_OK); } catch (e) { this.log.error(`The file ${fullPath} does not exist.`); return; } if (fs.lstatSync(fullPath).isDirectory()) { this.getLocalFiles(fullPath, files); } else { files.push(fullPath); } }); return files; } extractMetaParams(config) { const validParams = {}; const keys = Object.keys(config); for (let i = 0; i < keys.length; i++) { Object.assign(validParams, config[keys[i]]) } return validParams; } getBucketName(s) { if (s.bucketName) { return Promise.resolve(s.bucketName) } else if (s.bucketNameKey) { return resolveStackOutput(this, s.bucketNameKey) } else { return Promise.reject("Unable to find bucketName. Please provide a value for bucketName or bucketNameKey") } } } module.exports = ServerlessS3Sync;