tribune
Version:
Holy advocate to the Roman Senate, your Tribune will ensure the Consuls have your interests in mind.
258 lines (207 loc) • 7.97 kB
JavaScript
const url = require('url');
const async = require('async');
const TagArray = require('./tag-array');
const Service = require('./service');
const Consul = require('./consul');
const Endpoint = require('./endpoint');
class Tribune {
constructor(opts = {}) {
if (!opts.agentUrl) { throw new Error('opts.agentUrl is required'); }
const agentUrlComponents = url.parse(opts.agentUrl);
if (!agentUrlComponents.port) { agentUrlComponents.port = 80; }
if (typeof agentUrlComponents.port === 'string') {
agentUrlComponents.port = parseFloat(agentUrlComponents.port);
}
if (!agentUrlComponents.hostname) {
throw new Error('a hostname is required in opts.agentUrl');
}
if (!agentUrlComponents.protocol) {
throw new Error('a protocol is required in opts.agentUrl');
}
this.tags = new TagArray();
this._consul = new Consul(opts);
this.url = null;
this._agentUrlComponents = agentUrlComponents;
this._serviceTimeout = opts.serviceTimeout || 10000;
this._serviceTtl = opts.serviceTtl || 1800;
this._aclToken = opts.aclToken;
this._interval = null;
this._healthCheckUrl = null;
this._urlComponents = null;
this._serviceName = null;
this._serviceId = '';
this._preparedQueryId = '';
}
get isRegistered() {
return !!this._serviceId && !!this._preparedQueryId;
}
register(serviceName, opts = {}, cb = () => {}) {
if (this.isRegistered) {
return this.deregister((err) => {
if (err) { return cb(err); }
this.register(serviceName, opts, cb);
});
}
if (!serviceName) { return cb(new Error('serviceName is required')); }
if (!opts.url) { return cb(new Error('opts.url is required')); }
if (!opts.healthCheckUrl) { return cb(new Error('opts.healthCheckUrl is required')); }
const urlComponents = url.parse(opts.url);
if (!urlComponents.port) { urlComponents.port = 80; }
if (typeof urlComponents.port === 'string') {
urlComponents.port = parseFloat(urlComponents.port);
}
if (!urlComponents.hostname) { return cb(new Error('a hostname is required in opts.url')); }
if (!urlComponents.protocol) { return cb(new Error('a protocol is required in opts.url')); }
if (opts.healthCheckUrl[0] === '/') {
opts.healthCheckUrl = opts.healthCheckUrl.slice(1);
}
this.url = opts.url;
this._serviceName = serviceName;
this._urlComponents = urlComponents;
this._healthCheckUrl = opts.healthCheckUrl;
this._interval = opts.interval || 1000;
this.tags.set('protocol', this._urlComponents.protocol);
if (opts.routes) {
this.tags.set('routes', opts.routes.toString());
}
async.series([
(cb) => this._deregisterPreparedQueryDupe(cb),
(cb) => this._deregisterServiceDupes(cb),
(cb) => this._deregisterServiceGhosts(cb)
], (err) => {
if (err) { return cb(err); }
this._generateServiceId();
async.parallel([
(cb) => this._consul.service.register(this._generateServiceBody(), cb),
(cb) => this._consul.preparedQuery.register(this._generatePreparedQueryBody(), cb)
], (err, res) => {
if (err) { return cb(err); }
this._preparedQueryId = res[1];
cb(null);
});
});
}
deregister(cb = () => {}) {
if (!this.isRegistered ) {
return cb(new Error('Cannot deregister. Register not called.'));
}
async.series([
(cb) => this._consul.service.deregister(this._serviceId, cb),
(cb) => this._deregisterPreparedQueryGhost(cb)
], (err) => {
if (err) { return cb(err); }
this._serviceId = '';
this._preparedQueryId = '';
cb(null);
});
}
service(serviceName) {
return new Service(this, serviceName);
}
endpoints(query = {}, cb = () => {}) {
const notIn = query.notIn || [];
notIn.push('consul');
this._consul.service.get((err, services) => {
if (err) { return cb(err); }
const endpoints = Object.keys(services)
.map(key => new Endpoint(services[key]))
.filter(endpoint => notIn.indexOf(endpoint.name) === -1);
cb(null, endpoints);
});
}
status(opts = {}, cb = () => {}) {
const minPeers = opts.minPeers || 3;
async.parallel([
(cb) => this._consul.getLeader(cb),
(cb) => this._consul.getPeers(cb)
], (err, res) => {
if (err) { return cb(err); }
const leader = res[0];
const peers = res[1];
if (!leader) {
return cb(null, { statusCode: 503, message: 'Consul Leader status failed' });
}
if (!peers || peers.length < minPeers) {
return cb(null, { statusCode: 503, message:'Peers status failed' });
}
cb(null, { statusCode: 200, message: 'OK' });
});
}
_deregisterPreparedQueryGhost(cb = () => {}) {
this._consul.preparedQuery.execute(this._serviceName, undefined, (err, res) => {
if (err) { return cb(err); }
if (!!res.Nodes.length) { return cb(null); }
this._consul.preparedQuery.deregister(this._preparedQueryId, cb);
});
}
_deregisterPreparedQueryDupe(cb = () => {}) {
this._consul.preparedQuery.get((err, preparedQueries) => {
if (err) { return cb(err); }
if (!preparedQueries.length) { return cb(null); }
const pq = preparedQueries.find(x => x.Name === this._serviceName);
if (!pq) { return cb(null); }
this._consul.preparedQuery.deregister(pq.ID, cb);
});
}
_deregisterServiceDupes(cb = () => {}) {
this._consul.service.get((err, res) => {
if (err) { return cb(err); }
const { hostname, port } = this._urlComponents;
const serviceDupeIds = Object.keys(res)
.map((serviceId) => res[serviceId])
.filter((service) => service.Address === hostname && service.Port === port)
.map((service) => service.ID);
async.each(serviceDupeIds, (serviceDupeId, cb) => {
this._consul.service.deregister(serviceDupeId, cb);
}, cb);
});
}
_deregisterServiceGhosts(cb = () => {}) {
this._consul.service.checks((err, res) => {
if (err) { return cb(err); }
const serviceGhostIds = Object.keys(res)
.map((checkId) => res[checkId])
.filter((check) => check.Status === 'critical' && check.ServiceName === this._serviceName)
.map((check) => check.ServiceID);
async.each(serviceGhostIds, (serviceGhostId, cb) => {
this._consul.service.deregister(serviceGhostId, cb);
}, cb);
});
}
_getConsulServiceEndpoint(serviceName, cb = () => {}) {
this._consul.preparedQuery.execute(serviceName, 1, (err, res) => {
if (err) { return cb(err); }
if (!res) { return cb(null, null); }
cb(null, res.Nodes[0].Service);
});
}
_generateServiceBody() {
return {
ID : this._serviceId,
Name : this._serviceName,
Tags : this.tags,
Address: this._urlComponents.hostname,
Port : parseFloat(this._urlComponents.port),
Check: {
HTTP : `${this._healthCheckUrl}`,
Interval: `${this._interval}ms`
}
};
}
_generatePreparedQueryBody() {
return {
Name : this._serviceName,
Service: { Service: this._serviceName }
};
}
_generateServiceId() {
const pad = (str, len) => '0'.repeat(len - str.length) + str.slice(0, len);
const date = new Date();
const year = pad(date.getFullYear().toString(), 4);
const month = pad((date.getMonth() + 1).toString(), 2);
const day = pad((date.getDate()).toString(), 2);
const rand = pad(Math.floor(Math.random() * 1000).toString(), 4);
this._serviceId = `${this._serviceName}-${year}${month}${day}${rand}`;
}
}
module.exports = Tribune;