diff --git a/changelogs/unreleased/config-conditional-sections.md b/changelogs/unreleased/config-conditional-sections.md new file mode 100644 index 0000000000000000000000000000000000000000..019bcf244ef8171e214b0825af707e6a748f158d --- /dev/null +++ b/changelogs/unreleased/config-conditional-sections.md @@ -0,0 +1,3 @@ +## feature/config + +* For upgrading purposes, conditional sections are now supported (gh-9452). diff --git a/doc/examples/config/upgrade.yaml b/doc/examples/config/upgrade.yaml new file mode 100644 index 0000000000000000000000000000000000000000..430799b3550daa61d4f17b27e97177cc6a1089b5 --- /dev/null +++ b/doc/examples/config/upgrade.yaml @@ -0,0 +1,24 @@ +conditional: +- if: tarantool_version >= 3.99.0 && tarantool_version < 4.0.0 + # This section shouldn't be validated and shouldn't be applied. + replication: + new_option: foo +- if: tarantool_version < 3.99.0 + # This section is to be applied. + process: + title: '{{ instance_name }} -- in upgrade' + +credentials: + users: + guest: + roles: [super] + +iproto: + listen: 'unix/:./{{ instance_name }}.iproto' + +groups: + group-001: + replicasets: + replicaset-001: + instances: + instance-001: {} diff --git a/src/box/lua/config/cluster_config.lua b/src/box/lua/config/cluster_config.lua index ed76367815de7c64099a647c263cd51b417f0340..5fa4397da5ba756593c2c0c7fcd1ad7d439777c6 100644 --- a/src/box/lua/config/cluster_config.lua +++ b/src/box/lua/config/cluster_config.lua @@ -1,7 +1,26 @@ local schema = require('internal.config.utils.schema') local instance_config = require('internal.config.instance_config') +local expression = require('internal.config.utils.expression') -local function find_instance(_schema, data, instance_name) +-- Extract a field from a table. +-- +-- f({a = 1, b = 2}, 'a') -> {b = 2}, 1 +-- +-- The original table is not modified, but copied if necessary. +local function table_extract_field(t, field_name) + local field_value = t[field_name] + if field_value == nil then + return t + end + t = table.copy(t) + t[field_name] = nil + return t, field_value +end + +-- Cluster config methods. +local methods = {} + +function methods.find_instance(_self, data, instance_name) -- Find group, replicaset, instance configuration for the -- given instance. local groups = data.groups or {} @@ -24,13 +43,13 @@ local function find_instance(_schema, data, instance_name) return nil end -local function instantiate(_schema, data, instance_name) +function methods.instantiate(self, data, instance_name) -- No topology information provided. if data.groups == nil then return data end - local found = find_instance(nil, data, instance_name) + local found = self:find_instance(data, instance_name) if found == nil then local res = table.copy(data) @@ -120,7 +139,7 @@ end -- Validate instance_name, replicaset_name, group_name. -- -- Please, keep it is-sync with src/box/node_name.[ch]. -local function validate_name(_self, name) +function methods.validate_name(_self, name) local NODE_NAME_LEN_MAX = 63 if #name == 0 then return false, 'Zero length name is forbidden' @@ -145,14 +164,31 @@ end local name = schema.scalar({ type = 'string', validate = function(name, w) - local ok, err = validate_name(nil, name) + local ok, err = methods.validate_name(nil, name) if not ok then w.error(err) end end, }) -return schema.new('cluster_config', record_from_fields({ +-- Nested cluster config. It is an auxiliary schema. +-- +-- The cluster config has the following structure. +-- +-- { +-- conditional = array-of(*nested_cluster_config + if), +-- *nested_cluster_config, +-- } +-- +-- Note 1: The asterisk denotes unpacking all the fields into the +-- parent schema node. +-- +-- Note 2: The nested cluster config in the conditional section is +-- represented as an arbitrary map in the schema, but validatated +-- against the nested cluster config schema before merging into +-- the main config. +local schema_name = 'nested_cluster_config' +local nested_cluster_config = schema.new(schema_name, record_from_fields({ instance_config_with_scope('global'), groups = schema.map({ key = name, @@ -172,10 +208,122 @@ return schema.new('cluster_config', record_from_fields({ }), }), }), +})) + +-- {{{ Support conditional sections + +local conditional_vars = { + tarantool_version = _TARANTOOL:match('^%d+%.%d+%.%d+'), +} +assert(conditional_vars.tarantool_version ~= nil) + +-- Merge conditional sections into the main config. +-- +-- Accepts a cluster configuration data with conditional sections +-- as an input and returns the data with the sections removed and +-- applied to the main config. +-- +-- Conditional sections that don't fit the `if` clause criteria +-- are skipped. +-- +-- | conditional: +-- | - aaa: {bbb: 1} +-- | if: tarantool_version >= 3.0.0 +-- | - ccc: {ddd: 2} +-- | if: tarantool_version < 1.0.0 +-- | eee: {fff: 3} +-- +-- -> +-- +-- | aaa: {bbb: 1} +-- | # no ccc +-- | eee: {fff: 3} +function methods.apply_conditional(_self, data) + if data == nil then + return data + end + local data, conditional = table_extract_field(data, 'conditional') + if conditional == nil then + return data + end + + -- Look over the conditional sections and if the predicate in + -- `if` field evaluates to `true`, merge the section in the + -- data. + local res = data + for _, conditional_config in ipairs(conditional) do + local config, expr = table_extract_field(conditional_config, 'if') + if expression.eval(expr, conditional_vars) then + -- NB: The nested configuration doesn't allow a + -- presence of the 'conditional' field. Use the + -- corresponding schema to perform the merge: nested + -- cluster config. + -- + -- It is also important that we use this schema + -- instead of a map of 'any' values to perform a deep + -- merge, not just replace fields at the top level. + -- + -- NB: The section is already validated in + -- validate_conditional(). + res = nested_cluster_config:merge(res, config) + end + end + return res +end + +-- Verify conditional sections, whose `if` predicate evaluates to +-- `true`. +-- +-- Also, verify that the predicate is present in each of such +-- sections and has correct expression. +local function validate_conditional(data, w) + -- Each conditional section should have 'if' key. + local data, expr = table_extract_field(data, 'if') + if expr == nil then + w.error('A conditional section should have field "if"') + end + + -- Fail the validation if the expression is incorrect. + local ok, res = pcall(expression.eval, expr, conditional_vars) + if not ok then + w.error(res) + end + + -- Fail the validation if this conditional section is to be + -- applied, but it doesn't fit the schema. + -- + -- NB: This validation doesn't accept 'conditional' field, so + -- the appropriate schema is used for the validation (nested + -- cluster config). + if res then + nested_cluster_config:validate(data) + end +end + +return schema.new('cluster_config', record_from_fields({ + conditional = schema.array({ + items = schema.map({ + -- This map represents a cluster config with + -- additional 'if' key. + -- + -- However, it is not necessarily represents a cluster + -- config of the given tarantool version: it may be + -- older or newer and so it may contain fields that + -- are unknown from our point of view. + -- + -- It is why the mapping type is used here: we allow + -- arbitrary data and valitate only those items that + -- are going to be merged into the main config + -- (i.e. the validation is performed after the version + -- comparison). + key = schema.scalar({type = 'string'}), + value = schema.scalar({type = 'any'}), + validate = validate_conditional, + }) + }), + nested_cluster_config.schema, }), { - methods = { - instantiate = instantiate, - find_instance = find_instance, - validate_name = validate_name, - }, + methods = methods, }) + +-- }}} Support conditional sections diff --git a/src/box/lua/config/init.lua b/src/box/lua/config/init.lua index 3610d03d75b5c1519a3a59baf43155e837e2910e..4b42d7cc3fd28b96d9ca9c164e57ecb0d08b10b6 100644 --- a/src/box/lua/config/init.lua +++ b/src/box/lua/config/init.lua @@ -224,6 +224,26 @@ function methods._collect(self, opts) local source_iconfig if source.type == 'cluster' then local source_cconfig = source:get() + + -- Extract and merge conditional sections into the + -- data from the source. + -- + -- It is important to call :apply_conditional() before + -- :merge() for each cluster config. + -- + -- The 'conditional' field is an array and if several + -- sources have the field, the last one replaces all + -- the previous ones. If we merge all the configs and + -- then call :apply_conditional(), we loss all the + -- conditional sections except the last one. + -- + -- The same is applicable for config sources that + -- construct a config from several separately stored + -- parts using :merge(). They should call + -- :apply_conditional() for each of the parts before + -- :merge(). + source_cconfig = cluster_config:apply_conditional(source_cconfig) + cconfig = cluster_config:merge(source_cconfig, cconfig) source_iconfig = cluster_config:instantiate(cconfig, self._instance_name) diff --git a/test/config-luatest/conditional_section_test.lua b/test/config-luatest/conditional_section_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..bb2b098097731a837aaf737d53a9fe22e9c76b07 --- /dev/null +++ b/test/config-luatest/conditional_section_test.lua @@ -0,0 +1,199 @@ +local fun = require('fun') +local fio = require('fio') +local t = require('luatest') +local treegen = require('test.treegen') +local server = require('test.luatest_helpers.server') +local helpers = require('test.config-luatest.helpers') +local cluster_config = require('internal.config.cluster_config') + +local g = helpers.group() + +-- Verify that the example is working. +-- +-- It has one conditional section that shouldn't be applied: it +-- would fail otherwise, because it contains an unknown option. +-- +-- It also contains one more conditional section, which is to be +-- applied. It sets a custom process title, which is verified here. +g.test_example = function(g) + local dir = treegen.prepare_directory(g, {}, {}) + local config_file = fio.abspath('doc/examples/config/upgrade.yaml') + local opts = {config_file = config_file, chdir = dir} + g.server = server:new(fun.chain(opts, {alias = 'instance-001'}):tomap()) + g.server:start() + t.assert_equals(g.server:exec(function() + return box.cfg.custom_proc_title + end), 'instance-001 -- in upgrade') +end + +-- Basic scenario: one section that shouldn't be applied and one +-- to be applied. +g.test_basic = function(g) + helpers.success_case(g, { + options = { + ['conditional'] = { + { + ['if'] = 'tarantool_version < 1.0.0', + process = { + title = 'skipped section', + }, + }, + { + ['if'] = 'tarantool_version > 1.0.0', + process = { + title = 'applied section', + }, + }, + }, + ['process.title'] = 'main config', + }, + verify = function() + t.assert_equals(box.cfg.custom_proc_title, 'applied section') + end, + }) +end + +-- Verify that an unknown option is OK if it is in a sections that +-- is NOT going to be applied. +g.test_unknown_option = function(g) + helpers.success_case(g, { + options = { + ['conditional'] = { + { + ['if'] = 'tarantool_version < 1.0.0', + unknown_option = 'foo', + }, + { + ['if'] = 'tarantool_version > 1.0.0', + process = { + title = 'applied section', + }, + }, + }, + ['process.title'] = 'main config', + }, + verify = function() + t.assert_equals(box.cfg.custom_proc_title, 'applied section') + end, + }) +end + +-- Verify that the last conditional sections wins if they set the +-- same option. +g.test_priority = function(g) + helpers.success_case(g, { + options = { + ['conditional'] = { + { + ['if'] = 'tarantool_version > 1.0.0', + process = { + title = 'foo', + }, + }, + { + ['if'] = 'tarantool_version > 1.0.0', + process = { + title = 'bar', + }, + }, + { + ['if'] = 'tarantool_version > 1.0.0', + process = { + title = 'baz', + }, + }, + { + ['if'] = 'tarantool_version < 1.0.0', + process = { + title = 'skipped', + }, + }, + + }, + }, + verify = function() + t.assert_equals(box.cfg.custom_proc_title, 'baz') + end, + }) +end + +-- Several incorrect configuration cases. +g.test_validation = function() + -- 'if' should exists in each conditiona; section. + local exp_err = '[cluster_config] conditional[1]: A conditional section ' .. + 'should have field "if"' + t.assert_error_msg_equals(exp_err, function() + cluster_config:validate({ + ['conditional'] = { + { + process = { + title = 'foo', + }, + }, + }, + }) + end) + + -- The 'if' expression should be correct. + local exp_err = '[cluster_config] conditional[1]: An expression should ' .. + 'be a predicate, got variable' + t.assert_error_msg_equals(exp_err, function() + cluster_config:validate({ + ['conditional'] = { + { + ['if'] = 'incorrect', + process = { + title = 'foo', + }, + }, + }, + }) + end) + + -- A content of the section that is going to be applied is + -- validated as a cluster config. + local exp_err = '[nested_cluster_config] Unexpected field "unknown_option"' + t.assert_error_msg_equals(exp_err, function() + cluster_config:validate({ + ['conditional'] = { + { + ['if'] = 'tarantool_version > 1.0.0', + unknown_option = 'foo', + }, + }, + }) + end) +end + +-- Verify that it is possible to set an option in a nested scope: +-- say, for particular instance. +g.test_basic = function(g) + helpers.success_case(g, { + options = { + ['conditional'] = { + { + ['if'] = 'tarantool_version > 1.0.0', + groups = { + ['group-001'] = { + replicasets = { + ['replicaset-001'] = { + instances = { + ['instance-001'] = { + process = { + title = 'applied section', + }, + }, + }, + }, + }, + }, + }, + }, + }, + ['process.title'] = 'main config', + }, + verify = function() + t.assert_equals(box.cfg.custom_proc_title, 'applied section') + end, + }) +end