diff --git a/package.json b/package.json index 6b06ab1..bf35242 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "license": "MIT", "dependencies": { "babel-polyfill": "^6.2.0", + "clone": "^2.1.1", "colors": "^1.1.2", "commander": "^2.9.0", "invariant": "^2.2.2", @@ -36,7 +37,8 @@ "babel-preset-es2015": "^6.1.18", "babel-preset-stage-2": "^6.1.18", "expect.js": "^0.3.1", - "jest": "^20.0.4" + "jest": "^20.0.4", + "should": "^13.1.3" }, "jest": { "setupFiles": [ diff --git a/src/cli-apply.js b/src/cli-apply.js index d795288..05b9896 100644 --- a/src/cli-apply.js +++ b/src/cli-apply.js @@ -1,5 +1,7 @@ import execute from './core'; import adminApi from './adminApi'; +import readKongApi from './readKongApi'; +import sync from './syncConfigs'; import colors from 'colors'; import configLoader from './configLoader'; import program from 'commander'; @@ -13,6 +15,7 @@ program .option('--path ', 'Path to the configuration file') .option('--host ', 'Kong admin host (default: localhost:8001)') .option('--https', 'Use https for admin API requests') + .option('--force', 'remove any items from the host that are not in the local config') .option('--no-cache', 'Do not cache kong state in memory') .option('--ignore-consumers', 'Do not sync consumers') .option('--header [value]', 'Custom headers to be added to all requests', (nextHeader, headers) => { headers.push(nextHeader); return headers }, []) @@ -68,8 +71,25 @@ else { console.log(`Apply config to ${host}`.green); -execute(config, adminApi({host, https, ignoreConsumers, cache}), screenLogger) - .catch(error => { - console.error(`${error}`.red, '\n', error.stack); - process.exit(1); - }); +if (program.force) { + (async () => { + try { + let remoteConfig = await readKongApi(adminApi({host, https, ignoreConsumers})); + config = sync(config, remoteConfig, ignoreConsumers); + run(); + } catch (error) { + console.error(`${error}`.red, '\n', error.stack); + } + })(); +} else { + run(); +} + + +function run() { + execute(config, adminApi({host, https, ignoreConsumers, cache}), screenLogger) + .catch(error => { + console.error(`${error}`.red, '\n', error.stack); + process.exit(1); + }); +} diff --git a/src/syncConfigs.js b/src/syncConfigs.js new file mode 100644 index 0000000..33de969 --- /dev/null +++ b/src/syncConfigs.js @@ -0,0 +1,99 @@ +'use strict'; + +import clone from 'clone'; +import { getSchema } from './consumerCredentials' + +const rootKeys = ['apis', 'consumers', 'plugins', 'upstreams']; + +const ensureRemoved = (obj) => { + obj.ensure = 'removed'; +}; + +const markPluginsForRemoval = (local, remote) => { + remote.forEach(plugin => { + if (!local.find(p => p.name === plugin.name)) { + ensureRemoved(plugin); + local.unshift(plugin); + } + }); +}; + +const markApisForRemoval = (local, remote) => { + remote.forEach(api => { + let found = local.find(a => a.name === api.name); + if (found && api.plugins) { + found.plugins = found.plugins ? found.plugins : []; + markPluginsForRemoval(found.plugins, api.plugins); + } else { + ensureRemoved(api); + local.unshift(api); + } + }) +}; + +const markCredentialsForRemoval = (local, remote) => { + remote.forEach(cred => { + let key = getSchema(cred.name).id; + if (!local.find(c=> c.attributes[key] === cred.attributes[key])) { + ensureRemoved(cred); + local.unshift(cred); + } + }); +}; + +const markConsumersForRemoval = (local, remote) => { + remote.forEach(consumer => { + let found = local.find(c => c.username === consumer.username); + if (found) { + found.credentials = found.credentials ? found.credentials : []; + markCredentialsForRemoval(found.credentials, consumer.credentials); + } else { + ensureRemoved(consumer); + local.unshift(consumer); + } + }); +}; + +const markTargetsForRemoval = (local, remote) => { + remote.forEach(target => { + if (!local.find(t => t.target === target.target)) { + ensureRemoved(target); + local.unshift(target); + } + }); +}; + +const markUpstreamsForRemoval = (local, remote) => { + remote.forEach(upstream => { + let found = local.find(u => u.name === upstream.name); + if (found) { + found.targets = found.targets ? found.targets : []; + markTargetsForRemoval(found.targets, upstream.targets); + } else { + ensureRemoved(upstream); + local.unshift(upstream); + } + }); +}; + +export default (local, remote, ignoreConsumers) => { + const localKeys = Object.keys(local); + const remoteKeys = Object.keys(remote); + + local = clone(local); + remote = clone(remote); + + rootKeys.forEach(key => { + local[key] = localKeys.includes(key) ? local[key] : []; + remote[key] = remoteKeys.includes(key) ? remote[key] : []; + }); + + markApisForRemoval(local.apis, remote.apis); + markPluginsForRemoval(local.plugins, remote.plugins); + if (!ignoreConsumers) { + markConsumersForRemoval(local.consumers, remote.consumers); + } + markUpstreamsForRemoval(local.upstreams, remote.upstreams); + + return local; +}; diff --git a/test/fixtures/syncConfigs-base.json b/test/fixtures/syncConfigs-base.json new file mode 100644 index 0000000..6da34cc --- /dev/null +++ b/test/fixtures/syncConfigs-base.json @@ -0,0 +1,119 @@ +{ + "apis": [ + { + "name": "placement-service", + "plugins": [] + }, + { + "name": "testAPI1", + "plugins": [ + { "name": "request-termination" }, + { "name": "key-auth" } + ] + },{ + "name": "lookup-service", + "plugins": [] + }, + { + "name": "testAPI2", + "plugins": [ + { "name": "key-auth" } + ] + } + ], + + "consumers": [ + { + "username": "testConsumer1", + "credentials": [ + { + "name": "key-auth", + "attributes": { "key": "1234A" } + } + ] + }, + { + "username": "testConsumer2", + "credentials": [ + { + "name": "basic-auth", + "attributes": { "username": "jdoe" } + }, + { + "name": "key-auth", + "attributes": { "key": "5678B" } + } + ] + }, + { + "username": "anonymous", + "credentials": [ + { + "name": "key-auth", + "attributes": { "key": "9012C" } + } + ] + }, + { + "username": "iosapp", + "credentials": [ + { + "name": "key-auth", + "attributes": { "key": "3456D" } + } + ] + }, + { + "username": "android", + "credentials": [ + { + "name": "key-auth", + "attributes": { "key": "7890E" } + }, + { + "name": "key-auth", + "attributes": { "key": "1234F" } + } + ] + }, + { + "username": "testConsumer3", + "credentials": [ + { + "name": "oauth2", + "attributes": { "client_id": "5678G" } + } + ] + } + ], + + "plugins": [ + { "name": "testPlugin1" }, + { "name": "basic-auth" }, + { "name": "testPlugin2" }, + { "name": "response-transformer" }, + { "name": "testPlugin3" } + ], + + "upstreams": [ + { + "name": "lookupService", + "targets": [ + { "target": "app.lookup.service.mydomain.io:8080" } + ] + }, + { + "name": "testUpstream1", + "targets": [ + { "target": "test.target1.com:8080" } + ] + }, + { + "name": "testUpstream2", + "targets": [ + { "target": "test.target2a.com:8080" }, + { "target": "test.target2b.com:8080" } + ] + } + ] +} diff --git a/test/syncConfigs.js b/test/syncConfigs.js new file mode 100644 index 0000000..1c69421 --- /dev/null +++ b/test/syncConfigs.js @@ -0,0 +1,269 @@ +import should from 'should'; +import clone from 'clone'; +import orig from './fixtures/syncConfigs-base.json'; +import sync from '../src/syncConfigs.js' + +let local, remote; + +beforeEach(done => { + local = clone(orig); + remote = clone(orig); + done(); +}); + +describe('apis', () => { + describe('when apis exist on the remote but not locally', () => { + beforeEach(done => { + local.apis = local.apis.filter(a => !a.name.startsWith('test')); + local = sync(local, remote); + done(); + }); + + it('should mark the missing apis for removal', () => { + local.apis.filter(a => a.ensure === 'removed').length.should.eql(2); + + remote.apis + .filter(a => a.name.startsWith('test')) + .forEach(api => { + api.ensure = 'removed'; + local.apis.should.containEql(api); + }); + }); + }); + + describe('when api plugins exist on the remote but not locally', () => { + beforeEach(done => { + local.apis.forEach(a => a.plugins = a.plugins ? [] : a.plugins); + local = sync(local, remote); + done(); + }); + + it('should mark the missing api plugins for removal', () => { + let localApis = local.apis.filter(a => a.plugins && a.plugins.length > 0); + + remote.apis + .filter(a => a.plugins && a.plugins.length > 0) + .forEach(api => { + let found = localApis.find(localApi => localApi.name === api.name); + should.exist(found); + api.plugins.forEach(plugin => { + plugin.ensure = 'removed'; + found.plugins.should.containEql(plugin); + }); + }); + }); + }); +}); + +describe('global plugins', () => { + describe('when global plugins exist on the remote but not locally', () => { + beforeEach(done => { + local.plugins = local.plugins.filter(p => !p.name.startsWith('test')); + local = sync(local, remote); + done(); + }); + + it('should mark the missing plugins for removal', () => { + local.plugins.filter(p => p.ensure === 'removed').length.should.eql(3); + + remote.plugins + .filter(p => p.name.startsWith('test')) + .forEach(plugin => { + plugin.ensure = 'removed'; + local.plugins.should.containEql(plugin); + }); + }); + }); +}); + +describe('consumers', () => { + describe('when consumers exist on the remote but not locally', () => { + beforeEach(done => { + local.consumers = local.consumers.filter(c => !c.username.startsWith('test')); + done(); + }); + + describe('and ignoreConsumers is falsy', () => { + it('should mark the missing consumers for removal', () => { + local = sync(local, remote); + + local.consumers.filter(c => c.ensure === 'removed').length.should.eql(3); + + remote.consumers + .filter(c => c.username.startsWith('test')) + .forEach(consumer => { + consumer.ensure = 'removed'; + local.consumers.should.containEql(consumer); + }); + }); + }); + + describe('and ignoreConsumers is truthy', () => { + it('should not mark the missing consumers for removal', () => { + local = sync(local, remote, true); + + local.consumers.filter(c => c.ensure === 'removed').length.should.eql(0); + + remote.consumers + .filter(c => c.username.startsWith('test')) + .forEach(consumer => { + consumer.ensure = 'removed'; + local.consumers.should.not.containEql(consumer); + }); + }); + }); + }); + + describe('when consumer credentials exist on the remote but not locally', () => { + beforeEach(done => { + local.consumers = local.consumers.map(c => { + c.username.startsWith('test') ? c.credentials.pop() : null; + return c; + }); + done(); + }); + + it('should mark the missing credentials for removal', () => { + local = sync(local, remote); + let removed = local.consumers.filter(c => !!c.credentials.find(c => c.ensure === 'removed')); + + removed.length.should.eql(3); + removed.forEach(c => c.credentials.filter(c => c.ensure === 'removed').length.should.eql(1)); + + remote.consumers + .filter(c => c.username.startsWith('test')) + .forEach(consumer => { + let expected = consumer.credentials.pop(); + let found = local.consumers.find(c => c.username === consumer.username); + should.exist(found); + expected.ensure = 'removed'; + found.credentials.should.containEql(expected); + }); + }); + + describe('if ignoreConsumers is falsy', () => { + it('should not mark the missing credentials for removal', () => { + local = sync(local, remote, true); + let removed = local.consumers.filter(c => !!c.credentials.find(c => c.ensure === 'removed')); + + removed.length.should.eql(0); + + remote.consumers + .filter(c => c.username.startsWith('test')) + .forEach(consumer => { + let expected = consumer.credentials.pop(); + let found = local.consumers.find(c => c.username === consumer.username); + should.exist(found); + expected.ensure = 'removed'; + found.credentials.should.not.containEql(expected); + }); + }); + }); + }); +}); + +describe('upstreams', () => { + describe('when upstreams exist on the remote but not locally', () => { + beforeEach(done => { + local.upstreams = local.upstreams.filter(u => !u.name.startsWith('test')); + local = sync(local, remote); + done(); + }); + + it('should mark the missing upstreams for removal', () => { + let expected = remote.upstreams.filter(u => u.name.startsWith('test')); + + local.upstreams.filter(u => u.ensure === 'removed').length.should.eql(2); + + expected.forEach(upstream => { + upstream.ensure = 'removed'; + local.upstreams.should.containEql(upstream); + }); + }); + }); + + describe('when upstream targets exist on the remote but not locally', () => { + beforeEach(done => { + local.upstreams = local.upstreams.map(u => { + u.name.startsWith('test') ? u.targets.pop() : null; + return u; + }); + local = sync(local, remote); + done(); + }); + + it('should mark the missing upstream targets for removal', () => { + let removed = local.upstreams.filter(u => !!u.targets.find(t => t.ensure === 'removed')); + + removed.length.should.eql(2); + removed.forEach(u => u.targets.filter(t => t.ensure === 'removed').length.should.eql(1)); + + remote.upstreams + .filter(u => u.name.startsWith('test')) + .forEach(upstream => { + let expected = upstream.targets.pop(); + let found = local.upstreams.find(u => u.name === upstream.name); + should.exist(found); + expected.ensure = 'removed'; + found.targets.should.containEql(expected); + }); + }); + }); +}); + +describe('combined', () => { + const rootKeys = ['apis', 'consumers', 'plugins', 'upstreams']; + + describe('when root elements exist locally, but not on the remote', () => { + it('should do nothing to the local config', () => { + let expected = clone(local); + rootKeys.forEach(key => delete remote[key]); + local = sync(local, remote); + + expected.should.eql(local); + }); + }); + + describe('when root elements exist on the remote, but not locally', () => { + beforeEach(done => { + remote.foo = 'bar'; + rootKeys.forEach(key => delete local[key]); + local = sync(local, remote); + done(); + }); + + it('should create them locally and mark them for removal', () => { + Object.keys(local).should.containDeep(rootKeys); + rootKeys.forEach(val => { + local[val].forEach(obj => { + obj.should.containEql({ensure: 'removed'}); + }); + }); + }); + + it('should not add unexpected root elements', () => { + Object.keys(local).should.not.containDeep(['foo']); + }); + + describe('if ignoreConsumers is truthy', () => { + it('should mark everything except consumers for removal', () => { + local = clone(orig); + rootKeys.forEach(key => delete local[key]); + local = sync(local, remote, true); + + rootKeys.forEach(key => { + remote[key].forEach(o => { + o.ensure = 'removed'; + + if (key === 'consumers') { + local[key].should.not.containEql(o); + } else { + local[key].should.containEql(o); + } + }); + }); + }); + }); + }); +}); +