This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 316aea792 fix(standalone): harden the declarative configuration paths 
(#13886)
316aea792 is described below

commit 316aea792eae20e69e8109d781e5629998735c91
Author: AlinsRan <[email protected]>
AuthorDate: Tue Sep 8 09:05:22 2026 +0800

    fix(standalone): harden the declarative configuration paths (#13886)
---
 apisix/admin/config_validate.lua       |  42 +++++-
 apisix/admin/init.lua                  |  35 ++++-
 apisix/admin/standalone.lua            |  21 ++-
 apisix/admin/stream_routes.lua         |   7 +-
 apisix/cli/file.lua                    |   9 +-
 apisix/cli/ops.lua                     |   6 +-
 apisix/consumer.lua                    |   8 +-
 apisix/core/config_yaml.lua            |  13 +-
 t/admin/config-validate.t              | 199 +++++++++++++++++++++++++++
 t/admin/standalone.t                   | 124 +++++++++++++++++
 t/cli/test_deployment_null_sections.sh |  82 +++++++++++
 t/cli/test_standalone_plugin_reload.sh | 241 +++++++++++++++++++++++++++++++++
 12 files changed, 760 insertions(+), 27 deletions(-)

diff --git a/apisix/admin/config_validate.lua b/apisix/admin/config_validate.lua
index da9aad2f7..02e16c5dc 100644
--- a/apisix/admin/config_validate.lua
+++ b/apisix/admin/config_validate.lua
@@ -73,6 +73,27 @@ for dir in pairs(constants.STREAM_ETCD_DIRECTORY) do
     STREAM_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
 end
 
+-- The body is client-supplied, so its shape has to be checked before anything
+-- iterates it: `#items` and `ipairs` raise on a scalar, and a section that is
+-- not an array is silently dropped by standalone.update() while its version is
+-- advanced, which clears the resources that were there.
+local config_schema
+do
+    local properties = {}
+    -- only the resource sections: the *_conf_version fields keep their own
+    -- checks below and in standalone.update(), whose messages callers rely on
+    for key in pairs(ALL_RESOURCE_KEYS) do
+        properties[key] = {
+            type = "array",
+            items = {type = "object"},
+        }
+    end
+    config_schema = {
+        type = "object",
+        properties = properties,
+    }
+end
+
 
 local function check_duplicate(item, key, id_set)
     local identifier, identifier_type
@@ -131,6 +152,15 @@ function _M.validate_configuration(req_body, 
collect_all_errors)
     local is_valid = true
     local validation_results = {}
 
+    local shape_ok, shape_err = core.schema.check(config_schema, req_body)
+    if not shape_ok then
+        local err_msg = "invalid request body: " .. shape_err
+        if not collect_all_errors then
+            return false, err_msg
+        end
+        return false, {{resource_type = "", error = err_msg}}
+    end
+
     for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do
         local items = req_body[key]
         local resource = resources[key] or {}
@@ -217,7 +247,9 @@ function _M.validate()
     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
+        -- a null document (`~`) loads as lyaml's sentinel table, which would
+        -- otherwise pass as an empty object and clear every resource
+        if not ok or type(result) ~= "table" or result == yaml.null then
             err = "invalid yaml request body"
         else
             data = result
@@ -227,16 +259,20 @@ function _M.validate()
     end
 
     if err then
-        core.log.warn("invalid request body: ", req_body, " err: ", err)
+        -- the body is a full declarative configuration and can carry plugin
+        -- credentials and TLS private keys; log the parser error only
+        core.log.warn("invalid request body, err: ", err)
         return core.response.exit(400, {error_msg = "invalid request body: " 
.. err})
     end
 
     local ok, valid, validation_results = pcall(_M.validate_configuration, 
data, true)
     if not ok then
         core.log.warn("unexpected error during validation: ", tostring(valid))
+        -- the raw Lua error carries the source path and whatever the failing
+        -- code put in it; keep it in the log, not in the response
         return core.response.exit(400, {
             error_msg = "Configuration validation failed",
-            errors = {{error = tostring(valid)}}
+            errors = {{error = "unexpected validation error"}}
         })
     end
     if not valid then
diff --git a/apisix/admin/init.lua b/apisix/admin/init.lua
index bffefcac4..41b9aced8 100644
--- a/apisix/admin/init.lua
+++ b/apisix/admin/init.lua
@@ -82,8 +82,10 @@ local router
 local function check_token(ctx)
     local local_conf = core.config.local_conf()
 
-    -- check if admin_key is required
-    if local_conf.deployment.admin.admin_key_required == false then
+    -- check if admin_key is required; `admin:` written as YAML null makes
+    -- merge_conf drop the default table, so it cannot be indexed blindly
+    if core.table.try_read_attr(local_conf, "deployment", "admin",
+                                "admin_key_required") == false then
         return true
     end
 
@@ -347,6 +349,13 @@ end
 local function sync_local_conf_to_etcd(reset)
     local local_conf = core.config.local_conf()
 
+    if local_conf.deployment.config_provider == "yaml" then
+        -- standalone keeps its configuration in the shared dict, there is no
+        -- etcd to sync to. Guarded here rather than at the call sites so a new
+        -- caller cannot reintroduce the write.
+        return
+    end
+
     local plugins = {}
     for _, name in ipairs(local_conf.plugins) do
         core.table.insert(plugins, {
@@ -405,6 +414,16 @@ local function sync_local_conf_to_etcd(reset)
 end
 
 
+-- /v1/plugins/reload bumps the shared version and control/router.lua loads the
+-- plugins in its own handler for that event. Record the version it applied, or
+-- the reconciliation timer below sees a mismatch and loads them a second time.
+local function ack_plugins_reload()
+    if plugins_conf_ver_dict then
+        applied_plugins_conf_version = 
plugins_conf_ver_dict:get(PLUGINS_CONF_VERSION_KEY)
+    end
+end
+
+
 local function reload_plugins(data, event, source, pid)
     core.log.info("start to hot reload plugins")
 
@@ -553,13 +572,18 @@ function _M.init_worker()
     -- register reload plugin handler
     events = require("apisix.events")
     events:register(reload_plugins, reload_event, "PUT")
+    events:register(ack_plugins_reload, 
require("apisix.control.v1").RELOAD_EVENT, "PUT")
 
-    if plugins_conf_ver_dict and not is_yaml_config_provider then
+    if plugins_conf_ver_dict then
         -- The events broadcast has no delivery guarantee: a process that is
         -- (re)connecting to the events broker loses the event for good, which
         -- leaves it running e.g. the timers of plugins that were removed.
         -- Reconcile against the version in the shared dict, the same pattern
         -- admin/standalone.lua uses for the same reason.
+        --
+        -- This is not gated on the config provider: /v1/plugins/reload bumps
+        -- the same version and stays reachable in standalone mode, so a worker
+        -- that missed its broadcast has to be able to converge there too.
         applied_plugins_conf_version =
             plugins_conf_ver_dict:get(PLUGINS_CONF_VERSION_KEY) or 0
 
@@ -579,8 +603,9 @@ function _M.init_worker()
     end
 
     if ngx_worker_id() == 0 then
-        -- check if admin_key is required
-        if local_conf.deployment.admin.admin_key_required == false then
+        -- see check_token for why this is not indexed blindly
+        if core.table.try_read_attr(local_conf, "deployment", "admin",
+                                    "admin_key_required") == false then
             core.log.warn("Admin key is bypassed! ",
                 "If you are deploying APISIX in a production environment, ",
                 "please enable `admin_key_required` and set a secure admin 
key!")
diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua
index 8ab7280c4..0064d19bf 100644
--- a/apisix/admin/standalone.lua
+++ b/apisix/admin/standalone.lua
@@ -16,6 +16,7 @@
 local type         = type
 local pairs        = pairs
 local ipairs       = ipairs
+local pcall        = pcall
 local tostring     = tostring
 local tonumber     = tonumber
 local str_lower    = string.lower
@@ -120,7 +121,9 @@ local function update_config(apisix_yaml)
         if not ok then
             return nil, "failed to save config to shared dict: " .. err
         end
-        core.log.info("standalone config updated: ", raw)
+        -- the payload is a full declarative configuration and can carry TLS
+        -- private keys and plugin credentials; log its size, not its contents
+        core.log.info("standalone config updated, size: ", #raw)
     else
         core.log.crit(config_yaml.ERR_NO_SHARED_DICT)
     end
@@ -231,7 +234,8 @@ local function try_restore_from_shared_dict()
         core.log.info("no config found in shared dict")
         return true
     end
-    core.log.info("startup config loaded from shared dict: ", stored)
+    -- the payload can carry TLS private keys and plugin credentials
+    core.log.info("startup config loaded from shared dict, size: ", #stored)
 
     local _, raw, err = decode_config(tostring(stored))
     if not raw then
@@ -273,16 +277,21 @@ local function update(ctx)
     -- parse the request body
     local data
     if core.string.has_prefix(content_type, "application/yaml") then
-        data = yaml.load(req_body, { all = false })
-        if not data or type(data) ~= "table" then
+        local ok, result = pcall(yaml.load, req_body, { all = false })
+        -- a null document (`~`) loads as lyaml's sentinel table, which would
+        -- otherwise pass as an empty object and clear every resource
+        if not ok or type(result) ~= "table" or result == yaml.null 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)
-        core.response.exit(400, {error_msg = "invalid request body: " .. err})
+        -- same reason as above: the body is the configuration itself
+        core.log.error("invalid request body, err: ", err)
+        return core.response.exit(400, {error_msg = "invalid request body: " 
.. err})
     end
     req_body = data
 
diff --git a/apisix/admin/stream_routes.lua b/apisix/admin/stream_routes.lua
index 9bd94906b..0ec1dcf0e 100644
--- a/apisix/admin/stream_routes.lua
+++ b/apisix/admin/stream_routes.lua
@@ -64,12 +64,17 @@ local function check_conf(id, conf, need_id, schema, opts)
         end
     end
 
-    if conf.protocol and conf.protocol.superior_id and not 
opts.skip_references_check then
+    -- the self-reference check needs no lookup, so it stays outside the gate;
+    -- only the etcd fetch below is skipped for standalone validation
+    if conf.protocol and conf.protocol.superior_id then
         local superior_id = conf.protocol.superior_id
         if id and tostring(superior_id) == tostring(id) then
             return nil, {error_msg = "stream route can not set itself as 
superior_id"}
         end
+    end
 
+    if conf.protocol and conf.protocol.superior_id and not 
opts.skip_references_check then
+        local superior_id = conf.protocol.superior_id
         local key = "/stream_routes/" .. superior_id
         local res, err = core.etcd.get(key)
         if not res then
diff --git a/apisix/cli/file.lua b/apisix/cli/file.lua
index ec5bb534a..6e03801cf 100644
--- a/apisix/cli/file.lua
+++ b/apisix/cli/file.lua
@@ -266,7 +266,9 @@ function _M.read_yaml_conf(apisix_home)
 
     if not is_empty_file then
         local user_conf = yaml.load(user_conf_yaml)
-        if not user_conf then
+        -- lyaml returns a scalar for a document such as `foo`, which would 
blow
+        -- up in resolve_conf_var's pairs() below
+        if type(user_conf) ~= "table" then
             return nil, "invalid config.yaml file"
         end
 
@@ -290,7 +292,10 @@ function _M.read_yaml_conf(apisix_home)
         default_conf.deployment.config_provider = "etcd"
         if default_conf.deployment.role == "traditional" then
             default_conf.etcd = default_conf.deployment.etcd
-            if default_conf.deployment.role_traditional.config_provider == 
"yaml" then
+            -- `role_traditional:` written as YAML null makes merge_conf drop 
the
+            -- default table, so it cannot be indexed blindly
+            local role_traditional = default_conf.deployment.role_traditional
+            if role_traditional and role_traditional.config_provider == "yaml" 
then
                 default_conf.deployment.config_provider = "yaml"
             end
 
diff --git a/apisix/cli/ops.lua b/apisix/cli/ops.lua
index 6b0f6ee62..58428421c 100644
--- a/apisix/cli/ops.lua
+++ b/apisix/cli/ops.lua
@@ -332,8 +332,10 @@ local function init(env)
        and #allow_admin == 1 and allow_admin[1] == "127.0.0.0/24" then
         checked_admin_key = true
     end
-    -- check if admin_key is required
-    if yaml_conf.deployment.admin.admin_key_required == false then
+    -- check if admin_key is required; deployment.admin is guarded above and 
below
+    -- because `admin:` written as YAML null makes merge_conf drop it
+    if yaml_conf.deployment.admin
+       and yaml_conf.deployment.admin.admin_key_required == false then
         checked_admin_key = true
         print("Warning! Admin key is bypassed! "
                 .. "If you are deploying APISIX in a production environment, "
diff --git a/apisix/consumer.lua b/apisix/consumer.lua
index c13d3a73c..b7d72c4e0 100644
--- a/apisix/consumer.lua
+++ b/apisix/consumer.lua
@@ -104,7 +104,11 @@ local function construct_consumer_data(val, name, 
plugin_config)
         local consumer_name = 
get_consumer_name_from_credential_etcd_key(val.key)
         local the_consumer = consumers:get(consumer_name)
         if the_consumer and the_consumer.value then
-            consumer = consumers_id_lrucache(val.value.id .. name, 
val.modifiedIndex..
+            -- the parts are separated: without it ("ab", "c") and ("a", "bc")
+            -- build the same key, and modifiedIndex 1 + 23 the same version as
+            -- 12 + 3, which would serve a stale consumer
+            consumer = consumers_id_lrucache(val.value.id .. "#" .. name,
+                                                val.modifiedIndex .. "#" ..
                                                 the_consumer.modifiedIndex,
                 function (val, the_consumer)
                     consumer = core.table.clone(the_consumer.value)
@@ -120,7 +124,7 @@ local function construct_consumer_data(val, name, 
plugin_config)
                 " credential key: ", val.key, ", consumer name: ", 
consumer_name
         end
     else
-        consumer = consumers_id_lrucache(val.value.id .. name, 
val.modifiedIndex,
+        consumer = consumers_id_lrucache(val.value.id .. "#" .. name, 
val.modifiedIndex,
             function (val)
                 consumer = core.table.clone(val.value)
                 consumer.modifiedIndex = val.modifiedIndex
diff --git a/apisix/core/config_yaml.lua b/apisix/core/config_yaml.lua
index 12b515188..f05ca8d0a 100644
--- a/apisix/core/config_yaml.lua
+++ b/apisix/core/config_yaml.lua
@@ -311,8 +311,10 @@ local function sync_data(self)
         if self.item_schema then
             data_valid, err = check_schema(self.item_schema, item)
             if not data_valid then
+                -- the item is a resource and can carry TLS private keys or
+                -- plugin credentials, so only its identity is logged
                 log.error("failed to check item data of [", self.key,
-                          "] err:", err, " ,val: ", json.delay_encode(item))
+                          "] err:", err, ", key: ", conf_item.key)
             end
 
             if data_valid and self.checker then
@@ -321,7 +323,7 @@ local function sync_data(self)
                 data_valid, err = self.checker(item, conf_item.key)
                 if not data_valid then
                     log.error("failed to check item data of [", self.key,
-                              "] err:", err, " ,val: ", 
json.delay_encode(item))
+                              "] err:", err, ", key: ", conf_item.key)
                 end
             end
         end
@@ -348,8 +350,7 @@ local function sync_data(self)
             if type(item) ~= "table" then
                 data_valid = false
                 log.error("invalid item data of [", self.key .. "/" .. idx,
-                          "], val: ", json.delay_encode(item),
-                          ", it should be an object")
+                          "], type: ", type(item), ", it should be an object")
             end
 
             local id = item.id or item.username or ("arr_" .. idx)
@@ -361,7 +362,7 @@ local function sync_data(self)
                 data_valid, err = check_schema(self.item_schema, item)
                 if not data_valid then
                     log.error("failed to check item data of [", self.key,
-                              "] err:", err, " ,val: ", 
json.delay_encode(item))
+                              "] err:", err, ", key: ", conf_item.key)
                 end
             end
 
@@ -369,7 +370,7 @@ local function sync_data(self)
                 data_valid, err = self.checker(item, conf_item.key)
                 if not data_valid then
                     log.error("failed to check item data of [", self.key,
-                              "] err:", err, " ,val: ", 
json.delay_encode(item))
+                              "] err:", err, ", key: ", conf_item.key)
                 end
             end
 
diff --git a/t/admin/config-validate.t b/t/admin/config-validate.t
index 0226a9c17..6e1a4736c 100644
--- a/t/admin/config-validate.t
+++ b/t/admin/config-validate.t
@@ -656,3 +656,202 @@ location /t {
 --- error_code: 400
 --- response_body
 passed
+
+
+
+=== TEST 17: validate configs - a resource list that is not an array
+--- config
+location /t {
+    content_by_lua_block {
+        local t = require("lib.test_admin").test
+        local json = require("cjson")
+        local code, body = t('/apisix/admin/configs/validate',
+            ngx.HTTP_POST,
+            [[{"routes": "not-an-array"}]]
+            )
+
+        ngx.status = code
+        local data = json.decode(body)
+        assert(data.error_msg == "Configuration validation failed",
+            "expected validation failed, got: " .. tostring(data.error_msg))
+        assert(string.find(data.errors[1].error, "invalid request body", 1, 
true),
+            "unexpected error: " .. body)
+        ngx.say("passed")
+    }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 18: validate configs - a scalar request body
+--- config
+location /t {
+    content_by_lua_block {
+        local t = require("lib.test_admin").test
+        local json = require("cjson")
+        local code, body = t('/apisix/admin/configs/validate',
+            ngx.HTTP_POST,
+            [[123]]
+            )
+
+        ngx.status = code
+        local data = json.decode(body)
+        assert(data.error_msg == "Configuration validation failed",
+            "expected validation failed, got: " .. tostring(data.error_msg))
+        assert(string.find(data.errors[1].error, "invalid request body", 1, 
true),
+            "unexpected error: " .. body)
+        ngx.say("passed")
+    }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 19: validate configs - a stream route may not name itself as 
superior_id
+--- config
+location /t {
+    content_by_lua_block {
+        local t = require("lib.test_admin").test
+        local json = require("cjson")
+        local code, body = t('/apisix/admin/configs/validate',
+            ngx.HTTP_POST,
+            [[{
+                "stream_routes": [
+                    {
+                        "id": "r1",
+                        "server_addr": "127.0.0.1",
+                        "server_port": 1985,
+                        "protocol": {
+                            "name": "redis",
+                            "superior_id": "r1"
+                        },
+                        "upstream": {
+                            "nodes": {"127.0.0.1:1995": 1},
+                            "type": "roundrobin"
+                        }
+                    }
+                ]
+            }]]
+            )
+
+        ngx.status = code
+        local data = json.decode(body)
+        assert(data.error_msg == "Configuration validation failed",
+            "expected validation failed, got: " .. tostring(data.error_msg))
+        assert(string.find(data.errors[1].error,
+                           "stream route can not set itself as superior_id", 
1, true),
+            "unexpected error: " .. body)
+        ngx.say("passed")
+    }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 20: a top-level array is rejected
+--- config
+location /t {
+    content_by_lua_block {
+        local t = require("lib.test_admin").test
+        local json = require("cjson")
+        local code, body = t('/apisix/admin/configs/validate',
+            ngx.HTTP_POST,
+            [==[[{"routes": []}]]==]
+            )
+
+        ngx.status = code
+        local data = json.decode(body)
+        assert(data.error_msg == "Configuration validation failed",
+            "expected validation failed, got: " .. tostring(data.error_msg))
+        assert(string.find(data.errors[1].error, "invalid request body", 1, 
true),
+            "unexpected error: " .. body)
+        ngx.say("passed")
+    }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 21: a resource section that is not an array is rejected
+--- config
+location /t {
+    content_by_lua_block {
+        local t = require("lib.test_admin").test
+        local json = require("cjson")
+        local code, body = t('/apisix/admin/configs/validate',
+            ngx.HTTP_POST,
+            [[{"routes": {"id": "r1"}}]]
+            )
+
+        ngx.status = code
+        local data = json.decode(body)
+        assert(data.error_msg == "Configuration validation failed",
+            "expected validation failed, got: " .. tostring(data.error_msg))
+        assert(string.find(data.errors[1].error, "invalid request body", 1, 
true),
+            "unexpected error: " .. body)
+        ngx.say("passed")
+    }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 22: a scalar element is rejected
+--- config
+location /t {
+    content_by_lua_block {
+        local t = require("lib.test_admin").test
+        local json = require("cjson")
+        local code, body = t('/apisix/admin/configs/validate',
+            ngx.HTTP_POST,
+            [[{"routes": [123]}]]
+            )
+
+        ngx.status = code
+        local data = json.decode(body)
+        assert(data.error_msg == "Configuration validation failed",
+            "expected validation failed, got: " .. tostring(data.error_msg))
+        assert(string.find(data.errors[1].error, "invalid request body", 1, 
true),
+            "unexpected error: " .. body)
+        ngx.say("passed")
+    }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 23: a null element is rejected
+--- config
+location /t {
+    content_by_lua_block {
+        local t = require("lib.test_admin").test
+        local json = require("cjson")
+        local code, body = t('/apisix/admin/configs/validate',
+            ngx.HTTP_POST,
+            [[{"routes": [null]}]]
+            )
+
+        ngx.status = code
+        local data = json.decode(body)
+        assert(data.error_msg == "Configuration validation failed",
+            "expected validation failed, got: " .. tostring(data.error_msg))
+        assert(string.find(data.errors[1].error, "invalid request body", 1, 
true),
+            "unexpected error: " .. body)
+        ngx.say("passed")
+    }
+}
+--- error_code: 400
+--- response_body
+passed
diff --git a/t/admin/standalone.t b/t/admin/standalone.t
index 9f1cc60fe..9209cd40a 100644
--- a/t/admin/standalone.t
+++ b/t/admin/standalone.t
@@ -378,3 +378,127 @@ X-Digest: t17
 --- error_code: 202
 --- no_error_log
 report_failure(): update endpoint: http://127.0.0.1:2379 to unhealthy
+
+
+
+=== TEST 18: a pushed configuration never reaches the log
+--- request
+PUT /apisix/admin/configs
+{
+    "consumers": [
+        {
+            "username": "jack",
+            "plugins": {"key-auth": {"key": 
"SENTINEL-CREDENTIAL-MUST-NOT-BE-LOGGED"}}
+        }
+    ]
+}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t18
+--- error_code: 202
+--- no_error_log
+SENTINEL-CREDENTIAL-MUST-NOT-BE-LOGGED
+
+
+
+=== TEST 19: an unparsable body never reaches the log
+--- request
+PUT /apisix/admin/configs
+{"consumers": [ SENTINEL-BROKEN-BODY
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t19
+--- error_code: 400
+--- no_error_log
+SENTINEL-BROKEN-BODY
+
+
+
+=== TEST 20: seed a config
+--- request
+PUT /apisix/admin/configs
+{"routes":[{"id":"r1","uri":"/r1","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"}}]}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t20
+--- error_code: 202
+
+
+
+=== TEST 21: a resource section that is not an array is rejected
+--- request
+PUT /apisix/admin/configs
+{"routes": {"id": "r1"}}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t21
+--- error_code: 400
+
+
+
+=== TEST 22: a top-level array is rejected
+--- request
+PUT /apisix/admin/configs
+[{"routes": []}]
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t22
+--- error_code: 400
+
+
+
+=== TEST 23: the rejected pushes left the config and its version untouched
+--- request
+GET /apisix/admin/configs
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+--- error_code: 200
+--- response_body_like: .*X-Digest.*t20.*
+
+
+
+=== TEST 24: a malformed yaml body is rejected
+--- request
+PUT /apisix/admin/configs
+routes: [
+--- more_headers
+Content-Type: application/yaml
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t24
+--- error_code: 400
+--- response_body_like: .*invalid yaml request body.*
+
+
+
+=== TEST 25: a yaml null document is rejected
+--- request
+PUT /apisix/admin/configs
+~
+--- more_headers
+Content-Type: application/yaml
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t25
+--- error_code: 400
+--- response_body_like: .*invalid yaml request body.*
+
+
+
+=== TEST 26: a malformed yaml body is rejected by the validate endpoint too
+--- request
+POST /apisix/admin/configs/validate
+routes: [
+--- more_headers
+Content-Type: application/yaml
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+--- error_code: 400
+--- response_body_like: .*invalid yaml request body.*
+
+
+
+=== TEST 27: the rejected yaml pushes left the config and its version untouched
+--- request
+GET /apisix/admin/configs
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+--- error_code: 200
+--- response_body_like: .*X-Digest.*t20.*
diff --git a/t/cli/test_deployment_null_sections.sh 
b/t/cli/test_deployment_null_sections.sh
new file mode 100755
index 000000000..1bfd49938
--- /dev/null
+++ b/t/cli/test_deployment_null_sections.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+
+#
+# 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.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+. ./t/cli/common.sh
+
+# Writing a deployment section as YAML null makes merge_conf drop the default
+# table, so anything reading through it has to be guarded. Both reads below 
used
+# to die with a Lua stack trace instead of a configuration error.
+
+echo '
+apisix:
+    node_listen: 9080
+deployment:
+    role: traditional
+    role_traditional:
+' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if echo "$out" | grep -F "attempt to index"; then
+    echo "failed: a null role_traditional should not raise a Lua error"
+    exit 1
+fi
+
+echo "passed: a null role_traditional is tolerated"
+
+echo '
+apisix:
+    node_listen: 9080
+    enable_admin: true
+deployment:
+    role: traditional
+    role_traditional:
+        config_provider: etcd
+    admin:
+    etcd:
+        host:
+            - http://127.0.0.1:2379
+' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if echo "$out" | grep -F "attempt to index"; then
+    echo "failed: a null deployment.admin should not raise a Lua error"
+    exit 1
+fi
+
+if ! echo "$out" | grep -F 'Please modify "admin_key" in conf/config.yaml'; 
then
+    echo "failed: a null deployment.admin should report a missing admin key"
+    exit 1
+fi
+
+echo "passed: a null deployment.admin reports a missing admin key"
+
+echo 'just-a-scalar' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if echo "$out" | grep -F "bad argument"; then
+    echo "failed: a scalar config.yaml should not raise a Lua error"
+    exit 1
+fi
+
+if ! echo "$out" | grep -F "invalid config.yaml file"; then
+    echo "failed: a scalar config.yaml should be reported as invalid"
+    exit 1
+fi
+
+echo "passed: a scalar config.yaml is reported as invalid"
diff --git a/t/cli/test_standalone_plugin_reload.sh 
b/t/cli/test_standalone_plugin_reload.sh
new file mode 100755
index 000000000..abcf8cc3b
--- /dev/null
+++ b/t/cli/test_standalone_plugin_reload.sh
@@ -0,0 +1,241 @@
+#!/usr/bin/env bash
+
+#
+# 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.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Plugin load / unload against a real gateway process, with the configuration
+# served by the standalone Admin API rather than etcd. The reconciliation timer
+# in admin/init.lua is registered in this mode as well, because 
/v1/plugins/reload
+# stays reachable here and its broadcast has no delivery guarantee.
+
+. ./t/cli/common.sh
+
+ADMIN_KEY=edd1c9f034335f136f87ad84b625c8f1
+
+write_config() {
+    # $1: the plugin list body
+    echo "
+apisix:
+    node_listen: 9080
+    enable_admin: true
+    enable_control: true
+deployment:
+    role: traditional
+    role_traditional:
+        config_provider: yaml
+    admin:
+        allow_admin:
+            - 127.0.0.0/24
+        admin_key:
+            - name: admin
+              key: $ADMIN_KEY
+              role: admin
+nginx_config:
+    error_log_level: info
+plugins:
+$1
+" > conf/config.yaml
+}
+
+# Port 9 (discard) is never listening, so the status tells the two states apart
+# without needing a backend: 401 means key-auth ran and rejected the request,
+# 502 means the request got past the plugins and reached the proxy.
+status_of() {
+    curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:9080/hello";
+}
+
+# deadline-bounded, so the test does not depend on a fixed reload latency
+wait_for_status() {
+    local i
+    { set +x; } 2>/dev/null
+    for i in $(seq 1 50); do
+        if [ "$(status_of)" = "$1" ]; then
+            set -x
+            return 0
+        fi
+        sleep 0.2
+    done
+    set -x
+    echo "failed: $2 (last status: $(status_of))"
+    exit 1
+}
+
+# prometheus is never loaded or unloaded here, it is in both lists because the
+# prometheus-cache shared dict is only rendered when it is enabled at init time
+# and error-log-logger reaches the exporter through its require chain
+PLUGINS_ALL="    - key-auth
+    - serverless-pre-function
+    - public-api
+    - prometheus
+    - error-log-logger
+    - node-status"
+PLUGINS_MIN="    - serverless-pre-function
+    - public-api
+    - prometheus"
+
+write_config "$PLUGINS_ALL"
+make init
+make run
+wait_for_tcp 127.0.0.1 9180
+
+curl -s -o /dev/null -XPUT "http://127.0.0.1:9180/apisix/admin/configs"; \
+    -H "X-API-KEY: $ADMIN_KEY" -H "X-Digest: reload-1" \
+    -d '{
+        "routes": [
+            {
+                "id": "r1",
+                "uri": "/hello",
+                "plugins": {"key-auth": {}},
+                "upstream": {"nodes": {"127.0.0.1:9": 1}, "type": "roundrobin"}
+            },
+            {
+                "id": "bump",
+                "uri": "/bump",
+                "plugins": {
+                    "serverless-pre-function": {
+                        "phase": "rewrite",
+                        "functions": [
+                            "return function() 
ngx.shared[\"internal-status\"]:incr(\"plugins_conf_version\", 1, 0) 
ngx.exit(200) end"
+                        ]
+                    }
+                },
+                "upstream": {"nodes": {"127.0.0.1:9": 1}, "type": "roundrobin"}
+            },
+            {
+                "id": "status",
+                "uri": "/apisix/status",
+                "plugins": {"public-api": {}},
+                "upstream": {"nodes": {"127.0.0.1:9": 1}, "type": "roundrobin"}
+            }
+        ],
+        "consumers": [
+            {"username": "jack", "plugins": {"key-auth": {"key": "jack-key"}}}
+        ]
+    }'
+
+wait_for_status 401 "key-auth should reject an unauthenticated request"
+
+echo "passed: key-auth is in effect on a standalone configuration"
+
+echo "unloading key-auth"
+write_config "$PLUGINS_MIN"
+curl -s -o /dev/null -XPUT http://127.0.0.1:9090/v1/plugins/reload
+
+wait_for_status 502 "the request should reach the proxy once key-auth is 
unloaded"
+
+echo "passed: key-auth was unloaded by a reload"
+
+echo "loading key-auth again"
+write_config "$PLUGINS_ALL"
+curl -s -o /dev/null -XPUT http://127.0.0.1:9090/v1/plugins/reload
+
+wait_for_status 401 "key-auth should be in effect again after reloading it"
+
+echo "passed: key-auth was loaded again by a reload"
+
+if grep -q "sync local conf to etcd" logs/error.log; then
+    echo "failed: standalone has no etcd, the reload path must not sync to it"
+    exit 1
+fi
+
+echo "passed: no etcd sync was attempted"
+
+
+# --- missed-event reconciliation -------------------------------------------
+#
+# The events broadcast has no delivery guarantee: a worker that is 
(re)connecting
+# to the broker loses the event for good and is left running the plugins of the
+# previous list. GET /bump reproduces that state deterministically -- it 
advances
+# plugins_conf_version exactly the way a reload does, but posts no event -- so
+# only the reconciliation timer in admin/init.lua can converge the workers.
+
+# a plugin's api() routes live in a module-level registry that is rebuilt by
+# plugin.load(), so this tells a real reload from a per-route plugin lookup
+status_of_uri() {
+    curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:9080$1";
+}
+
+wait_for_uri_status() {
+    local i
+    { set +x; } 2>/dev/null
+    for i in $(seq 1 50); do
+        if [ "$(status_of_uri "$1")" = "$2" ]; then
+            set -x
+            return 0
+        fi
+        sleep 0.2
+    done
+    set -x
+    echo "failed: $3 (last status: $(status_of_uri "$1"))"
+    exit 1
+}
+
+timer_runs_since() {
+    # $1: byte offset in the error log to count from
+    tail -c "+$1" logs/error.log | grep -c "run 
timer\[plugin#error-log-logger\]" || true
+}
+
+if [ "$(status_of_uri /apisix/status)" = "404" ]; then
+    echo "failed: node-status is loaded, its api route should be registered"
+    exit 1
+fi
+
+# the background timer runs once a second, so this is bounded, not a race
+sleep 2
+if [ "$(timer_runs_since 1)" = "0" ]; then
+    echo "failed: error-log-logger is loaded, its timer should be running"
+    exit 1
+fi
+
+echo "passed: the plugin api route and the plugin timer are both live"
+
+echo "dropping the plugins without broadcasting the reload"
+write_config "$PLUGINS_MIN"
+curl -s -o /dev/null "http://127.0.0.1:9080/bump";
+
+wait_for_status 502 "the reconciliation timer should unload key-auth without 
an event"
+
+echo "passed: a version-behind worker converged on its own"
+
+wait_for_uri_status /apisix/status 404 "plugin.load() should have dropped the 
node-status api route"
+
+echo "passed: the plugin api registry was rebuilt, not just the route lookup"
+
+offset=$(( $(wc -c < logs/error.log) + 1 ))
+sleep 3
+if [ "$(timer_runs_since $offset)" != "0" ]; then
+    echo "failed: error-log-logger was unloaded, its timer must not still run"
+    exit 1
+fi
+
+echo "passed: no stale plugin timer survived the unload"
+
+echo "restoring the plugins without broadcasting the reload"
+write_config "$PLUGINS_ALL"
+offset=$(( $(wc -c < logs/error.log) + 1 ))
+curl -s -o /dev/null "http://127.0.0.1:9080/bump";
+
+wait_for_status 401 "the reconciliation timer should load key-auth back 
without an event"
+wait_for_uri_status /apisix/status 200 "the node-status api route should be 
registered again"
+
+sleep 2
+if [ "$(timer_runs_since $offset)" = "0" ]; then
+    echo "failed: error-log-logger was loaded again, its timer should run 
again"
+    exit 1
+fi
+
+echo "passed: the reverse transition converged the same way"

Reply via email to