aws-lambda-upload
Version:
Package and upload an AWS lambda with its minimal dependencies
411 lines (371 loc) • 14.8 kB
JavaScript
;
/* global describe, it, beforeEach, afterEach, before, after */
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
const bluebird = require('bluebird');
const fse = require('fs-extra');
const path = require('path');
const util = require('util');
const tmp = bluebird.promisifyAll(require('tmp'));
const childProcess = bluebird.promisifyAll(require('child_process'));
const main = require('../dist/main.js');
const localstack = require('./localstack');
const AWS = require('aws-sdk');
chai.use(chaiAsPromised);
const assert = chai.assert;
tmp.setGracefulCleanup();
localstack.addServices(['s3', 'lambda', 'cloudformation']);
AWS.config.credentials = new AWS.Credentials('localstack-key-id', 'localstack-access-key');
describe('spawn', function() {
it('should resolve or reject returned promise', function() {
return main.spawn('true', [])
.then(() => main.spawn('false', []))
.then(
() => assert(false, "Command should have failed"),
err => assert.equal(err.message, "Command failed with exit code 1")
);
});
});
describe('fsWalk', function() {
it('should list all files recursively', function() {
const entries = [];
return main.fsWalk('test/fixtures', (p, st) => entries.push([p, st.isDirectory()]))
.then(() => assert.deepEqual(entries, [
['test/fixtures', true],
['test/fixtures/cfn.yml', false],
['test/fixtures/foo.js', false],
['test/fixtures/foo_abs.js', false],
['test/fixtures/foo_ts.ts', false],
['test/fixtures/lib', true],
['test/fixtures/lib/bar.js', false],
['test/fixtures/lib/baz_ts.ts', false],
['test/fixtures/lib/lambda.js', false],
['test/fixtures/lib/lambda_dep.js', false],
['test/fixtures/node_modules', true],
['test/fixtures/node_modules/dep1', true],
['test/fixtures/node_modules/dep1/hello.js', false],
['test/fixtures/node_modules/dep1/package.json', false],
['test/fixtures/node_modules/dep2', true],
['test/fixtures/node_modules/dep2/package.json', false],
['test/fixtures/node_modules/dep2/world.js', false],
['test/fixtures/node_modules/dep3', true],
['test/fixtures/node_modules/dep3/bye.js', false],
['test/fixtures/node_modules/dep3/package.json', false],
['test/fixtures/tsconfig.json', false],
]));
});
});
/**
* Return promise for list of files in the archive, ignoring non-file lines and directories.
*/
function listZipNames(zipFile) {
return childProcess.execFileAsync('unzip', ['-l', zipFile])
.then(stdout => {
// Include just the last word (file name)
return stdout.split("\n").map(line => line.split(/ +/)[4])
// Ignore non-word lines (e.g. Linux and Max differ on whether '----' is printed).
.filter(name => name && /\w/.test(name))
// Filter out directories and the "Name" header in the first line.
.filter((name, i) => !(i === 0 && name === 'Name') && !name.endsWith('/'))
.sort();
});
}
/**
* Unzip archive and run the entryFile using node. Returns promise for its stdout.
*/
function runZipFile(zipFile, entryFile) {
return tmp.dirAsync({unsafeCleanup: true})
.then(dir => {
return childProcess.execFileAsync('unzip', ['-d', dir, zipFile])
.then(() => childProcess.execFileAsync('node', [path.join(dir, entryFile)], {cwd: dir}));
});
}
/**
* Ensure that array contains elements matching each regexp in regexpList, in the order given.
* Non-matching elements are ignored.
*/
function assertSubsetMatchesInOrder(array, regexpList) {
function findNext(index, r) {
for (let i = index; i < array.length; i++) {
if (r.test(array[i])) { return i; }
}
assert.fail(array.slice(i), regexpList[r],
`expected ${util.inspect(array.slice(i))} to include a match for ${util.inspect(r)}`);
}
let i = 0;
for (const r of regexpList) {
i = findNext(i, r) + 1;
}
}
/**
* Use in describe() to run tests with environment variable `name` set to `value`.
*/
function envContext(name, value) {
let old;
before(() => { old = process.env[name]; process.env[name] = value; });
after(() => { process.env[name] = old; });
}
/**
* Use in describe() to run tests with current directory set to dir.
*/
function chdirContext(dir) {
let old;
before(() => { old = process.cwd(); process.chdir(dir); });
after(() => { process.chdir(old); });
}
describe('aws-lambda-upload', function() {
this.timeout(30000); // Generous timeout for TravisCI.
let log = [];
const logger = {
info(msg) { log.push(msg); },
debug(msg) { log.push(msg); },
};
beforeEach(function() { log = []; });
describe('packageZipLocal', function() {
let localZipPath;
chdirContext('test/fixtures');
beforeEach(() => tmp.tmpNameAsync({postfix: '.zip'}).then(t => { localZipPath = t; }));
afterEach(() => fse.remove(localZipPath));
it('should create a zip-file', function() {
return main.packageZipLocal('foo.js', localZipPath, {logger})
.then(result => assert.equal(result, localZipPath))
.then(() => listZipNames(localZipPath))
.then(names => {
assert.deepEqual(names, [
'foo.js',
'lib/bar.js',
'node_modules/dep1/hello.js',
'node_modules/dep1/package.json',
'node_modules/dep2/package.json',
'node_modules/dep2/world.js',
]);
assert.match(log[0], /Packaging foo.js/);
})
.then(() => runZipFile(localZipPath, 'foo'))
.then(outputLines => assert.deepEqual(outputLines,
'imported dep1\n' +
'imported dep2\n' +
'imported lib/bar.js\n' +
'imported foo.js\n'
));
});
it('should create a helper file if startFile is not at top level', function() {
return main.packageZipLocal('lib/bar.js', localZipPath, {logger})
.then(result => assert.equal(result, localZipPath))
.then(() => listZipNames(localZipPath))
.then(names => {
assert.deepEqual(names, [
"bar.js",
"lib/bar.js",
'node_modules/dep2/package.json',
'node_modules/dep2/world.js',
]);
assert.match(log[0], /Packaging lib\/bar.js/);
})
.then(() => childProcess.execFileAsync('unzip', ['-q', '-c', localZipPath, 'bar.js']))
.then(contents => assert.include(contents, 'require("./lib/bar.js")'))
.then(() => runZipFile(localZipPath, 'bar'))
.then(outputLines => assert.deepEqual(outputLines,
'imported dep2\n' +
'imported lib/bar.js\n'
));
});
it('should reuse existing file when a cache is used', function() {
const cache = new Map();
return main.packageZipLocal('foo.js', localZipPath, {logger})
.then(() => { log.length = 0; })
.then(() => main.packageZipLocal('foo.js', localZipPath, {logger, cache}))
.then(() => {
assert.isTrue(log.some(l => /Collecting/.test(l)));
assert.isFalse(log.some(l => /Reusing cached/.test(l)));
log.length = 0;
})
.then(() => main.packageZipLocal('foo.js', localZipPath, {logger, cache}))
.then(() => {
assert.isFalse(log.some(l => /Collecting/.test(l)));
assert.isTrue(log.some(l => /Reusing cached/.test(l)));
});
});
describe('browserify options', function() {
it('should fail when missing necessary ones', function() {
// Absolute imports will (and should) fail without extra browserify options.
return assert.isRejected(main.packageZipLocal('foo_abs.js', localZipPath, {logger}),
/Cannot find.*lib\/bar/);
});
it('should support ignoreMissing option', function() {
return main.packageZipLocal('foo_abs.js', localZipPath, {logger, browserifyArgs: ['--im']})
.then(() => listZipNames(localZipPath))
.then(names => {
assert.deepEqual(names, [
'foo_abs.js',
'node_modules/dep1/hello.js',
'node_modules/dep1/package.json',
]);
});
});
describe('with NODE_PATH', function() {
envContext('NODE_PATH', '.');
it('should respect NODE_PATH', function() {
return main.packageZipLocal('foo_abs.js', localZipPath, {logger})
.then(result => assert.equal(result, localZipPath))
.then(() => listZipNames(localZipPath))
.then(names => {
assert.deepEqual(names, [
'foo_abs.js',
'lib/bar.js',
'node_modules/dep1/hello.js',
'node_modules/dep1/package.json',
'node_modules/dep2/package.json',
'node_modules/dep2/world.js',
]);
assert.match(log[0], /Packaging foo_abs.js/);
});
});
it('should support typescript with tsconfig option', function() {
return main.packageZipLocal('foo_ts.ts', localZipPath, {logger, tsconfig: '.'})
.then(() => listZipNames(localZipPath))
.then(names => {
assert.deepEqual(names, [
'foo_ts.js',
'lib/bar.js',
'lib/baz_ts.js',
'node_modules/dep1/hello.js',
'node_modules/dep1/package.json',
'node_modules/dep2/package.json',
'node_modules/dep2/world.js',
'node_modules/dep3/bye.js',
'node_modules/dep3/package.json',
]);
assert.match(log[0], /Packaging foo_ts.ts/);
})
.then(() => runZipFile(localZipPath, 'foo_ts'))
.then(outputLines => assert.deepEqual(outputLines,
'imported dep1\n' +
'imported dep2\n' +
'imported lib/bar.js\n' +
'imported dep3\n' +
'imported lib/baz_ts.ts true\n' +
'imported foo_ts.ts true\n'
));
});
});
});
});
describe('packageZipS3', function() {
let barZip = null;
chdirContext('test/fixtures');
it('should upload to S3', function() {
const s3EndpointUrl = localstack.getService('s3').endpoint;
return main.packageZipS3('lib/bar.js', {logger, s3EndpointUrl})
.then(s3Loc => {
barZip = s3Loc.key;
assert.match(barZip, /^[0-9a-f]{32}\.zip$/);
assert.deepEqual(s3Loc, {bucket: "aws-lambda-upload", key: barZip});
assert.match(log.find(l => /Bucket/.test(l)), /creating/);
assert.match(log[log.length - 1], /uploaded/);
const s3 = new AWS.S3({endpoint: s3EndpointUrl, s3ForcePathStyle: true});
return s3.getObject({Bucket: s3Loc.bucket, Key: s3Loc.key}).promise();
})
.then(data => {
return tmp.fileAsync({postfix: '.zip'})
.then(tmpFile => fse.writeFile(tmpFile, data.Body)
.then(() => listZipNames(tmpFile)));
})
.then(names => {
assert.deepEqual(names, [
"bar.js",
"lib/bar.js",
'node_modules/dep2/package.json',
'node_modules/dep2/world.js',
]);
});
});
it('should skip upload if such file already exists', function() {
const s3EndpointUrl = localstack.getService('s3').endpoint;
return main.packageZipS3('lib/bar.js', {logger, s3EndpointUrl})
.then(s3Loc => {
assert.deepEqual(s3Loc, {bucket: "aws-lambda-upload", key: barZip});
assert.match(log.find(l => /Bucket/.test(l)), /exists/);
assert.match(log[log.length - 1], /skipping upload/);
});
});
it('should respect s3 parameters', function() {
const s3EndpointUrl = localstack.getService('s3').endpoint;
const params = {logger, s3EndpointUrl, s3Bucket: 'foo', s3Prefix: 'bar/baz/'};
return main.packageZipS3('lib/bar.js', params)
.then(s3Loc => {
assert.deepEqual(s3Loc, {bucket: "foo", key: `bar/baz/${barZip}`});
assert.match(log[log.length - 1], /uploaded/);
});
});
});
describe('updateLambda', function() {
chdirContext('test/fixtures');
it('should update lambdas', function() {
const lambdaEndpointUrl = localstack.getService('lambda').endpoint;
const s3EndpointUrl = localstack.getService('s3').endpoint;
const region = 'us-fake';
const lambda = new AWS.Lambda({region, endpoint: lambdaEndpointUrl});
return main.packageZipS3('lib/lambda.js', {logger, region, s3EndpointUrl})
.then(s3Loc => lambda.createFunction({
FunctionName: 'testMyLambda',
Runtime: 'nodejs6.10',
Handler: 'lambda.myLambda',
Code: {S3Bucket: s3Loc.bucket, S3Key: s3Loc.key},
Role: 'test-role'
}).promise())
.then(() => main.updateLambda('lib/lambda.js', 'testMyLambda',
{logger, region, lambdaEndpointUrl}))
// TODO: We can't actually test it easily because localstack only supports lambdas with
// docker, and that seems to heavy a dependency for this kind of test.
// .then(() => lambda.invoke({FunctionName: 'testMyLambda'}).promise())
.then((data) => {
assert.isTrue(log.some(l => /Updated labmda testMyLambda/));
});
});
});
describe('cloudformationPackage', function() {
it('should upload code and transform cloudformation template', function() {
const s3EndpointUrl = localstack.getService('s3').endpoint;
const region = 'us-fake';
let zipName = null;
return main.cloudformationPackage('test/fixtures/cfn.yml', {logger, region, s3EndpointUrl})
.then(transformed => {
zipName = transformed.Resources.MyFunction2.Properties.Code.S3Key;
assert.match(zipName, /^[0-9a-f]{32}\.zip$/);
assert.deepEqual(transformed, {
"AWSTemplateFormatVersion": "2010-09-09",
"Transform": "AWS::Serverless-2016-10-31",
"Resources": {
"MyFunction": {
"Type": "AWS::Serverless::Function",
"Properties": {
"Handler": "index.handler",
"Runtime": "nodejs6.10",
"CodeUri": `s3://aws-lambda-upload/${zipName}`
}
},
"MyFunction2": {
"Type": "AWS::Lambda::Function",
"Properties": {
"FunctionName": "myTestLambda,",
"Handler": "lambda.myLambda,",
"Runtime": "nodejs6.10,",
"Code": {
"S3Bucket": "aws-lambda-upload",
"S3Key": zipName
}
}
}
}
});
assertSubsetMatchesInOrder(log, [
/Collecting files.*lib\/lambda.js/,
/s3:.* uploaded/,
/Reusing cached.*lib\/lambda.js/,
/s3:.* skipping upload/
]);
});
});
});
});