Files
MaxScale/maxctrl/lib/cluster.js
Markus Mäkelä 1ed6304281 MXS-2954: Use correct object for PATCH
The destination object instead of the source object was used. This caused
the PATCH to be a no-op.

Due to how the REST API behaves when it received parameters, the set of
synced parameters must be filtered. The REST API should be more lenient
towards alterations to parameters that don't actually change the value.
2020-04-06 19:37:53 +03:00

313 lines
15 KiB
JavaScript

/*
* Copyright (c) 2016 MariaDB Corporation Ab
*
* Use of this software is governed by the Business Source License included
* in the LICENSE.TXT file and at www.mariadb.com/bsl11.
*
* Change Date: 2024-03-10
*
* On the date above, in accordance with the Business Source License, use
* of this software will be governed by version 2 or later of the General
* Public License.
*/
require('./common.js')()
var colors = require('colors/safe')
function equalResources(oldVal, newVal) {
return _.isEqual(_.get(oldVal, 'attributes.parameters'), _.get(newVal, 'attributes.parameters')) &&
_.isEqual(_.get(oldVal, 'relationships.servers.data'), _.get(newVal, 'relationships.servers.data')) &&
_.isEqual(_.get(oldVal, 'relationships.services.data'), _.get(newVal, 'relationships.services.data')) &&
_.isEqual(_.get(oldVal, 'relationships.monitors.data'), _.get(newVal, 'relationships.monitors.data'))
}
function sameResource(oldVal, newVal) {
return oldVal.id == newVal.id
}
// Return objets that are in <a> but not in <b>
function getDifference(a, b) {
return _.differenceWith(a, b, sameResource)
}
// Return a list of objects that differ from each other
function getChangedObjects(a, b) {
var ours = _.intersectionWith(a, b, sameResource)
var theirs = _.intersectionWith(b, a, sameResource)
return _.differenceWith(ours, theirs, equalResources)
}
// Check if the diffs add or delete services
function haveExtraServices(src, dest, srcHost, destHost) {
var newObj = getDifference(src.services.data, dest.services.data)
var oldObj = getDifference(dest.services.data, src.services.data)
if (newObj.length > 0 || oldObj.length > 0) {
const EOL = require('os').EOL
var srcObj = _.transform(newObj, function(out, i) {
out.push(i.id)
})
var destObj = _.transform(oldObj, function(out, i) {
out.push(i.id)
})
err = ['Cannot diff host `' + srcHost + '` with target `' +
destHost + '`: New or deleted services on target host, ' +
'both hosts must have the same services.',
'Extra services on `' + srcHost + '`:',
JSON.stringify(srcObj, null, 4),
'Extra services on `' + destHost + '`:',
JSON.stringify(destObj, null, 4)
]
return error(err.join(EOL))
}
return undefined
}
// Resource collections
const collections = [
'servers',
'monitors',
'services',
'users'
]
// Individual resources
const endpoints = [
'maxscale',
'maxscale/logs'
]
// Calculate a diff between two MaxScale servers
function getDiffs(a, b) {
var src = {}
var dest = {}
var promises = []
collections.forEach(function(i) {
promises.push(doAsyncRequest(b, i, function(res) {
dest[i] = res
}))
promises.push(doAsyncRequest(a, i, function(res) {
src[i] = res
}))
})
endpoints.forEach(function(i) {
promises.push(doAsyncRequest(b, i, function(res) {
dest[i] = res
}))
promises.push(doAsyncRequest(a, i, function(res) {
src[i] = res
}))
})
return Promise.all(promises)
.then(function() {
// We can get the listeners only after we've requested the services
dest.services.data.forEach(function(i) {
dest['services/' + i.id + '/listeners'] = { data: i.attributes.listeners }
})
src.services.data.forEach(function(i) {
src['services/' + i.id + '/listeners'] = { data: i.attributes.listeners }
})
})
.then(function() {
return Promise.resolve([src, dest])
})
}
exports.haveExtraServices = haveExtraServices
exports.getDifference = getDifference
exports.getChangedObjects = getChangedObjects
exports.command = 'cluster <command>'
exports.desc = 'Cluster objects'
exports.handler = function() {}
exports.builder = function(yargs) {
yargs
.command('diff <target>', 'Show difference between host servers and <target>.', function(yargs) {
return yargs.epilog('The list of host servers is controlled with the --hosts option. ' +
'The target server should not be in the host list. Value of <target> ' +
'must be in HOST:PORT format')
.usage('Usage: cluster diff <target>')
}, function(argv) {
maxctrl(argv, function(host) {
return getDiffs(host, argv.target)
.then(function(diffs) {
var output = []
var src = diffs[0]
var dest = diffs[1]
var err = haveExtraServices(src, dest, host, argv.target)
if (err) {
return err
}
_.uniq(_.concat(Object.keys(src), Object.keys(dest))).forEach(function(i) {
var newObj = getDifference(src[i].data, dest[i].data)
var oldObj = getDifference(dest[i].data, src[i].data)
var changed = getChangedObjects(src[i].data, dest[i].data)
if (newObj.length) {
output.push("New:", i)
output.push(colors.green(JSON.stringify(newObj, null, 4)))
}
if (oldObj.length) {
output.push("Deleted:", i)
output.push(colors.red(JSON.stringify(oldObj, null, 4)))
}
if (changed.length) {
output.push("Changed:", i)
output.push(colors.yellow(JSON.stringify(changed, null, 4)))
}
})
endpoints.forEach(function(i) {
// Treating the resource endpoints as arrays allows the same functions to be used
// to compare individual resources and resource collections
var changed = getChangedObjects([src[i].data], [dest[i].data])
if (changed.length) {
output.push("Changed:", i)
output.push(colors.yellow(JSON.stringify(changed, null, 4)))
}
})
return output.join(require('os').EOL)
})
})
})
.command('sync <target>', 'Synchronize the cluster with target MaxScale server.', function(yargs) {
return yargs.epilog('This command will alter all MaxScale instances given in the --hosts ' +
'option to represent the <target> MaxScale. If the synchronization of ' +
'a MaxScale instance fails, it will be disabled by executing the `stop maxscale` ' +
'command on that instance. Synchronization can be attempted again if a previous ' +
'attempt failed due to a network failure or some other ephemeral error. Any other ' +
'errors require manual synchronization of the MaxScale configuration files and a ' +
'restart of the failed Maxscale.')
.usage('Usage: cluster sync <target>')
}, function(argv) {
maxctrl(argv, function(host) {
return getDiffs(argv.target, host)
.then(function(diffs) {
var promises = []
var src = diffs[0]
var dest = diffs[1]
var err = haveExtraServices(src, dest, host, argv.target)
if (err) {
return err
}
// Delete old servers
getDifference(dest.servers.data, src.servers.data).forEach(function(i) {
// First unlink the servers from all services and monitors
promises.push(
doAsyncRequest(host, 'servers/' + i.id, null, {method: 'PATCH', body: _.set({}, 'data.relationships', {})})
.then(function() {
return doAsyncRequest(host, 'servers/' + i.id, null, {method: 'DELETE'})
}))
})
// Add new servers
getDifference(src.servers.data, dest.servers.data).forEach(function(i) {
// Create the servers without relationships, those are generated when services and
// monitors are updated
var newserv = {
data: {
id: i.id,
type: i.type,
attributes: {
parameters: i.attributes.parameters
}
}
}
promises.push(doAsyncRequest(host, 'servers', null, {method: 'POST', body: newserv}))
})
return Promise.all(promises)
.then(function() {
var promises = []
// Delete old monitors
getDifference(dest.monitors.data, src.monitors.data).forEach(function(i) {
promises.push(
doAsyncRequest(host, 'monitors/' + i.id, null, {
method: 'PATCH', body: _.set({}, 'data.relationships', {})
})
.then(function() {
return doAsyncRequest(host, 'monitors/' + i.id, null, {method: 'DELETE'})
}))
})
return Promise.all(promises)
})
.then(function() {
var promises = []
// Add new monitors
getDifference(src.monitors.data, dest.monitors.data).forEach(function(i) {
promises.push(doAsyncRequest(host, 'monitors', null, {method: 'POST', body: {data: i}}))
})
return Promise.all(promises)
})
.then(function() {
// Add new and delete old listeners
var promises = []
var all_keys = _.concat(Object.keys(src), Object.keys(dest))
var unwanted_keys = _.concat(collections, endpoints)
var relevant_keys = _.uniq(_.difference(all_keys, unwanted_keys))
relevant_keys.forEach(function(i) {
getDifference(dest[i].data, src[i].data).forEach(function(j) {
promises.push(doAsyncRequest(host, i + '/' + j.id, null, {method: 'DELETE'}))
})
getDifference(src[i].data, dest[i].data).forEach(function(j) {
promises.push(doAsyncRequest(host, i, null, {method: 'POST', body: {data: j}}))
})
})
return Promise.all(promises)
})
.then(function() {
var promises = []
// PATCH all remaining resource collections in src from dest apart from the
// user resource as it requires passwords to be entered
_.difference(collections, ['users']).forEach(function(i) {
src[i].data.forEach(function(j) {
promises.push(doAsyncRequest(host, i + '/' + j.id, null, {method: 'PATCH', body: {data: j}}))
})
})
if (src['maxscale']) {
// TODO: Don't filter the altered parameters on the client side. The REST
// API should ignore no-op changes.
src['maxscale'].data.attributes.parameters = _.pick(
src['maxscale'].data.attributes.parameters,
['auth_connect_timeout',
'auth_read_timeout',
'auth_write_timeout',
'admin_auth',
'admin_log_auth_failures',
'passive',
'ms_timestamp',
'skip_permission_checks',
'query_retries',
'query_retry_timeout',
'retain_last_statements',
'dump_last_statements'])
}
// Do the same for individual resources
endpoints.forEach(function(i) {
promises.push(doAsyncRequest(host, i, null, {method: 'PATCH', body: src[i]}))
})
return Promise.all(promises)
})
})
})
})
.usage('Usage: cluster <command>')
.help()
.command('*', 'the default command', {}, function(argv) {
maxctrl(argv, function() {
return error('Unknown command. See output of `help cluster` for a list of commands.')
})
})
}