diff --git a/.github/workflows/test_on_push.yaml b/.github/workflows/test_on_push.yaml index fcabca1..3d5462c 100644 --- a/.github/workflows/test_on_push.yaml +++ b/.github/workflows/test_on_push.yaml @@ -11,12 +11,12 @@ jobs: github.event_name == 'pull_request' && github.event.pull_request.head.repo.owner.login != 'tarantool' strategy: matrix: - tarantool: ["1.10", "2.6", "2.7", "2.8", "2.10"] + tarantool: ["1.10", "2.6", "2.7", "2.8", "2.10", "2.11", "3.0"] fail-fast: false runs-on: [ubuntu-20.04] steps: - uses: actions/checkout@master - - uses: tarantool/setup-tarantool@v1 + - uses: tarantool/setup-tarantool@v3 with: tarantool-version: '${{ matrix.tarantool }}' diff --git a/CHANGELOG.md b/CHANGELOG.md index 302e76a..ef1feb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Add more logs (gh-326). - Add `justrun` helper as a tarantool runner and output catcher (gh-365). - Changed error message for too long Unix domain socket paths (gh-341). +- Add `cbuilder` helper as a declarative configuration builder (gh-366). ## 1.0.1 diff --git a/config.ld b/config.ld index ff11295..e90fa30 100644 --- a/config.ld +++ b/config.ld @@ -8,6 +8,7 @@ file = { 'luatest/server.lua', 'luatest/replica_set.lua', 'luatest/justrun.lua', + 'luatest/cbuilder.lua' } topics = { 'CHANGELOG.md', diff --git a/luatest/cbuilder.lua b/luatest/cbuilder.lua new file mode 100644 index 0000000..2bbd79f --- /dev/null +++ b/luatest/cbuilder.lua @@ -0,0 +1,231 @@ +--- Configuration builder. +-- +-- It allows to construct a declarative configuration for a test case using +-- less boilerplace code/options, especially when a replicaset is to be +-- tested, not a single instance. All the methods supports chaining (returns +-- the builder object back). +-- +-- @usage +-- +-- local config = Builder:new() +-- :add_instance('instance-001', { +-- database = { +-- mode = 'rw', +-- }, +-- }) +-- :add_instance('instance-002', {}) +-- :add_instance('instance-003', {}) +-- :config() +-- +-- By default, all instances are added to replicaset-001 in group-001, +-- but it's possible to select a different replicaset and/or group: +-- +-- local config = Builder:new() +-- :use_group('group-001') +-- :use_replicaset('replicaset-001') +-- :add_instance(<...>) +-- :add_instance(<...>) +-- :add_instance(<...>) +-- +-- :use_group('group-002') +-- :use_replicaset('replicaset-002') +-- :add_instance(<...>) +-- :add_instance(<...>) +-- :add_instance(<...>) +-- +-- :config() +-- +-- The default credentials and iproto options are added to +-- setup replication and to allow a test to connect to the +-- instances. +-- +-- There is a few other methods: +-- +-- * :set_replicaset_option('foo.bar', value) +-- * :set_instance_option('instance-001', 'foo.bar', value) +-- +-- @classmod luatest.cbuilder + +local checks = require('checks') +local fun = require('fun') + +local Builder = require('luatest.class').new() + +-- Do a post-reqiure of the `internal.config.cluster_config`, +-- since it is available from version 3.0.0+. Otherwise we +-- will get an error when initializing the module in `luatest.init`. +local cluster_config = {} + +local base_config = { + credentials = { + users = { + replicator = { + password = 'secret', + roles = {'replication'}, + }, + client = { + password = 'secret', + roles = {'super'}, + }, + }, + }, + iproto = { + listen = {{ + uri = 'unix/:./{{ instance_name }}.iproto' + }}, + advertise = { + peer = { + login = 'replicator', + } + }, + }, + replication = { + -- The default value is 1 second. It is good for a real + -- usage, but often suboptimal for testing purposes. + -- + -- If an instance can't connect to another instance (say, + -- because it is not started yet), it retries the attempt + -- after so called 'replication interval', which is equal + -- to replication timeout. + -- + -- One second waiting means one more second for a test + -- case and, if there are many test cases with a + -- replicaset construction, it affects the test timing a + -- lot. + -- + -- replication.timeout = 0.1 second reduces the timing + -- by half for my test. + timeout = 0.1, + }, +} + +function Builder:inherit(object) + setmetatable(object, self) + self.__index = self + return object +end + +function Builder:new(config) + cluster_config = require('internal.config.cluster_config') + + config = table.deepcopy(config or base_config) + self._config = config + self._group = 'group-001' + self._replicaset = 'replicaset-001' + return self +end + +--- Select a group for following calls. +-- +-- @string group_name Group of test. +function Builder:use_group(group_name) + checks('table', 'string') + self._group = group_name + return self +end + +--- Select a replicaset for following calls. +-- +-- @string replicaset_name Replica set name. +function Builder:use_replicaset(replicaset_name) + checks('table', 'string') + self._replicaset = replicaset_name + return self +end + +--- Set option to the cluster config. +-- +-- @string path Option path. +-- @param value Option value (int, string, table). +function Builder:set_global_option(path, value) + checks('table', 'string', '?') + cluster_config:set(self._config, path, value) + return self +end + +--- Set an option for the selected group. +-- +-- @string path Option path. +-- @param value Option value (int, string, table). +function Builder:set_group_option(path, value) + checks('table', 'string', '?') + path = fun.chain({ + 'groups', self._group, + }, path:split('.')):totable() + + cluster_config:set(self._config, path, value) + return self +end + +--- Set an option for the selected replicaset. +-- +-- @string path Option path. +-- @param value Option value (int, string, table). +function Builder:set_replicaset_option(path, value) + checks('table', 'string', '?') + path = fun.chain({ + 'groups', self._group, + 'replicasets', self._replicaset, + }, path:split('.')):totable() + + -- :set() validation is too tight. Workaround + -- it. Maybe we should reconsider this :set() behavior in a + -- future. + if value == nil then + local cur = self._config + for i = 1, #path - 1 do + -- Create missed fields. + local component = path[i] + if cur[component] == nil then + cur[component] = {} + end + + cur = cur[component] + end + cur[path[#path]] = value + return self + end + + cluster_config:set(self._config, path, value) + return self +end + +-- Set an option of a particular instance in the selected replicaset. +-- +-- @string instance_name Instance where the option will be saved. +-- @string path Option path. +-- @param value Option value (int, string, table). +function Builder:set_instance_option(instance_name, path, value) + checks('table', 'string', 'string', '?') + path = fun.chain({ + 'groups', self._group, + 'replicasets', self._replicaset, + 'instances', instance_name, + }, path:split('.')):totable() + + cluster_config:set(self._config, path, value) + return self +end + +--- Add an instance with the given options to the selected replicaset. +-- +-- @string instance_name Instance where the config will be saved. +-- @tab iconfig Declarative config for the instance. +function Builder:add_instance(instance_name, iconfig) + checks('table', 'string', '?') + local path = { + 'groups', self._group, + 'replicasets', self._replicaset, + 'instances', instance_name, + } + cluster_config:set(self._config, path, iconfig) + return self +end + +--- Return the resulting configuration. +-- +function Builder:config() + return self._config +end + +return Builder diff --git a/luatest/init.lua b/luatest/init.lua index 78c0fb7..2b9c5d4 100644 --- a/luatest/init.lua +++ b/luatest/init.lua @@ -43,6 +43,11 @@ luatest.log = require('luatest.log') -- @see luatest.justrun luatest.justrun = require('luatest.justrun') +--- Declarative configuration builder helper. +-- +-- @see luatest.cbuilder +luatest.cbuilder = require('luatest.cbuilder') + --- Add before suite hook. -- -- @function before_suite diff --git a/test/cbuilder_test.lua b/test/cbuilder_test.lua new file mode 100644 index 0000000..06e93bf --- /dev/null +++ b/test/cbuilder_test.lua @@ -0,0 +1,253 @@ +local t = require('luatest') + +local config_builder = require('luatest.cbuilder') +local utils = require('luatest.utils') + +local DEFAULT_CONFIG = { + credentials = { + users = { + client = {password = 'secret', roles = {'super'}}, + replicator = {password = 'secret', roles = {'replication'}}, + }, + }, + iproto = { + advertise = {peer = {login = 'replicator'}}, + listen = {{uri = 'unix/:./{{ instance_name }}.iproto'}}, + }, + replication = {timeout = 0.1}, +} + +local function merge_config(base, diff) + if type(base) ~= 'table' or type(diff) ~= 'table' then + return diff + end + local result = table.copy(base) + for k, v in pairs(diff) do + result[k] = merge_config(result[k], v) + end + return result +end + +local g = t.group() + +g.test_default_config = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + t.assert_equals(config_builder:new():config(), DEFAULT_CONFIG) + t.assert_equals(config_builder:new({}):config(), {}) +end + +g.test_set_global_option = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :set_global_option('replication.timeout', 0.5) + :set_global_option('console.enabled', false) + :set_global_option('credentials.users.guest.privileges', { + {permissions = {'read', 'write'}, spaces = {'src'}}, + {permissions = {'read', 'write'}, spaces = {'dest'}}, + }) + :config() + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + replication = {timeout = 0.5}, + console = {enabled = false}, + credentials = { + users = { + guest = { + privileges = { + {permissions = {'read', 'write'}, spaces = {'src'}}, + {permissions = {'read', 'write'}, spaces = {'dest'}}, + }, + }, + }, + }, + })) + local builder = config_builder:new() + t.assert_error_msg_contains( + 'Unexpected data type for a record: "string"', + builder.set_global_option, builder, 'replication', 'bar') + t.assert_error_msg_contains( + 'Expected "boolean", got "string"', + builder.set_global_option, builder, 'replication.anon', 'bar') +end + +g.test_add_instance = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :add_instance('foo', {}) + :add_instance('bar', { + replication = {anon = true}, + }) + :config() + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + groups = { + ['group-001'] = { + replicasets = { + ['replicaset-001'] = { + instances = { + foo = {}, + bar = {replication = {anon = true}}, + }, + }, + }, + }, + }, + })) + local builder = config_builder:new() + t.assert_error_msg_contains( + 'Unexpected data type for a record: "string"', + builder.add_instance, builder, 'foo', {replication = 'bar'}) + t.assert_error_msg_contains( + 'Expected "boolean", got "string"', + builder.add_instance, builder, 'foo', {replication = {anon = 'bar'}}) +end + +g.test_set_instance_option = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :add_instance('foo', {}) + :set_instance_option('foo', 'database.mode', 'rw') + :add_instance('bar', { + replication = {anon = true}, + }) + :set_instance_option('bar', 'replication.anon', false) + :set_instance_option('bar', 'replication.election_mode', 'off') + :config() + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + groups = { + ['group-001'] = { + replicasets = { + ['replicaset-001'] = { + instances = { + foo = {database = {mode = 'rw'}}, + bar = { + replication = { + anon = false, + election_mode = 'off', + }, + }, + }, + }, + }, + }, + }, + })) + local builder = config_builder:new():add_instance('foo', {}) + t.assert_error_msg_contains( + 'Unexpected data type for a record: "string"', + builder.set_instance_option, builder, 'foo', 'replication', 'bar') + t.assert_error_msg_contains( + 'Expected "boolean", got "string"', + builder.set_instance_option, builder, 'foo', 'replication.anon', 'bar') +end + +g.test_set_replicaset_option = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :add_instance('foo', {}) + :set_replicaset_option('leader', 'foo') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('replication.timeout', 0.5) + :config() + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + groups = { + ['group-001'] = { + replicasets = { + ['replicaset-001'] = { + leader = 'foo', + replication = { + failover = 'manual', + timeout = 0.5, + }, + instances = {foo = {}}, + }, + }, + }, + }, + })) + local builder = config_builder:new():add_instance('foo', {}) + t.assert_error_msg_contains( + 'Unexpected data type for a record: "string"', + builder.set_replicaset_option, builder, 'replication', 'bar') + t.assert_error_msg_contains( + 'Expected "boolean", got "string"', + builder.set_replicaset_option, builder, 'replication.anon', 'bar') +end + +g.test_custom_group_and_replicaset = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :use_group('group-a') + + :use_replicaset('replicaset-x') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('leader', 'instance-x1') + :add_instance('instance-x1', {}) + :add_instance('instance-x2', {}) + :set_instance_option('instance-x1', 'memtx.memory', 100000000) + + :use_replicaset('replicaset-y') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('leader', 'instance-y1') + :add_instance('instance-y1', {}) + :add_instance('instance-y2', {}) + :set_instance_option('instance-y1', 'memtx.memory', 100000000) + + :use_group('group-b') + + :use_replicaset('replicaset-z') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('leader', 'instance-z1') + :add_instance('instance-z1', {}) + :add_instance('instance-z2', {}) + :set_instance_option('instance-z1', 'memtx.memory', 100000000) + + :config() + + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + groups = { + ['group-a'] = { + replicasets = { + ['replicaset-x'] = { + leader = 'instance-x1', + replication = {failover = 'manual'}, + instances = { + ['instance-x1'] = {memtx = {memory = 100000000}}, + ['instance-x2'] = {}, + }, + }, + ['replicaset-y'] = { + leader = 'instance-y1', + replication = {failover = 'manual'}, + instances = { + ['instance-y1'] = {memtx = {memory = 100000000}}, + ['instance-y2'] = {}, + }, + }, + }, + }, + ['group-b'] = { + replicasets = { + ['replicaset-z'] = { + leader = 'instance-z1', + replication = {failover = 'manual'}, + instances = { + ['instance-z1'] = {memtx = {memory = 100000000}}, + ['instance-z2'] = {}, + }, + }, + }, + }, + }, + })) +end