serverless/lib/plugins/aws/provider/awsProvider.js
Frank Schmid 5526802a1b
Added request cache and queue to AWS provider
Make sure that requests are throttled. Use a queue for the requests.

Added new expected parameter to request() call in variables test

Added request cache tests
Check that request is indeed called 1000 times

Cache promises, not values

Use request cache for AWS variable requests

Use fromCallback instead of "new Promise" anti-pattern.

Added request cache to AWS provider
2017-12-05 12:39:39 +01:00

362 lines
12 KiB
JavaScript

'use strict';
const AWS = require('aws-sdk');
const BbPromise = require('bluebird');
const HttpsProxyAgent = require('https-proxy-agent');
const url = require('url');
const chalk = require('chalk');
const _ = require('lodash');
const userStats = require('../../../utils/userStats');
const naming = require('../lib/naming.js');
const https = require('https');
const fs = require('fs');
const objectHash = require('object-hash');
const PromiseQueue = require('promise-queue');
const constants = {
providerName: 'aws',
};
PromiseQueue.configure(BbPromise.Promise);
const impl = {
/**
* Determine whether the given credentials are valid. It turned out that detecting invalid
* credentials was more difficult than detecting the positive cases we know about. Hooray for
* whak-a-mole!
* @param credentials The credentials to test for validity
* @return {boolean} Whether the given credentials were valid
*/
validCredentials: (credentials) => {
let result = false;
if (credentials) {
if (
( // valid credentials loaded
credentials.accessKeyId && credentials.accessKeyId !== 'undefined' &&
credentials.secretAccessKey && credentials.secretAccessKey !== 'undefined'
) || (
// a role to assume has been successfully loaded, the associated STS request has been
// sent, and the temporary credentials will be asynchronously delivered.
credentials.roleArn
)
) {
result = true;
}
}
return result;
},
/**
* Add credentials, if present, to the given results
* @param results The results to add the given credentials to if they are valid
* @param credentials The credentials to validate and add to the results if valid
*/
addCredentials: (results, credentials) => {
if (impl.validCredentials(credentials)) {
results.credentials = credentials; // eslint-disable-line no-param-reassign
}
},
/**
* Add credentials, if present, from the environment
* @param results The results to add environment credentials to
* @param prefix The environment variable prefix to use in extracting credentials
*/
addEnvironmentCredentials: (results, prefix) => {
if (prefix) {
const environmentCredentials = new AWS.EnvironmentCredentials(prefix);
impl.addCredentials(results, environmentCredentials);
}
},
/**
* Add credentials from a profile, if the profile and credentials for it exists
* @param results The results to add profile credentials to
* @param profile The profile to load credentials from
*/
addProfileCredentials: (results, profile) => {
if (profile) {
const params = { profile };
if (process.env.AWS_SHARED_CREDENTIALS_FILE) {
params.filename = process.env.AWS_SHARED_CREDENTIALS_FILE;
}
const profileCredentials = new AWS.SharedIniFileCredentials(params);
if (!(profileCredentials.accessKeyId
|| profileCredentials.sessionToken
|| profileCredentials.roleArn)) {
throw new Error(`Profile ${profile} does not exist`);
}
impl.addCredentials(results, profileCredentials);
}
},
/**
* Add credentials, if present, from a profile that is specified within the environment
* @param results The prefix of the profile's declaration in the environment
* @param prefix The prefix for the environment variable
*/
addEnvironmentProfile: (results, prefix) => {
if (prefix) {
const profile = process.env[`${prefix}_PROFILE`];
impl.addProfileCredentials(results, profile);
}
},
};
class AwsProvider {
static getProviderName() {
return constants.providerName;
}
constructor(serverless, options) {
this.naming = { provider: this };
this.options = options;
this.provider = this; // only load plugin in an AWS service context
this.serverless = serverless;
this.sdk = AWS;
this.serverless.setProvider(constants.providerName, this);
this.requestCache = {};
this.requestQueue = new PromiseQueue(2, Infinity);
Object.assign(this.naming, naming);
// Use HTTPS Proxy (Optional)
const proxy = process.env.proxy
|| process.env.HTTP_PROXY
|| process.env.http_proxy
|| process.env.HTTPS_PROXY
|| process.env.https_proxy;
if (proxy) {
const proxyOptions = url.parse(proxy);
proxyOptions.secureEndpoint = true;
AWS.config.httpOptions.agent = new HttpsProxyAgent(proxyOptions);
}
const ca = process.env.ca
|| process.env.HTTPS_CA
|| process.env.https_ca;
let caCerts = [];
if (ca) {
// Can be a single certificate or multiple, comma separated.
const caArr = ca.split(',');
// Replace the newline -- https://stackoverflow.com/questions/30400341
caCerts = caCerts.concat(caArr.map(cert => cert.replace(/\\n/g, '\n')));
}
const cafile = process.env.cafile
|| process.env.HTTPS_CAFILE
|| process.env.https_cafile;
if (cafile) {
// Can be a single certificate file path or multiple paths, comma separated.
const caPathArr = cafile.split(',');
caCerts = caCerts.concat(caPathArr.map(cafilePath => fs.readFileSync(cafilePath.trim())));
}
if (caCerts.length > 0) {
const caOptions = {
rejectUnauthorized: true,
ca: caCerts,
};
// Update the agent -- http://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/node-registering-certs.html
AWS.config.httpOptions.agent = new https.Agent(caOptions);
}
// Configure the AWS Client timeout (Optional). The default is 120000 (2 minutes)
const timeout = process.env.AWS_CLIENT_TIMEOUT || process.env.aws_client_timeout;
if (timeout) {
AWS.config.httpOptions.timeout = parseInt(timeout, 10);
}
// Support deploymentBucket configuration as an object
const provider = this.serverless.service.provider;
if (provider && provider.deploymentBucket) {
if (_.isObject(provider.deploymentBucket)) {
// store the object in a new variable so that it can be reused later on
provider.deploymentBucketObject = provider.deploymentBucket;
if (provider.deploymentBucket.name) {
// (re)set the value of the deploymentBucket property to the name (which is a string)
provider.deploymentBucket = provider.deploymentBucket.name;
} else {
provider.deploymentBucket = null;
}
}
}
}
/**
* Execute an AWS request by calling the AWS SDK
* @param {string} service - Service name
* @param {string} method - Method name
* @param {Object} params - Parameters
* @param {string} stage - Stage
* @param {string} region - AWS region
* @param {boolean} [useCache=false] - Utilize cache to retrieve results
*/
request(service, method, params, stage, region, useCache) {
const that = this;
const credentials = that.getCredentials();
const shouldCache = !!useCache;
const paramsHash = objectHash.sha1(params);
const persistentRequest = (f) => new BbPromise((resolve, reject) => {
const doCall = () => {
f()
.then(resolve)
.catch((e) => {
if (e.statusCode === 429) {
that.serverless.cli.log("'Too many requests' received, sleeping 5 seconds");
setTimeout(doCall, 5000);
} else {
reject(e);
}
});
};
return doCall();
});
// Support S3 Transfer Acceleration
if (this.canUseS3TransferAcceleration(service, method)) {
this.enableS3TransferAcceleration(credentials);
}
if (shouldCache) {
const cachedRequest = _.get(this.requestCache, `${service}.${method}.${paramsHash}`);
if (cachedRequest) {
return BbPromise.resolve(cachedRequest);
}
}
const request = this.requestQueue.add(() => persistentRequest(() => {
const awsService = new that.sdk[service](credentials);
const req = awsService[method](params);
// TODO: Add listeners, put Debug statments here...
// req.on('send', function (r) {console.log(r)});
return BbPromise.fromCallback(cb => {
req.send(cb);
})
.catch(err => {
let message = err.message;
if (err.message === 'Missing credentials in config') {
const errorMessage = [
'AWS provider credentials not found.',
' Learn how to set up AWS provider credentials',
` in our docs here: ${chalk.green('http://bit.ly/aws-creds-setup')}.`,
].join('');
message = errorMessage;
userStats.track('user_awsCredentialsNotFound');
}
return BbPromise.reject(new this.serverless.classes.Error(message, err.statusCode));
});
})
.then(data => {
const result = BbPromise.resolve(data);
if (shouldCache) {
_.set(this.requestCache, `${service}.${method}.${paramsHash}`, result);
}
return result;
}));
if (shouldCache) {
_.set(this.requestCache, `${service}.${method}.${paramsHash}`, request);
}
return request;
}
/**
* Fetch credentials directly or using a profile from serverless yml configuration or from the
* well known environment variables
* @returns {{region: *}}
*/
getCredentials() {
const result = {};
const stageUpper = this.getStage() ? this.getStage().toUpperCase() : null;
// add specified credentials, overriding with more specific declarations
impl.addCredentials(result, this.serverless.service.provider.credentials); // config creds
if (this.serverless.service.provider.profile) {
// config profile
impl.addProfileCredentials(result, this.serverless.service.provider.profile);
}
impl.addEnvironmentCredentials(result, 'AWS'); // creds for all stages
impl.addEnvironmentProfile(result, 'AWS');
impl.addEnvironmentCredentials(result, `AWS_${stageUpper}`); // stage specific creds
impl.addEnvironmentProfile(result, `AWS_${stageUpper}`);
if (this.options['aws-profile']) {
impl.addProfileCredentials(result, this.options['aws-profile']); // CLI option profile
}
result.region = this.getRegion();
const deploymentBucketObject = this.serverless.service.provider.deploymentBucketObject;
if (deploymentBucketObject && deploymentBucketObject.serverSideEncryption
&& deploymentBucketObject.serverSideEncryption === 'aws:kms') {
result.signatureVersion = 'v4';
}
return result;
}
canUseS3TransferAcceleration(service, method) {
// TODO enable more S3 APIs?
return service === 'S3'
&& ['upload', 'putObject'].indexOf(method) !== -1
&& this.isS3TransferAccelerationEnabled();
}
isS3TransferAccelerationEnabled() {
return !!this.options['aws-s3-accelerate'];
}
disableTransferAccelerationForCurrentDeploy() {
delete this.options['aws-s3-accelerate'];
}
enableS3TransferAcceleration(credentials) {
this.serverless.cli.log('Using S3 Transfer Acceleration Endpoint...');
credentials.useAccelerateEndpoint = true; // eslint-disable-line no-param-reassign
credentials.signatureVersion = 'v2'; // eslint-disable-line no-param-reassign
// see https://github.com/aws/aws-sdk-js/issues/281
}
getRegion() {
const defaultRegion = 'us-east-1';
return _.get(this, 'options.region')
|| _.get(this, 'serverless.config.region')
|| _.get(this, 'serverless.service.provider.region')
|| defaultRegion;
}
getServerlessDeploymentBucketName() {
if (this.serverless.service.provider.deploymentBucket) {
return BbPromise.resolve(this.serverless.service.provider.deploymentBucket);
}
return this.request('CloudFormation',
'describeStackResource',
{
StackName: this.naming.getStackName(),
LogicalResourceId: this.naming.getDeploymentBucketLogicalId(),
}
).then((result) => result.StackResourceDetail.PhysicalResourceId);
}
getStage() {
const defaultStage = 'dev';
return _.get(this, 'options.stage')
|| _.get(this, 'serverless.config.stage')
|| _.get(this, 'serverless.service.provider.stage')
|| defaultStage;
}
getAccountId() {
return this.request('STS', 'getCallerIdentity', {})
.then((result) => result.Account);
}
}
module.exports = AwsProvider;