UNPKG

thalassa-aqueduct

Version:

Dynamic haproxy load balancer and configuration. Part of Thalassa

273 lines (226 loc) 8.59 kB
var crdt = require('crdt') , assert = require('assert') , deepEqual = require('deep-equal') , diff = require('changeset') , fs = require('fs') , extend = require('xtend') ; var Data = module.exports = function Data (opts) { if (!opts) opts = {}; this.doc = new crdt.Doc(); this.frontends = this.doc.createSet('_type', 'frontend'); this.backends = this.doc.createSet('_type', 'backend'); if (opts.persistence) { //this._bootstrapLevelDB(opts.persistence); this._bootstrapFileSystemPersistence(opts.persistence); } // Stats are kept separate from the frontends and backends because // change events on those trigger possible reloading of Haproxy // and we don't want to reload Haproxy every time we retreive stats :) // IDEA: reconsider separate stats storage and conditionally reload haproxy this.stats = this.doc.createSet('_type', 'stat'); this.log = opts.log || function (){}; }; Data.prototype.createStream = function() { return this.doc.createStream(); }; Data.prototype.createReadableStream = function() { return this.doc.createStream({writable: false, sendClock: true}); }; Data.prototype.setFrontend = function(obj) { assert(typeof obj.key === 'string' && obj.key.length > 0); assert(typeof obj.bind === 'string'); var id = this.frontendId(obj.key); if (obj.id) assert.equal(obj.id, id, 'key must correspond with id'); var frontend = { _type : 'frontend' , key : obj.key , bind : obj.bind // TODO validate bind comma separated list of host || * : port , backend : obj.backend // TODO validate, make sure the backend is defined ? , mode : obj.mode || 'http' , keepalive : obj.keepalive || 'default' // default|close|server-close, default default , rules : obj.rules || [] // TODO validate each rule , natives : obj.natives || [] }; stripUndefinedProps(frontend); this._updateDifferences(id, this.frontends.get(id), frontend); }; Data.prototype.setBackend = function(obj) { assert(typeof obj.key === 'string' && obj.key.length > 0); assert(obj.type === 'dynamic' || obj.type === 'static'); var id = this.backendId(obj.key); if (obj.id) assert.equal(obj.id, id, 'key must correspond with id'); var existing = this.backends.get(id); var backend = { _type : 'backend' , key : obj.key , type : obj.type , name : obj.name // TODO validate , version : obj.version // TODO validate , balance : obj.balance || 'roundrobin' // TODO validate , host : obj.host || null // for host header override , mode : obj.mode || 'http' , members : (Array.isArray(obj.members)) ? obj.members : [] , natives : obj.natives || [] }; stripUndefinedProps(backend); // custom health checks, only for http if (backend.mode === 'http' && obj.health) { backend.health = { method: obj.health.method || 'GET' , uri: obj.health.uri || '/' , httpVersion: obj.health.httpVersion || 'HTTP/1.0' , interval: obj.health.interval || 2000 }; // validation - host header required for HTTP/1.1 assert(!(backend.health.httpVersion === 'HTTP/1.1' && !backend.host), 'host required with health.httpVersion == HTTP/1.1'); } this._updateDifferences(id, existing, backend); }; Data.prototype.setBackendMembers = function(key, members) { var backend = this.backends.get(this.backendId(key)); if (backend) backend.set({ 'members': members }); }; Data.prototype.getFrontends = function() { return this.frontends.toJSON(); }; Data.prototype.getBackends = function() { return this.backends.toJSON(); }; Data.prototype.deleteFrontend = function(key) { var id = this.frontendId(key); this.doc.rm(id); }; Data.prototype.deleteBackend = function(key) { var id = this.backendId(key); this.doc.rm(id); }; Data.prototype.frontendId = function(key) { return "frontend/"+key; }; Data.prototype.backendId = function(key) { return "backend/"+key; }; Data.prototype.setFrontendStat = function(stat) { // expect { key: 'fontEndName', status: 'UP/DOWN or like UP 2/3' } var statId = stat.id; var statObj = this._createStatObj(statId, stat.key, 'frontend', stat); statObj.frontend = this.frontendId(stat.key); this._setStat(statId, statObj); }; Data.prototype.setBackendStat = function(stat) { // expect { key: 'key', status: 'UP/DOWN or like UP 2/3' } var statId = stat.id; var statObj = this._createStatObj(statId, stat.key, 'backend', stat); statObj.backend = this.backendId(stat.key); this._setStat(statId, statObj); }; Data.prototype.setBackendMemberStat = function(stat) { // expect { key: 'key', status: 'UP/DOWN or like UP 2/3' } var statId = stat.id; var statObj = this._createStatObj(statId, stat.key, 'backendMember', stat); statObj.backend = this.backendId(stat.backendName); this._setStat(statId, statObj); }; Data.prototype.rmBackendMemberStatsAllBut = function(key, memberNames) { var self = this; this.stats.toJSON() .forEach(function (stat) { if (stat.type === 'backendMember' && stat.key === key && memberNames.indexOf(stat.key) === -1) { self.doc.rm(stat.id); } }); }; Data.prototype._setStat = function (statId, statObj) { var hasChanged = !deepEqual(this.doc.get(statId).toJSON(), statObj); if (hasChanged) this.doc.set(statId, statObj); }; Data.prototype._createStatObj = function(id, key, type, stat) { // set just the status and no other stat return { id: id, _type: 'stat', type: type, key: key, status: stat.status }; //return extend(stat, { id: id, _type: 'stat', type: type, key: key}); }; Data.prototype._updateDifferences = function (id, existingRow, updatedObj) { if (!existingRow) return this.doc.set(id, updatedObj); var diffObj = {}; diff(existingRow.toJSON(), updatedObj).forEach(function (change) { var key = change.key[0]; if (key === 'id') return; if (!diffObj[key]) { if (change.type === 'put') diffObj[key] = updatedObj[key]; else if (change.type === 'del') diffObj[key] = undefined; } }); existingRow.set(diffObj); }; // Data.prototype.closeDb = function(cb) { // if (this.db) this.db.close(cb); // else cb(null); // }; // This leveldb back storage is not working, sometimes it either failing // to store some data or read it out. I had to revert back to constantly // serializing the contents into a flat file // // Data.prototype._bootstrapLevelDB = function(dbLocation) { // var self = this; // var doc = self.doc; // var levelup = require("levelup"); // var level_scuttlebutt = require("level-scuttlebutt"); // var SubLevel = require('level-sublevel'); // var db = this.db = SubLevel(levelup(dbLocation)); // var udid = require('udid')('thalassa-aqueduct'); // var sbDb = db.sublevel('scuttlebutt'); // level_scuttlebutt(sbDb, udid, function (name) { // return doc; // }); // sbDb.open(udid, function (err, model) { // self.log('debug', 'leveldb initialized, storing data at ' + dbLocation); // //model.on('change:key', console.log); // }); // }; Data.prototype._bootstrapFileSystemPersistence = function (fileLocation) { var self = this; var writing = false, queued = false; function _syncDown() { writing = true; queued = false; var contents = JSON.stringify({ version: 1, frontends: self.frontends, backends: self.backends }); fs.writeFile(fileLocation, contents, function (err) { if (err) self.log('error', 'failed writing serialized configuration ' + fileLocation +', ' + err.message); writing = false; if (queued) _syncDown(); }); } var syncDown = function () { if (writing) queued = true; else _syncDown(); }; fs.exists(fileLocation, function (exists) { if (exists) { var contents = fs.readFileSync(fileLocation); try { var data = JSON.parse(contents); data.frontends.forEach(function (frontend) { self.setFrontend(frontend); }); data.backends.forEach(function (backend) { self.setBackend(backend); }); } catch (err) { self.log('error', 'failed parsing serialized configuration JSON ' + fileLocation +', ' + err.message); } } self.frontends.on('changes', syncDown); self.backends.on('changes', syncDown); }); }; function stripUndefinedProps(obj) { Object.keys(obj).forEach(function(key) { if (obj[key] === undefined ) delete obj[key]; }); }