Skip to content

Commit

Permalink
feat: add lambda deployer
Browse files Browse the repository at this point in the history
  • Loading branch information
Chih-Ying committed Dec 5, 2019
1 parent bbec255 commit 808be3a
Show file tree
Hide file tree
Showing 15 changed files with 1,594 additions and 18 deletions.
8 changes: 3 additions & 5 deletions lib/builtins/deploy-delegates/cfn-deployer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,18 @@ module.exports = {
function bootstrap(options, callback) {
const { profile, userConfig, workspacePath } = options;
const templateLocation = path.join(workspacePath, SKILL_STACK_PUBLIC_FILE_NAME);
let updatedUserConfig;
try {
const templateContent = fs.readFileSync(path.join(__dirname, 'assets', SKILL_STACK_ASSET_FILE_NAME), 'utf-8');
const awsProfile = awsUtil.getAWSProfile(profile);
const awsDefaultRegion = awsUtil.getCLICompatibleDefaultRegion(awsProfile);
fs.writeFileSync(templateLocation, templateContent);
userConfig.templatePath = `.${path.sep}${path.join('infrastructure', path.basename(workspacePath), SKILL_STACK_PUBLIC_FILE_NAME)}`;
if (awsDefaultRegion) {
R.set(R.lensPath(['regionalOverrides', 'default', 'awsRegion']), awsDefaultRegion, userConfig);
}
updatedUserConfig = R.set(R.lensPath(['awsRegion']), awsDefaultRegion, userConfig);
} catch (e) {
return callback(e.message);
}

callback(null, { userConfig });
callback(null, { userConfig: updatedUserConfig });
}

/**
Expand Down
224 changes: 224 additions & 0 deletions lib/builtins/deploy-delegates/lambda-deployer/helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
const async = require('async');
const fs = require('fs');
const R = require('ramda');

const CONSTANTS = require('@src/utils/constants');
const IAMClient = require('@src/clients/aws-client/iam-client');
const LambdaClient = require('@src/clients/aws-client/lambda-client');
const Manifest = require('@src/model/manifest');
const retryUtils = require('@src/utils/retry-utility');
const stringUtils = require('@src/utils/string-utils');

module.exports = {
validateLambdaDeployState,
deployIAMRole,
deployLambdaFunction
};

function validateLambdaDeployState(reporter, awsProfile, awsRegion, currentRegionDeployState, callback) {
const lambdaData = currentRegionDeployState.lambda;
if (R.isNil(lambdaData) || R.isEmpty(lambdaData) || R.isNil(lambdaData.arn)) {
return callback(null, { updatedDeployState: currentRegionDeployState });
}
reporter.updateStatus('Validating the deploy state of existing Lambda function...');
let updatedDeployState;
const lambdaClient = new LambdaClient({ awsProfile, awsRegion });
const lambdaArn = lambdaData.arn;
lambdaClient.getFunction(lambdaArn, (err, data) => {
if (err) {
return callback(err);
}
// 1. check IAM role arn
const localIAMRole = currentRegionDeployState.iamRole;
const remoteIAMRole = data.Configuration.Role;
if (stringUtils.isNonBlankString(localIAMRole) && !R.equals(localIAMRole, remoteIAMRole)) {
return callback(`The IAM role for Lambda ARN (${lambdaArn}) should be ${remoteIAMRole}, but found ${localIAMRole}. \
Please solve this IAM role mismatch and re-deploy again.`);
}
updatedDeployState = R.set(R.lensPath(['iamRole']), remoteIAMRole, currentRegionDeployState);
// 2. check revision id
const localRevisionId = lambdaData.revisionId;
const remoteRevisionId = data.Configuration.RevisionId;
if (stringUtils.isNonBlankString(localRevisionId) && !R.equals(localRevisionId, remoteRevisionId)) {
return callback(`The current revisionId (The revision ID for Lambda ARN (${lambdaArn}) should be ${remoteRevisionId}, \
but found ${localRevisionId}. Please solve this revision mismatch and re-deploy again.`);
}
updatedDeployState = R.set(R.lensPath(['lambda', 'revisionId']), remoteRevisionId, currentRegionDeployState);
// 3. add lastModified
const lastModified = data.Configuration.LastModified;
updatedDeployState = R.set(R.lensPath(['lambda', 'lastModified']), lastModified, currentRegionDeployState);
callback(null, { updatedDeployState });
});
}

function deployIAMRole(reporter, awsProfile, alexaRegion, skillName, awsRegion, deployState, callback) {
const iamClient = new IAMClient({ awsProfile, awsRegion });
const roleArn = deployState.iamRole;
if (R.isNil(roleArn) || R.isEmpty(roleArn)) {
reporter.updateStatus('No IAM role exists. Creating an IAM role...');
_createIAMRole(reporter, iamClient, skillName, (roleErr, iamRoleArn) => {
if (roleErr) {
return callback(roleErr);
}
callback(null, iamRoleArn);
});
} else {
iamClient.getIAMRole(roleArn, (roleErr, roleData) => {
if (roleErr) {
if (roleErr.code === 'NoSuchEntity') {
callback(`The IAM role is not found. Please check if your IAM role from region ${alexaRegion} is valid.`);
} else {
callback(roleErr);
}
} else {
reporter.updateStatus(`Current IAM role : "${roleArn}"...`);
callback(null, roleData.Role.Arn);
}
});
}
}

function _createIAMRole(reporter, iamClient, skillName, callback) {
iamClient.createBasicLambdaRole(skillName, (roleErr, roleData) => {
if (roleErr) {
return callback(roleErr);
}
const roleArn = roleData.Role.Arn;
reporter.updateStatus(`Create Role (arn: ${roleArn}) in progress...`);
iamClient.attachBasicLambdaRolePolicy(roleArn, (policyErr) => {
if (policyErr) {
return callback(policyErr);
}
callback(null, roleData.Role.Arn);
});
});
}

function deployLambdaFunction(reporter, options, callback) {
const { profile, awsProfile, alexaRegion, awsRegion, skillId, skillName, code, iamRoleArn, userConfig, currentRegionDeployState } = options;
const lambdaClient = new LambdaClient({ awsProfile, awsRegion });
const zipFilePath = code.codeBuild;
if (R.isNil(currentRegionDeployState.lambda) || R.isEmpty(currentRegionDeployState.lambda)) {
reporter.updateStatus('No Lambda information exists. Creating a lambda function...');
const createLambdaOptions = {
profile,
alexaRegion,
skillId,
skillName,
iamRoleArn,
zipFilePath,
userConfig
};
_createLambdaFunction(reporter, lambdaClient, createLambdaOptions, (lambdaErr, lambdaResources) => {
if (lambdaErr) {
return callback(lambdaErr);
}
callback(null, lambdaResources);
});
} else {
reporter.updateStatus(`Updating current Lambda function with ARN ${currentRegionDeployState.lambda.arn}...`);
const updateLambdaOptions = {
zipFilePath,
userConfig,
currentRegionDeployState
};
_updateLambdaFunction(reporter, lambdaClient, updateLambdaOptions, (lambdaErr, lambdaResources) => {
if (lambdaErr) {
return callback(lambdaErr);
}
callback(null, lambdaResources);
});
}
}

function _createLambdaFunction(reporter, lambdaClient, options, callback) {
const { profile, alexaRegion, skillId, skillName, iamRoleArn, zipFilePath, userConfig } = options;
const zipFile = fs.readFileSync(zipFilePath);
const { runtime, handler } = userConfig;
const retryConfig = {
base: CONSTANTS.CONFIGURATION.RETRY.CREATE_LAMBDA_FUNCTION.BASE,
factor: CONSTANTS.CONFIGURATION.RETRY.CREATE_LAMBDA_FUNCTION.FACTOR,
maxRetry: CONSTANTS.CONFIGURATION.RETRY.CREATE_LAMBDA_FUNCTION.MAXRETRY
};
const RETRY_MESSAGE = 'The role defined for the function cannot be assumed by Lambda.';
const retryCall = (loopCallback) => {
// 1. Create a Lambda function
lambdaClient.createLambdaFunction(skillName, profile, alexaRegion, iamRoleArn, zipFile, runtime, handler, (lambdaErr, lambdaData) => {
if (lambdaErr) {
// There may be a (small) window of time after creating IAM role and adding policies, the role will trigger the error
// if creating lambda function during this timming. Thus, use retry to bypass this issue.
if (lambdaErr.message === RETRY_MESSAGE) {
return loopCallback(null, RETRY_MESSAGE);
}
return loopCallback(lambdaErr);
}
loopCallback(null, lambdaData);
});
};
const shouldRetryCondition = retryResponse => retryResponse === RETRY_MESSAGE;
retryUtils.retry(retryConfig, retryCall, shouldRetryCondition, (retryErr, lambdaData) => {
if (retryErr) {
return callback(retryErr);
}
const arn = lambdaData.FunctionArn;
// 2. Grant permissions to use a Lambda function
_addEventPermissions(lambdaClient, skillId, arn, (addErr) => {
if (addErr) {
return callback(addErr);
}
// 3. Get the latest revisionId from getFunction API
lambdaClient.getFunction(arn, (revisionErr, revisionData) => {
if (revisionErr) {
return callback(revisionErr);
}
callback(null, {
arn,
lastModified: lambdaData.LastModified,
revisionId: revisionData.Configuration.RevisionId
});
});
});
});
}

function _addEventPermissions(lambdaClient, skillId, functionArn, callback) {
// TODO: to move Manifest outside the deployer logic
const domainInfo = Manifest.getInstance().getApis();
const domainList = R.keys(domainInfo);
async.forEach(domainList, (domain, addCallback) => {
lambdaClient.addAlexaPermissionByDomain(domain, skillId, functionArn, (err) => {
if (err) {
return addCallback(err);
}
addCallback();
});
}, (error) => {
callback(error);
});
}

function _updateLambdaFunction(reporter, lambdaClient, options, callback) {
const { zipFilePath, userConfig, currentRegionDeployState } = options;
const zipFile = fs.readFileSync(zipFilePath);
const functionName = currentRegionDeployState.lambda.arn;
let { revisionId } = currentRegionDeployState.lambda;
lambdaClient.updateFunctionCode(zipFile, functionName, revisionId, (codeErr, codeData) => {
if (codeErr) {
return callback(codeErr);
}
reporter.updateStatus(`Update a lambda function (${functionName}) in progress...`);
const { runtime } = userConfig;
const { handler } = userConfig;
revisionId = codeData.RevisionId;
lambdaClient.updateFunctionConfiguration(functionName, runtime, handler, revisionId, (configErr, configData) => {
if (configErr) {
return callback(configErr);
}
callback(null, {
arn: configData.FunctionArn,
lastModified: configData.LastModified,
revisionId: configData.RevisionId
});
});
});
}
100 changes: 100 additions & 0 deletions lib/builtins/deploy-delegates/lambda-deployer/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const R = require('ramda');

const awsUtil = require('@src/clients/aws-client/aws-util');
const stringUtils = require('@src/utils/string-utils');
const helper = require('./helper');

const alexaAwsRegionMap = {
default: 'us-east-1',
NA: 'us-east-1',
EU: 'eu-west-1',
FE: 'ap-northeast-1'
};

module.exports = {
bootstrap,
invoke
};

/**
* Bootstrap ask-cli resources config with initial configuration.
* @param {Object} options
* @param {Function} callback
*/
function bootstrap(options, callback) {
const { profile, userConfig } = options;
const awsProfile = awsUtil.getAWSProfile(profile);
const awsDefaultRegion = awsUtil.getCLICompatibleDefaultRegion(awsProfile);
const updatedUserConfig = R.set(R.lensPath(['awsRegion']), awsDefaultRegion, userConfig);
callback(null, { userConfig: updatedUserConfig });
}

/**
* Invoke the actual deploy logic for skill's infrastructure
* @param {Object} reporter upstream CLI status reporter
* @param {Object} options
* @param {Function} callback
*/
function invoke(reporter, options, callback) {
const { profile, alexaRegion, skillId, skillName, code, userConfig, deployState } = options;
const awsProfile = awsUtil.getAWSProfile(profile);
if (!stringUtils.isNonBlankString(awsProfile)) {
return callback(`Profile [${profile}] doesn't have AWS profile linked to it. Please run "ask init" to re-configure your porfile.`);
}
let currentRegionDeployState = deployState[alexaRegion];
if (!currentRegionDeployState) {
currentRegionDeployState = {};
deployState[alexaRegion] = currentRegionDeployState;
}
const awsRegion = R.view(R.lensPath(['regionalOverrides', alexaRegion, 'awsRegion']), userConfig) || alexaAwsRegionMap[alexaRegion];
if (!stringUtils.isNonBlankString(awsRegion)) {
return callback(`Unsupported Alexa region: ${alexaRegion}. Please check your region name or use "regionalOverrides" to specify AWS region.`);
}

helper.validateLambdaDeployState(reporter, awsProfile, awsRegion, currentRegionDeployState, (validationErr, validationData) => {
if (validationErr) {
return callback(validationErr);
}
currentRegionDeployState = validationData.updatedDeployState;
helper.deployIAMRole(reporter, awsProfile, alexaRegion, skillName, awsRegion, currentRegionDeployState, (iamErr, iamRoleArn) => {
if (iamErr) {
return callback(iamErr);
}
deployState[alexaRegion].iamRole = iamRoleArn;
const deployLambdaConfig = {
profile,
awsProfile,
alexaRegion,
awsRegion,
skillId,
skillName,
code,
iamRoleArn,
userConfig,
currentRegionDeployState
};
helper.deployLambdaFunction(reporter, deployLambdaConfig, (lambdaErr, lambdaResult) => {
if (lambdaErr) {
const errMessage = `The lambda deploy failed for Alexa region "${alexaRegion}": ${lambdaErr}`;
const reasons = [errMessage];
return callback(null, {
reasons,
message: errMessage,
deployState: deployState[alexaRegion]
});
}
const { arn, lastModified, revisionId } = lambdaResult;
deployState[alexaRegion].lambda = {
arn,
lastModified,
revisionId
};
return callback(null, {
endpoint: { uri: arn },
message: `The lambda deploy succeeded for Alexa region "${alexaRegion}" with output Lambda ARN: ${arn}.`,
deployState: deployState[alexaRegion]
});
});
});
});
}
4 changes: 2 additions & 2 deletions lib/clients/aws-client/aws-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class SharedIniFile {
}

/**
* function used to retrieve default aws region
* function used to retrieve default aws region or return a global default aws region if a set one is not found.
* @param {string} runtimeProfile aws profile name
*/
function getCLICompatibleDefaultRegion(awsProfile) {
Expand All @@ -78,5 +78,5 @@ function getCLICompatibleDefaultRegion(awsProfile) {
const section = configFile.getProfile(profile);
region = section && section.region;
}
return region;
return region || CONSTANT.AWS_SKILL_INFRASTRUCTURE_DEFAULT_REGION;
}
Loading

0 comments on commit 808be3a

Please sign in to comment.