From 6546bb9c67bbd4fd6d065c2d9e5cfcb5eeab879d Mon Sep 17 00:00:00 2001 From: Oleg Jukovec Date: Mon, 8 Jul 2024 15:15:40 +0300 Subject: [PATCH] wip: add validation --- roles/metrics-export.lua | 205 +++++++++++- test/helper.lua | 5 + test/unit/metrics-export_test.lua | 19 +- test/unit/validate_test.lua | 511 ++++++++++++++++++++++++++++++ 4 files changed, 722 insertions(+), 18 deletions(-) create mode 100644 test/unit/validate_test.lua diff --git a/roles/metrics-export.lua b/roles/metrics-export.lua index 2ec614c..3c7817e 100644 --- a/roles/metrics-export.lua +++ b/roles/metrics-export.lua @@ -1,17 +1,208 @@ -local function validate() +local urilib = require("uri") +local M = {} + +local supported_formats = {"json", "prometheus"} + +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 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 + + local ok, ret = pcall(tonumber, uri.service) + if not ok 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 + port = ret + 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 range [1, 65535]" + end + return host, port, nil +end + +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 + local found = false + for _, supported in pairs(supported_formats) do + if endpoint.format == supported then + found = true + end + end + if not found then + error("http endpoint 'format' must be one of: " .. + table.concat(supported_formats, ", ") .. ", got " .. endpoint.format, 4) + end end -local function apply() +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 + for j, ej in ipairs(node.endpoints) do + if i ~= j and ei.path == ej.path then + error("http 'endpoints' must have different paths", 3) + end + end + end end -local function stop() +local function validate_http(opts) + if opts ~= nil and type(opts) ~= "table" then + error("http configuration must be a table, got " .. type(opts), 2) + end + opts = opts or {} + + if not is_array(opts) then + error("http configuration must be an array, not a map", 2) + end + + for _, http_node in ipairs(opts) do + validate_http_node(http_node) + end + for i, nodei in ipairs(opts) do + local hosti, porti, erri = parse_listen(nodei.listen) + assert(erri == nil) -- We should already successfully parse the URI. + for j, nodej in ipairs(opts) 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 -return { - validate = validate, - apply = apply, - stop = stop, +local export_type_validators = { + ["http"] = validate_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 export_type_validators[export_type] == nil then + error("unsupported export type '" .. tostring(export_type) .. "'") + end + export_type_validators[export_type](opts) + end +end + +M.apply = function() + +end + +M.stop = function() + +end + +return M diff --git a/test/helper.lua b/test/helper.lua index 0183be3..6b15c27 100644 --- a/test/helper.lua +++ b/test/helper.lua @@ -259,6 +259,11 @@ function helpers.tarantool_role_is_supported() return major >= 3 end +function helpers.skip_if_unsupported() + t.skip_if(not helpers.tarantool_role_is_supported(), + 'Tarantool role is supported only for Tarantool starting from v3.0.0') +end + function helpers.error_function() error("error function call") end diff --git a/test/unit/metrics-export_test.lua b/test/unit/metrics-export_test.lua index 48a77d3..67cad0d 100644 --- a/test/unit/metrics-export_test.lua +++ b/test/unit/metrics-export_test.lua @@ -1,22 +1,19 @@ local t = require('luatest') local helpers = require('test.helper') -local g = t.group('metrics_export_unit_test') +local g = t.group() 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 = { } + helpers.skip_if_unsupported() end) g.before_each(function() - g.role = require('roles.metrics-export') + 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) +local expected_methods = {"apply", "validate", "stop"} +for _, method in ipairs(expected_methods) do + g["test_exist_method_" .. method] = function() + t.assert_type(g.role[method], "function") + end end diff --git a/test/unit/validate_test.lua b/test/unit/validate_test.lua new file mode 100644 index 0000000..4d79eaf --- /dev/null +++ b/test/unit/validate_test.lua @@ -0,0 +1,511 @@ +local t = require('luatest') + +local g = t.group() + +g.before_all(function(gc) + gc.role = require('roles.metrics-export') +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 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 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 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 range [1, 65535]", + }, + ["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_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_error_" .. name] = function() + t.assert_error_msg_contains(case.err, function() + g.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_ok_" .. name] = function() + t.assert_equals(g.role.validate(case.cfg), nil) + end +end