diff --git a/apisix/admin/init.lua b/apisix/admin/init.lua index 9b375b22e688..54277a5e9478 100644 --- a/apisix/admin/init.lua +++ b/apisix/admin/init.lua @@ -468,6 +468,11 @@ local standalone_uri_route = { methods = {"GET", "PUT", "HEAD"}, handler = standalone_run, }, + { + paths = [[/apisix/admin/configs/validate]], + methods = {"POST"}, + handler = standalone_run, + }, } diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index abe03de62d02..ae1d84e8cac5 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -1,4 +1,3 @@ --- -- Licensed to the Apache Software Foundation (ASF) under one or more -- contributor license agreements. See the NOTICE file distributed with -- this work for additional information regarding copyright ownership. @@ -22,6 +21,7 @@ local str_find = string.find local str_sub = string.sub local tostring = tostring local ngx = ngx +local pcall = pcall local ngx_time = ngx.time local get_method = ngx.req.get_method local shared_dict = ngx.shared["standalone-config"] @@ -158,6 +158,128 @@ local function check_conf(checker, schema, item, typ) end +local function validate_configuration(req_body, collect_all_errors) + local validation_results = { + valid = true, + errors = {} + } + + for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do + local items = req_body[key] + local resource = resources[key] or {} + + -- Validate conf_version_key if present + local new_conf_version = req_body[conf_version_key] + if new_conf_version and type(new_conf_version) ~= "number" then + local error_msg + if collect_all_errors then + error_msg = conf_version_key .. " must be a number, got " .. type(new_conf_version) + else + error_msg = conf_version_key .. " must be a number" + end + + if not collect_all_errors then + return false, error_msg + end + validation_results.valid = false + table_insert(validation_results.errors, { + resource_type = key, + error = error_msg + }) + end + + if items and #items > 0 then + local item_schema = resource.schema + local item_checker = resource.checker + local id_set = {} + + for index, item in ipairs(items) do + local item_temp = tbl_deepcopy(item) + local valid, err = check_conf(item_checker, item_schema, item_temp, key) + if not valid then + local err_prefix = "invalid " .. key .. " at index " .. (index - 1) .. ", err: " + local err_msg = type(err) == "table" and err.error_msg or err + local error_msg = err_prefix .. err_msg + + if not collect_all_errors then + return false, error_msg + end + validation_results.valid = false + table_insert(validation_results.errors, { + resource_type = key, + index = index - 1, + error = error_msg + }) + end + + -- check for duplicate IDs + local duplicated, dup_err = check_duplicate(item, key, id_set) + if duplicated then + if not collect_all_errors then + return false, dup_err + end + validation_results.valid = false + table_insert(validation_results.errors, { + resource_type = key, + index = index - 1, + error = dup_err + }) + end + end + end + end + + if collect_all_errors then + return validation_results.valid, validation_results + else + return validation_results.valid, nil + end +end + +local function validate(ctx) + local content_type = core.request.header(nil, "content-type") or "application/json" + local req_body, err = core.request.get_body() + if err then + return core.response.exit(400, {error_msg = "invalid request body: " .. err}) + end + + if not req_body or #req_body <= 0 then + return core.response.exit(400, {error_msg = "invalid request body: empty request body"}) + end + + local data + if core.string.has_prefix(content_type, "application/yaml") then + local ok, result = pcall(yaml.load, req_body, { all = false }) + if not ok or type(result) ~= "table" then + err = "invalid yaml request body" + else + data = result + end + else + data, err = core.json.decode(req_body) + end + + if err then + core.log.error("invalid request body: ", req_body, " err: ", err) + return core.response.exit(400, {error_msg = "invalid request body: " .. err}) + end + + local valid, validation_results = validate_configuration(data, true) + + if valid then + return core.response.exit(200, { + message = "Configuration is valid", + valid = true + }) + else + return core.response.exit(400, { + error_msg = "Configuration validation failed", + valid = false, + errors = validation_results.errors + }) + end +end + local function update(ctx) -- check digest header existence local digest = core.request.header(nil, METADATA_DIGEST) @@ -211,54 +333,44 @@ local function update(ctx) return core.response.exit(204) end - -- check input by jsonschema + local valid, error_msg = validate_configuration(req_body, false) + if not valid then + return core.response.exit(400, { error_msg = error_msg }) + end + + -- check input by jsonschema and build the final config local apisix_yaml = {} for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do local conf_version = config and config[conf_version_key] or 0 local items = req_body[key] local new_conf_version = req_body[conf_version_key] - local resource = resources[key] or {} - if not new_conf_version then - new_conf_version = conf_version + 1 - else - if type(new_conf_version) ~= "number" then - return core.response.exit(400, { - error_msg = conf_version_key .. " must be a number", - }) - end + + if new_conf_version then if new_conf_version < conf_version then return core.response.exit(400, { error_msg = conf_version_key .. " must be greater than or equal to (" .. conf_version .. ")", }) end + else + new_conf_version = conf_version + 1 end - apisix_yaml[conf_version_key] = new_conf_version if new_conf_version == conf_version then apisix_yaml[key] = config and config[key] elseif items and #items > 0 then apisix_yaml[key] = table_new(#items, 0) - local item_schema = resource.schema - local item_checker = resource.checker local id_set = {} - for index, item in ipairs(items) do - local item_temp = tbl_deepcopy(item) - local valid, err = check_conf(item_checker, item_schema, item_temp, key) - if not valid then - local err_prefix = "invalid " .. key .. " at index " .. (index - 1) .. ", err: " - local err_msg = type(err) == "table" and err.error_msg or err - core.response.exit(400, { error_msg = err_prefix .. err_msg }) - end + for _, item in ipairs(items) do -- prevent updating resource with the same ID -- (e.g., service ID or other resource IDs) in a single request local duplicated, err = check_duplicate(item, key, id_set) if duplicated then core.log.error(err) - core.response.exit(400, { error_msg = err }) + return core.response.exit(400, { error_msg = err }) end table_insert(apisix_yaml[key], item) @@ -280,7 +392,6 @@ local function update(ctx) return core.response.exit(202) end - local function get(ctx) local accept = core.request.header(nil, "accept") or "application/json" local want_yaml_resp = core.string.has_prefix(accept, "application/yaml") @@ -288,9 +399,9 @@ local function get(ctx) local config, err = get_config() if not config then if err ~= NOT_FOUND_ERR then - core.log.error("failed to get config from shared dict: ", err) + core.log.error("failed to get config from shared_dict: ", err) return core.response.exit(500, { - error_msg = "failed to get config from shared dict: " .. err + error_msg = "failed to get config from shared_dict: " .. err }) end config = {} @@ -330,14 +441,13 @@ local function get(ctx) return core.response.exit(200, resp) end - local function head(ctx) local config, err = get_config() if not config then if err ~= NOT_FOUND_ERR then - core.log.error("failed to get config from shared dict: ", err) + core.log.error("failed to get config from shared_dict: ", err) return core.response.exit(500, { - error_msg = "failed to get config from shared dict: " .. err + error_msg = "failed to get config from shared_dict: " .. err }) end end @@ -347,20 +457,24 @@ local function head(ctx) return core.response.exit(200) end - function _M.run() local ctx = ngx.ctx.api_ctx local method = str_lower(get_method()) if method == "put" then return update(ctx) + elseif method == "post" then + local path = ctx.var.uri + if path == "/apisix/admin/configs/validate" then + return validate(ctx) + else + return core.response.exit(404, {error_msg = "Not found"}) + end elseif method == "head" then return head(ctx) else return get(ctx) end end - - local patch_schema do local resource_schema = { diff --git a/t/admin/standalone.spec.ts b/t/admin/standalone.spec.ts index 31addaebd49b..a0cfe0bdda99 100644 --- a/t/admin/standalone.spec.ts +++ b/t/admin/standalone.spec.ts @@ -194,6 +194,86 @@ describe('Admin - Standalone', () => { }); describe('Normal', () => { + it('validate config (success case with json)', async () => { + const resp = await client.post(`${ENDPOINT}/validate`, config1); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual({ + message: 'Configuration is valid', + valid: true, + }); + }); + + it('validate config (success case with yaml)', async () => { + const resp = await client.post(`${ENDPOINT}/validate`, YAML.stringify(config1), { + headers: { 'Content-Type': 'application/yaml' }, + }); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual({ + message: 'Configuration is valid', + valid: true, + }); + }); + + it('validate config (success case with multiple resources)', async () => { + const multiResourceConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + { + id: 'r2', + uri: '/r2', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + services: [ + { + id: 's1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + routes_conf_version: 1, + services_conf_version: 1, + }; + + const resp = await client.post(`${ENDPOINT}/validate`, multiResourceConfig); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual({ + message: 'Configuration is valid', + valid: true, + }); + }); + + it('validate config with consumer credentials', async () => { + const resp = await client.post(`${ENDPOINT}/validate`, credential1); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual({ + message: 'Configuration is valid', + valid: true, + }); + }); + + it('validate config does not persist changes', async () => { + // First validate a configuration + const validateResp = await client.post(`${ENDPOINT}/validate`, config1); + expect(validateResp.status).toEqual(200); + + // Then check that the configuration was not persisted + const getResp = await client.get(ENDPOINT); + expect(getResp.data.routes).toBeUndefined(); + }); + it('dump empty config (default json format)', async () => { const resp = await client.get(ENDPOINT); expect(resp.status).toEqual(200); @@ -641,5 +721,228 @@ describe('Admin - Standalone', () => { 'invalid routes at index 0, err: invalid configuration: failed to match pattern "^((uri|server_name|server_addr|request_uri|remote_port|remote_addr|query_string|host|hostname|mqtt_client_id)|arg_[0-9a-zA-z_-]+)$" with "args_invalid"', }); }); + it('validate config (duplicate route id)', async () => { + const duplicateConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + { + id: 'r1', // Duplicate ID + uri: '/r2', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, duplicateConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('found duplicate id r1 in routes'), + }), + ]), + }); + }); + + it('validate config (invalid route configuration)', async () => { + const invalidConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + // Add an invalid field that should definitely fail validation + invalid_field: 'this_should_fail' + }, + }, + ], + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, invalidConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('invalid routes at index 0'), + }), + ]), + }); + }); + + it('validate config (invalid version number)', async () => { + const invalidVersionConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + routes_conf_version: 'not_a_number', // Invalid version type + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, invalidVersionConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('routes_conf_version must be a number'), + }), + ]), + }); + }); + + it('validate config (empty body)', async () => { + const resp = await clientException.post(`${ENDPOINT}/validate`, ''); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'invalid request body: empty request body', + }); + }); + + it('validate config (invalid YAML)', async () => { + const resp = await clientException.post(`${ENDPOINT}/validate`, 'invalid: yaml: [', { + headers: { 'Content-Type': 'application/yaml' }, + }); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: expect.stringContaining('invalid request body:'), + }); + }); + + it('validate config (duplicate consumer username)', async () => { + const duplicateConsumerConfig = { + consumers: [ + { + username: 'consumer1', + plugins: { + 'key-auth': { + key: 'consumer1', + }, + }, + }, + { + username: 'consumer1', // Duplicate username + plugins: { + 'key-auth': { + key: 'consumer1', + }, + }, + }, + ], + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, duplicateConsumerConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'consumers', + error: expect.stringContaining('found duplicate username consumer1 in consumers'), + }), + ]), + }); + }); + + it('validate config (duplicate consumer credential id)', async () => { + const duplicateCredentialConfig = { + consumers: [ + { + username: 'john_1', + }, + { + id: 'john_1/credentials/john-a', + plugins: { + 'key-auth': { + key: 'auth-a', + }, + }, + }, + { + id: 'john_1/credentials/john-a', // Duplicate credential ID + plugins: { + 'key-auth': { + key: 'auth-a', + }, + }, + }, + ], + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, duplicateCredentialConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'consumers', + error: expect.stringContaining('found duplicate credential id john_1/credentials/john-a in consumers'), + }), + ]), + }); + }); + + it('validate config (invalid plugin)', async () => { + const resp = await clientException.post( + `${ENDPOINT}/validate`, + routeWithUnknownPlugins, + ); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('unknown plugin [invalid-plugin]'), + }), + ]), + }); + }); + + it('validate config (invalid upstream)', async () => { + const resp = await clientException.post( + `${ENDPOINT}/validate`, + routeWithInvalidUpstream, + ); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('failed to match pattern'), + }), + ]), + }); + }); }); });