diff --git a/changelogs/unreleased/gh-9078-roles.md b/changelogs/unreleased/gh-9078-roles.md new file mode 100644 index 0000000000000000000000000000000000000000..00ae8694fbe5d642b33bc089c5c6c60a7e401384 --- /dev/null +++ b/changelogs/unreleased/gh-9078-roles.md @@ -0,0 +1,4 @@ +## feature/config + +* Introduced the initial support for roles - programs that run when + a configuration is loaded or reloaded (gh-9078). diff --git a/src/box/CMakeLists.txt b/src/box/CMakeLists.txt index e108f3b1f434d01d94bdf2cd1540e5ca872251ca..616d5aef395dd2ab51f2b0cb66765b3ba63ec685 100644 --- a/src/box/CMakeLists.txt +++ b/src/box/CMakeLists.txt @@ -44,6 +44,7 @@ lua_source(lua_sources lua/config/applier/console.lua config_applier_console lua_source(lua_sources lua/config/applier/credentials.lua config_applier_credentials_lua) lua_source(lua_sources lua/config/applier/fiber.lua config_applier_fiber_lua) lua_source(lua_sources lua/config/applier/mkdir.lua config_applier_mkdir_lua) +lua_source(lua_sources lua/config/applier/roles.lua config_applier_roles_lua) lua_source(lua_sources lua/config/applier/sharding.lua config_applier_sharding_lua) lua_source(lua_sources lua/config/cluster_config.lua config_cluster_config_lua) lua_source(lua_sources lua/config/configdata.lua config_configdata_lua) diff --git a/src/box/lua/config/applier/roles.lua b/src/box/lua/config/applier/roles.lua new file mode 100644 index 0000000000000000000000000000000000000000..9e343794c5dba82d8a537fe33483a6dc2d193e3b --- /dev/null +++ b/src/box/lua/config/applier/roles.lua @@ -0,0 +1,87 @@ +local log = require('internal.config.utils.log') + +local last_loaded = {} +local last_loaded_names_ordered = {} + +local function stop_roles(roles_to_skip) + for id = #last_loaded_names_ordered, 1, -1 do + local role_name = last_loaded_names_ordered[id] + if roles_to_skip == nil or roles_to_skip[role_name] == nil then + log.verbose('roles.apply: stop role ' .. role_name) + local ok, err = pcall(last_loaded[role_name].stop) + if not ok then + error(('Error stopping role %s: %s'):format(role_name, err), 0) + end + end + end +end + +local function apply(config) + local configdata = config._configdata + local role_names = configdata:get('roles', {use_default = true}) + if role_names == nil or next(role_names) == nil then + stop_roles() + return + end + + -- Remove duplicates. + local roles = {} + local roles_ordered = {} + for _, role_name in pairs(role_names) do + if roles[role_name] == nil then + table.insert(roles_ordered, role_name) + end + roles[role_name] = true + end + + -- Stop removed roles. + stop_roles(roles) + + -- Run roles. + local roles_cfg = configdata:get('roles_cfg', {use_default = true}) or {} + local loaded = {} + local loaded_names_ordered = {} + + -- Load roles. + for _, role_name in ipairs(roles_ordered) do + local role = last_loaded[role_name] + if not role then + log.verbose('roles.apply: load role ' .. role_name) + role = require(role_name) + local funcs = {'validate', 'apply', 'stop'} + for _, func_name in pairs(funcs) do + if type(role[func_name]) ~= 'function' then + local err = 'Role %s does not contain function %s' + error(err:format(role_name, func_name), 0) + end + end + end + loaded[role_name] = role + table.insert(loaded_names_ordered, role_name) + end + + -- Validate configs for all roles. + for _, role_name in ipairs(roles_ordered) do + local ok, err = pcall(loaded[role_name].validate, roles_cfg[role_name]) + if not ok then + error(('Wrong config for role %s: %s'):format(role_name, err), 0) + end + end + + -- Apply configs for all roles. + for _, role_name in ipairs(roles_ordered) do + log.verbose('roles.apply: apply config for role ' .. role_name) + local ok, err = pcall(loaded[role_name].apply, roles_cfg[role_name]) + if not ok then + error(('Error applying role %s: %s'):format(role_name, err), 0) + end + end + + last_loaded = loaded + last_loaded_names_ordered = loaded_names_ordered +end + +return { + name = 'roles', + apply = apply, +} diff --git a/src/box/lua/config/init.lua b/src/box/lua/config/init.lua index 3f45bf11a1a7c9f70557a4837a91d216b64f765b..2bd0cd5102296c77d52630f14abe4c93f9312a63 100644 --- a/src/box/lua/config/init.lua +++ b/src/box/lua/config/init.lua @@ -150,6 +150,7 @@ function methods._initialize(self) self:_register_applier(require('internal.config.applier.console')) self:_register_applier(require('internal.config.applier.fiber')) self:_register_applier(require('internal.config.applier.sharding')) + self:_register_applier(require('internal.config.applier.roles')) self:_register_applier(require('internal.config.applier.app')) if extras ~= nil then diff --git a/src/box/lua/config/instance_config.lua b/src/box/lua/config/instance_config.lua index e3881119ce2318f50cf1cf687bff666afb526006..af259943b49117a492a25a5be263079e7908e458 100644 --- a/src/box/lua/config/instance_config.lua +++ b/src/box/lua/config/instance_config.lua @@ -1802,6 +1802,13 @@ return schema.new('instance_config', schema.record({ "compatibility", }), })), + roles_cfg = schema.map({ + key = schema.scalar({type = 'string'}), + value = schema.scalar({type = 'any'}), + }), + roles = schema.array({ + items = schema.scalar({type = 'string'}) + }), }, { -- This kind of validation cannot be implemented as the -- 'validate' annotation of a particular schema node. There diff --git a/src/box/lua/init.c b/src/box/lua/init.c index feab7f57fdfa9bdd94fcad42fc7b831f1da69193..36d7e794aac3f810c8628aaf702708a3277c72e7 100644 --- a/src/box/lua/init.c +++ b/src/box/lua/init.c @@ -144,6 +144,7 @@ extern char session_lua[], config_applier_credentials_lua[], config_applier_fiber_lua[], config_applier_mkdir_lua[], + config_applier_roles_lua[], config_applier_sharding_lua[], config_cluster_config_lua[], config_configdata_lua[], @@ -387,6 +388,10 @@ static const char *lua_sources[] = { "internal.config.applier.sharding", config_applier_sharding_lua, + "config/applier/roles", + "internal.config.applier.roles", + config_applier_roles_lua, + "config/init", "config", config_init_lua, diff --git a/test/config-luatest/helpers.lua b/test/config-luatest/helpers.lua index f998f31db6d58badfc5391d4cd6166bc4e3e1522..f068024b7d343ec73991a7c67d9c88d00b121396 100644 --- a/test/config-luatest/helpers.lua +++ b/test/config-luatest/helpers.lua @@ -88,6 +88,7 @@ local simple_config = { local function prepare_case(g, opts) local dir = opts.dir + local roles = opts.roles local script = opts.script local options = opts.options @@ -95,6 +96,12 @@ local function prepare_case(g, opts) dir = treegen.prepare_directory(g, {}, {}) end + if roles ~= nil and next(roles) ~= nil then + for name, body in pairs(roles) do + treegen.write_script(dir, name .. '.lua', body) + end + end + if script ~= nil then treegen.write_script(dir, 'main.lua', script) end @@ -135,6 +142,10 @@ end -- Start a server with the given script and the given -- configuration, run a verification function on it. -- +-- * opts.roles +-- +-- Role codes for writing into corresponding files. +-- -- * opts.script -- -- Code write into the main.lua file. @@ -164,6 +175,7 @@ end -- Start tarantool process with the given script/config and check -- the error. -- +-- * opts.roles -- * opts.script -- * opts.options -- @@ -185,12 +197,17 @@ end -- Start a server, write a new script/config, reload, run a -- verification function. -- +-- * opts.roles -- * opts.script -- * opts.options -- * opts.verify -- -- Same as in success_case(). -- +-- * opts.roles_2 +-- +-- A new list of roles to prepare before config:reload(). +-- -- * opts.script_2 -- -- A new script to write into the main.lua file before @@ -205,6 +222,7 @@ end -- -- Verify test invariants after config:reload(). local function reload_success_case(g, opts) + local roles_2 = opts.roles_2 local script_2 = opts.script_2 local options = assert(opts.options) local verify_2 = assert(opts.verify_2) @@ -214,6 +232,7 @@ local function reload_success_case(g, opts) prepare_case(g, { dir = prepared.dir, + roles = roles_2, script = script_2, options = options_2, }) @@ -227,31 +246,44 @@ end -- Start a server, write a new script/config, reload, run a -- verification function. -- +-- * opts.roles -- * opts.script -- * opts.options -- * opts.verify -- -- Same as in success_case(). -- +-- * opts.roles_2 +-- +-- A new list of roles to prepare before config:reload(). +-- -- * opts.script_2 -- -- A new script to write into the main.lua file before -- config:reload(). -- +-- * opts.options_2 +-- +-- A new config to use for the config:reload(). It is optional, +-- if not provided opts.options is used instead. +-- -- * opts.exp_err -- -- An error that config:reload() must raise. local function reload_failure_case(g, opts) - local script_2 = assert(opts.script_2) + local script_2 = opts.script_2 + local roles_2 = opts.roles_2 local options = assert(opts.options) + local options_2 = opts.options_2 or options local exp_err = assert(opts.exp_err) local prepared = success_case(g, opts) prepare_case(g, { dir = prepared.dir, + roles = roles_2, script = script_2, - options = options, + options = options_2, }) t.assert_error_msg_equals(exp_err, g.server.exec, g.server, function() local config = require('config') diff --git a/test/config-luatest/roles_test.lua b/test/config-luatest/roles_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..c637af8cea8f02c18faa9408925adb84ea8d10bc --- /dev/null +++ b/test/config-luatest/roles_test.lua @@ -0,0 +1,355 @@ +local t = require('luatest') +local helpers = require('test.config-luatest.helpers') + +local g = helpers.group() + +-- Make sure the role is properly loaded. +g.test_single_role_success = function(g) + local one = [[ + local function apply(cfg) + _G.bar = cfg + end + + _G.foo = 42 + _G.bar = nil + + return { + validate = function() end, + apply = apply, + stop = function() end, + } + ]] + local verify = function() + local config = require('config') + t.assert_equals(_G.foo, 42) + local roles_cfg = config:get('roles_cfg') + t.assert_equals(roles_cfg['one'], 12345) + t.assert_equals(roles_cfg['one'], _G.bar) + end + + helpers.success_case(g, { + roles = {one = one}, + options = { + ['roles_cfg'] = {one = 12345}, + ['roles'] = {'one'} + }, + verify = verify, + }) +end + +-- Make sure the role is loaded only once during each run. +g.test_role_repeat_success = function(g) + local one = [[ + local function apply(cfg) + _G.foo = _G.foo * cfg + end + + _G.foo = 42 + + return { + validate = function() end, + apply = apply, + stop = function() end, + } + ]] + local verify = function() + local config = require('config') + local roles_cfg = config:get('roles_cfg') + t.assert_equals(roles_cfg['one'], 3) + t.assert_equals(_G.foo, 42 * 3) + end + + helpers.success_case(g, { + roles = {one = one}, + options = { + ['roles_cfg'] = {one = 3}, + ['roles'] = {'one', 'one', 'one', 'one', 'one', 'one', 'one'} + }, + verify = verify, + }) +end + +-- Make sure all roles are loaded correctly and in correct order. +g.test_multiple_role_success = function(g) + local one = [[ + local function apply(cfg) + _G.foo = tonumber(cfg) + end + + _G.foo = nil + + return { + validate = function() end, + apply = apply, + stop = function() end, + } + ]] + + local two = [[ + local function apply(cfg) + _G.foo = _G.foo + tonumber(cfg) + end + + return { + validate = function() end, + apply = apply, + stop = function() end, + } + ]] + + local three = [[ + local function apply(cfg) + _G.foo = foo / tonumber(cfg) + end + + return { + validate = function() end, + apply = apply, + stop = function() end, + } + ]] + + local verify = function() + local config = require('config') + t.assert_equals(config:get('roles'), {'one', 'two', 'three'}) + local roles_cfg = config:get('roles_cfg') + local one = roles_cfg['one'] + local two = roles_cfg['two'] + local three = roles_cfg['three'] + t.assert_equals(one, 42) + t.assert_equals(two, '24') + t.assert_equals(three, 3) + t.assert_equals(_G.foo, 22) + end + + helpers.success_case(g, { + roles = {one = one, two = two, three = three}, + options = { + ['roles_cfg'] = {one = 42, two = '24', three = 3}, + ['roles'] = {'one', 'two', 'three'} + }, + verify = verify, + }) +end + +-- Make sure the roles call apply() during a reload. +g.test_role_reload_success = function(g) + local one = [[ + local function apply(cfg) + _G.foo = _G.foo * cfg + end + + _G.foo = 42 + + return { + validate = function() end, + apply = apply, + stop = function() end, + } + ]] + + local verify = function() + local config = require('config') + local roles_cfg = config:get('roles_cfg') + t.assert_equals(roles_cfg['one'], 2) + t.assert_equals(_G.foo, 42 * 2) + end + + local verify_2 = function() + local config = require('config') + local roles_cfg = config:get('roles_cfg') + t.assert_equals(roles_cfg['one'], 3) + t.assert_equals(_G.foo, 42 * 2 * 3) + end + + helpers.reload_success_case(g, { + roles = {one = one}, + roles_2 = {one = one}, + options = { + ['roles_cfg'] = {one = 2}, + ['roles'] = {'one'} + }, + options_2 = { + ['roles_cfg'] = {one = 3}, + ['roles'] = {'one'} + }, + verify = verify, + verify_2 = verify_2, + }) +end + +-- Make sure the roles are stopped after reload if they were removed from the +-- configuration. +g.test_role_stop_success = function(g) + local one = [[ + local function apply(cfg) + _G.foo = _G.foo * cfg + end + + local function stop() + _G.foo = _G.foo - 100 + end + + _G.foo = 42 + + return { + validate = function() end, + apply = apply, + stop = stop, + } + ]] + + local verify = function() + local config = require('config') + t.assert_equals(config:get('roles'), {'one'}) + local roles_cfg = config:get('roles_cfg') + t.assert_equals(roles_cfg['one'], 2) + t.assert_equals(_G.foo, 42 * 2) + end + + local verify_2 = function() + local config = require('config') + t.assert_equals(config:get('roles'), nil) + local roles_cfg = config:get('roles_cfg') + t.assert_equals(roles_cfg['one'], 1) + t.assert_equals(_G.foo, 42 * 2 - 100) + end + + helpers.reload_success_case(g, { + roles = {one = one}, + roles_2 = {one = one}, + options = { + ['roles_cfg'] = {one = 2}, + ['roles'] = {'one'} + }, + options_2 = { + ['roles_cfg'] = {one = 1}, + }, + verify = verify, + verify_2 = verify_2, + }) +end + +-- Ensure that errors during config validation are handled correctly. +g.test_role_validate_error = function(g) + local one = [[ + local function validate(cfg) + error('something wrong', 0) + end + + return { + validate = validate, + apply = function() end, + stop = function() end, + } + ]] + + helpers.failure_case(g, { + roles = {one = one}, + options = { + ['roles_cfg'] = {one = 1}, + ['roles'] = {'one'} + }, + exp_err = 'Wrong config for role one: something wrong' + }) +end + +-- Ensure that errors during role application are handled correctly. +g.test_role_apply_error = function(g) + local one = [[ + local function apply(cfg) + error('something wrong', 0) + end + + return { + validate = function() end, + apply = apply, + stop = function() end, + } + ]] + + helpers.failure_case(g, { + roles = {one = one}, + options = { + ['roles_cfg'] = {one = 1}, + ['roles'] = {'one'} + }, + exp_err = 'Error applying role one: something wrong' + }) +end + +-- Make sure an error is raised if not all methods are present. +g.test_role_no_method_error = function(g) + local one = [[ + return { + apply = function() end, + stop = function() end, + } + ]] + helpers.failure_case(g, { + roles = {one = one}, + options = { + ['roles_cfg'] = {one = 1}, + ['roles'] = {'one'} + }, + exp_err = 'Role one does not contain function validate' + }) + + one = [[ + return { + validate = function() end, + stop = function() end, + } + ]] + helpers.failure_case(g, { + roles = {one = one}, + options = { + ['roles_cfg'] = {one = 1}, + ['roles'] = {'one'} + }, + exp_err = 'Role one does not contain function apply' + }) + + one = [[ + return { + validate = function() end, + apply = function() end, + } + ]] + helpers.failure_case(g, { + roles = {one = one}, + options = { + ['roles_cfg'] = {one = 1}, + ['roles'] = {'one'} + }, + exp_err = 'Role one does not contain function stop' + }) +end + +-- Ensure that errors during role stopping are handled correctly. +-- +-- Also verify that the error is raised by config:reload() and the same error +-- appears in the alerts. +g.test_role_reload_error = function(g) + local one = [[ + return { + validate = function() end, + apply = function() end, + stop = function() error('Wrongly stopped', 0) end, + } + ]] + + helpers.reload_failure_case(g, { + roles = {one = one}, + roles_2 = {one = one}, + options = { + ['roles_cfg'] = {one = 1}, + ['roles'] = {'one'} + }, + options_2 = { + ['roles_cfg'] = {one = 1}, + }, + verify = function() end, + exp_err = 'Error stopping role one: Wrongly stopped' + }) +end