From 320105fe93c74362ec56a3968f607c56bdc1d61f Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Tue, 8 Sep 2015 12:36:07 +0200 Subject: [PATCH 1/2] add new command couch-config get/set Get - will get all the configs settings for all nodes in a cluster and display them for easy viewing Set - will set the config for all nodes in a cluster --- doc/api/nmo-couch-config.md | 21 +++ doc/cli/nmo-couch-config.md | 26 ++++ package.json | 1 + src/couch-config.js | 159 +++++++++++++++++++++ src/nmo.js | 3 +- test/couch-config.js | 274 ++++++++++++++++++++++++++++++++++++ 6 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 doc/api/nmo-couch-config.md create mode 100644 doc/cli/nmo-couch-config.md create mode 100644 src/couch-config.js create mode 100644 test/couch-config.js diff --git a/doc/api/nmo-couch-config.md b/doc/api/nmo-couch-config.md new file mode 100644 index 0000000..45be4e2 --- /dev/null +++ b/doc/api/nmo-couch-config.md @@ -0,0 +1,21 @@ +nmo-config(3) -- configuration +============================== + +## SYNOPSIS + + nmo.commands.couch-config.set(cluster, nodes, section, key, value) + nmo.commands.couch-config.get(cluster, nodes, [section]) + + + +## DESCRIPTION + +Manage the nmo configuration. + + - set: + +Sets the value for a key of a CouchDB config section for each node in a cluster. + + - get: + +Gets the config for each node in a cluster and displays it for easy viewing diff --git a/doc/cli/nmo-couch-config.md b/doc/cli/nmo-couch-config.md new file mode 100644 index 0000000..a075084 --- /dev/null +++ b/doc/cli/nmo-couch-config.md @@ -0,0 +1,26 @@ +nmo-couch-config(1) -- Set/Get couch configuration for a cluster +================================= + +## SYNOPSIS + + nmo couch-config get [][--json] + nmo couch-config set
+ +## DESCRIPTION + +- get: + +Gets the set configuration for the whole cluster or a specified node. +If a key is specified it will only get the configuration for that section + +- set: + +Set the value for a given key of a section of the config. This will update the config for all nodes in a cluster. +The cluster must be specified in the .nmorc file. + +## EXAMPLE + + nmo couch-config get mycluster + nmo couch-config get mycluster couch_httpd_auth + + nmo couch-config set mycluster uuids max_count 2000 diff --git a/package.json b/package.json index ce4dad3..079e5f3 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "nopt": "~3.0.1", "npmlog": "~1.2.0", "osenv": "~0.1.0", + "prettyjson": "^1.1.3", "valid-url": "~1.0.9", "wreck": "~5.6.0", "xtend": "~4.0.0" diff --git a/src/couch-config.js b/src/couch-config.js new file mode 100644 index 0000000..4bc2733 --- /dev/null +++ b/src/couch-config.js @@ -0,0 +1,159 @@ +import * as utils from './utils.js'; +import log from 'npmlog'; +import Wreck from 'wreck'; +import Promise from 'bluebird'; +import prettyjson from 'prettyjson'; +import nmo from './nmo.js'; +import isonline, { getClusterUrls } from './isonline.js'; + +export function cli (cmd, cluster, section, key, value) { + return new Promise((resolve, reject) => { + + if (!cmd || !cluster || !exports[cmd]) { + const err = new Error('Usage: nmo couch-config add/set ...'); + err.type = 'EUSAGE'; + return reject(err); + } + + exports[cmd].apply(exports[cmd], [cluster, getClusterNodes(cluster), section, key, value]) + .then(resolve) + .catch(reject); + }); +} + +export function getClusterNodes (clusterName) { + const nodes = nmo.config.get(clusterName); + if (!nodes) { + const err = new Error('Cluster does not exist'); + err.type = 'EUSAGE'; + throw err; + } + + return nodes; +} + +export function get (cluster, nodes, section) { + const promise = Promise.reduce(Object.keys(nodes), (obj, node) => { + const url = buildConfigUrl(node, nodes[node], section); + return getConfig(node, url).then(({node, config}) => { + obj[node] = config; + return obj; + }); + }, {}); + + promise.then((nodeConfigs) => { + Object.keys(nodeConfigs).forEach(node => { + console.log('NODE:', node); + console.log(prettyjson.render(nodeConfigs[node], {})); + }); + }); + + return promise; +} + +export function set(cluster, nodes, section, key, value) { + const urls = getClusterUrls(cluster); + return isonline.apply(isonline, urls).then(results => { + const offline = Object.keys(results).filter(node => { + if (!results[node]) { + return true; + } + + return false + }); + + if (offline.length > 0) { + const msg = offline.map(node => 'Node ' + offline + ' is offline.').join(''); + const err = new Error(msg); + err.type = 'EUSAGE'; + throw err; + } + + const promises = Object.keys(nodes).map(node => { + return setConfig(node, buildConfigUrl(node, nodes[node], section, key), value); + }); + + const allPromise = Promise.all(promises); + allPromise + .then((resp) => { + console.log(prettyjson.render(resp)); + }) + .catch((err) => { + throw err; + }); + + return allPromise; + }); +} + +export function setConfig (node, url, value) { + return new Promise((resolve, reject) => { + let er = utils.checkUrl(url); + + if (!er && !/^(http:|https:)/.test(url)) { + er = new Error('invalid protocol, must be https or http'); + } + + if (er) { + er.type = 'EUSAGE'; + return reject(er); + } + const cleanedUrl = utils.removeUsernamePw(url); + log.http('request', 'PUT', cleanedUrl); + + Wreck.put(url, {payload: JSON.stringify(value)}, (err, res, payload) => { + if (err) { + const error = new Error('Error on set config for node ' + node + ' ' + err); + error.type = 'EUSAGE'; + return reject(error); + } + + log.http(res.statusCode, cleanedUrl); + resolve({ + node: node, + oldvalue: JSON.parse(payload), + newvalue: value + }); + }); + }); +} + +export function buildConfigUrl (node, url, section, key) { + let configUrl = url + '/_node/' + node + '/_config'; + + if (section) { + configUrl += '/' + section; + } + + if (key) { + configUrl += '/' + key; + } + + return configUrl; +} + +export function getConfig (node, url) { + return new Promise((resolve, reject) => { + let er = utils.checkUrl(url); + + if (!er && !/^(http:|https:)/.test(url)) { + er = new Error('invalid protocol, must be https or http'); + } + + if (er) { + er.type = 'EUSAGE'; + return reject(er); + } + const cleanedUrl = utils.removeUsernamePw(url); + log.http('request', 'GET', cleanedUrl); + + Wreck.get(url, (err, res, payload) => { + if (err) { + return reject(err); + } + + log.http(res.statusCode, cleanedUrl); + resolve({node: node, config: JSON.parse(payload)}); + }); + }); +} diff --git a/src/nmo.js b/src/nmo.js index c60c038..60309e6 100644 --- a/src/nmo.js +++ b/src/nmo.js @@ -8,7 +8,8 @@ const commands = [ 'config', 'cluster', 'v', - 'import-csv' + 'import-csv', + 'couch-config' ]; const nmo = { diff --git a/test/couch-config.js b/test/couch-config.js new file mode 100644 index 0000000..a533eb7 --- /dev/null +++ b/test/couch-config.js @@ -0,0 +1,274 @@ +import assert from 'assert'; + +import Lab from 'lab'; +export const lab = Lab.script(); +import nock from 'nock'; +import nmo from '../src/nmo.js'; +import {cli, setConfig, getClusterNodes, buildConfigUrl, getConfig, get, set} from '../src/couch-config.js'; + +lab.experiment('couch-config', () => { + lab.beforeEach((done) => { + nmo + .load({nmoconf: __dirname + '/fixtures/randomini'}) + .then(() => done()) + .catch(() => done()); + }); + + lab.experiment('cli', () => { + + lab.test('no arguments', (done) => { + + cli().catch(err => { + assert.ok(/Usage/.test(err.message)); + done(); + }); + + }); + + lab.test('non-existing command', (done) => { + + cli('wrong', 'command').catch(err => { + assert.ok(/Usage/.test(err.message)); + done(); + }); + + }); + + lab.test('error on missing cluster', (done) => { + + cli('get').catch(err => { + assert.ok(/Usage/.test(err.message)); + done(); + }); + + }); + + lab.test('error on non-existing cluster', (done) => { + + cli('get', 'not-exist').catch(err => { + assert.ok(/Cluster/.test(err.message)); + done(); + }); + + }); + + }); + + lab.experiment('api', () => { + + lab.test('getClusterNodes returns existing nodes', (done) => { + const nodes = getClusterNodes('clusterone'); + + assert.deepEqual(nodes, { + node0: 'http://127.0.0.1', + node1: 'http://192.168.0.1' + }); + + done(); + }); + + lab.test('buildConfigUrl builds correctly with node and url', (done) => { + const url = buildConfigUrl('node', 'http://127.0.0.1'); + assert.deepEqual(url, 'http://127.0.0.1/_node/node/_config'); + done(); + }); + + lab.test('buildConfigUrl builds correctly with node, url and section', (done) => { + const url = buildConfigUrl('node', 'http://127.0.0.1', 'a-section'); + assert.deepEqual(url, 'http://127.0.0.1/_node/node/_config/a-section'); + done(); + }); + + lab.test('getConfig throws error on bad url', (done) => { + getConfig('node1', 'bad-url') + .catch(err => { + assert.ok(/not a valid url/.test(err.message)); + done(); + }); + }); + + lab.test('getConfig throws error on invalid protocol', (done) => { + getConfig('node1', 'ftp://bad-url') + .catch(err => { + assert.ok(/invalid protocol/.test(err.message)); + done(); + }); + }); + + lab.test('gets config bad url returns false', (done) => { + getConfig('node1', 'http://127.0.0.2/') + .catch(err => { + assert.deepEqual(err.code, 'ECONNREFUSED'); + done(); + }); + }); + + lab.test('gets config for node', (done) => { + const resp = { + config1: 'hello', + config2: 'boom' + }; + + nock('http://127.0.0.1') + .get('/_node/node1/_config/uuid') + .reply(200, resp); + + getConfig('node1', 'http://127.0.0.1/_node/node1/_config/uuid') + .then(config => { + assert.deepEqual(config, { + node: 'node1', + config: resp + }); + done(); + }); + }); + + }); + + lab.experiment('get cmd', () => { + let oldConsole = console.log; + + lab.afterEach(done => { + console.log = oldConsole; + done(); + }); + + lab.test('get returns config', (done) => { + const nodes = { + node1: 'http://127.0.0.1' + }; + + const resp = { + config1: 'hello', + config2: 'boom' + }; + + nock('http://127.0.0.1') + .get('/_node/node1/_config/uuid') + .reply(200, resp); + + get('cluster', nodes, 'uuid') + .then(config => { + assert.deepEqual(config, { + node1: { + config1: 'hello', + config2: 'boom' + } + }); + done(); + }); + }); + + lab.test('get prints config', (done) => { + const nodes = { + node1: 'http://127.0.0.1' + }; + + const resp = { + config1: 'hello', + config2: 'boom' + }; + + nock('http://127.0.0.1') + .get('/_node/node1/_config/uuid') + .reply(200, resp); + + console.log = (msg) => { + if (/NODE:/.test(msg)) { + return; + } + + assert.ok(/config1/.test(msg)); + assert.ok(/config2/.test(msg)); + + done(); + }; + + get('cluster', nodes, 'uuid'); + }); + }); + + lab.experiment('set cmd', () => { + + lab.test('returns error if all nodes are not online', done => { + nock('http://127.0.0.1') + .get('/') + .reply(500); + + nock('http://192.168.0.1') + .get('/') + .reply(500); + + set('clusterone', 'nodes', 'section', 'key', 'value') + .catch(err => { + console.log('ERR', err); + assert.ok(/is offline/.test(err.message)); + done(); + }); + + }); + + lab.test('sets config on all nodes for cluster', done => { + //isonline + nock('http://127.0.0.1') + .get('/') + .reply(200); + + nock('http://192.168.0.1') + .get('/') + .reply(200); + + //config update + nock('http://127.0.0.1') + .put('/_node/node0/_config/section/key', JSON.stringify('value')) + .reply(200, JSON.stringify("oldvalue")); + + nock('http://192.168.0.1') + .put('/_node/node1/_config/section/key', JSON.stringify('value')) + .reply(200, JSON.stringify("oldvalue")); + + set('clusterone', getClusterNodes('clusterone'), 'section', 'key', 'value') + .then(resp => { + assert.deepEqual(resp, [ + { node: 'node0', oldvalue: 'oldvalue', newvalue: 'value' }, + { node: 'node1', oldvalue: 'oldvalue', newvalue: 'value' } ]); + done(); + }); + + }); + + lab.test('sets config throws error', done => { + //isonline + nock('http://127.0.0.1') + .get('/') + .reply(200); + + nock('http://192.168.0.1') + .get('/') + .reply(200); + + //config update + nock('http://127.0.0.1') + .put('/_node/node0/_config/section/key', JSON.stringify('value')) + .reply(200, JSON.stringify("oldvalue")); + + set('clusterone', getClusterNodes('clusterone'), 'section', 'key', 'value') + .catch(err => { + assert.ok(/Error on set config for node/.test(err.message)); + done(); + }); + + }); + + lab.test('setsConfig warns on incorrect url', done => { + + setConfig('node1', 'ftp://127.0.0.1', 'section', 'key', 'value') + .catch(err => { + assert.ok(/invalid protocol/.test(err.message)); + done(); + }); + + }); + + }); +}); From 5897c7613327ba868efb67d097884b7bc7cf06f9 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Thu, 10 Sep 2015 10:18:55 +0200 Subject: [PATCH 2/2] Refactor common url check code --- src/couch-config.js | 12 ++---------- src/isonline.js | 9 ++------- src/utils.js | 10 ++++++++++ test/utils.js | 17 +++++++++++++++++ 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/couch-config.js b/src/couch-config.js index 4bc2733..b96163e 100644 --- a/src/couch-config.js +++ b/src/couch-config.js @@ -88,11 +88,7 @@ export function set(cluster, nodes, section, key, value) { export function setConfig (node, url, value) { return new Promise((resolve, reject) => { - let er = utils.checkUrl(url); - - if (!er && !/^(http:|https:)/.test(url)) { - er = new Error('invalid protocol, must be https or http'); - } + let er = utils.validUrl(url); if (er) { er.type = 'EUSAGE'; @@ -134,11 +130,7 @@ export function buildConfigUrl (node, url, section, key) { export function getConfig (node, url) { return new Promise((resolve, reject) => { - let er = utils.checkUrl(url); - - if (!er && !/^(http:|https:)/.test(url)) { - er = new Error('invalid protocol, must be https or http'); - } + let er = utils.validUrl(url); if (er) { er.type = 'EUSAGE'; diff --git a/src/isonline.js b/src/isonline.js index a3a88aa..5fbce1d 100644 --- a/src/isonline.js +++ b/src/isonline.js @@ -73,11 +73,7 @@ function isonline (...args) { function isNodeOnline (url) { return new Promise((resolve, reject) => { - let er = utils.checkUrl(url); - - if (!er && !/^(http:|https:)/.test(url)) { - er = new Error('invalid protocol, must be https or http'); - } + const er = utils.validUrl(url); if (er) { er.type = 'EUSAGE'; @@ -87,8 +83,7 @@ function isNodeOnline (url) { log.http('request', 'GET', cleanedUrl); Wreck.get(url, (err, res, payload) => { - if (err && (err.code === 'ECONNREFUSED' - || err.code === 'ENOTFOUND')) { + if (err && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND')) { return resolve({[url]: false}); } diff --git a/src/utils.js b/src/utils.js index 1c02195..885dedb 100644 --- a/src/utils.js +++ b/src/utils.js @@ -8,6 +8,16 @@ import Promise from 'bluebird'; import log from 'npmlog'; import url from 'url'; +export function validUrl (url) { + let er = checkUrl(url); + + if (!er && !/^(http:|https:)/.test(url)) { + er = new Error('invalid protocol, must be https or http'); + } + + return er; +} + export function isUri (url) { return !!checkUri(url); } diff --git a/test/utils.js b/test/utils.js index 41a31ea..8770e64 100644 --- a/test/utils.js +++ b/test/utils.js @@ -7,6 +7,23 @@ export const lab = Lab.script(); import * as utils from '../src/utils.js'; import * as common from './common.js'; +lab.experiment('utils: validUrl', () => { + + lab.test('checks protocol of url', done => { + const err = utils.validUrl('ftp://wrong.com'); + + assert.ok(/invalid protocol/.test(err.message)); + done(); + }); + + lab.test('returns null if url is valid', done => { + const err = utils.validUrl('http://good.com'); + + assert.ok(err === null); + done(); + }); + +}); lab.experiment('utils: uri', () => {