diff --git a/.travis.yml b/.travis.yml index 65ad290f72d1..d1111ae82f75 100644 --- a/.travis.yml +++ b/.travis.yml @@ -80,6 +80,16 @@ matrix: - npm run build -- --scope "@loopback/test-repository-postgresql" --include-dependencies - psql -U postgres -a -f acceptance/repository-postgresql/test/schema.sql - cd acceptance/repository-postgresql && npm run mocha + - node_js: "10" + os: linux + env: + - TASK=test-repository-cloudant + script: + # set up docker image and create admin for cloudant. + - npm run docker:setup + - npm run postinstall -- --scope "@loopback/test-repository-cloudant" --include-dependencies + - npm run build -- --scope "@loopback/test-repository-cloudant" --include-dependencies + - source cloudant-config.sh && cd acceptance/repository-cloudant && npm run mocha branches: only: diff --git a/acceptance/repository-cloudant/package-lock.json b/acceptance/repository-cloudant/package-lock.json index 938e72668dd9..34a3162c8672 100644 --- a/acceptance/repository-cloudant/package-lock.json +++ b/acceptance/repository-cloudant/package-lock.json @@ -5,9 +5,9 @@ "requires": true, "dependencies": { "@types/node": { - "version": "10.14.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.22.tgz", - "integrity": "sha512-9taxKC944BqoTVjE+UT3pQH0nHZlTvITwfsOZqyc+R3sfJuxaTtxWjfn1K2UlxyPcKHf0rnaXcVFrS9F9vf0bw==", + "version": "10.17.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.5.tgz", + "integrity": "sha512-RElZIr/7JreF1eY6oD5RF3kpmdcreuQPjg5ri4oQ5g9sq7YWU8HkfB3eH8GwAwxf5OaCh0VPi7r4N/yoTGelrA==", "dev": true }, "accept-language": { diff --git a/acceptance/repository-cloudant/package.json b/acceptance/repository-cloudant/package.json index b5fbc7a8291b..60356e6c09d6 100644 --- a/acceptance/repository-cloudant/package.json +++ b/acceptance/repository-cloudant/package.json @@ -21,8 +21,8 @@ "@loopback/build": "^1.7.1", "@loopback/eslint-config": "^4.1.2", "@loopback/repository": "^1.15.2", - "@loopback/repository-tests": "^0.5.2", - "@types/node": "^10.14.22", + "@loopback/repository-tests": "^0.6.1", + "@types/node": "^10.17.5", "loopback-connector-cloudant": "2.3.2" }, "files": [ diff --git a/docker.setup.js b/docker.setup.js new file mode 100644 index 000000000000..c4552b671bc4 --- /dev/null +++ b/docker.setup.js @@ -0,0 +1,310 @@ +// Copyright IBM Corp. 2017,2019. All Rights Reserved. +// Node module: loopback-connector-cloudant +// This file is licensed under the Artistic License 2.0. +// License text available at https://opensource.org/licenses/Artistic-2.0 + +'use strict'; + +const _ = require('lodash'); +const async = require('async'); +const spawn = require('child_process').spawn; +const docker = new require('dockerode')(); +const fmt = require('util').format; +const http = require('http'); +const ms = require('ms'); +const fs = require('fs-extra'); + +// // we don't pass any node flags, so we can call _mocha instead the wrapper +// const mochaBin = require.resolve('mocha/bin/_mocha'); + +process.env.CLOUDANT_DATABASE = 'testdb'; +process.env.CLOUDANT_PASSWORD = 'pass'; +process.env.CLOUDANT_USERNAME = 'admin'; + +// these are placeholders. They get set dynamically based on what IP and port +// get assigned by docker. +process.env.CLOUDANT_PORT = 'TBD'; +process.env.CLOUDANT_HOST = 'TBD'; +process.env.CLOUDANT_URL = 'TBD'; + +const CONNECT_RETRIES = 30; +const CONNECT_DELAY = ms('5s'); + +let containerToDelete = null; + +async.waterfall( + [ + dockerStart('ibmcom/couchdb3:latest'), + sleep(ms('2s')), + setCloudantEnv, + waitFor('/_all_dbs'), + createAdmin(), + createDB('testdb'), + listUser(), + exportENV(), + ], + function(testErr) { + if (testErr) { + console.error('error running tests:', testErr); + process.exit(1); + } + }, +); + +function sleep(n) { + return function delayedPassThrough() { + const args = [].slice.call(arguments); + // last argument is the callback + const next = args.pop(); + // prepend `null` to indicate no error + args.unshift(null); + setTimeout(function() { + next.apply(null, args); + }, n); + }; +} + +function dockerStart(imgName) { + return function pullAndStart(next) { + console.log('pulling image: %s', imgName); + docker.pull(imgName, function(err, stream) { + docker.modem.followProgress(stream, function(err, output) { + if (err) { + return next(err); + } + console.log('starting container from image: %s', imgName); + docker.createContainer( + { + Image: imgName, + HostConfig: { + PublishAllPorts: true, + }, + }, + function(err, container) { + console.log( + 'recording container for later cleanup: ', + container.id, + ); + containerToDelete = container; + if (err) { + return next(err); + } + container.start(function(err, data) { + next(err, container); + }); + }, + ); + }); + }); + }; +} + +function setCloudantEnv(container, next) { + container.inspect(function(err, c) { + // if swarm, Node.Ip will be set to actual node's IP + // if not swarm, but remote docker, use docker host's IP + // if local docker, use localhost + const host = _.get(c, 'Node.IP', _.get(docker, 'modem.host', '127.0.0.1')); + // couchdb uses TCP/IP port 5984 + // container's port 5984 is dynamically mapped to an external port + const port = _.get(c, [ + 'NetworkSettings', + 'Ports', + '5984/tcp', + '0', + 'HostPort', + ]); + process.env.CLOUDANT_PORT = port; + process.env.CLOUDANT_HOST = host; + const usr = process.env.CLOUDANT_USERNAME; + const pass = process.env.CLOUDANT_PASSWORD; + process.env.CLOUDANT_URL = + 'http://' + usr + ':' + pass + '@' + host + ':' + port; + console.log( + 'env:', + _.pick(process.env, [ + 'CLOUDANT_URL', + 'CLOUDANT_HOST', + 'CLOUDANT_PORT', + 'CLOUDANT_USERNAME', + 'CLOUDANT_PASSWORD', + 'CLOUDANT_DATABASE', + ]), + ); + next(null, container); + }); +} + +function waitFor(path) { + return function waitForPath(container, next) { + const opts = { + host: process.env.CLOUDANT_HOST, + port: process.env.CLOUDANT_PORT, + path: path, + }; + + console.log(`waiting for instance to respond: ${opts}`); + return ping(null, CONNECT_RETRIES); + + function ping(err, tries) { + console.log('ping (%d/%d)', CONNECT_RETRIES - tries, CONNECT_RETRIES); + if (tries < 1) { + next(err || new Error('failed to contact Cloudant')); + } + http + .get(opts, function(res) { + res.pipe(devNull()); + res.on('error', tryAgain); + res.on('end', function() { + if (res.statusCode === 200) { + setImmediate(next, null, container); + } else { + tryAgain(); + } + }); + }) + .on('error', tryAgain); + function tryAgain(err) { + setTimeout(ping, CONNECT_DELAY, err, tries - 1); + } + } + }; +} + +function createAdmin() { + return function createAdminUser(container, next) { + const data = '"pass"'; + const uri = + '/_node/couchdb@127.0.0.1/_config/admins/' + + process.env.CLOUDANT_USERNAME; + const opts = { + method: 'PUT', + path: uri, + header: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + host: process.env.CLOUDANT_HOST, + port: process.env.CLOUDANT_PORT, + body: data, + }; + + const req = http.request(opts, function(res) { + res.pipe(devNull()); + res.on('error', next); + res.on('end', function() { + setImmediate(next, null, container); + }); + }); + req.write(data); + req.end(); + }; +} + +function createDB(db) { + return function create(container, next) { + const opts = { + method: 'PUT', + path: '/' + db, + host: process.env.CLOUDANT_HOST, + port: process.env.CLOUDANT_PORT, + auth: process.env.CLOUDANT_USERNAME + ':' + process.env.CLOUDANT_PASSWORD, + }; + console.log('creating db: %j', db); + http + .request(opts, function(res) { + res.pipe(devNull()); + res.on('error', next); + res.on('end', function() { + setImmediate(next, null, container); + }); + }) + .on('error', next) + .end(); + }; +} + +function listUser() { + return function printUsers(container, next) { + const path = '/_node/couchdb@127.0.0.1/_config/admins'; + const opts = { + method: 'GET', + path: path, + host: process.env.CLOUDANT_HOST, + port: process.env.CLOUDANT_PORT, + auth: process.env.CLOUDANT_USERNAME + ':' + process.env.CLOUDANT_PASSWORD, + }; + // console.log('creating db: %j', db); + http + .request(opts, function(res) { + res.pipe(devNull()); + res.on('error', next); + + res.setEncoding('utf8'); + let rawData = ''; + res.on('data', chunk => { + rawData += chunk; + }); + + res.on('end', function() { + try { + const parsedData = JSON.parse(rawData); + console.log(parsedData); + } catch (e) { + console.error(e.message); + } + setImmediate(next, null, container); + }); + }) + .on('error', next) + .end(); + }; +} + +function exportENV() { + return function createENVFile(container, next) { + const content = + "export CLOUDANT_URL='" + + process.env.CLOUDANT_URL + + "'\n" + + "export CLOUDANT_DATABASE='" + + process.env.CLOUDANT_DATABASE + + "'\n" + + "export CLOUDANT_USER='" + + process.env.CLOUDANT_USERNAME + + "'\n" + + "export CLOUDANT_PASSWORD='" + + process.env.CLOUDANT_PASSWORD + + "'\n" + + 'export CLOUDANT_PORT=' + + process.env.CLOUDANT_PORT + + '\n' + + "export CLOUDANT_HOST='" + + process.env.CLOUDANT_HOST + + "'"; + console.log('current directory: ' + __dirname); + fs.writeFileSync('cloudant-config.sh', content, 'utf8'); + next(); + }; +} + +// clean up any previous containers +function dockerCleanup(next) { + if (containerToDelete) { + console.log('cleaning up container: %s', containerToDelete.id); + containerToDelete.remove({force: true}, function(err) { + next(err); + }); + } else { + setImmediate(next); + } +} + +// A Writable Stream that just consumes a stream. Useful for draining readable +// streams so that they 'end' properly, like sometimes-empty http responses. +function devNull() { + return new require('stream').Writable({ + write: function(_chunk, _encoding, cb) { + return cb(null); + }, + }); +}