2019-11-20 09:48:32 +01:00

583 lines
20 KiB
JavaScript

'use strict';
const AWS = require('aws-sdk');
const BbPromise = require('bluebird');
const crypto = require('crypto');
const fs = require('fs');
const _ = require('lodash');
const path = require('path');
class AwsCompileFunctions {
constructor(serverless, options) {
this.serverless = serverless;
this.options = options;
const servicePath = this.serverless.config.servicePath || '';
this.packagePath =
this.serverless.service.package.path || path.join(servicePath || '.', '.serverless');
this.provider = this.serverless.getProvider('aws');
if (
this.serverless.service.provider.versionFunctions === undefined ||
this.serverless.service.provider.versionFunctions === null
) {
this.serverless.service.provider.versionFunctions = true;
}
this.hooks = {
'package:compileFunctions': () =>
BbPromise.bind(this)
.then(this.downloadPackageArtifacts)
.then(this.compileFunctions),
};
}
compileRole(newFunction, role) {
const compiledFunction = newFunction;
const unnsupportedRoleError = new this.serverless.classes.Error(
`Unsupported role provided: "${JSON.stringify(role)}"`
);
switch (typeof role) {
case 'object':
if ('Fn::GetAtt' in role) {
// role is an "Fn::GetAtt" object
compiledFunction.Properties.Role = role;
compiledFunction.DependsOn = (compiledFunction.DependsOn || []).concat(
role['Fn::GetAtt'][0]
);
} else if ('Fn::ImportValue' in role) {
// role is an "Fn::ImportValue" object
compiledFunction.Properties.Role = role;
} else {
throw unnsupportedRoleError;
}
break;
case 'string':
if (role.startsWith('arn:aws')) {
// role is a statically definied iam arn
compiledFunction.Properties.Role = role;
} else if (role === 'IamRoleLambdaExecution') {
// role is the default role generated by the framework
compiledFunction.Properties.Role = { 'Fn::GetAtt': [role, 'Arn'] };
compiledFunction.DependsOn = (compiledFunction.DependsOn || []).concat(
'IamRoleLambdaExecution'
);
} else {
// role is a Logical Role Name
compiledFunction.Properties.Role = { 'Fn::GetAtt': [role, 'Arn'] };
compiledFunction.DependsOn = (compiledFunction.DependsOn || []).concat(role);
}
break;
default:
throw unnsupportedRoleError;
}
}
downloadPackageArtifact(functionName) {
const { region } = this.options;
const S3 = new AWS.S3({ region });
const functionObject = this.serverless.service.getFunction(functionName);
const artifactFilePath =
_.get(functionObject, 'package.artifact') ||
_.get(this, 'serverless.service.package.artifact');
const regex = new RegExp('s3\\.amazonaws\\.com/(.+)/(.+)');
const match = artifactFilePath.match(regex);
if (match) {
return new BbPromise((resolve, reject) => {
const tmpDir = this.serverless.utils.getTmpDirPath();
const filePath = path.join(tmpDir, match[2]);
const readStream = S3.getObject({
Bucket: match[1],
Key: match[2],
}).createReadStream();
const writeStream = fs.createWriteStream(filePath);
readStream.on('error', error => reject(error));
readStream
.pipe(writeStream)
.on('error', reject)
.on('close', () => {
if (functionObject.package.artifact) {
functionObject.package.artifact = filePath;
} else {
this.serverless.service.package.artifact = filePath;
}
return resolve(filePath);
});
});
}
return BbPromise.resolve();
}
compileFunction(functionName) {
const newFunction = this.cfLambdaFunctionTemplate();
const functionObject = this.serverless.service.getFunction(functionName);
functionObject.package = functionObject.package || {};
const serviceArtifactFileName = this.provider.naming.getServiceArtifactName();
const functionArtifactFileName = this.provider.naming.getFunctionArtifactName(functionName);
let artifactFilePath =
functionObject.package.artifact || this.serverless.service.package.artifact;
if (
!artifactFilePath ||
(this.serverless.service.artifact && !functionObject.package.artifact)
) {
let artifactFileName = serviceArtifactFileName;
if (this.serverless.service.package.individually || functionObject.package.individually) {
artifactFileName = functionArtifactFileName;
}
artifactFilePath = path.join(
this.serverless.config.servicePath,
'.serverless',
artifactFileName
);
}
if (this.serverless.service.package.deploymentBucket) {
newFunction.Properties.Code.S3Bucket = this.serverless.service.package.deploymentBucket;
}
const s3Folder = this.serverless.service.package.artifactDirectoryName;
const s3FileName = artifactFilePath.split(path.sep).pop();
newFunction.Properties.Code.S3Key = `${s3Folder}/${s3FileName}`;
if (!functionObject.handler) {
const errorMessage = [
`Missing "handler" property in function "${functionName}".`,
' Please make sure you point to the correct lambda handler.',
' For example: handler.hello.',
' Please check the docs for more info',
].join('');
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
const Handler = functionObject.handler;
const FunctionName = functionObject.name;
const MemorySize =
Number(functionObject.memorySize) ||
Number(this.serverless.service.provider.memorySize) ||
1024;
const Timeout =
Number(functionObject.timeout) || Number(this.serverless.service.provider.timeout) || 6;
const Runtime =
functionObject.runtime || this.serverless.service.provider.runtime || 'nodejs12.x';
newFunction.Properties.Handler = Handler;
newFunction.Properties.FunctionName = FunctionName;
newFunction.Properties.MemorySize = MemorySize;
newFunction.Properties.Timeout = Timeout;
newFunction.Properties.Runtime = Runtime;
// publish these properties to the platform
this.serverless.service.functions[functionName].memory = MemorySize;
this.serverless.service.functions[functionName].timeout = Timeout;
this.serverless.service.functions[functionName].runtime = Runtime;
if (functionObject.description) {
newFunction.Properties.Description = functionObject.description;
}
if (functionObject.condition) {
newFunction.Condition = functionObject.condition;
}
if (functionObject.dependsOn) {
newFunction.DependsOn = (newFunction.DependsOn || []).concat(functionObject.dependsOn);
}
if (functionObject.tags || this.serverless.service.provider.tags) {
const tags = Object.assign({}, this.serverless.service.provider.tags, functionObject.tags);
newFunction.Properties.Tags = [];
_.forEach(tags, (Value, Key) => {
newFunction.Properties.Tags.push({ Key, Value });
});
}
if (functionObject.onError) {
const arn = functionObject.onError;
if (typeof arn === 'string') {
const splittedArn = arn.split(':');
if (splittedArn[0] === 'arn' && (splittedArn[2] === 'sns' || splittedArn[2] === 'sqs')) {
const dlqType = splittedArn[2];
const iamRoleLambdaExecution = this.serverless.service.provider
.compiledCloudFormationTemplate.Resources.IamRoleLambdaExecution;
let stmt;
newFunction.Properties.DeadLetterConfig = {
TargetArn: arn,
};
if (dlqType === 'sns') {
stmt = {
Effect: 'Allow',
Action: ['sns:Publish'],
Resource: [arn],
};
} else if (dlqType === 'sqs') {
const errorMessage = [
'onError currently only supports SNS topic arns due to a',
' race condition when using SQS queue arns and updating the IAM role.',
' Please check the docs for more info.',
].join('');
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
// update the PolicyDocument statements (if default policy is used)
if (iamRoleLambdaExecution) {
iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement.push(stmt);
}
} else {
const errorMessage = 'onError config must be a SNS topic arn or SQS queue arn';
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
} else if (this.isArnRefGetAttOrImportValue(arn)) {
newFunction.Properties.DeadLetterConfig = {
TargetArn: arn,
};
} else {
const errorMessage = [
'onError config must be provided as an arn string,',
' Ref, Fn::GetAtt or Fn::ImportValue',
].join('');
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
}
let kmsKeyArn;
const serviceObj = this.serverless.service.serviceObject;
if ('awsKmsKeyArn' in functionObject) {
kmsKeyArn = functionObject.awsKmsKeyArn;
} else if (serviceObj && 'awsKmsKeyArn' in serviceObj) {
kmsKeyArn = serviceObj.awsKmsKeyArn;
}
if (kmsKeyArn) {
const arn = kmsKeyArn;
if (typeof arn === 'string') {
const splittedArn = arn.split(':');
if (splittedArn[0] === 'arn' && splittedArn[2] === 'kms') {
const iamRoleLambdaExecution = this.serverless.service.provider
.compiledCloudFormationTemplate.Resources.IamRoleLambdaExecution;
newFunction.Properties.KmsKeyArn = arn;
const stmt = {
Effect: 'Allow',
Action: ['kms:Decrypt'],
Resource: [arn],
};
// update the PolicyDocument statements (if default policy is used)
if (iamRoleLambdaExecution) {
iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement = _.unionWith(
iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement,
[stmt],
_.isEqual
);
}
} else {
const errorMessage = 'awsKmsKeyArn config must be a KMS key arn';
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
} else {
const errorMessage = 'awsKmsKeyArn config must be provided as a string';
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
}
const tracing =
functionObject.tracing ||
(this.serverless.service.provider.tracing && this.serverless.service.provider.tracing.lambda);
if (tracing) {
if (typeof tracing === 'boolean' || typeof tracing === 'string') {
let mode = tracing;
if (typeof tracing === 'boolean') {
mode = 'Active';
}
const iamRoleLambdaExecution = this.serverless.service.provider
.compiledCloudFormationTemplate.Resources.IamRoleLambdaExecution;
newFunction.Properties.TracingConfig = {
Mode: mode,
};
const stmt = {
Effect: 'Allow',
Action: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords'],
Resource: ['*'],
};
// update the PolicyDocument statements (if default policy is used)
if (iamRoleLambdaExecution) {
iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement = _.unionWith(
iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement,
[stmt],
_.isEqual
);
}
} else {
const errorMessage = 'tracing requires a boolean value or the "mode" provided as a string';
throw new this.serverless.classes.Error(errorMessage);
}
}
if (functionObject.environment || this.serverless.service.provider.environment) {
newFunction.Properties.Environment = {};
newFunction.Properties.Environment.Variables = Object.assign(
{},
this.serverless.service.provider.environment,
functionObject.environment
);
let invalidEnvVar = null;
_.forEach(_.keys(newFunction.Properties.Environment.Variables), key => {
// taken from the bash man pages
if (!key.match(/^[A-Za-z_][a-zA-Z0-9_]*$/)) {
invalidEnvVar = `Invalid characters in environment variable ${key}`;
return false; // break loop with lodash
}
const value = newFunction.Properties.Environment.Variables[key];
if (_.isObject(value)) {
const isCFRef =
_.isObject(value) && !_.some(value, (v, k) => k !== 'Ref' && !_.startsWith(k, 'Fn::'));
if (!isCFRef) {
invalidEnvVar = `Environment variable ${key} must contain string`;
return false;
}
}
return true;
});
if (invalidEnvVar) {
return BbPromise.reject(new this.serverless.classes.Error(invalidEnvVar));
}
}
if ('role' in functionObject) {
this.compileRole(newFunction, functionObject.role);
} else if ('role' in this.serverless.service.provider) {
this.compileRole(newFunction, this.serverless.service.provider.role);
} else {
this.compileRole(newFunction, 'IamRoleLambdaExecution');
}
if (!functionObject.vpc) functionObject.vpc = {};
if (!this.serverless.service.provider.vpc) this.serverless.service.provider.vpc = {};
newFunction.Properties.VpcConfig = {
SecurityGroupIds:
functionObject.vpc.securityGroupIds ||
this.serverless.service.provider.vpc.securityGroupIds,
SubnetIds: functionObject.vpc.subnetIds || this.serverless.service.provider.vpc.subnetIds,
};
if (
!newFunction.Properties.VpcConfig.SecurityGroupIds ||
!newFunction.Properties.VpcConfig.SubnetIds
) {
delete newFunction.Properties.VpcConfig;
}
if (functionObject.reservedConcurrency || functionObject.reservedConcurrency === 0) {
// Try convert reservedConcurrency to integer
const reservedConcurrency = _.parseInt(functionObject.reservedConcurrency);
if (_.isInteger(reservedConcurrency)) {
newFunction.Properties.ReservedConcurrentExecutions = reservedConcurrency;
} else {
const errorMessage = [
'You should use integer as reservedConcurrency value on function: ',
`${newFunction.Properties.FunctionName}`,
].join('');
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
}
newFunction.DependsOn = [this.provider.naming.getLogGroupLogicalId(functionName)].concat(
newFunction.DependsOn || []
);
if (functionObject.layers && _.isArray(functionObject.layers)) {
newFunction.Properties.Layers = functionObject.layers;
} else if (
this.serverless.service.provider.layers &&
_.isArray(this.serverless.service.provider.layers)
) {
newFunction.Properties.Layers = this.serverless.service.provider.layers;
}
const functionLogicalId = this.provider.naming.getLambdaLogicalId(functionName);
const newFunctionObject = {
[functionLogicalId]: newFunction,
};
_.merge(
this.serverless.service.provider.compiledCloudFormationTemplate.Resources,
newFunctionObject
);
const newVersion = this.cfLambdaVersionTemplate();
// Create hashes for the artifact and the logical id of the version resource
// The one for the version resource must include the function configuration
// to make sure that a new version is created on configuration changes and
// not only on source changes.
const fileHash = crypto.createHash('sha256');
const versionHash = crypto.createHash('sha256');
fileHash.setEncoding('base64');
versionHash.setEncoding('base64');
// Read the file in chunks and add them to the hash (saves memory and performance)
return BbPromise.fromCallback(cb => {
const readStream = fs.createReadStream(artifactFilePath);
readStream
.on('data', chunk => {
fileHash.write(chunk);
versionHash.write(chunk);
})
.on('close', () => {
cb();
})
.on('error', error => {
cb(error);
});
}).then(() => {
// Include function configuration in version id hash (without the Code part)
const properties = _.omit(_.get(newFunction, 'Properties', {}), 'Code');
_.forOwn(properties, value => {
const hashedValue = _.isObject(value) ? JSON.stringify(value) : _.toString(value);
versionHash.write(hashedValue);
});
// Finalize hashes
fileHash.end();
versionHash.end();
const fileDigest = fileHash.read();
const versionDigest = versionHash.read();
newVersion.Properties.CodeSha256 = fileDigest;
newVersion.Properties.FunctionName = { Ref: functionLogicalId };
if (functionObject.description) {
newVersion.Properties.Description = functionObject.description;
}
// use the version SHA in the logical resource ID of the version because
// AWS::Lambda::Version resource will not support updates
const versionLogicalId = this.provider.naming.getLambdaVersionLogicalId(
functionName,
versionDigest
);
const newVersionObject = {
[versionLogicalId]: newVersion,
};
const versionFunction =
functionObject.versionFunction != null
? functionObject.versionFunction
: this.serverless.service.provider.versionFunctions;
if (versionFunction) {
_.merge(
this.serverless.service.provider.compiledCloudFormationTemplate.Resources,
newVersionObject
);
}
// Add function versions to Outputs section
const functionVersionOutputLogicalId = this.provider.naming.getLambdaVersionOutputLogicalId(
functionName
);
const newVersionOutput = this.cfOutputLatestVersionTemplate();
newVersionOutput.Value = { Ref: versionLogicalId };
if (versionFunction) {
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Outputs, {
[functionVersionOutputLogicalId]: newVersionOutput,
});
}
return BbPromise.resolve();
});
}
downloadPackageArtifacts() {
const allFunctions = this.serverless.service.getAllFunctions();
return BbPromise.each(allFunctions, functionName => this.downloadPackageArtifact(functionName));
}
compileFunctions() {
const allFunctions = this.serverless.service.getAllFunctions();
return BbPromise.each(allFunctions, functionName => this.compileFunction(functionName));
}
// helper functions
isArnRefGetAttOrImportValue(arn) {
return (
typeof arn === 'object' &&
_.some(_.keys(arn), k => _.includes(['Ref', 'Fn::GetAtt', 'Fn::ImportValue'], k))
);
}
cfLambdaFunctionTemplate() {
return {
Type: 'AWS::Lambda::Function',
Properties: {
Code: {
S3Bucket: {
Ref: 'ServerlessDeploymentBucket',
},
S3Key: 'S3Key',
},
FunctionName: 'FunctionName',
Handler: 'Handler',
MemorySize: 'MemorySize',
Role: 'Role',
Runtime: 'Runtime',
Timeout: 'Timeout',
},
};
}
cfLambdaVersionTemplate() {
return {
Type: 'AWS::Lambda::Version',
// Retain old versions even though they will not be in future
// CloudFormation stacks. On stack delete, these will be removed when
// their associated function is removed.
DeletionPolicy: 'Retain',
Properties: {
FunctionName: 'FunctionName',
CodeSha256: 'CodeSha256',
},
};
}
cfOutputLatestVersionTemplate() {
return {
Description: 'Current Lambda function version',
Value: 'Value',
};
}
}
module.exports = AwsCompileFunctions;