diff --git a/.gitignore b/.gitignore index d5e79b7..a43c224 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules config/* -!config/*.example.js \ No newline at end of file +!config/*.example.js +!config/setindent.js \ No newline at end of file diff --git a/README.md b/README.md index 2248ad5..93a867c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # ConsulWatcher -This is a much simplified version of HashiCorp's Consul-Template, because what it accomplishes is awesome but I really dislike the Go template syntax. All configuration in this version are plain JavaScript. +This is a much simplified version of HashiCorp's Consul-Template, because what it accomplishes is awesome but I really dislike the Go template syntax. All configuration in this version is plain JavaScript. It's main purpose is creating output files based on a Consul catalog, much like Consul-Template, but since the update handlers are just JavaScript functions you are free to do whatever you want, like calling a webservice or using a template library. \ No newline at end of file diff --git a/config/index.example.js b/config/index.example.js index cdcf929..e1f9bb0 100644 --- a/config/index.example.js +++ b/config/index.example.js @@ -1,48 +1,49 @@ +const winston = require('winston'); +const setIndent = require('./setindent'); + + const config = { + /* + + Consul agent configuration + Determines the agent or server which will be queried and + monitored for the service catalog. + + Recommended to be a local agent connected to the cluster. + + Passed to the initialization of the Node Consul client. + For all options, see: https://github.com/silas/node-consul#init + + */ + consul: { + host: 'pc-mvrenswoude' + }, + + + /* + + Logging + See: https://github.com/winstonjs/winston#transports + + */ + logging: { + transports: [ + new winston.transports.Console({ + level: 'debug', + timestamp: true, + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + }) + ] + }, + onUpdate: [], afterUpdate: null }; -/* - - Consul agent configuration - Determines the agent or server which will be queried and - monitored for the service catalog. - - Recommended to be a local agent connected to the cluster. - - Passed to the initialization of the Node Consul client. - For all options, see: https://github.com/silas/node-consul#init - -*/ -config.consul = { - host: 'localhost' -} - - - -/* - - Logging - See: https://github.com/winstonjs/winston#transports - -*/ -const winston = require('winston'); - -config.logging = { - transports: [ - new winston.transports.Console({ - level: 'debug', - timestamp: true, - format: winston.format.combine( - winston.format.colorize(), - winston.format.simple() - ) - }) - ] -}; - /* @@ -65,26 +66,41 @@ config.logging = { const fs = require('fs').promises; -config.onUpdate.push((catalog, logger) => +config.onUpdate.push(async (catalog, logger) => { // Use catalog parameter to generate output let output = ''; for (const service of catalog.services) { - output += -`Service: ${service.name} - Tags: ${JSON.stringify(service.tags)} - Address: ${await service.getAddress()} - Port: ${await service.getPort()} + let instances = ''; -`; + for (const instance of await service.getInstances()) + { + instances += setIndent(2, ` + Address: ${instance.address} + Port: ${instance.port} + + `); + } + + output += setIndent(` + Service: ${service.name} + Tags: ${JSON.stringify(service.tags)} + + `); + + output += instances + '\n'; }; await fs.writeFile('example-output.txt', output); }); +// Example on how to split the update handlers into a separate file +config.onUpdate.push(require('./included.example')); + + /* afterUpdate handler This is a single handler which is called after all the onUpdate handlers diff --git a/config/setindent.js b/config/setindent.js new file mode 100644 index 0000000..5543044 --- /dev/null +++ b/config/setindent.js @@ -0,0 +1,49 @@ +// Inspired by dedent: https://github.com/dmnd/dedent +function setIndent(indentOrValue, value = null) +{ + let indent; + + if (typeof indentOrValue === 'number') + indent = indentOrValue; + else + { + indent = 0; + value = indentOrValue; + } + + + + const lines = value.split('\n'); + let minIndent = null; + + + // Determine minimum indent present in the value + for (const line of lines) + { + const whitespace = line.match(/^(\s+)/); + if (whitespace !== null) + minIndent = minIndent === null ? whitespace[1].length : Math.min(whitespace[1].length, minIndent); + } + + + // Calculate the difference to the requested indentation + const indentDelta = minIndent === null ? indent : minIndent - indent; + let result; + + if (indentDelta < 0) + { + const add = ' '.repeat(-indentDelta); + result = lines.map(line => add + line).join('\n'); + } + else + result = lines.map(line => line.slice(indentDelta)).join('\n'); + + + // If the first or last line is empty, trim it (allows the template string to + // start at the next line to align properly). Similarly, if the last line is + // only whitespace, keep the newline but trim the spaces. + return result.replace(/^\n/g, '').replace(/[ \t]+$/g, ''); +} + + +module.exports = setIndent; \ No newline at end of file diff --git a/index.js b/index.js index 72e50e9..e339836 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,14 @@ +const options = require('minimist')(process.argv.slice(2)); +const winston = require('winston'); + const ConsulCatalog = require('./lib/consulcatalog'); -const logger = require('./lib/logger'); -const config = require('./config'); +const config = options.hasOwnProperty('config') ? require(options.config) : require('./config'); + +const logger = winston.createLogger({ + transports: config.logging.transports, +}); + const catalog = new ConsulCatalog(logger, config); // TODO detect if the connection is down for too long, allow a custom notification to be sent - -// TODO provide a way to easily switch between configs, for multiple environments diff --git a/lib/consulcatalog.js b/lib/consulcatalog.js index 441aa1d..2697d7a 100644 --- a/lib/consulcatalog.js +++ b/lib/consulcatalog.js @@ -68,7 +68,7 @@ class ConsulCatalog serviceByName(name) { - return this._services.find(service => service.name == name) || null; + return this._services.find(service => service.name === name) || null; } @@ -97,7 +97,7 @@ class ConsulCatalog self._services = self._services.filter(service => { const serviceIndex = serviceNames.indexOf(service.name); - if (serviceIndex == -1) + if (serviceIndex === -1) { // Previously detected service no longer appears in Consul, remove // any watches that may be present and remove it from the list @@ -116,12 +116,12 @@ class ConsulCatalog }); // All remaining entries in serviceNames are new - serviceNames.forEach(name => + for (const name of serviceNames) { self._logger.debug(`Found new service: ${name}`) self._services.push(new ConsulService(self, name, data[name])); changed = true; - }); + }; return changed; } @@ -139,7 +139,7 @@ class ConsulCatalog } - if (self._debouncedUpdate == null) + if (self._debouncedUpdate === null) { self._debouncedUpdate = debounce(() => { @@ -149,11 +149,11 @@ class ConsulCatalog self._logger.info('Running update handlers'); const handlerPromises = []; - self._config.onUpdate.forEach(handler => + for (const handler of self._config.onUpdate) { const handlerPromise = Promise.resolve(handler(self, self._logger)); handlerPromises.push(handlerPromise); - }); + }; Promise.all(handlerPromises) .then(() => @@ -171,6 +171,11 @@ class ConsulCatalog self._doUpdate(); } }); + }) + .catch(e => + { + // TODO better exception handling + self._logger.error('Error while running update handlers: ', e); }); }, 500); } @@ -189,69 +194,22 @@ class ConsulService this._logger = catalog._logger; this._watch = null; - this._rawData = null; - this._rawDataPromise = null; + this._instances = null; this.name = name; this.tags = tags; } - async getAddress() - { - var rawData; - try - { - rawData = await this._getRawData(); - } - catch(e) - { - // TODO better exception handling - this._logger.error('Error while retrieving service status', err); - return null; - } - - if (rawData.length == 0) - return null; - - if (rawData[0].Service.Address != '') - return rawData[0].Service.Address; - - return rawData[0].Node.Address; - } - - - async getPort() - { - var rawData; - try - { - rawData = await this._getRawData(); - } - catch(e) - { - // TODO better exception handling - this._logger.error('Error while retrieving service status', err); - return null; - } - - return rawData.length > 0 ? rawData[0].Service.Port : null; - } - - - // TODO getHealth - - - - _getRawData() + async getInstances() { const self = this; - if (self._rawDataPromise !== null) - return self._rawDataPromise; + if (self._instances !== null) + return Promise.resolve(self._instances); // Get status information for the service and start watching it - self._rawDataPromise = new Promise((resolve, reject) => + return new Promise((resolve, reject) => { let firstResponse = true; @@ -269,7 +227,7 @@ class ConsulService if (firstResponse) { firstResponse = false; - resolve(self._rawData); + resolve(self._instances); } }); @@ -284,13 +242,8 @@ class ConsulService firstResponse = false; reject(err); } - - // Try again the next time - self._rawDataPromise = null; }); }); - - return self._rawDataPromise; } @@ -317,16 +270,77 @@ class ConsulService _applyHealthData(data) { - if (data == this._rawData) - return false; + const self = this; + const isUpdate = self._instances !== null; + + let changed = false; + + // Remove instances that no longer exist + const instanceIds = {}; + for (const dataInstance of data) + instanceIds[dataInstance.Service.ID] = dataInstance; + + + self._instances = (self._instances != null ? self._instances : []).filter(instance => + { + if (instanceIds.hasOwnProperty(instance.id)) + { + // Previously detected instance no longer appears in Consul, + // remove it from the list + changed = true; + return false; + } + + + if (instance._applyHealthData(instanceIds[instance.id])) + changed = true; + + // Remove from instanceIds to indicate it has already been applied + delete instanceIds[instance.id]; + + return true; + }); + + // All remaining entries in instanceIds are new + for (const id of Object.keys(instanceIds)) + { + self._logger.debug(`Found new service instance: ${id}`) + self._instances.push(new ConsulServiceInstance(id, instanceIds[id])); + changed = true; + }; - const isUpdate = this._rawData != null; - this._rawData = data; // If this is the first time we've received data, it is guaranteed to be the result of // an update handler requesting this data and the handlers do not need to be called again. - return isUpdate; + return isUpdate && changed; } } + +class ConsulServiceInstance +{ + constructor(id, data) + { + this.id = id; + this.tags = []; + this.address = null; + this.port = null; + this.rawData = null; + + this._applyHealthData(data); + } + + + _applyHealthData(data) + { + this.rawData = data; + + this.id = data.Service.ID; + this.tags = data.Service.Tags; + this.address = data.Service.Address !== '' ? data.Service.Address : data.Node.Address; + this.port = data.Service.Port; + } +} + + module.exports = ConsulCatalog; \ No newline at end of file diff --git a/lib/logger.js b/lib/logger.js deleted file mode 100644 index 56033a0..0000000 --- a/lib/logger.js +++ /dev/null @@ -1,9 +0,0 @@ -const winston = require('winston'); -const config = require('../config'); - -// TODO make configurable -let logger = winston.createLogger({ - transports: config.logging.transports, -}); - -module.exports = logger; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1f275fc..6fc9e45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,11 +82,6 @@ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz", "integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==" }, - "dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=" - }, "enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", @@ -144,6 +139,11 @@ "triple-beam": "^1.3.0" } }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 028155e..bc8e2cf 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dependencies": { "consul": "^0.37.0", "debounce": "^1.2.0", - "dedent": "^0.7.0", + "minimist": "^1.2.5", "winston": "^3.3.3" }, "devDependencies": {