diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index ce7aa07..f61ae57 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -85,9 +85,7 @@ jobs: id: cache-rocks with: path: .rocks/ - key: "cache-rocks-${{ matrix.tarantool }}${{ env.VERSION_POSTFIX }}-\ - ${{ matrix.cartridge-version }}-\ - ${{ matrix.metrics-version }}" + key: "cache-rocks-${{ matrix.tarantool }}${{ env.VERSION_POSTFIX }}" - name: Setup tt run: | diff --git a/AUTHORS b/AUTHORS index e3580f2..1f96daa 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,7 +3,7 @@ many contributions from the community. Below follows a list of people, who contributed their code. -Fedor Terekhin +Fedor Terekhin, Oleg Jukovec NOTE: If you can commit a change to this list, please do not hesitate to add your name to it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a87ba5..820afa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The role implementation (#6). + ### Fixed ### Changed diff --git a/Makefile b/Makefile index 451c8a6..2190bd7 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,25 @@ MAKEFILE_PATH := $(abspath $(lastword $(MAKEFILE_LIST))) PROJECT_DIR := $(patsubst %/,%,$(dir $(MAKEFILE_PATH))) +# Look for .rocks/bin directories upward starting from the project +# directory. +# +# It is useful for luacheck and luatest. +# +# Note: The PROJECT_DIR holds a real path. +define ENABLE_ROCKS_BIN + $(if $(wildcard $1/.rocks/bin), + $(eval ROCKS_PATH := $(if $(ROCKS_PATH),$(ROCKS_PATH):,)$1/.rocks/bin) + ) + $(if $1, + $(eval $(call ENABLE_ROCKS_BIN,$(patsubst %/,%,$(dir $1)))) + ) +endef +$(eval $(call ENABLE_ROCKS_BIN,$(PROJECT_DIR))) + +# Add found .rocks/bin to PATH. +PATH := $(if $(ROCKS_PATH),$(ROCKS_PATH):$(PATH),$(PATH)) + SHELL := $(shell which bash) SEED ?= $(shell /bin/bash -c "echo $$RANDOM") diff --git a/metrics-export-role-scm-1.rockspec b/metrics-export-role-scm-1.rockspec index d23c163..2d88402 100644 --- a/metrics-export-role-scm-1.rockspec +++ b/metrics-export-role-scm-1.rockspec @@ -1,18 +1,24 @@ package = "metrics-export-role" version = "scm-1" + source = { url = "git+https://github.com/tarantool/metrics-export-role", branch = "master", } + description = { summary = "The Tarantool 3 role for metrics export via HTTP", homepage = "https://github.com/tarantool/metrics-export-role", license = "BSD2", maintainer = "Fedor Terekhin " } + dependencies = { "lua >= 5.1", + "tarantool >= 3.0", + "http >= 1.5.0", } + build = { type = "builtin", modules = { diff --git a/roles/metrics-export.lua b/roles/metrics-export.lua index 2ec614c..293dc95 100644 --- a/roles/metrics-export.lua +++ b/roles/metrics-export.lua @@ -1,17 +1,329 @@ -local function validate() +local urilib = require("uri") +local http_server = require('http.server') +local M = {} + +local function is_array(tbl) + assert(type(tbl) == "table", "a table expected") + for k, _ in pairs(tbl) do + local found = false + for idx, _ in ipairs(tbl) do + if type(k) == type(idx) and k == idx then + found = true + end + end + if not found then + return false + end + end + return true +end + +local function delete_route(httpd, name) + local route = assert(httpd.iroutes[name]) + httpd.iroutes[name] = nil + table.remove(httpd.routes, route) + + -- Update httpd.iroutes numeration. + for n, r in ipairs(httpd.routes) do + if r.name then + httpd.iroutes[r.name] = n + end + end +end + +-- Removes extra '/' from start and end of the path to avoid paths duplication. +local function remove_side_slashes(path) + if path:startswith('/') then + path = string.lstrip(path, '/') + end + if path:endswith('/') then + path = string.rstrip(path, '/') + end + return '/' .. path +end + +local function parse_listen(listen) + if listen == nil then + return nil, nil, "must exist" + end + if type(listen) ~= "string" and type(listen) ~= "number" then + return nil, nil, "must be a string or a number, got " .. type(listen) + end + + local host + local port + if type(listen) == "string" then + local uri, err = urilib.parse(listen) + if err ~= nil then + return nil, nil, "failed to parse URI: " .. err + end + + if uri.scheme ~= nil then + if uri.scheme == "unix" then + uri.unix = uri.path + else + return nil, nil, "URI scheme is not supported" + end + end + + if uri.login ~= nil or uri.password then + return nil, nil, "URI login and password are not supported" + end + + if uri.query ~= nil then + return nil, nil, "URI query component is not supported" + end + + if uri.unix ~= nil then + host = "unix/" + port = uri.unix + else + if uri.service == nil then + return nil, nil, "URI must contain a port" + end + + port = tonumber(uri.service) + if port == nil then + return nil, nil, "URI port must be a number" + end + if uri.host ~= nil then + host = uri.host + elseif uri.ipv4 ~= nil then + host = uri.ipv4 + elseif uri.ipv6 ~= nil then + host = uri.ipv6 + else + host = "0.0.0.0" + end + end + elseif type(listen) == "number" then + host = "0.0.0.0" + port = listen + end + + if type(port) == "number" and (port < 1 or port > 65535) then + return nil, nil, "port must be in the range [1, 65535]" + end + return host, port, nil +end + +local http_handlers = { + json = function(req) + local json_exporter = require('metrics.plugins.json') + return req:render({ text = json_exporter.export() }) + end, + prometheus = function(...) + local http_handler = require('metrics.plugins.prometheus').collect_http + return http_handler(...) + end, +} +-- It is used as an error string with the predefined order. +local http_supported_formats_str = "json, prometheus" + +local function validate_http_endpoint(endpoint) + if type(endpoint) ~= "table" then + error("http endpoint must be a table, got " .. type(endpoint), 4) + end + if endpoint.path == nil then + error("http endpoint 'path' must exist", 4) + end + if type(endpoint.path) ~= "string" then + error("http endpoint 'path' must be a string, got " .. type(endpoint.path), 4) + end + if string.sub(endpoint.path, 1, 1) ~= '/' then + error("http endpoint 'path' must start with '/', got " .. endpoint.path, 4) + end + + if endpoint.format == nil then + error("http endpoint 'format' must exist", 4) + end + if type(endpoint.format) ~= "string" then + error("http endpoint 'format' must be a string, got " .. type(endpoint.format), 4) + end + + if not http_handlers[endpoint.format] then + error("http endpoint 'format' must be one of: " .. + http_supported_formats_str .. ", got " .. endpoint.format, 4) + end +end + +local function validate_http_node(node) + if type(node) ~= "table" then + error("http configuration node must be a table, got " .. type(node), 3) + end + + local _, _, err = parse_listen(node.listen) + if err ~= nil then + error("failed to parse http 'listen' param: " .. err, 3) + end + + node.endpoints = node.endpoints or {} + if type(node.endpoints) ~= "table" then + error("http 'endpoints' must be a table, got " .. type(node.endpoints), 3) + end + if not is_array(node.endpoints) then + error("http 'endpoints' must be an array, not a map", 3) + end + for _, endpoint in ipairs(node.endpoints) do + validate_http_endpoint(endpoint) + end + + for i, ei in ipairs(node.endpoints) do + local pathi = remove_side_slashes(ei.path) + for j, ej in ipairs(node.endpoints) do + if i ~= j then + local pathj = remove_side_slashes(ej.path) + if pathi == pathj then + error("http 'endpoints' must have different paths", 3) + end + end + end + end end -local function apply() +local http_servers = nil + +local function validate_http(conf) + if conf ~= nil and type(conf) ~= "table" then + error("http configuration must be a table, got " .. type(conf), 2) + end + conf = conf or {} + + if not is_array(conf) then + error("http configuration must be an array, not a map", 2) + end + + for _, http_node in ipairs(conf) do + validate_http_node(http_node) + end + for i, nodei in ipairs(conf) do + local hosti, porti, erri = parse_listen(nodei.listen) + assert(erri == nil) -- We should already successfully parse the URI. + for j, nodej in ipairs(conf) do + if i ~= j then + local hostj, portj, errj = parse_listen(nodej.listen) + assert(errj == nil) -- The same. + if hosti == hostj and porti == portj then + error("http configuration nodes must have different listen targets", 2) + end + end + end + end end -local function stop() +local function apply_http(conf) + local enabled = {} + for _, node in ipairs(conf) do + if #(node.endpoints or {}) > 0 then + local host, port, err = parse_listen(node.listen) + if err ~= nil then + error("failed to parse URI: " .. err, 2) + end + local listen = node.listen + + http_servers = http_servers or {} + enabled[listen] = true + + if http_servers[listen] == nil then + local httpd = http_server.new(host, port) + httpd:start() + http_servers[listen] = { + httpd = httpd, + routes = {}, + } + end + local server = http_servers[listen] + local httpd = server.httpd + local old_routes = server.routes + + local new_routes = {} + for _, endpoint in ipairs(node.endpoints) do + local path = remove_side_slashes(endpoint.path) + new_routes[path] = endpoint.format + end + + -- Remove old routes. + for path, format in pairs(old_routes) do + if new_routes[path] == nil or new_routes[path] ~= format then + delete_route(httpd, path) + old_routes[path] = nil + end + end + + -- Add new routes. + for path, format in pairs(new_routes) do + if old_routes[path] == nil then + httpd:route({ + method = "GET", + path = path, + }, http_handlers[format]) + else + assert(old_routes[path] == nil + or old_routes[path] == new_routes[path]) + end + end + + -- Update routers for a server. + server.routes = new_routes + end + end + for listen, server in pairs(http_servers) do + if not enabled[listen] then + server.httpd:stop() + http_servers[listen] = nil + end + end end -return { - validate = validate, - apply = apply, - stop = stop, +local function stop_http() + for _, server in pairs(http_servers or {}) do + server.httpd:stop() + end + http_servers = nil +end + +local exports = { + ["http"] = { + validate = validate_http, + apply = apply_http, + stop = stop_http, + }, } + +M.validate = function(conf) + if conf ~= nil and type(conf) ~= "table" then + error("configuration must be a table, got " .. type(conf)) + end + + for export_type, opts in pairs(conf or {}) do + if type(export_type) ~= "string" then + error("export type must be a string, got " .. type(export_type)) + end + if exports[export_type] == nil then + error("unsupported export type '" .. tostring(export_type) .. "'") + end + exports[export_type].validate(opts) + end +end + +M.apply = function(conf) + M.stop() + -- This should be called on the role's lifecycle, but it's better to give + -- a meaningful error if something goes wrong. + M.validate(conf) + + for export_type, opts in pairs(conf or {}) do + exports[export_type].apply(opts) + end +end + +M.stop = function() + for _, callbacks in pairs(exports) do + callbacks.stop() + end +end + +return M diff --git a/test/entrypoint/config.yaml b/test/entrypoint/config.yaml new file mode 100644 index 0000000..adfd9a3 --- /dev/null +++ b/test/entrypoint/config.yaml @@ -0,0 +1,32 @@ +credentials: + users: + guest: + roles: [super] + +groups: + group-001: + replicasets: + replicaset-001: + instances: + master: + roles: [roles.metrics-export] + roles_cfg: + roles.metrics-export: + http: + - listen: 8081 + endpoints: + - path: /metrics/json + format: json + - path: /metrics/prometheus/ + format: prometheus + - listen: '127.0.0.1:8082' + endpoints: + - path: /metrics/prometheus + format: prometheus + - path: /metrics/json/ + format: json + iproto: + listen: + - uri: '127.0.0.1:3313' + database: + mode: rw diff --git a/test/helper.lua b/test/helper.lua deleted file mode 100644 index 0183be3..0000000 --- a/test/helper.lua +++ /dev/null @@ -1,286 +0,0 @@ -local t = require("luatest") -local fio = require("fio") - -local helpers = require("luatest.helpers") - -helpers.project_root = fio.dirname(debug.sourcedir()) - -function helpers.create_space(space_name, engine) - local space_format = { - { - name = "id", - type = "number" - }, - { - name = "first_name", - type = "string" - }, - { - name = "value", - type = "number", - is_nullable = true - }, - { - name = "count", - type = "number", - is_nullable = true - }, - { - name = "non_unique_id", - type = "number", - is_nullable = true, - }, - { - name = "json_path_field", - is_nullable = true, - }, - { - name = "multikey_field", - is_nullable = true - }, - { - name = "functional_field", - is_nullable = true - }, - } - - local space = box.schema.create_space(space_name, { - engine = engine - }) - space:format(space_format) - - return space -end - -function helpers.create_space_with_tree_index(engine) - local space = helpers.create_space("tree", engine) - - space:create_index("primary", { - type = "TREE", - parts = { - { - field = 1 - } - } - }) - space:create_index("index_for_first_name", { - type = "TREE", - parts = { - { - field = 2 - } - } - }) - space:create_index("multipart_index", { - type = "TREE", - parts = { - { - field = 3, - is_nullable = true - }, - { - field = 4, - is_nullable = true - } - } - }) - space:create_index("non_unique_index", { - type = "TREE", - parts = { - { - field = 5, - is_nullable = true - } - }, - unique = false - }) - - if _TARANTOOL >= "2" then - space:create_index("json_path_index", { - type = "TREE", - parts = { - { - field = 6, - type = "scalar", - path = "age", - is_nullable = true - } - } - }) - space:create_index("multikey_index", { - type = "TREE", - parts = { - { - field = 7, - type = "str", - path = "data[*].name" - } - } - }) - if engine ~= "vinyl" then - space:create_index("functional_index", { - type = "TREE", - parts = { - { - field = 1, - type = "string" - } - }, - func = "tree_func" - }) - end - end - - return space -end - -function helpers.create_space_with_hash_index(engine) - local space = helpers.create_space("hash", engine) - space:create_index("primary", { - type = "HASH", - parts = { - { - field = 1 - } - } - }) - space:create_index("index_for_first_name", { - type = "HASH", - parts = { - { - field = 2 - } - } - }) - space:create_index("multipart_index", { - type = "HASH", - parts = { - { - field = 1 - }, - { - field = 2 - } - } - }) - - return space -end - -function helpers.create_space_with_bitset_index(engine) - local space = helpers.create_space("bitset", engine) - space:create_index("primary", { - type = "TREE", - parts = { - { - field = 1 - } - } - }) - space:create_index("index_for_first_name", { - type = "BITSET", - parts = { - { - field = 2, - type = "string" - } - }, - unique = false - }) - - return space -end - -t.after_suite(function() - fio.rmtree(t.datadir) -end) - -t.before_suite(function() - t.datadir = fio.tempdir() - box.cfg{ - wal_dir = t.datadir, - memtx_dir = t.datadir, - vinyl_dir = t.datadir, - } - - local tree_code = [[function(tuple) - if tuple[8] then - return {string.sub(tuple[8],2,2)} - end - return {tuple[2]} - end]] - if _TARANTOOL >= "2" then - box.schema.func.create("tree_func", { - body = tree_code, - is_deterministic = true, - is_sandboxed = true - }) - end -end) - -function helpers.is_metrics_supported() - local is_package, metrics = pcall(require, "metrics") - if not is_package then - return false - end - -- metrics >= 0.11.0 is required - local counter = require('metrics.collectors.counter') - return metrics.unregister_callback and counter.remove -end - -function helpers.iterate_with_func(task) - return task.index:pairs(task.start_key(), { iterator = task.iterator_type }) - :take_while( - function() - return task:process_while() - end - ) -end - -helpers.iteration_result = {} -function helpers.is_expired_debug(_, tuple) - table.insert(helpers.iteration_result, tuple) - return true -end - -function helpers.tarantool_version() - local major_minor_patch = _G._TARANTOOL:split('-', 1)[1] - local major_minor_patch_parts = major_minor_patch:split('.', 2) - - local major = tonumber(major_minor_patch_parts[1]) - local minor = tonumber(major_minor_patch_parts[2]) - local patch = tonumber(major_minor_patch_parts[3]) - - return major, minor, patch -end - -function helpers.tarantool_role_is_supported() - local major, _, _ = helpers.tarantool_version() - return major >= 3 -end - -function helpers.error_function() - error("error function call") -end - -function helpers.get_error_function(error_msg) - return function() - error(error_msg) - end -end - -function helpers.create_persistent_function(name, body) - box.schema.func.create(name, { - body = body or "function(...) return true end", - if_not_exists = true - }) -end - -local root = fio.dirname(fio.dirname(fio.abspath(package.search('test.helper')))) - -helpers.lua_path = root .. '/?.lua;' .. - root .. '/?/init.lua;' .. - root .. '/.rocks/share/tarantool/?.lua;' .. - root .. '/.rocks/share/tarantool/?/init.lua' - -return helpers diff --git a/test/helpers/init.lua b/test/helpers/init.lua new file mode 100644 index 0000000..0fb8d20 --- /dev/null +++ b/test/helpers/init.lua @@ -0,0 +1,26 @@ +local t = require("luatest") + +local helpers = {} + +local function tarantool_version() + local major_minor_patch = _G._TARANTOOL:split('-', 1)[1] + local major_minor_patch_parts = major_minor_patch:split('.', 2) + + local major = tonumber(major_minor_patch_parts[1]) + local minor = tonumber(major_minor_patch_parts[2]) + local patch = tonumber(major_minor_patch_parts[3]) + + return major, minor, patch +end + +local function tarantool_role_is_supported() + local major, _, _ = tarantool_version() + return major >= 3 +end + +function helpers.skip_if_unsupported() + t.skip_if(not tarantool_role_is_supported(), + 'Tarantool role is supported only for Tarantool starting from v3.0.0') +end + +return helpers diff --git a/test/helper_server.lua b/test/helpers/server.lua similarity index 100% rename from test/helper_server.lua rename to test/helpers/server.lua diff --git a/test/integration/metrics-export_test.lua b/test/integration/metrics-export_test.lua deleted file mode 100644 index 039bb79..0000000 --- a/test/integration/metrics-export_test.lua +++ /dev/null @@ -1,66 +0,0 @@ -local t = require('luatest') -local fio = require('fio') - -local helpers = require('test.helper') -local Server = require('test.helper_server') - -local g = t.group('metrics_export_integration_test') - -g.before_all(function (cg) - t.skip_if(not helpers.tarantool_role_is_supported(), - 'Tarantool role is supported only for Tarantool starting from v3.0.0') - - local workdir = fio.tempdir() - cg.router = Server:new({ - config_file = fio.abspath(fio.pathjoin('test', 'integration', 'simple_app', 'config.yaml')), - env = {LUA_PATH = helpers.lua_path}, - chdir = workdir, - alias = 'master', - workdir = workdir, - }) -end) - -g.before_each(function(cg) - fio.mktree(cg.router.workdir) - - -- We start instance before each test because - -- we need to force reload of metrics-export role and also instance environment - -- from previous tests can influence test result. - -- (e.g function creation, when testing that role doesn't start w/o it) - -- Restarting instance is the easiest way to achieve it. - -- It takes around 1s to start an instance, which considering small amount - -- of integration tests is not a problem. - cg.router:start{wait_until_ready = true} -end) - -g.after_each(function(cg) - cg.router:stop() - fio.rmtree(cg.router.workdir) -end) - -g.test_dummy = function(cg) - cg.router:exec(function() - box.schema.create_space('users', {if_not_exists = true}) - - box.space.users:format({ - {name = 'id', type = 'unsigned'}, - {name = 'first name', type = 'string'}, - {name = 'second name', type = 'string', is_nullable = true}, - {name = 'age', type = 'number', is_nullable = false}, - }) - - box.space.users:create_index('primary', { - parts = { - {field = 1, type = 'unsigned'}, - }, - }) - - box.space.users:insert{1, 'Samantha', 'Carter', 30} - box.space.users:insert{2, 'Fay', 'Rivers', 41} - box.space.users:insert{3, 'Zachariah', 'Peters', 13} - box.space.users:insert{4, 'Milo', 'Walters', 74} - end) - t.assert_equals(cg.router:exec(function() - return #box.space.users:select({}, {limit = 10}) - end), 4) -end diff --git a/test/integration/role_test.lua b/test/integration/role_test.lua new file mode 100644 index 0000000..a0cce08 --- /dev/null +++ b/test/integration/role_test.lua @@ -0,0 +1,79 @@ +local fio = require('fio') +local json = require('json') +local helpers = require('test.helpers') +local http_client = require('http.client') +local server = require('test.helpers.server') + +local t = require('luatest') +local g = t.group() + +g.before_all(function() + helpers.skip_if_unsupported() +end) + +g.before_each(function(cg) + cg.workdir = fio.tempdir() + fio.mktree(cg.workdir) + + fio.copytree(".rocks", fio.pathjoin(cg.workdir, ".rocks")) + fio.copytree("roles", fio.pathjoin(cg.workdir, "roles")) + + cg.router = server:new({ + config_file = fio.abspath(fio.pathjoin('test', 'entrypoint', 'config.yaml')), + chdir = cg.workdir, + alias = 'master', + workdir = cg.workdir, + }) + + -- It takes around 1s to start an instance, which considering small amount + -- of integration tests is not a problem. But at the same time, we have a + -- clean work environment. + cg.router:start{wait_until_ready = true} +end) + +g.after_each(function(cg) + cg.router:stop() + fio.rmtree(cg.workdir) +end) + +local function assert_json(uri) + local response = http_client.get(uri) + t.assert_equals(response.status, 200) + t.assert(response.body) + + local decoded = json.decode(response.body) + t.assert(#decoded > 0) + + local found = false + for _, metric in ipairs(decoded) do + if metric.metric_name == "tnt_info_uptime" then + found = true + end + end + t.assert(found) +end + +local function assert_prometheus(uri) + local response = http_client.get(uri) + t.assert_equals(response.status, 200) + t.assert(response.body) + + -- It’s not very clear how to keep the check simple and clear here. So we + -- just took a line from `prometheus` format which should be in the future + -- releases and don't clash with JSON to avoid false-positive. + t.assert_str_contains(response.body, "# TYPE tnt_info_uptime gauge") + local ok = pcall(json.decode, response.body) + t.assert_not(ok) +end + +g.test_endpoints = function() + assert_json("http://127.0.0.1:8081/metrics/json") + assert_json("http://127.0.0.1:8081/metrics/json/") + assert_prometheus("http://127.0.0.1:8081/metrics/prometheus") + assert_prometheus("http://127.0.0.1:8081/metrics/prometheus/") + + assert_prometheus("http://127.0.0.1:8082/metrics/prometheus") + assert_prometheus("http://127.0.0.1:8082/metrics/prometheus/") + assert_json("http://127.0.0.1:8082/metrics/json") + assert_json("http://127.0.0.1:8082/metrics/json/") +end diff --git a/test/integration/simple_app/config.yaml b/test/integration/simple_app/config.yaml deleted file mode 100644 index 69fe25f..0000000 --- a/test/integration/simple_app/config.yaml +++ /dev/null @@ -1,17 +0,0 @@ -credentials: - users: - guest: - roles: [super] - -groups: - group-001: - replicasets: - replicaset-001: - roles: [roles.metrics-export] - instances: - master: - iproto: - listen: - - uri: '127.0.0.1:3313' - database: - mode: rw diff --git a/test/integration/simple_app/instances.yml b/test/integration/simple_app/instances.yml deleted file mode 100644 index b79b9b8..0000000 --- a/test/integration/simple_app/instances.yml +++ /dev/null @@ -1 +0,0 @@ -master: diff --git a/test/unit/http_test.lua b/test/unit/http_test.lua new file mode 100644 index 0000000..8552407 --- /dev/null +++ b/test/unit/http_test.lua @@ -0,0 +1,226 @@ +local json = require('json') +local http_client = require('http.client') +local metrics = require('metrics') + +local t = require('luatest') +local g = t.group() + +g.before_all(function(cg) + cg.role = require('roles.metrics-export') +end) + +g.before_each(function(cg) + cg.counter = metrics.counter('some_counter') + cg.counter:inc(1, {label = 'ANY'}) +end) + +g.after_each(function(cg) + cg.role.stop() + metrics.registry:unregister(cg.counter) +end) + +local function assert_none(uri) + local response = http_client.get(uri) + t.assert_not_equals(response.status, 200) + t.assert_not(response.body) +end + +local function assert_json(uri) + local response = http_client.get(uri) + t.assert(response.body) + + local data = response.body + local decoded = json.decode(data) + for _, node in ipairs(decoded) do + node.timestamp = nil + end + t.assert_equals(decoded, { + { + label_pairs = {label = "ANY"}, + metric_name = "some_counter", + value = 1, + }, + }) +end + +local function assert_prometheus(uri) + local response = http_client.get(uri) + t.assert(response.body) + + local data = response.body + -- luacheck: ignore + local expected_prometheus = [[# HELP some_counter +# TYPE some_counter counter +some_counter{label="ANY"} 1 +]] + t.assert_equals(data, expected_prometheus) +end + +g.test_json_endpoint = function(cg) + cg.role.apply({ + http = { + { + listen = 8081, + endpoints = { + { + path = "/json_metrics", + format = "json", + }, + }, + }, + }, + }) + assert_json("http://127.0.0.1:8081/json_metrics") +end + +g.test_prometheus_endpoint = function(cg) + cg.role.apply({ + http = { + { + listen = 8081, + endpoints = { + { + path = "/prometheus_metrics", + format = "prometheus", + }, + }, + }, + }, + }) + assert_prometheus("http://127.0.0.1:8081/prometheus_metrics") +end + +g.test_mixed = function(cg) + cg.role.apply({ + http = { + { + listen = 8081, + endpoints = { + { + path = "/metrics1", + format = "json", + }, + { + path = "/metrics2", + format = "prometheus", + }, + }, + }, + { + listen = 8082, + endpoints = { + { + path = "/metrics3", + format = "json", + }, + { + path = "/metrics4", + format = "prometheus", + }, + }, + }, + }, + }) + assert_json("http://127.0.0.1:8081/metrics1") + assert_prometheus("http://127.0.0.1:8081/metrics2") + assert_none("http://127.0.0.1:8081/metrics3") + assert_none("http://127.0.0.1:8081/metrics4") + + assert_none("http://127.0.0.1:8082/metrics1") + assert_none("http://127.0.0.1:8082/metrics2") + assert_json("http://127.0.0.1:8082/metrics3") + assert_prometheus("http://127.0.0.1:8082/metrics4") +end + +g.test_reapply = function(cg) + cg.role.apply({ + http = { + { + listen = 8081, + endpoints = { + { + path = "/metrics1", + format = "json", + }, + { + path = "/metrics2", + format = "prometheus", + }, + }, + }, + { + listen = 8082, + endpoints = { + { + path = "/metrics/1", + format = "json", + }, + }, + }, + }, + }) + assert_json("http://127.0.0.1:8081/metrics1") + assert_prometheus("http://127.0.0.1:8081/metrics2") + assert_json("http://127.0.0.1:8082/metrics/1") + + cg.role.apply({ + http = { + { + listen = 8081, + endpoints = { + { + path = "/metrics1", + format = "prometheus", + }, + }, + }, + }, + }) + assert_prometheus("http://127.0.0.1:8081/metrics1") + assert_none("http://127.0.0.1:8081/metrics2") + assert_none("http://127.0.0.1:8082/metrics/1") +end + +g.test_stop = function(cg) + cg.role.apply({ + http = { + { + listen = 8081, + endpoints = { + { + path = "/metrics1", + format = "json", + }, + }, + }, + }, + }) + assert_json("http://127.0.0.1:8081/metrics1") + + cg.role.stop() + assert_none("http://127.0.0.1:8082/metrics/1") +end + +g.test_endpoint_and_slashes = function(cg) + cg.role.apply({ + http = { + { + listen = 8081, + endpoints = { + { + path = "/endpoint", + format = "json", + }, + { + path = "/endpoint/2/", + format = "json", + }, + }, + }, + }, + }) + assert_json("http://127.0.0.1:8081/endpoint") + assert_json("http://127.0.0.1:8081/endpoint/") + assert_json("http://127.0.0.1:8081/endpoint/2") + assert_json("http://127.0.0.1:8081/endpoint/2/") +end diff --git a/test/unit/metrics-export_test.lua b/test/unit/metrics-export_test.lua deleted file mode 100644 index 48a77d3..0000000 --- a/test/unit/metrics-export_test.lua +++ /dev/null @@ -1,22 +0,0 @@ -local t = require('luatest') -local helpers = require('test.helper') - -local g = t.group('metrics_export_unit_test') - -g.before_all(function() - t.skip_if(not helpers.tarantool_role_is_supported(), - 'Tarantool role is supported only for Tarantool starting from v3.0.0') - g.default_cfg = { } -end) - -g.before_each(function() - g.role = require('roles.metrics-export') -end) - -g.after_each(function() - g.role.stop() -end) - -function g.test_dummy() - t.assert_equals(g.role.validate(), nil) -end diff --git a/test/unit/validate_test.lua b/test/unit/validate_test.lua new file mode 100644 index 0000000..6c05d0a --- /dev/null +++ b/test/unit/validate_test.lua @@ -0,0 +1,554 @@ +local t = require('luatest') + +local g = t.group() + +g.before_all(function(gc) + gc.role = require('roles.metrics-export') +end) + +g.after_each(function(gc) + gc.role.stop() +end) + +local error_cases = { + ["cfg_not_table"] = { + cfg = 4, + err = "configuration must be a table, got number", + }, + ["export_type_not_string"] = { + cfg = { + [4] = {}, + }, + err = "export type must be a string, got number", + }, + ["unsupported_export_type"] = { + cfg = { + unsupported = {}, + }, + err = "unsupported export type 'unsupported'" + }, + ["http_not_table"] = { + cfg = { + http = 4, + }, + err = "http configuration must be a table, got number", + }, + ["http_is_map"] = { + cfg = { + http = { + k = 123, + }, + }, + err = "http configuration must be an array, not a map", + }, + ["http_is_map_mixed_with_array"] = { + cfg = { + http = { + k = 123, + [1] = 234, + }, + }, + err = "http configuration must be an array, not a map", + }, + ["http_node_not_a_table"] = { + cfg = { + http = { + 1, + }, + }, + err = "http configuration node must be a table, got number", + }, + ["http_node_listen_not_exist"] = { + cfg = { + http = { + { + listen = nil, + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: must exist", + }, + ["http_node_listen_not_string_and_not_number"] = { + cfg = { + http = { + { + listen = {}, + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: must be a string or a number, got table", + }, + ["http_node_listen_port_too_small"] = { + cfg = { + http = { + { + listen = 0, + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: port must be in the range [1, 65535]", + }, + ["http_node_listen_port_too_big"] = { + cfg = { + http = { + { + listen = 65536, + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: port must be in the range [1, 65535]", + }, + ["http_node_listen_uri_no_port"] = { + cfg = { + http = { + { + listen = "localhost", + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: URI must contain a port", + }, + ["http_node_listen_uri_port_too_small"] = { + cfg = { + http = { + { + listen = "localhost:0", + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: port must be in the range [1, 65535]", + }, + ["http_node_listen_uri_port_too_big"] = { + cfg = { + http = { + { + listen = "localhost:65536", + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: port must be in the range [1, 65535]", + }, + ["http_node_listen_uri_port_not_number"] = { + cfg = { + http = { + { + listen = "localhost:foo", + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: URI port must be a number", + }, + ["http_node_listen_uri_non_unix_scheme"] = { + cfg = { + http = { + { + listen = "http://localhost:123", + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: URI scheme is not supported", + }, + ["http_node_listen_uri_login_password"] = { + cfg = { + http = { + { + listen = "login:password@localhost:123", + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: URI login and password are not supported", + }, + ["http_node_listen_uri_query"] = { + cfg = { + http = { + { + listen = "localhost:123/?foo=bar", + endpoints = {}, + }, + }, + }, + err = "failed to parse http 'listen' param: URI query component is not supported", + }, + ["http_node_endpoints_not_table"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = 4, + }, + }, + }, + err = "http 'endpoints' must be a table, got number", + }, + ["http_node_endpoints_map"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = {k = 123}, + }, + }, + }, + err = "http 'endpoints' must be an array, not a map", + }, + ["http_node_endpoints_array_mixed_with_map"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + k = 123, + [1] = {}, + }, + }, + }, + }, + err = "http 'endpoints' must be an array, not a map", + }, + ["http_node_endpoint_not_table"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + 4, + }, + }, + }, + }, + err = "http endpoint must be a table, got number", + }, + ["http_node_endpoint_path_not_exist"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = nil, + format = "json", + }, + }, + }, + }, + }, + err = "http endpoint 'path' must exist", + }, + ["http_node_endpoint_path_not_string"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = 4, + format = "json", + }, + }, + }, + }, + }, + err = "http endpoint 'path' must be a string, got number", + }, + ["http_node_endpoint_path_invalid"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = "asd", + format = "json", + }, + }, + }, + }, + }, + err = "http endpoint 'path' must start with '/', got asd", + }, + ["http_node_endpoint_format_not_exist"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = "/foo", + format = nil, + }, + }, + }, + }, + }, + err = "http endpoint 'format' must exist", + }, + ["http_node_endpoint_format_not_string"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = "/foo", + format = 123, + }, + }, + }, + }, + }, + err = "http endpoint 'format' must be a string, got number", + }, + ["http_node_endpoint_format_invalid"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = "/foo", + format = "jeson", + }, + }, + }, + }, + }, + err = "http endpoint 'format' must be one of: json, prometheus, got jeson", + }, + ["http_node_endpoint_same_paths"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = "/foo", + format = "json", + }, + { + path = "/foo", + format = "prometheus", + }, + }, + }, + }, + }, + err = "http 'endpoints' must have different paths", + }, + ["http_node_endpoint_duplicate_paths"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = "//foo///", + format = "json", + }, + { + path = "////foo/////", + format = "prometheus", + }, + }, + }, + }, + }, + err = "http 'endpoints' must have different paths", + }, + ["http_nodes_same_listen"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = {}, + }, + { + listen = "localhost:123", + endpoints = {}, + }, + }, + }, + err = "http configuration nodes must have different listen targets", + }, +} + +for name, case in pairs(error_cases) do + g["test_validate_error_" .. name] = function(gc) + t.assert_error_msg_contains(case.err, function() + gc.role.validate(case.cfg) + end) + end +end + +for name, case in pairs(error_cases) do + g["test_apply_validate_error_" .. name] = function(gc) + t.assert_error_msg_contains(case.err, function() + gc.role.validate(case.cfg) + end) + end +end + +local ok_cases = { + ["nil"] = { + cfg = nil, + }, + ["empty"] = { + cfg = {}, + }, + ["empty_http"] = { + cfg = { + http = {}, + }, + }, + ["http_node_listen_port_min"] = { + cfg = { + http = { + { + listen = 1, + }, + }, + }, + }, + ["http_node_listen_port_max"] = { + cfg = { + http = { + { + listen = 65535, + }, + }, + }, + }, + ["http_node_listen_uri_port_min"] = { + cfg = { + http = { + { + listen = "localhost:1", + }, + }, + }, + }, + ["http_node_listen_uri_port_max"] = { + cfg = { + http = { + { + listen = "localhost:65535", + }, + }, + }, + }, + ["http_node_listen_uri_unix_scheme"] = { + cfg = { + http = { + { + listen = "unix:///foo/bar/some.sock", + }, + }, + }, + }, + ["http_node_listen_uri_unix_scheme_tt_style"] = { + cfg = { + http = { + { + listen = "unix:/foo/bar/some.sock", + }, + }, + }, + }, + ["http_node_listen_uri_unix"] = { + cfg = { + http = { + { + listen = "/foo/bar/some.sock", + }, + }, + }, + }, + ["http_node_endpoints_empty"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = {}, + }, + }, + }, + }, + ["http_node_endpoints_format_json"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = "/foo", + format = "json", + }, + }, + }, + }, + }, + }, + ["http_node_endpoints_format_prometheus"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = "/foo", + format = "prometheus", + }, + }, + }, + }, + }, + }, + ["http_node_endpoints_with_different_paths"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = { + { + path = "/foo", + format = "prometheus", + }, + { + path = "/fooo", + format = "prometheus", + }, + }, + }, + }, + }, + }, + ["http_node_endpoints_with_different_listens"] = { + cfg = { + http = { + { + listen = "localhost:123", + endpoints = {}, + }, + { + listen = "localhost:124", + endpoints = {}, + }, + }, + }, + }, +} + +for name, case in pairs(ok_cases) do + g["test_validate_ok_" .. name] = function(gc) + t.assert_equals(gc.role.validate(case.cfg), nil) + end +end