diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d13361 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# 3.0 + - Added: + - A new required option `dataSource` in the config - to support multiple backends + - A new required option `loadBalancerFile` in the config - which is a path to the Load Balancer file. + - Additional backends are now supported - made possible by refactoring & wrapping the etcD support + - Changed: + - Add a failure flash when Azure gives you Auth problems to match the Google Auth experience + +--- diff --git a/config/config.json b/config/config.json index b4eb13f..6f545ab 100644 --- a/config/config.json +++ b/config/config.json @@ -1,9 +1,11 @@ { "RequiresAuth": false, + "dataSource": "etcd", "etcdHost": "127.0.0.1", "etcdPort": "4001", "hobknobHost": "localhost", "hobknobPort": "3006", + "loadBalancerFile": "/etc/lbstatus/hobknob", "categories": [ { "id": 0, diff --git a/server/app.js b/server/app.js index 2640be7..f61dd9c 100644 --- a/server/app.js +++ b/server/app.js @@ -11,7 +11,7 @@ var auditRoutes = require('./routes/auditRoutes'); var applicationRoutes = require('./routes/applicationRoutes'); var featureRoutes = require('./routes/featureRoutes'); var path = require('path'); -var acl = require('./acl'); +var acl = require('./domain/acl'); var config = require('./../config/config.json'); var _ = require('underscore'); var passport = require('./auth').init(config); @@ -119,7 +119,7 @@ app.get('/auth/azureadoauth2', ); app.get('/auth/azureadoauth2/callback', - passport.authenticate('azure', { failureRedirect: '/oops' }), + passport.authenticate('azure', { failureRedirect: '/oops', failureFlash: true }), function (req, res) { // Successful authentication, redirect home. res.redirect('/'); diff --git a/server/domain/acl.js b/server/domain/acl.js new file mode 100644 index 0000000..26974ca --- /dev/null +++ b/server/domain/acl.js @@ -0,0 +1,34 @@ +'use strict'; + +var config = require('./../../config/config.json'); +var acl = function() { + switch (config.dataSource.toLowerCase()) { + case 'etcd': + return require('./etcd/acl'); + + default: + return null; + } +}; + +module.exports = { + grant: function (userEmail, resource, callback) { + acl().grant(userEmail, resource, callback); + }, + + assert: function (userEmail, resource, callback) { + acl().assert(userEmail, resource, callback); + }, + + revoke: function (userEmail, resource, callback) { + acl().revoke(userEmail, resource, callback); + }, + + revokeAll: function(resource, callback) { + acl().revokeAll(resource, callback); + }, + + getAllUsers: function (resource, callback) { + acl().getAllUsers(resource, callback); + } +}; diff --git a/server/domain/application.js b/server/domain/application.js index 5c266a5..6b7c4d8 100644 --- a/server/domain/application.js +++ b/server/domain/application.js @@ -1,126 +1,38 @@ 'use strict'; -var etcd = require('../etcd'); -var _ = require('underscore'); var config = require('./../../config/config.json'); -var acl = require('./../acl'); -var audit = require('./../audit'); -var etcdBaseUrl = 'http://' + config.etcdHost + ':' + config.etcdPort + '/v2/keys/'; - -var getUserDetails = function (req) { - return config.RequiresAuth ? req.user._json : {name: 'Anonymous'}; +var application = function() { + switch (config.dataSource.toLowerCase()) { + case 'etcd': + return require('./etcd/application'); + + default: + return null; + } }; module.exports = { getApplications: function (cb) { - etcd.client.get('v1/toggles/', {recursive: false}, function (err, result) { - if (err) { - if (err.errorCode === 100) { // key not found - return cb(null, []); - } - - return cb(err); - } - - var applications = _.map(result.node.nodes || [], function (node) { - var splitKey = node.key.split('/'); - return splitKey[splitKey.length - 1]; - }); - cb(null, applications); - }); + application().getApplications(cb); }, addApplication: function (applicationName, req, cb) { - var path = 'v1/toggles/' + applicationName; - etcd.client.mkdir(path, function (err) { - if (err) { - return cb(err); - } - - audit.addApplicationAudit(getUserDetails(req), applicationName, 'Created', function () { - if (err) { - console.log(err); // todo: better logging - } - }); - - // todo: not sure if this is correct - if (config.RequiresAuth) { - var userEmail = getUserDetails(req).email.toLowerCase(); // todo: need better user management - acl.grant(userEmail, applicationName, function (grantErr) { - if (grantErr) { - cb(grantErr); - return; - } - cb(); - }); - } else { - cb(); - } - }); + application().addApplication(applicationName, req, cb); }, deleteApplication: function (applicationName, req, cb) { - var path = 'v1/toggles/' + applicationName; - etcd.client.delete(path, {recursive: true}, function (err) { - if (err) { - return cb(err); - } - - audit.addApplicationAudit(getUserDetails(req), applicationName, 'Deleted', function () { - if (err) { - console.log(err); - } - }); - - if (config.RequiresAuth) { - acl.revokeAll(applicationName, function (revokeErr) { - if (revokeErr) { - return cb(revokeErr); - } - cb(); - }); - } else { - cb(); - } - }); + application().deleteApplication(applicationName, req, cb); }, getApplicationMetaData: function (applicationName, cb) { - etcd.client.get('v1/metadata/' + applicationName, {recursive: true}, function (err, result) { - if (err) { - if (err.errorCode === 100) { // key not found - cb(null, {}); - } else { - cb(err); - } - return; - } - var metaDataKeyValues = _.map(result.node.nodes, function (subNode) { - var metaDataKey = _.last(subNode.key.split('/')); - return [metaDataKey, subNode.value]; - }); - cb(null, _.object(metaDataKeyValues)); - }); + application().getApplicationMetaData(applicationName, cb); }, deleteApplicationMetaData: function (applicationName, cb) { - etcd.client.delete('v1/metadata/' + applicationName, {recursive: true}, function (err, result) { - if (err) { - if (err.errorCode === 100) { // key not found - cb(); - } else { - cb(err); - } - return; - } - cb(); - }); + application().deleteApplicationMetaData(applicationName, cb); }, saveApplicationMetaData: function (applicationName, metaDataKey, metaDataValue, cb) { - var path = 'v1/metadata/' + applicationName + '/' + metaDataKey; - etcd.client.set(path, metaDataValue, function (err) { - return cb(err); - }); + application().saveApplicationMetaData(applicationName, metaDataKey, metaDataValue, cb); } }; diff --git a/server/domain/audit.js b/server/domain/audit.js new file mode 100644 index 0000000..438d42b --- /dev/null +++ b/server/domain/audit.js @@ -0,0 +1,30 @@ +'use strict'; + +var config = require('./../../config/config.json'); +var audit = function() { + switch (config.dataSource.toLowerCase()) { + case 'etcd': + return require('./etcd/audit'); + + default: + return null; + } +}; + +module.exports = { + getApplicationAuditTrail: function (applicationName, callback) { + audit().getApplicationAuditTrail(applicationName, callback); + }, + + getFeatureAuditTrail: function (applicationName, featureName, callback) { + audit().getFeatureAuditTrail(applicationName, featureName, callback); + }, + + addApplicationAudit: function (user, applicationName, action, callback) { + audit().addApplicationAudit(user, applicationName, action, callback); + }, + + addFeatureAudit: function (user, applicationName, featureName, toggleName, value, action, callback) { + audit().addFeatureAudit(user, applicationName, featureName, toggleName, value, action, callback); + } +}; diff --git a/server/domain/category.js b/server/domain/category.js index d3a56fa..aa3a2eb 100644 --- a/server/domain/category.js +++ b/server/domain/category.js @@ -2,9 +2,8 @@ var _ = require('underscore'); var config = require('./../../config/config.json'); -var acl = require('./../acl'); -var audit = require('./../audit'); -var etcdBaseUrl = 'http://' + config.etcdHost + ':' + config.etcdPort + '/v2/keys/'; +var acl = require('./acl'); +var audit = require('./audit'); var getCategory = function (id, name, description, columns, features) { return { @@ -41,4 +40,3 @@ module.exports.getCategoriesFromConfig = function () { }); return _.object(categories); }; - diff --git a/server/acl.js b/server/domain/etcd/acl.js similarity index 96% rename from server/acl.js rename to server/domain/etcd/acl.js index cfedd2a..2f793e5 100644 --- a/server/acl.js +++ b/server/domain/etcd/acl.js @@ -1,76 +1,76 @@ -'use strict'; - -var etcd = require('./etcd'); -var _ = require('underscore'); - -var EtcdAclStore = function () { -}; - -EtcdAclStore.prototype.grant = function (userEmail, resource, callback) { - etcd.client.set('v1/toggleAcl/' + resource + '/' + userEmail.toLowerCase(), userEmail.toLowerCase(), function (err) { - if (err) callback(err); - callback(); - }); -}; - -EtcdAclStore.prototype.assert = function (userEmail, resource, callback) { - etcd.client.get('v1/toggleAcl/' + resource + '/' + userEmail.toLowerCase(), function (err) { - if (err) { - if (err.errorCode === 100) { // key not found - callback(null, false); - } else { - callback(err); - } - return; - } - callback(null, true); - }); -}; - -EtcdAclStore.prototype.revoke = function (userEmail, resource, callback) { - etcd.client.delete('v1/toggleAcl/' + resource + '/' + userEmail.toLowerCase(), function (err) { - if (err) { - if (err.errorCode === 100) { // key not found - callback(); - } else { - callback(err); - } - return; - } - callback(); - }); -}; - -EtcdAclStore.prototype.revokeAll = function(resource, callback) { - etcd.client.delete('v1/toggleAcl/' + resource, {recursive: true}, function (err) { - if (err) { - if (err.errorCode === 100) { // key not found - callback(); - } else { - callback(err); - } - return; - } - callback(); - }); -}; - -EtcdAclStore.prototype.getAllUsers = function (resource, callback) { - etcd.client.get('v1/toggleAcl/' + resource, {recursive: true}, function (err, result) { - if (err) { - if (err.errorCode === 100) { // key not found - callback(null, []); - } else { - callback(err); - } - return; - } - - var users = _.map(result.node.nodes || [], function (node) { - return node.value; - }); - callback(null, users); - }); -}; - -module.exports = new EtcdAclStore(); +'use strict'; + +var etcd = require('./etcd'); +var _ = require('underscore'); + +var EtcdAclStore = function () { +}; + +EtcdAclStore.prototype.grant = function (userEmail, resource, callback) { + etcd.client.set('v1/toggleAcl/' + resource + '/' + userEmail.toLowerCase(), userEmail.toLowerCase(), function (err) { + if (err) callback(err); + callback(); + }); +}; + +EtcdAclStore.prototype.assert = function (userEmail, resource, callback) { + etcd.client.get('v1/toggleAcl/' + resource + '/' + userEmail.toLowerCase(), function (err) { + if (err) { + if (err.errorCode === 100) { // key not found + callback(null, false); + } else { + callback(err); + } + return; + } + callback(null, true); + }); +}; + +EtcdAclStore.prototype.revoke = function (userEmail, resource, callback) { + etcd.client.delete('v1/toggleAcl/' + resource + '/' + userEmail.toLowerCase(), function (err) { + if (err) { + if (err.errorCode === 100) { // key not found + callback(); + } else { + callback(err); + } + return; + } + callback(); + }); +}; + +EtcdAclStore.prototype.revokeAll = function(resource, callback) { + etcd.client.delete('v1/toggleAcl/' + resource, {recursive: true}, function (err) { + if (err) { + if (err.errorCode === 100) { // key not found + callback(); + } else { + callback(err); + } + return; + } + callback(); + }); +}; + +EtcdAclStore.prototype.getAllUsers = function (resource, callback) { + etcd.client.get('v1/toggleAcl/' + resource, {recursive: true}, function (err, result) { + if (err) { + if (err.errorCode === 100) { // key not found + callback(null, []); + } else { + callback(err); + } + return; + } + + var users = _.map(result.node.nodes || [], function (node) { + return node.value; + }); + callback(null, users); + }); +}; + +module.exports = new EtcdAclStore(); diff --git a/server/domain/etcd/application.js b/server/domain/etcd/application.js new file mode 100644 index 0000000..e02bcba --- /dev/null +++ b/server/domain/etcd/application.js @@ -0,0 +1,126 @@ +'use strict'; + +var etcd = require('./etcd'); +var _ = require('underscore'); +var config = require('./../../../config/config.json'); +var acl = require('./../acl'); +var audit = require('./../audit'); +var etcdBaseUrl = 'http://' + config.etcdHost + ':' + config.etcdPort + '/v2/keys/'; + +var getUserDetails = function (req) { + return config.RequiresAuth ? req.user._json : {name: 'Anonymous'}; +}; + +module.exports = { + getApplications: function (cb) { + etcd.client.get('v1/toggles/', {recursive: false}, function (err, result) { + if (err) { + if (err.errorCode === 100) { // key not found + return cb(null, []); + } + + return cb(err); + } + + var applications = _.map(result.node.nodes || [], function (node) { + var splitKey = node.key.split('/'); + return splitKey[splitKey.length - 1]; + }); + cb(null, applications); + }); + }, + + addApplication: function (applicationName, req, cb) { + var path = 'v1/toggles/' + applicationName; + etcd.client.mkdir(path, function (err) { + if (err) { + return cb(err); + } + + audit.addApplicationAudit(getUserDetails(req), applicationName, 'Created', function () { + if (err) { + console.log(err); // todo: better logging + } + }); + + // todo: not sure if this is correct + if (config.RequiresAuth) { + var userEmail = getUserDetails(req).email.toLowerCase(); // todo: need better user management + acl.grant(userEmail, applicationName, function (grantErr) { + if (grantErr) { + cb(grantErr); + return; + } + cb(); + }); + } else { + cb(); + } + }); + }, + + deleteApplication: function (applicationName, req, cb) { + var path = 'v1/toggles/' + applicationName; + etcd.client.delete(path, {recursive: true}, function (err) { + if (err) { + return cb(err); + } + + audit.addApplicationAudit(getUserDetails(req), applicationName, 'Deleted', function () { + if (err) { + console.log(err); + } + }); + + if (config.RequiresAuth) { + acl.revokeAll(applicationName, function (revokeErr) { + if (revokeErr) { + return cb(revokeErr); + } + cb(); + }); + } else { + cb(); + } + }); + }, + + getApplicationMetaData: function (applicationName, cb) { + etcd.client.get('v1/metadata/' + applicationName, {recursive: true}, function (err, result) { + if (err) { + if (err.errorCode === 100) { // key not found + cb(null, {}); + } else { + cb(err); + } + return; + } + var metaDataKeyValues = _.map(result.node.nodes, function (subNode) { + var metaDataKey = _.last(subNode.key.split('/')); + return [metaDataKey, subNode.value]; + }); + cb(null, _.object(metaDataKeyValues)); + }); + }, + + deleteApplicationMetaData: function (applicationName, cb) { + etcd.client.delete('v1/metadata/' + applicationName, {recursive: true}, function (err, result) { + if (err) { + if (err.errorCode === 100) { // key not found + cb(); + } else { + cb(err); + } + return; + } + cb(); + }); + }, + + saveApplicationMetaData: function (applicationName, metaDataKey, metaDataValue, cb) { + var path = 'v1/metadata/' + applicationName + '/' + metaDataKey; + etcd.client.set(path, metaDataValue, function (err) { + return cb(err); + }); + } +}; diff --git a/server/audit.js b/server/domain/etcd/audit.js similarity index 95% rename from server/audit.js rename to server/domain/etcd/audit.js index 0aa7916..2782ff8 100644 --- a/server/audit.js +++ b/server/domain/etcd/audit.js @@ -2,7 +2,7 @@ var etcd = require('./etcd'); var _ = require('underscore'); -var config = require('./../config/config.json'); +var config = require('./../../../config/config.json'); module.exports = { getApplicationAuditTrail: function (applicationName, callback) { diff --git a/server/etcd.js b/server/domain/etcd/etcd.js similarity index 68% rename from server/etcd.js rename to server/domain/etcd/etcd.js index 0aa2fbe..829d11c 100644 --- a/server/etcd.js +++ b/server/domain/etcd/etcd.js @@ -1,6 +1,6 @@ 'use strict'; -var config = require('./../config/config.json'); +var config = require('./../../../config/config.json'); var Etcd = require('node-etcd'); module.exports.client = new Etcd(config.etcdHost, config.etcdPort); diff --git a/server/domain/etcd/feature.js b/server/domain/etcd/feature.js new file mode 100644 index 0000000..117e9b1 --- /dev/null +++ b/server/domain/etcd/feature.js @@ -0,0 +1,399 @@ +'use strict'; + +var etcd = require('./etcd'); +var _ = require('underscore'); +var config = require('./../../../config/config.json'); +var acl = require('./../acl'); +var category = require('./../category'); +var etcdBaseUrl = 'http://' + config.etcdHost + ':' + config.etcdPort + '/v2/keys/'; +var s = require('string'); +var hooks = require('../../src/hooks/featureHooks'); + +var isMetaNode = function (node) { + return s(node.key).endsWith('@meta'); +}; + +var getUserDetails = function (req) { + return config.RequiresAuth ? req.user._json : {name: 'Anonymous'}; +}; + +var getMetaData = function (featureNode) { + var metaNode = _.find(featureNode.nodes, function (n) { + return isMetaNode(n); + }); + if (metaNode) { + return JSON.parse(metaNode.value); + } + return { + categoryId: 0 + }; +}; + +var isMultiFeature = function (metaData) { + return metaData.categoryId !== category.simpleCategoryId; +}; + +var getNodeName = function (node) { + var splitKey = node.key.split('/'); + return splitKey[splitKey.length - 1]; +}; + +var getSimpleFeature = function (name, node, description) { + var value = node.value && node.value.toLowerCase() === 'true'; + return { + name: name, + description: description, + values: [value], + categoryId: 0, + fullPath: etcdBaseUrl + 'v1/toggles/' + name + }; +}; + +var getMultiFeature = function (name, node, metaData, categories, description) { + var foundCategory = categories[metaData.categoryId]; + var values = _.map(foundCategory.columns, function (column) { + var columnNode = _.find(node.nodes, function (c) { + return c.key === node.key + '/' + column; + }); + return columnNode && columnNode.value && columnNode.value.toLowerCase() === 'true'; + }); + return { + name: name, + description: description, + values: values, + categoryId: metaData.categoryId, + fullPath: etcdBaseUrl + 'v1/toggles/' + name + }; +}; + +var getFeature = function (node, categories, descriptionMap) { + var name = getNodeName(node); + if (name === '@meta') { + return null; + } + + var description = descriptionMap[name]; + + var metaData = getMetaData(node); + if (isMultiFeature(metaData)) { + return getMultiFeature(name, node, metaData, categories, description); + } + + return getSimpleFeature(name, node, description); +}; + +var handleEtcdNotFoundError = function (err, cb) { + if (err.errorCode === 100) { // key not found + cb(); + } else { + cb(err); + } +}; + +var getCategoriesWithFeatureValues = function (applicationNode, descriptionsMap) { + var categories = category.getCategoriesFromConfig(); + _.each(applicationNode.nodes, function (featureNode) { + var feature = getFeature(featureNode, categories, descriptionsMap); + if (feature) { + categories[feature.categoryId].features.push(feature); + } + }); + return categories; +}; + +var trimEmptyCategoryColumns = function (categories) { + var featureHasValueAtIndex = function (index) { + return function (feature) { + return feature.values[index] !== null && feature.values[index] !== undefined; + }; + }; + _.each(categories, function (foundCategory) { + if (foundCategory.id !== 0) { + var columnsToRemove = []; + for (var i = 0; i < foundCategory.columns.length; i++) { + var aFeatureHasColumnValue = _.some(foundCategory.features, featureHasValueAtIndex(i)); + if (!aFeatureHasColumnValue) { + columnsToRemove.push(foundCategory.columns[i]); + } + } + _.each(columnsToRemove, function (columnName) { + var columnIndex = _.indexOf(foundCategory.columns, columnName); + foundCategory.columns.splice(columnIndex, 1); + _.each(foundCategory.features, function (feature) { + feature.values.splice(columnIndex, 1); + }); + }); + } + }); +}; + +var getDescriptionsMap = function (node) { + var descriptions = _.map(node.nodes, function (descriptionNode) { + return [getNodeName(descriptionNode), descriptionNode.value]; + }); + + return _.object(descriptions); +}; + +module.exports.getFeatureCategories = function (applicationName, cb) { + var path = 'v1/toggles/' + applicationName; + etcd.client.get(path, {recursive: true}, function (err, result) { + if (err) { + handleEtcdNotFoundError(err, cb); + return; + } + + etcd.client.get('v1/metadata/' + applicationName + '/descriptions', function (descriptionError, descriptionResult) { + if (descriptionError) { + console.log(descriptionError); + } + + var descriptionsMap = !descriptionError ? getDescriptionsMap(descriptionResult.node) : {}; + + var categories = getCategoriesWithFeatureValues(result.node, descriptionsMap); + trimEmptyCategoryColumns(categories); + + cb(null, { + categories: categories + }); + }); + }); +}; + + +var getSimpleFeatureToggle = function (featureName, featureNode) { + return [{ + name: featureName, + value: featureNode.value === 'true' + }]; +}; + +var getMultiFeatureToggles = function (featureNode) { + return _ + .chain(featureNode.nodes) + .filter(function (node) { + return !isMetaNode(node); + }) + .map(function (node) { + return { + name: _.last(node.key.split('/')), + value: node.value === 'true' + }; + }) + .value(); +}; + +var getToggleSuggestions = function (metaData, toggles) { + var categories = category.getCategoriesFromConfig(); + return _.difference(categories[metaData.categoryId].columns, _.map(toggles, function (toggle) { + return toggle.name; + })); +}; + +module.exports.getFeature = function (applicationName, featureName, cb) { + var path = 'v1/toggles/' + applicationName + '/' + featureName; + etcd.client.get(path, {recursive: true}, function (err, result) { + if (err) { + handleEtcdNotFoundError(err, cb); + return; + } + + getFeatureDescription(applicationName, result, function (featureErr, featureDescription) { + getFeatureToggles(featureName, result, function (toggleErr, toggles, toggleSuggestions, isMulti) { + cb(null, { + applicationName: applicationName, + featureName: featureName, + featureDescription: featureDescription, + toggles: toggles, + isMultiToggle: isMulti, + toggleSuggestions: toggleSuggestions + }); + }); + }); + }); +}; + +var addFeatureDescription = function (applicationName, featureName, featureDescription, cb) { + var descriptionPath = 'v1/metadata/' + applicationName + '/descriptions/' + featureName; + + etcd.client.set(descriptionPath, featureDescription, function (err) { + if (err) { + console.log(err); // todo: better logging + } + if (cb) cb(); + }); +}; + +var getFeatureDescription = function (applicationName, feature, cb) { + var descriptionPath = 'v1/metadata/' + applicationName + '/descriptions'; + + etcd.client.get(descriptionPath, function (error, result) { + if (error) { + console.log(error); + } + + var descriptionsMap = !error ? getDescriptionsMap(result.node) : {}; + var featureDescription = getFeature(feature.node, category.getCategoriesFromConfig(), descriptionsMap).description; + + cb(null, featureDescription); + }); +}; + +var getFeatureToggles = function (featureName, feature, cb) { + var metaData = getMetaData(feature.node); + var isMulti = isMultiFeature(metaData); + + var toggles; + var toggleSuggestions; + + if (isMulti) { + toggles = getMultiFeatureToggles(feature.node); + toggleSuggestions = getToggleSuggestions(metaData, toggles); + } else { + toggles = getSimpleFeatureToggle(featureName, feature.node); + } + + cb(null, toggles, toggleSuggestions, isMulti); +}; + +var addMultiFeature = function (path, applicationName, featureName, featureDescription, metaData, req, cb) { + var metaPath = path + '/@meta'; + etcd.client.set(metaPath, JSON.stringify(metaData), function (err) { + if (err) { + cb(err); + return; + } + + addFeatureDescription(applicationName, featureName, featureDescription); + + hooks.run({ + fn: 'addFeature', + user: getUserDetails(req), + applicationName: applicationName, + featureName: featureName, + value: false + }); + + cb(); + }); +}; + +var addSimpleFeature = function (path, applicationName, featureName, featureDescription, metaData, req, cb) { + etcd.client.set(path, false, function (err) { + if (err) { + return cb(err); + } + + addFeatureDescription(applicationName, featureName, featureDescription); + + hooks.run({ + fn: 'addFeatureToggle', + user: getUserDetails(req), + applicationName: applicationName, + featureName: featureName, + toggleName: null, + value: false + }); + + cb(); + }); +}; + +module.exports.addFeature = function (applicationName, featureName, featureDescription, categoryId, req, cb) { + var metaData = { + categoryId: categoryId + }; + + var path = 'v1/toggles/' + applicationName + '/' + featureName; + + var isMulti = isMultiFeature(metaData); + + if (isMulti) { + addMultiFeature(path, applicationName, featureName, featureDescription, metaData, req, cb); + } else { + addSimpleFeature(path, applicationName, featureName, featureDescription, metaData, req, cb); + } +}; + +module.exports.updateFeatureToggle = function (applicationName, featureName, value, req, cb) { + var path = 'v1/toggles/' + applicationName + '/' + featureName; + etcd.client.set(path, value, function (err) { + if (err) { + cb(err); + return; + } + + hooks.run({ + fn: 'updateFeatureToggle', + user: getUserDetails(req), + applicationName: applicationName, + featureName: featureName, + toggleName: null, + value: value + }); + + cb(); + }); +}; + +module.exports.updateFeatureDescription = function (applicationName, featureName, newFeatureDescription, req, cb) { + addFeatureDescription(applicationName, featureName, newFeatureDescription, cb); +}; + +module.exports.addFeatureToggle = function (applicationName, featureName, toggleName, req, cb) { + var path = 'v1/toggles/' + applicationName + '/' + featureName + '/' + toggleName; + etcd.client.set(path, false, function (err) { + if (err) { + cb(err); + return; + } + + hooks.run({ + fn: 'addFeatureToggle', + user: getUserDetails(req), + applicationName: applicationName, + featureName: featureName, + toggleName: toggleName, + value: false + }); + + cb(); + }); +}; + +module.exports.updateFeatureMultiToggle = function (applicationName, featureName, toggleName, value, req, cb) { + var path = 'v1/toggles/' + applicationName + '/' + featureName + '/' + toggleName; + etcd.client.set(path, value, function (err) { + if (err) { + cb(err); + return; + } + + hooks.run({ + fn: 'updateFeatureToggle', + user: getUserDetails(req), + applicationName: applicationName, + featureName: featureName, + toggleName: toggleName, + value: value + }); + + cb(); + }); +}; + +module.exports.deleteFeature = function (applicationName, featureName, req, cb) { + var path = 'v1/toggles/' + applicationName + '/' + featureName; + etcd.client.delete(path, {recursive: true}, function (err) { + if (err) cb(err); + + hooks.run({ + fn: 'deleteFeature', + user: getUserDetails(req), + applicationName: applicationName, + featureName: featureName + }); + + cb(); + }); +}; diff --git a/server/domain/feature.js b/server/domain/feature.js index cf7e789..f881cc7 100644 --- a/server/domain/feature.js +++ b/server/domain/feature.js @@ -1,399 +1,46 @@ 'use strict'; -var etcd = require('../etcd'); -var _ = require('underscore'); var config = require('./../../config/config.json'); -var acl = require('./../acl'); -var category = require('./category'); -var etcdBaseUrl = 'http://' + config.etcdHost + ':' + config.etcdPort + '/v2/keys/'; -var s = require('string'); -var hooks = require('../src/hooks/featureHooks'); +var feature = function() { + switch (config.dataSource.toLowerCase()) { + case 'etcd': + return require('./etcd/feature'); -var isMetaNode = function (node) { - return s(node.key).endsWith('@meta'); + default: + return null; + } }; -var getUserDetails = function (req) { - return config.RequiresAuth ? req.user._json : {name: 'Anonymous'}; -}; - -var getMetaData = function (featureNode) { - var metaNode = _.find(featureNode.nodes, function (n) { - return isMetaNode(n); - }); - if (metaNode) { - return JSON.parse(metaNode.value); - } - return { - categoryId: 0 - }; -}; - -var isMultiFeature = function (metaData) { - return metaData.categoryId !== category.simpleCategoryId; -}; - -var getNodeName = function (node) { - var splitKey = node.key.split('/'); - return splitKey[splitKey.length - 1]; -}; - -var getSimpleFeature = function (name, node, description) { - var value = node.value && node.value.toLowerCase() === 'true'; - return { - name: name, - description: description, - values: [value], - categoryId: 0, - fullPath: etcdBaseUrl + 'v1/toggles/' + name - }; -}; - -var getMultiFeature = function (name, node, metaData, categories, description) { - var foundCategory = categories[metaData.categoryId]; - var values = _.map(foundCategory.columns, function (column) { - var columnNode = _.find(node.nodes, function (c) { - return c.key === node.key + '/' + column; - }); - return columnNode && columnNode.value && columnNode.value.toLowerCase() === 'true'; - }); - return { - name: name, - description: description, - values: values, - categoryId: metaData.categoryId, - fullPath: etcdBaseUrl + 'v1/toggles/' + name - }; -}; - -var getFeature = function (node, categories, descriptionMap) { - var name = getNodeName(node); - if (name === '@meta') { - return null; - } - - var description = descriptionMap[name]; - - var metaData = getMetaData(node); - if (isMultiFeature(metaData)) { - return getMultiFeature(name, node, metaData, categories, description); - } - - return getSimpleFeature(name, node, description); -}; - -var handleEtcdNotFoundError = function (err, cb) { - if (err.errorCode === 100) { // key not found - cb(); - } else { - cb(err); - } -}; - -var getCategoriesWithFeatureValues = function (applicationNode, descriptionsMap) { - var categories = category.getCategoriesFromConfig(); - _.each(applicationNode.nodes, function (featureNode) { - var feature = getFeature(featureNode, categories, descriptionsMap); - if (feature) { - categories[feature.categoryId].features.push(feature); - } - }); - return categories; -}; - -var trimEmptyCategoryColumns = function (categories) { - var featureHasValueAtIndex = function (index) { - return function (feature) { - return feature.values[index] !== null && feature.values[index] !== undefined; - }; - }; - _.each(categories, function (foundCategory) { - if (foundCategory.id !== 0) { - var columnsToRemove = []; - for (var i = 0; i < foundCategory.columns.length; i++) { - var aFeatureHasColumnValue = _.some(foundCategory.features, featureHasValueAtIndex(i)); - if (!aFeatureHasColumnValue) { - columnsToRemove.push(foundCategory.columns[i]); - } - } - _.each(columnsToRemove, function (columnName) { - var columnIndex = _.indexOf(foundCategory.columns, columnName); - foundCategory.columns.splice(columnIndex, 1); - _.each(foundCategory.features, function (feature) { - feature.values.splice(columnIndex, 1); - }); - }); - } - }); -}; - -var getDescriptionsMap = function (node) { - var descriptions = _.map(node.nodes, function (descriptionNode) { - return [getNodeName(descriptionNode), descriptionNode.value]; - }); - - return _.object(descriptions); -}; - -module.exports.getFeatureCategories = function (applicationName, cb) { - var path = 'v1/toggles/' + applicationName; - etcd.client.get(path, {recursive: true}, function (err, result) { - if (err) { - handleEtcdNotFoundError(err, cb); - return; - } - - etcd.client.get('v1/metadata/' + applicationName + '/descriptions', function (descriptionError, descriptionResult) { - if (descriptionError) { - console.log(descriptionError); - } - - var descriptionsMap = !descriptionError ? getDescriptionsMap(descriptionResult.node) : {}; - - var categories = getCategoriesWithFeatureValues(result.node, descriptionsMap); - trimEmptyCategoryColumns(categories); - - cb(null, { - categories: categories - }); - }); - }); -}; - - -var getSimpleFeatureToggle = function (featureName, featureNode) { - return [{ - name: featureName, - value: featureNode.value === 'true' - }]; -}; - -var getMultiFeatureToggles = function (featureNode) { - return _ - .chain(featureNode.nodes) - .filter(function (node) { - return !isMetaNode(node); - }) - .map(function (node) { - return { - name: _.last(node.key.split('/')), - value: node.value === 'true' - }; - }) - .value(); -}; - -var getToggleSuggestions = function (metaData, toggles) { - var categories = category.getCategoriesFromConfig(); - return _.difference(categories[metaData.categoryId].columns, _.map(toggles, function (toggle) { - return toggle.name; - })); -}; +module.exports = { + getFeatureCategories: function (applicationName, cb) { + feature().getFeatureCategories(applicationName, cb); + }, -module.exports.getFeature = function (applicationName, featureName, cb) { - var path = 'v1/toggles/' + applicationName + '/' + featureName; - etcd.client.get(path, {recursive: true}, function (err, result) { - if (err) { - handleEtcdNotFoundError(err, cb); - return; - } + getFeature: function (applicationName, featureName, cb) { + feature().getFeature(applicationName, featureName, cb); + }, - getFeatureDescription(applicationName, result, function (featureErr, featureDescription) { - getFeatureToggles(featureName, result, function (toggleErr, toggles, toggleSuggestions, isMulti) { - cb(null, { - applicationName: applicationName, - featureName: featureName, - featureDescription: featureDescription, - toggles: toggles, - isMultiToggle: isMulti, - toggleSuggestions: toggleSuggestions - }); - }); - }); - }); -}; - -var addFeatureDescription = function (applicationName, featureName, featureDescription, cb) { - var descriptionPath = 'v1/metadata/' + applicationName + '/descriptions/' + featureName; - - etcd.client.set(descriptionPath, featureDescription, function (err) { - if (err) { - console.log(err); // todo: better logging - } - if (cb) cb(); - }); -}; - -var getFeatureDescription = function (applicationName, feature, cb) { - var descriptionPath = 'v1/metadata/' + applicationName + '/descriptions'; - - etcd.client.get(descriptionPath, function (error, result) { - if (error) { - console.log(error); - } - - var descriptionsMap = !error ? getDescriptionsMap(result.node) : {}; - var featureDescription = getFeature(feature.node, category.getCategoriesFromConfig(), descriptionsMap).description; - - cb(null, featureDescription); - }); -}; - -var getFeatureToggles = function (featureName, feature, cb) { - var metaData = getMetaData(feature.node); - var isMulti = isMultiFeature(metaData); - - var toggles; - var toggleSuggestions; - - if (isMulti) { - toggles = getMultiFeatureToggles(feature.node); - toggleSuggestions = getToggleSuggestions(metaData, toggles); - } else { - toggles = getSimpleFeatureToggle(featureName, feature.node); - } - - cb(null, toggles, toggleSuggestions, isMulti); -}; - -var addMultiFeature = function (path, applicationName, featureName, featureDescription, metaData, req, cb) { - var metaPath = path + '/@meta'; - etcd.client.set(metaPath, JSON.stringify(metaData), function (err) { - if (err) { - cb(err); - return; - } - - addFeatureDescription(applicationName, featureName, featureDescription); - - hooks.run({ - fn: 'addFeature', - user: getUserDetails(req), - applicationName: applicationName, - featureName: featureName, - value: false - }); - - cb(); - }); -}; - -var addSimpleFeature = function (path, applicationName, featureName, featureDescription, metaData, req, cb) { - etcd.client.set(path, false, function (err) { - if (err) { - return cb(err); - } + addFeature: function (applicationName, featureName, featureDescription, categoryId, req, cb) { + feature().addFeature(applicationName, featureName, featureDescription, categoryId, req, cb); + }, - addFeatureDescription(applicationName, featureName, featureDescription); + updateFeatureToggle: function (applicationName, featureName, value, req, cb) { + feature().updateFeatureToggle(applicationName, featureName, value, req, cb); + }, - hooks.run({ - fn: 'addFeatureToggle', - user: getUserDetails(req), - applicationName: applicationName, - featureName: featureName, - toggleName: null, - value: false - }); + updateFeatureDescription: function (applicationName, featureName, value, req, cb) { + feature().updateFeatureDescription(applicationName, featureName, newFeatureDescription, req, cb); + }, - cb(); - }); -}; - -module.exports.addFeature = function (applicationName, featureName, featureDescription, categoryId, req, cb) { - var metaData = { - categoryId: categoryId - }; - - var path = 'v1/toggles/' + applicationName + '/' + featureName; + addFeatureToggle: function (applicationName, featureName, toggleName, req, cb) { + feature().addFeatureToggle(applicationName, featureName, toggleName, req, cb); + }, - var isMulti = isMultiFeature(metaData); + updateFeatureMultiToggle: function (applicationName, featureName, toggleName, value, req, cb) { + feature().updateFeatureMultiToggle(applicationName, featureName, toggleName, value, req, cb); + }, - if (isMulti) { - addMultiFeature(path, applicationName, featureName, featureDescription, metaData, req, cb); - } else { - addSimpleFeature(path, applicationName, featureName, featureDescription, metaData, req, cb); + deleteFeature: function(applicationName, featureName, req, cb) { + feature().deleteFeature(applicationName, featureName, req, cb); } }; - -module.exports.updateFeatureToggle = function (applicationName, featureName, value, req, cb) { - var path = 'v1/toggles/' + applicationName + '/' + featureName; - etcd.client.set(path, value, function (err) { - if (err) { - cb(err); - return; - } - - hooks.run({ - fn: 'updateFeatureToggle', - user: getUserDetails(req), - applicationName: applicationName, - featureName: featureName, - toggleName: null, - value: value - }); - - cb(); - }); -}; - -module.exports.updateFeatureDescription = function (applicationName, featureName, newFeatureDescription, req, cb) { - addFeatureDescription(applicationName, featureName, newFeatureDescription, cb); -}; - -module.exports.addFeatureToggle = function (applicationName, featureName, toggleName, req, cb) { - var path = 'v1/toggles/' + applicationName + '/' + featureName + '/' + toggleName; - etcd.client.set(path, false, function (err) { - if (err) { - cb(err); - return; - } - - hooks.run({ - fn: 'addFeatureToggle', - user: getUserDetails(req), - applicationName: applicationName, - featureName: featureName, - toggleName: toggleName, - value: false - }); - - cb(); - }); -}; - -module.exports.updateFeatureMultiToggle = function (applicationName, featureName, toggleName, value, req, cb) { - var path = 'v1/toggles/' + applicationName + '/' + featureName + '/' + toggleName; - etcd.client.set(path, value, function (err) { - if (err) { - cb(err); - return; - } - - hooks.run({ - fn: 'updateFeatureToggle', - user: getUserDetails(req), - applicationName: applicationName, - featureName: featureName, - toggleName: toggleName, - value: value - }); - - cb(); - }); -}; - -module.exports.deleteFeature = function (applicationName, featureName, req, cb) { - var path = 'v1/toggles/' + applicationName + '/' + featureName; - etcd.client.delete(path, {recursive: true}, function (err) { - if (err) cb(err); - - hooks.run({ - fn: 'deleteFeature', - user: getUserDetails(req), - applicationName: applicationName, - featureName: featureName - }); - - cb(); - }); -}; diff --git a/server/routes/auditRoutes.js b/server/routes/auditRoutes.js index ff7a734..4feaa1f 100644 --- a/server/routes/auditRoutes.js +++ b/server/routes/auditRoutes.js @@ -1,6 +1,6 @@ 'use strict'; -var audit = require('./../audit'); +var audit = require('./../domain/audit'); module.exports = { getFeatureAuditTrail: function (req, res) { diff --git a/server/routes/authorisationRoutes.js b/server/routes/authorisationRoutes.js index faf2426..11d32df 100644 --- a/server/routes/authorisationRoutes.js +++ b/server/routes/authorisationRoutes.js @@ -1,10 +1,8 @@ 'use strict'; -var etcd = require('../etcd'); var _ = require('underscore'); -var acl = require('../acl'); +var acl = require('../domain/acl'); var validator = require('validator'); -var config = require('./../../config/config.json'); module.exports = { getUsers: function (req, res) { diff --git a/server/routes/loadbalancerRoutes.js b/server/routes/loadbalancerRoutes.js index b5bd6e7..121598d 100644 --- a/server/routes/loadbalancerRoutes.js +++ b/server/routes/loadbalancerRoutes.js @@ -1,9 +1,11 @@ 'use strict'; var fs = require('fs'); +var config = require('../../config/config.json'); exports.lbstatus = function (req, res) { - fs.readFile('/etc/lbstatus/hobknob', 'utf8', function (err, data) { + var filePath = config.loadBalancerFile; + fs.readFile(filePath, 'utf8', function (err, data) { if (err) { res.send(500, err); } diff --git a/server/src/hooks/audit.js b/server/src/hooks/audit.js index f9512a3..efad028 100644 --- a/server/src/hooks/audit.js +++ b/server/src/hooks/audit.js @@ -1,6 +1,6 @@ 'use strict'; -var audit = require('../../audit'); +var audit = require('../../domain/audit'); module.exports = { /*