diff --git a/README.md b/README.md index 92bf4ec..fa2a8fa 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,13 @@ gulp.task('deploy', function() { timestamp: true, // optional: If set to false, the zip will not have a timestamp waitForDeploy: true, // optional: if set to false the task will end as soon as it starts deploying amazon: { - accessKeyId: "< your access key (fyi, the 'short' one) >", // optional - secretAccessKey: "< your secret access key (fyi, the 'long' one) >", // optional - signatureVersion: "v4", // optional + credentials: { + accessKeyId: "< your access key (fyi, the 'short' one) >", // optional + secretAccessKey: "< your secret access key (fyi, the 'long' one) >", // optional + } + config: { // optional + signatureVersion: "v4", + } region: 'eu-west-1', bucket: 'elasticbeanstalk-apps', applicationName: 'MyApplication', @@ -38,7 +42,11 @@ gulp.task('deploy', function() { The code above would work as follows * Take the files sepcified by `gulp.src` and zip them on a file named `{ version }-{ timestamp }.zip` (i.e: `1.0.0-2016.04.08_13.26.32.zip`) -* If amazon credentials (`accessKeyId`, `secretAccessKey`) are provided in the `amazon` object, set them on the `AWS.config.credentials`. If not provided, the default values from AWS CLI configuration will be used. +* There are multiple ways to provide AWS credentials: + 1. as an [AWS.Credentials](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Credentials.html) object or an object of any inheriting class + 2. as an object holding parameters to AWS.Credentials or any of the following inheriting classes: [AWS.CognitoIdentityCredentials](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CognitoIdentityCredentials.html), [AWS.SharedIniFileCredentials](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/SharedIniFileCredentials.html), [AWS.SAMLCredentials](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/SAMLCredentials.html), [AWS.TemporaryCredentials](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/TemporaryCredentials.html), which gulp-elasticbeanstalk-deploy will then try to autodetect. + 3. as a string either holding the path to [AWS.FileSystemCredentials](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/FileSystemCredentials.html) or the prefix for [AWS.EnvironmentCredentials](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/EnvironmentCredentials.html). + 2. If no credentials are provided, the default values from AWS CLI configuration will be used. * Try to upload the zipped file to the bucket specified by `amazon.bucket`. If it fails because the bucket doesn't exist, try to create the bucket and then try to upload the zipped file again * Uploads the ziped files to the bucket on the path `{{ name }}/{{ filename }}` (i.e: `my-application/1.0.0-2016.04.08_13.26.32.zip`) * Creates a new version on the Application specified by `applicationName` with VersionLabel `{ version }-{ timestamp }` (i.e: `1.0.0-2016.04.08_13.26.32`) diff --git a/package.json b/package.json index d740755..f9903da 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "left-pad": "^1.1.1", "lodash": "^4.8.2", "plexer": "^1.0.1", - "through2": "^2.0.1" + "through2": "^2.0.1", + "uuid": "^3.1.0" }, "devDependencies": { "babel-cli": "^6.14.0", diff --git a/src/aws.js b/src/aws.js index 44eaef0..cc77d93 100644 --- a/src/aws.js +++ b/src/aws.js @@ -61,13 +61,18 @@ export class S3File { * * @async * @method S3File#create + * @param {String} [region] Region where the bucket is to be created * @return {Promise} Resolved once the action has completed - * + * @see [AWS.S3#createBucket]{@link http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#createBucket-property} */ - async create() { + async create(region) { return await new Promise((resolve, reject) => { - this.s3bucket.createBucket((err, result) => { + this.s3bucket.createBucket({ + CreateBucketConfiguration: { + LocationConstraint: region + } + }, (err, result) => { if (err) reject(err) else resolve(result) }) diff --git a/src/plugin.js b/src/plugin.js index e95706e..9ad7ce0 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -1,4 +1,4 @@ -import { readFileSync } from 'fs' +import { readFileSync, existsSync } from 'fs' import { join } from 'path' import { omit, isEqual } from 'lodash' import { log as gulpLog, colors, PluginError } from 'gulp-util' @@ -9,6 +9,43 @@ import AWS from 'aws-sdk' import pad from 'left-pad' import { S3File, Bean } from './aws' +const credentialProviders = [ + { + Ctor: AWS.Credentials, + fields: [ + [ 'accessKeyId', 'secretAccessKey' ] + ] + }, + { + Ctor: AWS.SAMLCredentials, + fields: [ + [ 'RoleArn', 'PrincipalArn', 'SAMLAssertion' ] + ] + }, + { + Ctor: AWS.CognitoIdentityCredentials, + fields: [ + [ 'IdentityPoolId' ], + [ 'IdentityId' ] + ] + }, + { + // we can only detect these if a custom profile is specified + // but that is fine because shared ini file credentials using the default profile + // are used by the AWS SDK when no credentials are specified + Ctor: AWS.SharedIniFileCredentials, + fields: [ + [ 'profile' ] + ] + }, + { + Ctor: AWS.TemporaryCredentials, + fields: [ + [ 'SerialNumber', 'TokenCode' ], + [ 'RoleArn' ] + ] + } +] const IS_TEST = process.env['NODE_ENV'] === 'test' const log = IS_TEST ? () => {} : gulpLog @@ -129,7 +166,7 @@ export async function deploy(opts, file, s3file, bean) { if (e.code !== 'NoSuchBucket') throw e - await s3file.create() + await s3file.create(opts.region) await s3file.upload(file) } @@ -152,11 +189,11 @@ export async function deploy(opts, file, s3file, bean) { * * versionLabel: version. version + currentTimestamp if timestamp was true * * filename: versionLavel + '.zip' * - * If the resulting object amazon property contains accessKeyId and - * secretAccessKey, both will be added as `AWS.config.credentials`. + * If the resulting object amazon property contains credentials, + they will be added as `AWS.config.credentials`. * - * If the resulting object amazon property contains signatureVersion, it will - * be added to `AWS.config`, ese v4 will be used as signatureVersion + * If the resulting object amazon property contains a configuration object, + * it will be applied to `AWS.config`. * * @param {Object} opts * @return {Object} @@ -191,17 +228,57 @@ export function buildOptions(opts) { if (!options.amazon) throw new PluginError(PLUGIN_NAME, 'No amazon config provided') - // if keys are provided, create new credentials, otherwise defaults will be used - if (options.amazon.accessKeyId && options.amazon.secretAccessKey) { + AWS.config.update(Object.assign({ + signatureVersion: 'v4' + }, options.amazon.config || {})) + + // legacy support for signatureVersion now covered by config parameter + if (options.amazon.signatureVersion) + AWS.config.signatureVersion = options.amazon.signatureVersion + + if (options.amazon.credentials !== undefined) { + const creds = options.amazon.credentials + const credsType = typeof(creds) + + if (credsType === 'string') { + // if the credentials are of type string, assume the user is specifying + // an environment variable name prefix + AWS.config.credentials = existsSync(creds) ? new AWS.FileSystemCredentials(creds) + : new AWS.EnvironmentCredentials(creds) + } else if (credsType !== 'object') { + // otherwise the credentials must be an object + throw new PluginError(PLUGIN_NAME, `Amazon credentials must be an object, got a '${typeof(creds)}'.`) + } else if (creds.constructor.name === 'Credentials' || + typeof(creds.constructor.__super__) === 'function' && + creds.constructor.__super__.name === 'Credentials') { + // support pre-build objects of or inheriting the AWS.Credentials class + AWS.config.credentials = creds + } else { + // otherwise try to find a matching provider for the supplied credentials object + const provider = credentialProviders.find(prov => + prov.fields.find(fields => + fields.every(field => creds[field] !== undefined) + ) + ) + if (provider === undefined) + throw new PluginError(PLUGIN_NAME, `Could not find a matching AWS credentials provider for the supplied credentials object.`) + + try { + AWS.config.credentials = new provider.Ctor(creds) + } catch(err) { + throw new PluginError(PLUGIN_NAME, `An error occured while trying to construct AWS.${provider.Ctor.name} from supplied credentials object: ${err}`) + } + } + } else if (options.amazon.accessKeyId && options.amazon.secretAccessKey) { + // legacy support for the access key id and secret access key + // passed in directly via the options.amazon object + log('options.amazon.accessKeyId and options.amazon.secretAccessKey are deprecated and will be removed in a future version. Use options.amazon.credentials instead.') AWS.config.credentials = new AWS.Credentials({ accessKeyId: opts.amazon.accessKeyId, secretAccessKey: opts.amazon.secretAccessKey }) } - // Set v4 by default - AWS.config.signatureVersion = options.amazon.signatureVersion || 'v4' - return options } diff --git a/test/test.js b/test/test.js index 4cd7689..b1312da 100644 --- a/test/test.js +++ b/test/test.js @@ -1,5 +1,5 @@ /* eslint require-jsdoc: "off", new-cap: "off", no-invalid-this: "off" */ -import { readFileSync } from 'fs' +import { readFileSync, writeFileSync, unlinkSync } from 'fs' import should from 'should' import { spy, stub } from 'sinon' import AWS from 'aws-sdk' @@ -7,6 +7,9 @@ import { File } from 'gulp-util' import { S3File, Bean } from '../src/aws' import * as plugin from '../src/plugin' import gulpEbDeploy from '../src' +import os from 'os' +import uuidv4 from 'uuid/v4' +import path from 'path' describe('Gulp plugin', () => { let file @@ -539,7 +542,36 @@ describe('Gulp plugin', () => { } AWS.config.credentials = null }) - it('updates AWS.config.credentials with the provided values', () => { + + it('sets AWS.config with signatureVersion v4 by default', () => { + spy(AWS, 'Credentials') + buildOptions({ + amazon: {} + }) + AWS.config.signatureVersion.should.be.equal('v4') + }) + + it('allows to set a signatureVersion for AWS.config (legacy)', () => { + buildOptions({ + amazon: { + signatureVersion: 'v2' + } + }) + AWS.config.signatureVersion.should.be.equal('v2') + }) + + it('allows to supply additional paramters to passed into AWS.config', () => { + buildOptions({ + amazon: { + config: { + signatureVersion: 'v2' + } + } + }) + AWS.config.signatureVersion.should.be.equal('v2') + }) + + it('updates AWS.config.credentials with legacy values', () => { spy(AWS, 'Credentials') buildOptions({ amazon: { @@ -548,26 +580,127 @@ describe('Gulp plugin', () => { } }) AWS.Credentials.calledOnce.should.be.true() + AWS.config.credentials.should.be.instanceOf(AWS.Credentials) AWS.config.credentials.accessKeyId.should.be.equal('__accessKeyId') AWS.config.credentials.secretAccessKey.should.be.equal('__secretAccessKey') }) - it('sets AWS.config with signatureVersion v4 by default', () => { - spy(AWS, 'Credentials') + it('updates AWS.config.credentials with access key id and secret access key.', () => { buildOptions({ - amazon: {} + amazon: { + credentials: { + accessKeyId: '__accessKeyId', + secretAccessKey: '__secretAccessKey' + } + } }) - AWS.config.signatureVersion.should.be.equal('v4') + AWS.config.credentials.should.be.instanceOf(AWS.Credentials) + AWS.config.credentials.accessKeyId.should.be.equal('__accessKeyId') + AWS.config.credentials.secretAccessKey.should.be.equal('__secretAccessKey') }) - it('allows to set a signatureVersion for AWS.config', () => { - spy(AWS, 'Credentials') + it('updates AWS.config.credentials with SAML credentials.', () => { buildOptions({ amazon: { - signatureVersion: 'v2' + credentials: { + RoleArn: '__roleArn', + PrincipalArn: '__principalArn', + SAMLAssertion: '__samlAssertion' + } } }) - AWS.config.signatureVersion.should.be.equal('v2') + AWS.config.credentials.should.be.instanceOf(AWS.SAMLCredentials) + AWS.config.credentials.params.RoleArn.should.be.equal('__roleArn') + AWS.config.credentials.params.PrincipalArn.should.be.equal('__principalArn') + AWS.config.credentials.params.SAMLAssertion.should.be.equal('__samlAssertion') + }) + + it('updates AWS.config.credentials with MFA temporary credentials.', () => { + AWS.config.credentials = new AWS.Credentials() + buildOptions({ + amazon: { + credentials: { + SerialNumber: '__serialNumber', + TokenCode: '__tokenCode' + } + } + }) + AWS.config.credentials.should.be.instanceOf(AWS.TemporaryCredentials) + AWS.config.credentials.params.SerialNumber.should.be.equal('__serialNumber') + AWS.config.credentials.params.TokenCode.should.be.equal('__tokenCode') + }) + + it('updates AWS.config.credentials with IAM role temporary credentials.', () => { + AWS.config.credentials = new AWS.Credentials() + buildOptions({ + amazon: { + credentials: { + RoleArn: '__roleArn' + } + } + }) + AWS.config.credentials.should.be.instanceOf(AWS.TemporaryCredentials) + AWS.config.credentials.params.RoleArn.should.be.equal('__roleArn') + }) + + it('updates AWS.config.credentials with Cognito identity ID credentials.', () => { + buildOptions({ + amazon: { + credentials: { + IdentityId: '__indentityId' + } + } + }) + AWS.config.credentials.should.be.instanceOf(AWS.CognitoIdentityCredentials) + AWS.config.credentials.params.IdentityId.should.be.equal('__indentityId') + }) + + it('updates AWS.config.credentials with Cognito identity pool ID credentials.', () => { + buildOptions({ + amazon: { + credentials: { + IdentityPoolId: '__indentityPoolId' + } + } + }) + AWS.config.credentials.should.be.instanceOf(AWS.CognitoIdentityCredentials) + AWS.config.credentials.params.IdentityPoolId.should.be.equal('__indentityPoolId') + }) + + it('updates AWS.config.credentials with an environment credential prefix.', () => { + process.env.__envPrefix_ACCESS_KEY_ID = '__accessKeyId' + process.env.__envPrefix_SECRET_ACCESS_KEY = '__secretAccessKey' + + buildOptions({ + amazon: { + credentials: '__envPrefix' + } + }) + AWS.config.credentials.should.be.instanceOf(AWS.EnvironmentCredentials) + AWS.config.credentials.accessKeyId.should.be.equal('__accessKeyId') + AWS.config.credentials.secretAccessKey.should.be.equal('__secretAccessKey') + + process.env.__envPrefix_ACCESS_KEY_ID = '' + process.env.__envPrefix_SECRET_ACCESS_KEY = '' + }) + + it('updates AWS.config.credentials with credentials loaded from a credential file', () => { + const fileName = path.join(os.tmpdir(), `credentials-${uuidv4()}.json`) + writeFileSync(fileName, JSON.stringify({ + accessKeyId: '__accessKeyId', + secretAccessKey: '__secretAccessKey' + })) + + buildOptions({ + amazon: { + credentials: fileName + } + }) + unlinkSync(fileName) + + AWS.config.credentials.should.be.instanceOf(AWS.FileSystemCredentials) + AWS.config.credentials.accessKeyId.should.be.equal('__accessKeyId') + AWS.config.credentials.secretAccessKey.should.be.equal('__secretAccessKey') }) it('does not update AWS.config.credentials if no access parameters were specified', () => { @@ -578,6 +711,48 @@ describe('Gulp plugin', () => { AWS.Credentials.called.should.be.false() should(AWS.config.credentials).be.null() }) + + it('updates AWS.config.credentials with a Credentials object', () => { + spy(AWS, 'Credentials') + const credentials = new AWS.Credentials() + buildOptions({ + amazon: { + credentials: credentials + } + }) + AWS.Credentials.calledOnce.should.be.true() + AWS.config.credentials.should.be.equal(credentials) + }) + + it('throws an error when provided credentials are not a string or object', () => { + (() => buildOptions({ + amazon: { + credentials: 0 + } + })).should.throw() + }) + + it('throws an error when no matching credential provider is found', () => { + (() => buildOptions({ + amazon: { + credentials: { + unknown: '__unknown' + } + } + })).should.throw() + }) + + it('rethrows an error thrown in the an AWS credentials constructor', () => { + // temporary credentials missing master credentials + (() => buildOptions({ + amazon: { + credentials: { + SerialNumber: '__serialNumber', + TokenCode: '__tokenCode' + } + } + })).should.throw() + }) }) describe('gulpEbDeploy', () => {