backendless-consul-config-provider
Version:
Consul Configs provider for Backendless' JS Servers
236 lines (175 loc) • 6.5 kB
JavaScript
const BackendlessRequest = require('backendless-request')
const Utils = require('./utils')
const Logger = require('./logger')
const DEFAULT_PREFIX = 'default'
const REDIS_PREFIX = 'config/redis'
const REDIS_DEFAULT_PREFIX = 'config/redis/default'
const DEFAULT_SENTINEL_MASTER_NAME = 'mymaster'
const SENTINEL_ADDRESSES = 'config/redis/sentinel/addresses'
const RedisTypes = [
'bl/debug', 'bl/production', 'cache', 'rt', 'events', 'user-session', 'properties',
'developer/session', 'analytics', 'sync', 'messaging', 'taskman', 'logservice', 'gamification'
]
const getSentinelMasterConfigPath = redisType => `config/redis/sentinel/${ redisType }/master_name`
const getSentinelPasswordConfigPath = () => `config/redis/sentinel/password`
module.exports = class ConsulApi {
constructor(url, aclToken) {
Logger.info(`Consul URL: ${ url } ${ aclToken ? 'WITH' : 'WITHOUT' } ACL TOKEN`)
this.url = url
this.aclToken = aclToken
}
loadItemsTreeByKey(serviceKey, envConfigs) {
Logger.info(`Load config tree from Consul by key: ${ serviceKey }`)
const remainsEnvConfigs = Object.assign({}, envConfigs)
const tree = {}
return Promise.resolve()
.then(() => this.loadItems(serviceKey))
.then(items => {
Logger.info(`Loaded config tree from Consul by key [${ serviceKey }]: ${ JSON.stringify(items) }`)
items.forEach(item => {
if (item.Value === null) {
Logger.warn(`Skipping Config item: ${ JSON.stringify(item) } due to item value is NULL`)
} else {
const key = item.Key.replace(serviceKey, '')
const envConfigKey = serviceKey + key
const value = adaptEnvValue(envConfigs[envConfigKey], parseSourceConsulValue(item.Value))
delete remainsEnvConfigs[envConfigKey]
setItemValueIntoTree(key, value, tree)
}
})
})
.then(() => {
Object.keys(remainsEnvConfigs).forEach(key => {
if (key.startsWith(serviceKey)) {
setItemValueIntoTree(key.replace(serviceKey, ''), remainsEnvConfigs[key], tree)
}
})
})
.then(() => tree)
}
loadItemsTreeBySchema(schema, envConfigs) {
Logger.info(`Load config tree from Consul by schema: ${ JSON.stringify(schema, null, 2) }`)
const tree = {}
const requests = []
const processObject = (mapping, target) => Object.keys(mapping).forEach(key => {
if (Utils.isObject(mapping[key])) {
processObject(mapping[key], target[key] = Utils.ensureObject(target[key]))
} else {
const itemKey = mapping[key]
const isRedisConfigItem = itemKey.includes(REDIS_PREFIX)
const setKeyToTarget = value => {
if (Utils.isObject(value)) {
return target = Object.assign(target, value)
}
return target[key] = adaptEnvValue(envConfigs[itemKey], value)
}
let promise = isRedisConfigItem ? this.loadRedisConfigItem(itemKey) : this.loadItem(itemKey)
promise = promise
.then(setKeyToTarget)
.catch(() => {
// could not load value by passed key, it might be optional, just ignore the error
})
requests.push(promise)
}
})
processObject(schema, tree)
return Promise
.all(requests)
.then(() => tree)
}
loadItem(key, opts = {}) {
let request = this.request(key, { raw: true })
if (opts.silent) {
request = request.catch(() => undefined)
}
return request
}
loadItems(key) {
return this.request(key, { recurse: true })
}
async loadSentinels(isHostItem, redisType) {
const sentinelAddresses = await this.loadItem(SENTINEL_ADDRESSES, { silent: true })
if (!isHostItem || !sentinelAddresses) {
return
}
// if sentinel address specified, connect through sentinel
const [defaultSentinelMaster, sentinelMasterName, sentinelPassword] = await Promise.all([
this.loadItem(getSentinelMasterConfigPath(DEFAULT_PREFIX), { silent: true }),
this.loadItem(getSentinelMasterConfigPath(redisType), { silent: true }),
this.loadItem(getSentinelPasswordConfigPath(), { silent: true }),
])
const sentinels = sentinelAddresses.split(';').map(address => {
const [host, port] = address.split(':')
return { host, port: Number(port) }
})
return {
sentinels,
sentinelPassword,
name: sentinelMasterName || defaultSentinelMaster || DEFAULT_SENTINEL_MASTER_NAME,
}
}
async loadRedisConfigItem(key) {
const value = await this.loadItem(key, { silent: true })
const isHostItem = key.includes('host')
if (value !== '' && value !== undefined) {
return value
}
const redisType = RedisTypes.find(type => key.startsWith(`${ REDIS_PREFIX }/${ type }`))
const keyPath = key.slice(`${ REDIS_PREFIX }/${ redisType }`.length)
const defaultKey = `${ REDIS_DEFAULT_PREFIX }${ keyPath }`
const sentinels = await this.loadSentinels(isHostItem, redisType)
if (sentinels) {
return sentinels
}
// else use direct connection (no sentinel) to default host from config or localhost
return this.loadItem(defaultKey)
}
request(key, query) {
const headers = {}
if (this.aclToken) {
headers['X-Consul-Token'] = this.aclToken
}
return BackendlessRequest
.get(`${ this.url }/v1/kv/${ key }`)
.set(headers)
.query(query)
}
}
function setItemValueIntoTree(itemKey, itemValue, tree) {
const keys = itemKey.split('/')
const targetKey = keys.pop()
let parent = tree
keys.forEach(key => {
parent = parent[key] = Utils.ensureObject(parent[key])
})
parent[targetKey] = itemValue
}
function parseSourceConsulValue(source) {
const str = Buffer.from(source, 'base64').toString()
try {
return JSON.parse(str)
} catch (e) {
//can't parse, that means it's just a string
return str
}
}
const ValueFormatter = {
'number': value => Number(value),
'boolean': value => {
value = value && value.toLowerCase()
if (value === 'true' || value === 'yes' || value === '1') {
return true
}
if (value === 'false' || value === 'no' || value === '0') {
return false
}
},
}
function adaptEnvValue(envValue, consulValue) {
if (envValue) {
const formatter = ValueFormatter[typeof consulValue]
return formatter ? formatter(envValue) : envValue
}
return consulValue
}