UNPKG

lambda-tools

Version:

Scripts for working with AWS Lambda backed microservices

240 lines (211 loc) 9.53 kB
'use strict'; /** * Helper Lambda function for deploying API Gateway instances, * based on a Swagger definition from S3 */ const S3 = require('./s3-utility'); const APIG = require('./apig-utility'); const response = require('cfn-response'); const swagger = require('swagger-parser'); const fs = require('fs'); const _ = require('lodash'); /** * Helper function for replacing variables in the API definition * * @returns {Promise} which resolves into the same localPath the file was * written back to */ function replaceVariables(localPath, variables) { return new Promise(function(resolve, reject) { // Read the file let body = fs.readFileSync(localPath, 'utf8'); // Do the replacement for all of the variables const keys = Object.keys(variables); keys.forEach(function(key) { const value = variables[key]; const re = new RegExp(`"\\$${key}"`, 'g'); // Parse the value (to see if it is falsey) try { const parsed = JSON.parse(value); body = body.replace(re, parsed ? `"${parsed}"` : `null`); } catch (err) { // Not valid JSON, treat it as a string body = body.replace(re, `"${value}"`); } }); console.log('Variables replaced', body); fs.writeFile(localPath, body, function(err) { if (err) return reject(err); resolve(localPath); }); }); } exports.handler = function(event, context) { // Determine the nature of this request const command = event.RequestType; // Log the event (this will help debugging) console.log('Handle ' + command + ' request'); // eslint-disable-line no-console console.log('Event', JSON.stringify(event, null, 4)); // eslint-disable-line no-console // Validate context if (!event) { console.error(new Error('Context MUST have an event')); return response.send(event, context, response.FAILED, {}); } const properties = event.ResourceProperties; const oldProperties = event.OldResourceProperties ? event.OldResourceProperties : {}; if (!properties) { console.error(new Error('Context event must have a \'ResourceProperties\' key')); return response.send(event, context, response.FAILED, {}); } const definition = properties.Definition; const oldDefinition = oldProperties.Definition ? oldProperties.Definition : {}; if (!definition.S3Bucket || !definition.S3Key) { console.error(new Error('Resource properties must include \'Definition\'')); return response.send(event, context, response.FAILED, {}); } // We always want to download the S3 file (as it contains the API name) const localPath = '/tmp/api.json'; let promise = S3.downloadFile(definition.S3Bucket, definition.S3Key, definition.S3ObjectVersion, localPath) .then(function(filePath) { console.log('Validating Swagger file'); return swagger.validate(filePath).then(function() { return filePath; }); }) .then(function(filePath) { console.log('Fetched API from S3'); // Replace variables in file return replaceVariables(filePath, properties.Variables || {}); }) .then(function(filePath) { console.log('Replaced variables', filePath); // Parse the specification return JSON.parse(fs.readFileSync(filePath)); }) .then(function(api) { console.log('Derived API name', api.info.title); // Resolve to the API name (which next steps want to use) return api.info.title; }).then(function(apiName) { return APIG.fetchExistingAPI(apiName).then(function(api) { console.log('Fetched existing API', api); return _.merge({ name: apiName }, api); }); }); // What comes next depends on the operation if (command === 'Delete') { // Deleting is slightly more complex // We need to either simply delete a stage, or delete both the stage // as well as the API Gateway instance itself const stageName = properties.StageName; const deploymentId = event.PhysicalResourceId; if (stageName && deploymentId) { promise = promise.then(function(existing) { if (!existing.id) { // Nothing to delete console.log('Nothing to delete'); return existing; } console.log('Validating that the stage can be deleted', existing.id, stageName, deploymentId); return APIG.fetchStage(existing.id, stageName).then(function(stage) { if (stage.deploymentId !== deploymentId) { // Ignore and continue as if we deleted the stage return { id: deploymentId }; } // Actually delete the stage and if needed also the API console.log('Deleting stage and optionally API', existing.id, stageName); return APIG.deleteStageAndAPI(existing.id, stageName).then(function() { return { id: deploymentId }; }); }).catch(function() { // Ignore and continue as if we deleted the stage return { id: deploymentId }; }); }); } else { // Delete the API, only if there are no stages on it promise = promise.then(function(existing) { if (!existing.id) { // Nothing to delete console.log('Nothing to delete'); return existing; } console.log('Deleting API (if no stages remains)', existing.id); return APIG.deleteAPIIfStageless(existing.id).then(function() { console.log('Done'); return { id: deploymentId }; }); }); } } else if (command === 'Update' || command === 'Create') { promise = promise.then(function(existing) { // If there is an existing API, check for differences in API definition const sameDefinitions = _.isEqual(definition, oldDefinition); const sameStage = properties.StageName === oldProperties.StageName; const sameVariables = _.isEqual(properties.Variables, oldProperties.Variables); // Simple check, if everything else is the same and only the file definition // has changed, then we can compare the ETags of the files to see whether // their contents has changed, if not, we can skip the update if (existing.id && sameStage && sameVariables && !sameDefinitions) { return Promise.all([ S3.headFile(definition.S3Bucket, definition.S3Key, definition.S3ObjectVersion), S3.headFile(oldDefinition.S3Bucket, oldDefinition.S3Key, oldDefinition.S3ObjectVersion) ]).then(function(results) { // Compare the ETags const etags = results.map(function(result) { return result.ETag; }).reduce(function(prev, next) { if (prev.indexOf(next) === -1) { return prev.concat(next); } return prev; }, []); if (etags.length > 1) { // We need to update, more than one ETag console.log('Updating existing API, properties have changed'); return APIG.updateAPI(existing.id, localPath); } else { console.log('Skipping, not enough has changed'); existing.skipDeploy = true; return existing; } }); } else if (existing.id) { console.log('Updating API'); return APIG.updateAPI(existing.id, localPath); } else { console.log('Creating API'); return APIG.createAPI(existing.name, localPath); } }).then(function(api) { if (api.skipDeploy) { console.log('Skipping stage deployment'); return { id: event.PhysicalResourceId }; } console.log('Deploying stage', properties.StageName); return APIG.deployStage(api.id, properties.StageName); }); } // Trigger the promise, handle completion/errors promise.then(function(existing) { console.log('Done', existing); if (existing) { return response.send(event, context, response.SUCCESS, existing, existing.id ? existing.id : event.PhysicalResourceId); } else { return response.send(event, context, response.SUCCESS, {}, event.PhysicalResourceId); } }).catch(function(err) { console.error('Error', err); return response.send(event, context, response.FAILED, {}, event.PhysicalResourceId); }); };