From 9d2d2fbc7e37cf0161d3fba1d90be3c761b31565 Mon Sep 17 00:00:00 2001 From: Jason Dobry Date: Wed, 4 Nov 2015 14:56:12 -0800 Subject: [PATCH] Add deployment tests. Closes #21. --- .gitignore | 6 +- .travis.yml | 33 +++- package.json | 1 + test/appengine/test.js | 308 +++++++++++++++++++++++++-------- test/encrypted/secrets.tar.enc | Bin 0 -> 13328 bytes 5 files changed, 277 insertions(+), 71 deletions(-) create mode 100644 test/encrypted/secrets.tar.enc diff --git a/.gitignore b/.gitignore index d49592be1d9..39aea992530 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ **/node_modules/** npm-debug.log -coverage/ \ No newline at end of file +coverage/ + +test/encrypted/secrets.tar +test/encrypted/express-demo.json +test/encrypted/hapi-demo.json \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index dce4356f0c0..30b3d57d618 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -sudo: false +sudo: required language: node_js node_js: - "stable" @@ -20,6 +20,7 @@ cache: services: - redis-server + - docker env: - PATH=$PATH:$HOME/gcloud/google-cloud-sdk/bin GOOGLE_APPLICATION_CREDENTIALS=$TRAVIS_BUILD_DIR/nodejs-docs-samples.json TEST_BUCKET_NAME=cloud-samples-tests TEST_PROJECT_ID=cloud-samples-tests #Other environment variables on same line @@ -38,12 +39,25 @@ before_install: - if [ ! -d $HOME/gcloud/google-cloud-sdk ]; then mkdir -p $HOME/gcloud && - wget https://dl.google.com/dl/cloudsdk/release/google-cloud-sdk.tar.gz --directory-prefix=$HOME/gcloud && + wget https://dl.google.com/dl/cloudsdk/channels/rapid/google-cloud-sdk.tar.gz --directory-prefix=$HOME/gcloud && cd $HOME/gcloud && tar xzf google-cloud-sdk.tar.gz && printf '\ny\n\ny\ny\n' | ./google-cloud-sdk/install.sh && + source /home/travis/.bash_profile && cd $TRAVIS_BUILD_DIR; fi +- gcloud components update -q +- gcloud components update app -q +- openssl aes-256-cbc -K $encrypted_4e84c7c7ab67_key -iv $encrypted_4e84c7c7ab67_iv -in test/encrypted/secrets.tar.enc -out test/encrypted/secrets.tar -d +- if [ -a test/encrypted/secrets.tar ]; then + cd test/encrypted && tar xvf secrets.tar && cd ../..; + fi +- if [ -a test/encrypted/express-demo.json ]; then + gcloud auth activate-service-account --key-file test/encrypted/express-demo.json; + fi +- if [ -a test/encrypted/hapi-demo.json ]; then + gcloud auth activate-service-account --key-file test/encrypted/hapi-demo.json; + fi - openssl aes-256-cbc -K $encrypted_95e832a36b06_key -iv $encrypted_95e832a36b06_iv -in nodejs-docs-samples.json.enc -out nodejs-docs-samples.json -d - if [ -a nodejs-docs-samples.json ]; then gcloud auth activate-service-account --key-file nodejs-docs-samples.json; @@ -55,3 +69,18 @@ install: after_success: - npm run coveralls + +before_deploy: + - cd appengine/express +deploy: + - provider: gae + keyfile: test/encrypted/express-demo.json + project: express-demo + config: appengine/express/app.yaml + default: true + version: demo + docker_build: local + on: + repo: GoogleCloudPlatform/nodejs-docs-samples + all_branches: true + node: stable diff --git a/package.json b/package.json index ddf938af216..1ff43530246 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "googleapis": "~2.1.3" }, "devDependencies": { + "async": "^1.5.0", "coveralls": "^2.11.4", "istanbul": "^0.4.0", "jshint": "~2.8.0", diff --git a/test/appengine/test.js b/test/appengine/test.js index 74c3030e18d..9ee4082a14a 100644 --- a/test/appengine/test.js +++ b/test/appengine/test.js @@ -15,6 +15,7 @@ var spawn = require('child_process').spawn; var request = require('request'); +var async = require('async'); var cwd = process.cwd(); @@ -25,6 +26,8 @@ function getPath(dir) { var sampleTests = [ { dir: 'express', + projectId: 'express-demo', + account: 'account-2@express-demo.iam.gserviceaccount.com', cmd: 'node', arg1: './bin/www', msg: 'Hello World! Express.js on Google App Engine.' @@ -43,6 +46,8 @@ var sampleTests = [ }, { dir: 'hapi', + projectId: 'hapi-demo', + 'account': 'account-1@hapi-demo.iam.gserviceaccount.com', cmd: 'node', arg1: 'index.js', msg: 'Hello World! Hapi.js on Google App Engine.' @@ -93,97 +98,264 @@ if (process.env.TRAVIS_NODE_VERSION !== 'stable') { }); } +// Send a request to the given url and test that the response body has the +// expected value +function testRequest(url, sample, cb) { + request(url, function (err, res, body) { + if (err) { + // Request error + return cb(err); + } else { + if (body && body.indexOf(sample.msg) !== -1 && + (res.statusCode === 200 || res.statusCode === sample.code)) { + // Success + return cb(null, true); + } else { + // Short-circuit app test + var message = sample.dir + ': failed verification!\n' + + 'Expected: ' + sample.msg + '\n' + + 'Actual: ' + body; + + // Response body did not match expected + return cb(new Error(message)); + } + } + }); +} + +// Helper for deciding whether to hide certain noisy console output +function shouldHide(data) { + if (data && typeof data === 'function' && (data.indexOf('.../') !== -1 || + data.indexOf('...-') !== -1 || + data.indexOf('...\\') !== -1 || + data.indexOf('...|') !== -1)) { + return true; + } +} + describe('appengine/', function () { sampleTests.forEach(function (sample) { it(sample.dir + ': dependencies should install', function (done) { + // Allow extra time for "npm install" this.timeout(sample.timeout || 120000); - var calledDone = false; - var proc = spawn('npm', ['install'], { - cwd: getPath(sample.dir) - }); + testInstallation(sample, done); + }); - proc.on('error', function (err) { - if (!calledDone) { - calledDone = true; - done(err); - } - }); + it(sample.dir + ': should return 200 and Hello World', function (done) { + testLocalApp(sample, done); + }); + }); - if (!process.env.TRAVIS) { - proc.stderr.on('data', function (data) { - console.log('stderr: ' + data); - }); - } + if (!process.env.TRAVIS || process.env.TRAVIS_NODE_VERSION !== 'stable') { + return; + } - proc.on('exit', function (code) { - if (!calledDone) { - calledDone = true; - if (code !== 0) { - done(new Error(sample.dir + ': failed to install dependencies!')); - } else { - done(); - } - } - }); + it.skip('should deploy all samples', function (done) { + // 10 minutes because deployments are slow + this.timeout(10 * 60 * 1000); + + testDeployments(done); + }); +}); + +function testInstallation(sample, done) { + + // Keep track off whether "done" has been called yet + var calledDone = false; + + var proc = spawn('npm', ['install'], { + cwd: getPath(sample.dir) + }); + + proc.on('error', finish); + + if (!process.env.TRAVIS) { + proc.stderr.on('data', function (data) { + console.log('stderr: ' + data); }); + } - it(sample.dir + ': should return 200 and Hello World', function (done) { - var timeoutId; - var intervalId; - var success = false; + proc.on('exit', function (code) { + if (code !== 0) { + finish(new Error(sample.dir + ': failed to install dependencies!')); + } else { + finish(); + } + }); + + // Exit helper so we don't call "cb" more than once + function finish(err) { + if (!calledDone) { + calledDone = true; + done(err); + } + } +} + +function testLocalApp(sample, done) { + var calledDone = false; + + var proc = spawn(sample.cmd, [sample.arg1], { + cwd: getPath(sample.dir) + }); + + proc.on('error', finish); + + if (!process.env.TRAVIS) { + proc.stderr.on('data', function (data) { + console.log('stderr: ' + data); + }); + } + + proc.on('exit', function (code, signal) { + if (code !== 0 && signal !== 'SIGKILL') { + return finish(new Error(sample.dir + ': failed to run!')); + } else { + return finish(); + } + }); + + // Give the server time to start up + setTimeout(function () { + // Test that the app is working + testRequest('http://localhost:8080', sample, function (err) { + proc.kill('SIGKILL'); + return finish(err); + }); + }, 5000); + + // Exit helper so we don't call "cb" more than once + function finish(err) { + if (!calledDone) { + calledDone = true; + done(err); + } + } +} + +function testDeployments(done) { + + // Only deploy samples that have a projectId + var samplesToDeploy = sampleTests.filter(function (sample) { + return sample.projectId; + }); + + // Create deployment tasks + var tasks = samplesToDeploy.map(function (sample) { + return function (cb) { + // Keep track off whether "cb" has been called yet var calledDone = false; - var proc = spawn(sample.cmd, [sample.arg1], { - cwd: getPath(sample.dir) + var _cwd = getPath(sample.dir); + var args = [ + 'preview', + 'app', + 'deploy', + 'app.yaml', + // Skip prompt + '-q', + '--project', + sample.projectId, + '--promote', + // Deploy over existing version so we don't have to clean up + '--version', + 'demo', + // Use the service account for the sample's gcloud project + '--account', + sample.account, + // Override any existing deployment + '--force', + // Build locally, much faster + '--docker-build', + 'local' + ]; + + console.log(_cwd + ' $ gcloud ' + args.join(' ')); + + // Don't use "npm run deploy" because we need extra flags + var proc = spawn('gcloud', args, { + cwd: _cwd }); - proc.on('error', function (err) { + // Exit helper so we don't call "cb" more than once + function finish(err, result) { if (!calledDone) { calledDone = true; - done(err); + cb(err, result); } - }); - - if (!process.env.TRAVIS) { - proc.stderr.on('data', function (data) { - console.log('stderr: ' + data); - }); } - proc.on('exit', function (code, signal) { - if (!calledDone) { - calledDone = true; - if (code !== 0 && signal !== 'SIGKILL') { - done(new Error(sample.dir + ': failed to run!')); - } else { - if (!success) { - done(new Error(sample.dir + ': failed verification!')); - } else { - done(); - } - } + // Print stderr of process + proc.stderr.on('data', function (data) { + if (!shouldHide(data)) { + console.log(sample.projectId + ' stderr: ' + data); } }); - timeoutId = setTimeout(end, 5000); - intervalId = setInterval(testRequest, 1000); + // Print stdout of process + proc.stdout.on('data', function (data) { + if (!shouldHide(data)) { + console.log(sample.projectId + ' stdout: ' + data); + } + }); - function end() { - clearTimeout(timeoutId); - clearInterval(intervalId); - proc.kill('SIGKILL'); - } + // This is called if the process fails to start. "error" event may or may + // not be fired in addition to the "exit" event. + proc.on('error', finish); - function testRequest() { - request('http://localhost:8080', function (err, res, body) { - if (body && body.indexOf(sample.msg) !== -1 && - (res.statusCode === 200 || res.statusCode === sample.code)) { - success = true; - end(); - } - }); + // Process has completed + proc.on('exit', function (code) { + if (code !== 0) { // Deployment failed + // Pass error as second argument so we don't short-circuit the + // parallel tasks + return finish(null, new Error(sample.dir + ': failed to deploy!')); + } else { // Deployment succeeded + // Test that sample app is running successfully + return async.waterfall([ + function (cb) { + // Give apps time to start + setTimeout(cb, 5000); + }, + function (cb) { + // Test "default" module + var url = 'http://' + sample.projectId + '.appspot.com'; + testRequest(url, sample, cb); + }, + function (result, cb) { + // Test versioned url of "default" module + var demoUrl = 'http://demo.' + sample.projectId + '.appspot.com'; + testRequest(demoUrl, sample, cb); + } + ], finish); + } + }); + }; + }); + + // Deploy sample apps in parallel + return async.parallel(tasks, function (err, results) { + if (err) { + return done(err); + } else { + var success = true; + var message = ''; + // Find errors that didn't short-circuit the parallel tasks + results.forEach(function (result) { + if (result instanceof Error) { + // Gather error messages + message = message + result.message + '\n'; + success = false; + } else { + // "result" should be "true" for those apps that passed verification + success = success && result; + } + }); + if (success) { + return done(); + } else { + return done(new Error(message)); } - }); + } }); -}); +} diff --git a/test/encrypted/secrets.tar.enc b/test/encrypted/secrets.tar.enc new file mode 100644 index 0000000000000000000000000000000000000000..3a73d01e7b02f0f60be0ead6d5cd1c26d4840880 GIT binary patch literal 13328 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 aa_3^eJc=>&CR&VSj}u)UJsnFY^+iTfO5^kZ literal 0 HcmV?d00001