Skip to content

Commit

Permalink
feat(configuration): module configuration manager
Browse files Browse the repository at this point in the history
Complete rewrite of the configuration management. Configuration is loaded from:
- The module's default values
- The configuration file
- Env variables

In this respective order
  • Loading branch information
slvnperron committed Apr 29, 2018
1 parent 65b5e3e commit 1c432e3
Show file tree
Hide file tree
Showing 9 changed files with 273 additions and 21 deletions.
2 changes: 1 addition & 1 deletion packages/core/botpress/dev-bot/botfile.js
Expand Up @@ -30,7 +30,7 @@ module.exports = {
/*
Some modules might generate static configuration files
*/
modulesConfigDir: process.env.BOTPRESS_CONFIG_DIR || './modules_config',
modulesConfigDir: process.env.BOTPRESS_CONFIG_DIR || './config',

/*
Path to Content Types
Expand Down
1 change: 1 addition & 0 deletions packages/core/botpress/package.json
Expand Up @@ -26,6 +26,7 @@
"howler": "^2.0.3",
"joi": "^13.2.0",
"js-yaml": "^3.8.4",
"json5": "^1.0.1",
"jsonwebtoken": "^7.1.9",
"knex": "^0.12.6",
"loaders.css": "^0.1.2",
Expand Down
18 changes: 11 additions & 7 deletions packages/core/botpress/src/botpress.js
Expand Up @@ -33,8 +33,6 @@ import cluster from 'cluster'
import dotenv from 'dotenv'
import ms from 'ms'

import EventBus from './bus'

import createMiddlewares from './middlewares'
import createLogger from './logger'
import createSecurity from './security'
Expand All @@ -52,14 +50,17 @@ import createRenderers from './renderers'
import createUsers from './users'
import createContentManager from './content/service'
import defaultGetItemProviders from './content/getItemProviders'
import createHelpers from './helpers'
import stats from './stats'

import EventBus from './bus'
import ConfigurationManager from './config-manager'
import FlowProvider from './dialog/provider'
import StateManager from './dialog/state'
import DialogEngine from './dialog/engine'
import DialogProcessors from './dialog/processors'
import DialogJanitor from './dialog/janitor'
import SkillsManager from './skills'
import createHelpers from './helpers'
import stats from './stats'
import Queue from './queues/memory'

import packageJson from '../package.json'
Expand Down Expand Up @@ -145,13 +146,13 @@ class botpress {

const isFirstRun = fs.existsSync(path.join(projectLocation, '.welcome'))
const dataLocation = getDataLocation(botfile.dataDir, projectLocation)
const modulesConfigDir = getDataLocation(botfile.modulesConfigDir, projectLocation)
const configLocation = getDataLocation(botfile.modulesConfigDir, projectLocation)
const dbLocation = path.join(dataLocation, 'db.sqlite')
const version = packageJson.version

const logger = createLogger(dataLocation, botfile.log)
mkdirIfNeeded(dataLocation, logger)
mkdirIfNeeded(modulesConfigDir, logger)
mkdirIfNeeded(configLocation, logger)

logger.info(`Starting botpress version ${version}`)

Expand All @@ -170,6 +171,8 @@ class botpress {
cloud.updateRemoteEnv() // async on purpose
}

const configManager = new ConfigurationManager({ configLocation, botfile, logger })

const security = await createSecurity({
dataLocation,
securityConfig: botfile.login,
Expand All @@ -179,7 +182,7 @@ class botpress {
logger
})

const modules = createModules(logger, projectLocation, dataLocation, kvs)
const modules = createModules(logger, projectLocation, dataLocation, configManager)

const moduleDefinitions = modules._scan()

Expand Down Expand Up @@ -294,6 +297,7 @@ class botpress {
modules,
db,
kvs,
configManager,
cloud,
renderers,
get umm() {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/botpress/src/cli/templates/init/botfile.js
Expand Up @@ -38,7 +38,7 @@ const botfile = {
Some modules might generate static configuration files
@memberof Botfile#
*/
modulesConfigDir: process.env.BOTPRESS_CONFIG_DIR || './modules_config',
modulesConfigDir: process.env.BOTPRESS_CONFIG_DIR || './config',

/**
Path to Content Types
Expand Down
166 changes: 166 additions & 0 deletions packages/core/botpress/src/config-manager/index.js
@@ -0,0 +1,166 @@
/**
* The Configuration Manager is in charge of the configuration
* for all the modules. It knows how to provision and load configuration
* from the right places (env variables, files, botfile).
* @namespace ConfigurationManager
* @private
*/

import Joi from 'joi'
import _ from 'lodash'
import yn from 'yn'
import path from 'path'
import fs from 'fs'
import json5 from 'json5'

import ModuleConfiguration from './module'

const validations = {
any: (value, validation) => validation(value),
string: (value, validation) => typeof value === 'string' && validation(value),
choice: (value, validation) => _.includes(validation, value),
bool: (value, validation) => (yn(value) === true || yn(value) === false) && validation(value)
}

const transformers = {
bool: value => yn(value)
}

const defaultValues = {
any: null,
string: '',
bool: false
}

const amendOption = (option, name) => {
const validTypes = _.keys(validations)
if (!option.type || !_.includes(validTypes, option.type)) {
throw new Error(`Invalid type (${option.type || ''}) for config key (${name})`)
}

const validation = option.validation || (() => true)

if (typeof option.default !== 'undefined' && !validations[option.type](option.default, validation)) {
throw new Error(`Invalid default value (${option.default}) for (${name})`)
}

if (!option.default && !_.includes(_.keys(defaultValues), option.type)) {
throw new Error(`Default value is mandatory for type ${option.type} (${name})`)
}

return {
type: option.type,
required: option.required || false,
env: option.env || null,
default: option.default || defaultValues[option.type],
validation: validation
}
}

const amendOptions = options => {
return _.mapValues(options, amendOption)
}

export default class ConfigurationManager {
constructor(options) {
if (process.env.NODE_ENV !== 'production') {
const schema = Joi.object().keys({
configLocation: Joi.string()
.min(1)
.required(),
botfile: Joi.object().required(),
logger: Joi.object().required()
})

Joi.assert(options, schema, 'Invalid constructor elements for Configuration Manager')
}

this.configLocation = options.configLocation
this.botfile = options.botfile
this.logger = options.logger
this._memoizedLoadAll = _.memoize(this._loadAll.bind(this))
}

_loadFromDefaultValues(options) {
return _.mapValues(options, value => value.default)
}

_loadFromConfigFile(file, options) {
const filePath = path.resolve(this.configLocation, file)

if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf8')
return json5.parse(content)
}

return {}
}

_loadFromEnvVariables(options) {
const obj = {}

_.mapValues(process.env, (value, key) => {
if (_.isNil(value)) {
return
}
const entry = _.findKey(options, { env: key })
if (entry) {
obj[entry] = value
}
})

return obj
}

_loadAll(file, options = {}) {
options = amendOptions(options)

let config = this._loadFromDefaultValues(options)
Object.assign(config, this._loadFromConfigFile(file, options))
Object.assign(config, this._loadFromEnvVariables(options))

// Transform the values if there's a transformer for this type of value
config = _.mapValues(config, (value, key) => {
const { type } = options[key]
if (transformers[type]) {
return transformers[type](value)
} else {
return value
}
})

return config
}

/**
* Returns a Configuration for a specific module
* @param {[type]} module [description]
* @private
* @return {ModuleConfiguration} A module-specific configuration
*/
getModuleConfiguration(module) {
return new ModuleConfiguration({
manager: this,
module: module,
configLocation: this.configLocation,
logger: this.logger
})
}

/**
* Loads configuration from the right module
* @param {String} file The name of the configuration file
* @param {Object} options
* @private
* @return {Object} The full configuration object, assembled from various sources
*/
async loadAll(file, options, caching = true) {
const getter = caching ? this._memoizedLoadAll : this._loadAll
return getter(file, options)
}

async get(file, key, options, caching = true) {
const config = await this.loadAll(file, options, caching)
return config[key]
}
}
71 changes: 71 additions & 0 deletions packages/core/botpress/src/config-manager/module.js
@@ -0,0 +1,71 @@
import path from 'path'
import fs from 'fs'

export default class ModuleConfiguration {
constructor(options) {
this.manager = options.manager
this.module = options.module
this.logger = options.logger
this.configLocation = options.configLocation
}

_getFileName() {
const sanitizedName = this.module.name
.replace(/^@botpress(-)?\//i, '')
.replace(/^botpress(-)?/i, '')
.replace(path.delimiter, '_')

return `${sanitizedName}.json`
}

_getOptions() {
return this.module.options
}

_hasDefaultConfig() {
const filePath = path.resolve(this.module.path, 'config.json')
return fs.existsSync(filePath)
}

_readDefaultConfig() {
const filePath = path.resolve(this.module.path, 'config.json')
return fs.readFileSync(filePath, 'utf8')
}

async loadAll(caching = true) {
return this.manager.loadAll(this._getFileName(), this._getOptions(), caching)
}

async get(key, caching = true) {
return this.manager.get(this._getFileName(), key, this._getOptions(), caching)
}

/**
* Copy the module's default configuration file to the bot's config directory
*/
async bootstrap() {
if (!this._hasDefaultConfig()) {
return
}

const file = this._getFileName()
const filePath = path.resolve(this.configLocation, file)
const content = this._readDefaultConfig()

fs.writeFileSync(filePath, content, 'utf8')
this.logger.info(`Configuration for module "${this.module.name}" has been created at ${filePath}`)
}

/**
* Checks whether the module has a configuration file
* and if the bot doesn't have the configuration file for it.
* @return {Boolean}
*/
async isConfigMissing() {
const file = this._getFileName()
const filePath = path.resolve(this.configLocation, file)

console.log(this._hasDefaultConfig(), filePath)
return this._hasDefaultConfig() && !fs.existsSync(filePath)
}
}
12 changes: 6 additions & 6 deletions packages/core/botpress/src/configurator.js
Expand Up @@ -173,14 +173,14 @@ const createConfig = ({ kvs, name, botfile = {}, options = {}, projectLocation =
.then(all => removeUnusedKeys(options, all))
}

const get = name => {
const get = key => {
return kvs
.get('__config', name + '.' + name)
.then(value => overwriteFromDefaultValues(options, { [name]: value }))
.then(all => overwriteFromBotfileValues(name, options, botfile, all))
.then(all => overwriteFromConfigFileValues(name, options, projectLocation, all))
.get('__config', name + '.' + key)
.then(value => overwriteFromDefaultValues(options, { [key]: value }))
.then(all => overwriteFromBotfileValues(key, options, botfile, all))
.then(all => overwriteFromConfigFileValues(key, options, projectLocation, all))
.then(all => overwriteFromEnvValues(options, all))
.then(obj => obj[name])
.then(obj => obj[key])
}

const set = (name, value) => {
Expand Down
16 changes: 10 additions & 6 deletions packages/core/botpress/src/modules.js
Expand Up @@ -14,7 +14,7 @@ import { print, isDeveloping, npmCmd, resolveModuleRootPath, resolveFromDir, res
const MODULES_URL = 'https://s3.amazonaws.com/botpress-io/all-modules.json'
const FETCH_TIMEOUT = 5000

module.exports = (logger, projectLocation, dataLocation, kvs) => {
module.exports = (logger, projectLocation, dataLocation, configManager) => {
const log = (level, ...args) => {
if (logger && logger[level]) {
logger[level].apply(this, args)
Expand Down Expand Up @@ -50,13 +50,17 @@ module.exports = (logger, projectLocation, dataLocation, kvs) => {
mod.handlers = loader

try {
mod.configuration = createConfig({
kvs: kvs,
const configuration = configManager.getModuleConfiguration({
name: mod.name,
botfile: botpress.botfile,
projectLocation,
options: loader.config || {}
options: loader.config,
path: mod.root
})

if (await configuration.isConfigMissing()) {
await configuration.bootstrap()
}

mod.configuration = configuration
} catch (err) {
logger.error(`Invalid module configuration in module ${mod.name}:`, err)
}
Expand Down

0 comments on commit 1c432e3

Please sign in to comment.