diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index c0e9b22883a0..27de24d2bdd8 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -17,8 +17,6 @@ local type = type local pairs = pairs local ipairs = ipairs -local tonumber = tonumber -local tostring = tostring local str_lower = string.lower local ngx = ngx local get_method = ngx.req.get_method @@ -30,25 +28,42 @@ local events = require("apisix.events") local core = require("apisix.core") local config_yaml = require("apisix.core.config_yaml") local check_schema = require("apisix.core.schema").check +local tbl_deepcopy = require("apisix.core.table").deepcopy local EVENT_UPDATE = "standalone-api-configuration-update" local _M = {} -local function update_and_broadcast_config(apisix_yaml, conf_version) - local config = core.json.encode({ - conf = apisix_yaml, - conf_version = conf_version, - }) +local function get_config() + local config = shared_dict:get("config") + if not config then + return nil, "not found" + end + + local err + config, err = core.json.decode(config) + if not config then + return nil, "failed to decode json: " .. err + end + return config +end + + +local function update_and_broadcast_config(apisix_yaml) + local raw, err = core.json.encode(apisix_yaml) + if not raw then + core.log.error("failed to encode json: ", err) + return nil, "failed to encode json: " .. err + end if shared_dict then -- the worker that handles Admin API calls is responsible for writing the shared dict - local ok, err = shared_dict:set("config", config) + local ok, err = shared_dict:set("config", raw) if not ok then return nil, "failed to save config to shared dict: " .. err end - core.log.info("standalone config updated: ", config) + core.log.info("standalone config updated: ", raw) else core.log.crit(config_yaml.ERR_NO_SHARED_DICT) end @@ -59,26 +74,6 @@ end local function update(ctx) local content_type = core.request.header(nil, "content-type") or "application/json" - local conf_version - if ctx.var.arg_conf_version then - conf_version = tonumber(ctx.var.arg_conf_version) - if not conf_version then - return core.response.exit(400, {error_msg = "invalid conf_version: " - .. ctx.var.arg_conf_version - .. ", should be a integer" }) - end - else - conf_version = ngx.time() - end - -- check if conf_version greater than the current version - local _, ver = config_yaml._get_config() - if conf_version <= ver then - return core.response.exit(400, {error_msg = "invalid conf_version: conf_version (" - .. conf_version - .. ") should be greater than the current version (" - .. ver .. ")"}) - end - -- read the request body local req_body, err = core.request.get_body() if err then @@ -105,28 +100,63 @@ local function update(ctx) end req_body = data + local config, err = get_config() + if not config then + if err ~= "not found" then + 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 + }) + end + end + -- check input by jsonschema local apisix_yaml = {} local created_objs = config_yaml.fetch_all_created_obj() + for key, obj in pairs(created_objs) do - if req_body[key] and #req_body[key] > 0 then - apisix_yaml[key] = table_new(1, 0) + local conf_version_key = obj.conf_version_key + local conf_version = config and config[conf_version_key] or obj.conf_version + local items = req_body[key] + local new_conf_version = req_body[conf_version_key] + 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 < conf_version then + return core.response.exit(400, { + error_msg = conf_version_key .. + " must be greater than or equal to (" .. conf_version .. ")", + }) + end + 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 = obj.item_schema local item_checker = obj.checker - for index, item in ipairs(req_body[key]) do + for index, item in ipairs(items) do + local item_temp = tbl_deepcopy(item) local valid, err -- need to recover to 0-based subscript local err_prefix = "invalid " .. key .. " at index " .. (index - 1) .. ", err: " if item_schema then - valid, err = check_schema(obj.item_schema, item) + valid, err = check_schema(obj.item_schema, item_temp) if not valid then core.log.error(err_prefix, err) core.response.exit(400, {error_msg = err_prefix .. err}) end end if item_checker then - valid, err = item_checker(item) + valid, err = item_checker(item_temp) if not valid then core.log.error(err_prefix, err) core.response.exit(400, {error_msg = err_prefix .. err}) @@ -137,12 +167,11 @@ local function update(ctx) end end - local ok, err = update_and_broadcast_config(apisix_yaml, conf_version) + local ok, err = update_and_broadcast_config(apisix_yaml) if not ok then core.response.exit(500, err) end - core.response.set_header("X-APISIX-Conf-Version", tostring(conf_version)) return core.response.exit(202) end @@ -151,9 +180,21 @@ 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") - local _, ver, config = config_yaml._get_config() + local config, err = get_config() + if not config then + if err ~= "not found" then + 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 + }) + end + config = {} + local created_objs = config_yaml.fetch_all_created_obj() + for _, obj in pairs(created_objs) do + config[obj.conf_version_key] = obj.conf_version + end + end - core.response.set_header("X-APISIX-Conf-Version", tostring(ver)) local resp, err if want_yaml_resp then core.response.set_header("Content-Type", "application/yaml") @@ -207,7 +248,7 @@ function _M.init_worker() core.log.error("failed to decode json: ", err) return end - config_yaml._update_config(config.conf, config.conf_version) + config_yaml._update_config(config) end events:register(update_config, EVENT_UPDATE, EVENT_UPDATE) end diff --git a/apisix/core/config_yaml.lua b/apisix/core/config_yaml.lua index 8cd16e9acd7f..428cdfc0d93b 100644 --- a/apisix/core/config_yaml.lua +++ b/apisix/core/config_yaml.lua @@ -25,9 +25,9 @@ local yaml = require("lyaml") local log = require("apisix.core.log") local json = require("apisix.core.json") local new_tab = require("table.new") +local tbl_deepcopy = require("apisix.core.table").deepcopy local check_schema = require("apisix.core.schema").check local profile = require("apisix.core.profile") -local tbl_deepcopy = require("apisix.core.table").deepcopy local lfs = require("lfs") local file = require("apisix.cli.file") local exiting = ngx.worker.exiting @@ -70,11 +70,9 @@ local mt = { local apisix_yaml -local apisix_yaml_raw -- save a deepcopy of the latest configuration for API local apisix_yaml_mtime - -local function update_config(table, mtime) +local function update_config(table, conf_version) if not table then log.error("failed update config: empty table") return @@ -87,18 +85,11 @@ local function update_config(table, mtime) end apisix_yaml = table - apisix_yaml_raw = tbl_deepcopy(table) - apisix_yaml_mtime = mtime + apisix_yaml_mtime = conf_version end _M._update_config = update_config -local function get_config() - return apisix_yaml, apisix_yaml_mtime, apisix_yaml_raw -end -_M._get_config = get_config - - local function is_use_admin_api() local local_conf, _ = config_local.local_conf() return local_conf and local_conf.apisix and local_conf.apisix.enable_admin @@ -158,29 +149,57 @@ local function sync_data(self) return nil, "missing 'key' arguments" end - if not apisix_yaml_mtime then - log.warn("wait for more time") - return nil, "failed to read local file " .. apisix_yaml_path + local conf_version + if is_use_admin_api() then + conf_version = apisix_yaml[self.conf_version_key] or 0 + else + if not apisix_yaml_mtime then + log.warn("wait for more time") + return nil, "failed to read local file " .. apisix_yaml_path + end + conf_version = apisix_yaml_mtime end - if self.conf_version == apisix_yaml_mtime then + if not conf_version or conf_version == self.conf_version then return true end local items = apisix_yaml[self.key] - log.info(self.key, " items: ", json.delay_encode(items)) if not items then self.values = new_tab(8, 0) self.values_hash = new_tab(0, 8) - self.conf_version = apisix_yaml_mtime + self.conf_version = conf_version return true end - if self.values then - for _, item in ipairs(self.values) do - config_util.fire_all_clean_handlers(item) + if self.values and #self.values > 0 then + if is_use_admin_api() then + -- filter self.values to retain only those whose IDs exist in the new items list. + local exist_values = new_tab(8, 0) + self.values_hash = new_tab(0, 8) + + local exist_items = {} + for _, item in ipairs(items) do + exist_items[tostring(item.id)] = true + end + -- remove objects that exist in the self.values but do not exist in the new items. + -- for removed items, trigger cleanup handlers. + for _, item in ipairs(self.values) do + local id = item.value.id + if not exist_items[id] then + config_util.fire_all_clean_handlers(item) + else + insert_tab(exist_values, item) + self.values_hash[id] = #exist_values + end + end + self.values = exist_values + else + for _, item in ipairs(self.values) do + config_util.fire_all_clean_handlers(item) + end + self.values = nil end - self.values = nil end if self.single_item then @@ -189,7 +208,8 @@ local function sync_data(self) self.values_hash = new_tab(0, 1) local item = items - local conf_item = {value = item, modifiedIndex = apisix_yaml_mtime, + local modifiedIndex = item.modifiedIndex or conf_version + local conf_item = {value = item, modifiedIndex = modifiedIndex, key = "/" .. self.key} local data_valid = true @@ -221,23 +241,26 @@ local function sync_data(self) end else - self.values = new_tab(#items, 0) - self.values_hash = new_tab(0, #items) + if not self.values then + self.values = new_tab(8, 0) + self.values_hash = new_tab(0, 8) + end local err for i, item in ipairs(items) do - local id = tostring(i) + local idx = tostring(i) local data_valid = true if type(item) ~= "table" then data_valid = false - log.error("invalid item data of [", self.key .. "/" .. id, + log.error("invalid item data of [", self.key .. "/" .. idx, "], val: ", json.delay_encode(item), ", it should be an object") end - local key = item.id or "arr_" .. i - local conf_item = {value = item, modifiedIndex = apisix_yaml_mtime, - key = "/" .. self.key .. "/" .. key} + local id = item.id or ("arr_" .. idx) + local modifiedIndex = item.modifiedIndex or conf_version + local conf_item = {value = item, modifiedIndex = modifiedIndex, + key = "/" .. self.key .. "/" .. id} if data_valid and self.item_schema then data_valid, err = check_schema(self.item_schema, item) @@ -256,12 +279,24 @@ local function sync_data(self) end if data_valid then - insert_tab(self.values, conf_item) - local item_id = conf_item.value.id or self.key .. "#" .. id - item_id = tostring(item_id) - self.values_hash[item_id] = #self.values - conf_item.value.id = item_id - conf_item.clean_handlers = {} + local item_id = tostring(id) + local pre_index = self.values_hash[item_id] + if pre_index then + -- remove the old item + local pre_val = self.values[pre_index] + if pre_val and + (not item.modifiedIndex or pre_val.modifiedIndex ~= item.modifiedIndex) then + config_util.fire_all_clean_handlers(pre_val) + self.values[pre_index] = conf_item + conf_item.value.id = item_id + conf_item.clean_handlers = {} + end + else + insert_tab(self.values, conf_item) + self.values_hash[item_id] = #self.values + conf_item.value.id = item_id + conf_item.clean_handlers = {} + end if self.filter then self.filter(conf_item) @@ -270,7 +305,7 @@ local function sync_data(self) end end - self.conf_version = apisix_yaml_mtime + self.conf_version = conf_version return true end @@ -317,6 +352,7 @@ local function _automatic_fetch(premature, self) log.info("no config found in shared dict") goto SKIP_SHARED_DICT end + log.info("startup config loaded from shared dict: ", config) config, err = json.decode(tostring(config)) if not config then @@ -324,7 +360,7 @@ local function _automatic_fetch(premature, self) goto SKIP_SHARED_DICT end - _M._update_config(config.conf, config.conf_version) + _M._update_config(config) log.info("config loaded from shared dict") ::SKIP_SHARED_DICT:: @@ -395,6 +431,16 @@ function _M.new(key, opts) key = sub_str(key, 2) end + if is_use_admin_api() then + if item_schema and item_schema.properties then + local item_schema_cp = tbl_deepcopy(item_schema) + -- allow clients to specify modifiedIndex to control resource changes. + item_schema_cp.properties.modifiedIndex = { + type = "integer", + } + item_schema = item_schema_cp + end + end local obj = setmetatable({ automatic = automatic, item_schema = item_schema, @@ -408,6 +454,7 @@ function _M.new(key, opts) last_err = nil, last_err_time = nil, key = key, + conf_version_key = key and key .. "_conf_version", single_item = single_item, filter = filter_fun, }, mt) @@ -471,7 +518,6 @@ end function _M.init_worker() if is_use_admin_api() then apisix_yaml = {} - apisix_yaml_raw = {} apisix_yaml_mtime = 0 return true end diff --git a/docs/en/latest/deployment-modes.md b/docs/en/latest/deployment-modes.md index bc17c4366caa..4e916ae148a1 100644 --- a/docs/en/latest/deployment-modes.md +++ b/docs/en/latest/deployment-modes.md @@ -115,73 +115,163 @@ This method is more suitable for two types of users: Now, we have two standalone running modes, file-driven and API-driven. -1. The file-driven mode is the kind APISIX has always supported. +#### File-driven - The routing rules in the `conf/apisix.yaml` file are loaded into memory immediately after the APISIX node service starts. At each interval (default: 1 second), APISIX checks for updates to the file. If changes are detected, it reloads the rules. +The file-driven mode is the kind APISIX has always supported. - *Note*: Reloading and updating routing rules are all hot memory updates. There is no replacement of working processes, since it's a hot update. +The routing rules in the `conf/apisix.yaml` file are loaded into memory immediately after the APISIX node service starts. At each interval (default: 1 second), APISIX checks for updates to the file. If changes are detected, it reloads the rules. - This requires us to set the APISIX role to data plane. That is, set `deployment.role` to `data_plane` and `deployment.role_data_plane.config_provider` to `yaml`. +*Note*: Reloading and updating routing rules are all hot memory updates. There is no replacement of working processes, since it's a hot update. - Refer to the example below: +This requires us to set the APISIX role to data plane. That is, set `deployment.role` to `data_plane` and `deployment.role_data_plane.config_provider` to `yaml`. - ```yaml - deployment: - role: data_plane - role_data_plane: - config_provider: yaml - #END - ``` +Refer to the example below: + +```yaml +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +``` - This makes it possible to disable the Admin API and discover configuration changes and reloads based on the local file system. +This makes it possible to disable the Admin API and discover configuration changes and reloads based on the local file system. -2. The API-driven is an emerging paradigm for standalone. +#### API-driven (Experimental) - The routing rules will be entirely in memory and not in a file, and it will need to be updated using the dedicated Standalone Admin API. +> This mode is experimental, please do not rely on it in your production environment. +> We use it to validate certain specific workloads and if it is appropriate we will turn it into an officially supported feature, otherwise it will be removed. - I.e. we need to send an HTTP PUT request to this API containing the configuration in JSON or YAML format, which will flush the configuration used by each worker in the current APISIX instance. +##### Overview - Changes will overwrite the entire configuration and take effect immediately without requiring a reboot, as it is hot updated. +API-driven mode is an emerging paradigm for standalone deployment, where routing rules are stored entirely in memory rather than in a configuration file. Updates must be made through the dedicated Standalone Admin API. Each update replaces the full configuration and takes effect immediately through hot updates, without requiring a restart. - This requires us to set the APISIX role to traditional (since we need to start both the API gateway and the Admin API endpoint) and use the yaml config provider. That is, set `deployment.role` to `traditional` and `deployment.role_traditional.config_provider` to `yaml`. +##### Configuration - Refer to the example below: +To enable this mode, set the APISIX role to `traditional` (to start both the API gateway and the Admin API endpoint) and use the YAML config provider. Example configuration: - ```yaml - deployment: - role: traditional - role_traditional: - config_provider: yaml - #END - ``` +```yaml +deployment: + role: traditional + role_traditional: + config_provider: yaml +``` + +This disables the local file source of configuration in favor of the API. When APISIX starts, it uses an empty configuration until updated via the API. - This disables the local file source of configuration in favor of the API. When APISIX starts, it uses the empty configuration until you update it via the API. +##### API Endpoints - The following are API endpoints: +* `conf_version` by resource type - ```shell - ## Update configuration - ## The conf_version is not required, if it is not entered by the client, the current 10-digit epoch time is used by default. - curl -X PUT http://127.0.0.1:9180/apisix/admin/configs?conf_version=1234 \ - -H "X-API-KEY: " - -H "Content-Type: application/json" ## or application/yaml - --data-binary @config.json + Use `_conf_version` to indicate the client’s current version for each resource type (e.g. routes, upstreams, services, etc.). - ## Get latest configuration - curl -X GET http://127.0.0.1:9180/apisix/admin/configs - -H "X-API-KEY: " - -H "Accept: application/json" ## or application/yaml + ```json + { + "routes_conf_version": 12, + "upstreams_conf_version": 102, + "routes": [], + "upstreams": [] + } ``` - The update API validates the input and returns an error if it is invalid. If the configuration is accepted, it responds with a `202 Accepted` status and includes the latest configuration version in the `X-APISIX-Conf-Version` header. + APISIX compares each provided `_conf_version` against its in-memory `_conf_version` for that resource type. If the provided `_conf_version` is: + + - **Greater than** the current `conf_version`, APISIX will **rebuild/reset** that resource type’s data to match your payload. + + - **Equal to** the current `conf_version`, APISIX treats the resource as **unchanged** and **ignores** it (no data is rebuilt). + + - **Less than** the current `conf_version`, APISIX considers your update **stale** and **rejects** the request for that resource type with a **400 Bad Request**. + +* `modifiedIndex` by individual resource + + Allow setting an index for each resource. APISIX compares this index to its modifiedIndex to determine whether to accept the update. + +##### Example + +1. get configuration + +```shell +curl -X GET http://127.0.0.1:9180/apisix/admin/configs \ + -H "X-API-KEY: " \ + -H "Accept: application/json" ## or application/yaml +``` + +This returns the current configuration in JSON or YAML format. + +```json +{ + "consumer_groups_conf_version": 0, + "consumers_conf_version": 0, + "global_rules_conf_version": 0, + "plugin_configs_conf_version": 0, + "plugin_metadata_conf_version": 0, + "protos_conf_version": 0, + "routes_conf_version": 0, + "secrets_conf_version": 0, + "services_conf_version": 0, + "ssls_conf_version": 0, + "upstreams_conf_version": 0 +} +``` + +2. full update + +```shell +curl -X PUT http://127.0.0.1:9180/apisix/admin/configs \ + -H "X-API-KEY: " \ + -H "Content-Type: application/json" ## or application/yaml \ + -d '{}' +``` + +3. update based on resource type + +In APISIX memory, the current configuration is: + +```json +{ + "routes_conf_version": 1000, + "upstreams_conf_version": 1000, +} +``` + +Update the previous upstreams configuration by setting a higher version number, such as 1001, to replace the current version 1000: + +```shell +curl -X PUT http://127.0.0.1:9180/apisix/admin/configs \ + -H "X-API-KEY: ${API_KEY}" \ + -H "Content-Type: application/json" \ + -d ' +{ + "routes_conf_version": 1000, + "upstreams_conf_version": 1001, + "routes": [ + { + "modifiedIndex": 1000, + "id": "r1", + "uri": "/hello", + "upstream_id": "u1" + } + ], + "upstreams": [ + { + "modifiedIndex": 1001, + "id": "u1", + "nodes": { + "127.0.0.1:1980": 1, + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + } + ] +}' +``` - The get API also returns the version number via the `X-APISIX-Conf-Version` header, and returns a response body containing the configuration in a specific format as requested by the client `Accept` header. +:::note - These APIs apply the same security requirements as the Admin API — such as API key, TLS/mTLS, CORS, and IP allowlist — no changes or additions. +These APIs apply the same security requirements as the Admin API, including API key, TLS/mTLS, CORS, and IP allowlist. - The API accepts input in the same format as the file-based mode described above, although it also allows the user to input JSON instead of just YAML. The following example still applies. However, the API does not rely on the `#END` suffix because HTTP will guarantee input integrity. +The API accepts input in the same format as the file-based mode, supporting both JSON and YAML. Unlike the file-based mode, the API does not rely on the `#END` suffix, as HTTP guarantees input integrity. - *Note*: In this case, the Admin API based on etcd is not available. The configuration can only be flushed as a whole, rather than modified partially, and the client must send a request containing the complete new configuration to the API. +::: ### How to configure rules diff --git a/t/admin/standalone.spec.ts b/t/admin/standalone.spec.ts index 5b8ed4a0bd13..911fbfea1797 100644 --- a/t/admin/standalone.spec.ts +++ b/t/admin/standalone.spec.ts @@ -45,6 +45,26 @@ const config2 = { }, ], }; +const invalidConfVersionConfig1 = { + routes_conf_version: -1, +}; +const invalidConfVersionConfig2 = { + routes_conf_version: "adc", +}; +const routeWithModifiedIndex = { + routes: [ + { + id: "r1", + uri: "/r1", + modifiedIndex: 1, + upstream: { + nodes: { "127.0.0.1:1980": 1 }, + type: "roundrobin", + }, + plugins: { "proxy-rewrite": { uri: "/hello" } }, + }, + ], +}; const clientConfig = { baseURL: "http://localhost:1984", headers: { @@ -69,9 +89,11 @@ describe("Admin - Standalone", () => { it("dump empty config (default json format)", async () => { const resp = await client.get(ENDPOINT); expect(resp.status).toEqual(200); - expect(resp.headers["content-type"]).toEqual("application/json"); - expect(resp.headers["x-apisix-conf-version"]).toEqual("0"); - expect(resp.data).toEqual({}); + expect(resp.data.routes_conf_version).toEqual(0); + expect(resp.data.ssls_conf_version).toEqual(0); + expect(resp.data.services_conf_version).toEqual(0); + expect(resp.data.upstreams_conf_version).toEqual(0); + expect(resp.data.consumers_conf_version).toEqual(0); }); it("dump empty config (yaml format)", async () => { @@ -80,23 +102,32 @@ describe("Admin - Standalone", () => { }); expect(resp.status).toEqual(200); expect(resp.headers["content-type"]).toEqual("application/yaml"); - expect(resp.headers["x-apisix-conf-version"]).toEqual("0"); - - // The lyaml-encoded empty Lua table becomes an array, which is expected, but shouldn't be - expect(resp.data).toEqual([]); + expect(resp.data.routes_conf_version).toEqual(0); + expect(resp.data.ssls_conf_version).toEqual(0); + expect(resp.data.services_conf_version).toEqual(0); + expect(resp.data.upstreams_conf_version).toEqual(0); + expect(resp.data.consumers_conf_version).toEqual(0); }); it("update config (add routes, by json)", async () => { - const resp = await client.put(ENDPOINT, config1, { - params: { conf_version: 1 }, - }); + const resp = await client.put(ENDPOINT, config1); expect(resp.status).toEqual(202); }); it("dump config (json format)", async () => { const resp = await client.get(ENDPOINT); expect(resp.status).toEqual(200); - expect(resp.headers["x-apisix-conf-version"]).toEqual("1"); + expect(resp.data.routes_conf_version).toEqual(1); + expect(resp.data.ssls_conf_version).toEqual(1); + expect(resp.data.services_conf_version).toEqual(1); + expect(resp.data.upstreams_conf_version).toEqual(1); + expect(resp.data.consumers_conf_version).toEqual(1); + }); + + it("check default value", async () => { + const resp = await client.get(ENDPOINT); + expect(resp.status).toEqual(200); + expect(resp.data.routes).toEqual(config1.routes); }); it("dump config (yaml format)", async () => { @@ -105,7 +136,6 @@ describe("Admin - Standalone", () => { responseType: 'text', }); expect(resp.status).toEqual(200); - expect(resp.headers["x-apisix-conf-version"]).toEqual("1"); expect(resp.data).toContain("routes:") expect(resp.data).toContain("id: r1") expect(resp.data.startsWith('---')).toBe(false); @@ -123,7 +153,6 @@ describe("Admin - Standalone", () => { ENDPOINT, YAML.stringify(config2), { - params: { conf_version: 2 }, headers: { "Content-Type": "application/yaml" }, } ); @@ -133,7 +162,11 @@ describe("Admin - Standalone", () => { it("dump config (json format)", async () => { const resp = await client.get(ENDPOINT); expect(resp.status).toEqual(200); - expect(resp.headers["x-apisix-conf-version"]).toEqual("2"); + expect(resp.data.routes_conf_version).toEqual(2); + expect(resp.data.ssls_conf_version).toEqual(2); + expect(resp.data.services_conf_version).toEqual(2); + expect(resp.data.upstreams_conf_version).toEqual(2); + expect(resp.data.consumers_conf_version).toEqual(2); }); it('check route "r1"', () => @@ -159,7 +192,83 @@ describe("Admin - Standalone", () => { it('check route "r2"', () => expect(client.get("/r2")).rejects.toThrow( "Request failed with status code 404" - )); + )); + + it("only set routes_conf_version", async () => { + const resp = await client.put( + ENDPOINT, + YAML.stringify({ routes_conf_version: 15 }), + {headers: {"Content-Type": "application/yaml"}, + }); + expect(resp.status).toEqual(202); + + const resp_1 = await client.get(ENDPOINT); + expect(resp_1.status).toEqual(200); + expect(resp_1.data.routes_conf_version).toEqual(15); + expect(resp_1.data.ssls_conf_version).toEqual(4); + expect(resp_1.data.services_conf_version).toEqual(4); + expect(resp_1.data.upstreams_conf_version).toEqual(4); + expect(resp_1.data.consumers_conf_version).toEqual(4); + + const resp2 = await client.put( + ENDPOINT, + YAML.stringify({ routes_conf_version: 17 }), + {headers: {"Content-Type": "application/yaml"}, + }); + expect(resp2.status).toEqual(202); + + const resp2_1 = await client.get(ENDPOINT); + expect(resp2_1.status).toEqual(200); + expect(resp2_1.data.routes_conf_version).toEqual(17); + expect(resp2_1.data.ssls_conf_version).toEqual(5); + expect(resp2_1.data.services_conf_version).toEqual(5); + expect(resp2_1.data.upstreams_conf_version).toEqual(5); + expect(resp2_1.data.consumers_conf_version).toEqual(5); + }); + + it("control resource changes using modifiedIndex", async () => { + const c1 = structuredClone(routeWithModifiedIndex); + c1.routes[0].modifiedIndex = 1; + + const c2 = structuredClone(c1); + c2.routes[0].uri = "/r2"; + + const c3 = structuredClone(c2); + c3.routes[0].modifiedIndex = 2; + + // Update with c1 + const resp = await client.put(ENDPOINT, c1); + expect(resp.status).toEqual(202); + + // Check route /r1 exists + const resp_1 = await client.get("/r1"); + expect(resp_1.status).toEqual(200); + + // Update with c2 + const resp2 = await client.put(ENDPOINT, c2); + expect(resp2.status).toEqual(202); + + // Check route /r1 exists + // But it is not applied because the modifiedIndex is the same as the old value + const resp2_2 = await client.get("/r1"); + expect(resp2_2.status).toEqual(200); + + // Check route /r2 not exists + const resp2_1 = await client.get("/r2").catch((err) => err.response); + expect(resp2_1.status).toEqual(404); + + // Update with c3 + const resp3 = await client.put(ENDPOINT, c3); + expect(resp3.status).toEqual(202); + + // Check route /r1 not exists + const resp3_1 = await client.get("/r1").catch((err) => err.response); + expect(resp3_1.status).toEqual(404); + + // Check route /r2 exists + const resp3_2 = await client.get("/r2"); + expect(resp3_2.status).toEqual(200); + }); }); describe("Exceptions", () => { @@ -171,31 +280,33 @@ describe("Admin - Standalone", () => { it("update config (lower conf_version)", async () => { const resp = await clientException.put( ENDPOINT, - YAML.stringify(config2), + YAML.stringify(invalidConfVersionConfig1), { - params: { conf_version: 0 }, - headers: { "Content-Type": "application/yaml" }, + headers: { + "Content-Type": "application/yaml", + }, } ); expect(resp.status).toEqual(400); expect(resp.data).toEqual({ error_msg: - "invalid conf_version: conf_version (0) should be greater than the current version (3)", + "routes_conf_version must be greater than or equal to (20)", }); }); it("update config (invalid conf_version)", async () => { const resp = await clientException.put( ENDPOINT, - YAML.stringify(config2), + YAML.stringify(invalidConfVersionConfig2), { - params: { conf_version: "abc" }, - headers: { "Content-Type": "application/yaml" }, + headers: { + "Content-Type": "application/yaml", + }, } ); expect(resp.status).toEqual(400); expect(resp.data).toEqual({ - error_msg: "invalid conf_version: abc, should be a integer", + error_msg: "routes_conf_version must be a number", }); }); diff --git a/t/admin/standalone.t b/t/admin/standalone.t index b5c9726e00c1..f34059f9746f 100644 --- a/t/admin/standalone.t +++ b/t/admin/standalone.t @@ -64,11 +64,71 @@ qr/PASS admin\/standalone.spec.ts/ -=== TEST 2: configure route +=== TEST 2: init conf_version --- config location /t {} # force the worker to restart by changing the configuration --- request -PUT /apisix/admin/configs?conf_version=101 +PUT /apisix/admin/configs +{ + "consumer_groups_conf_version": 1000, + "consumers_conf_version": 1000, + "global_rules_conf_version": 1000, + "plugin_configs_conf_version": 1000, + "plugin_metadata_conf_version": 1000, + "protos_conf_version": 1000, + "routes_conf_version": 1000, + "secrets_conf_version": 1000, + "services_conf_version": 1000, + "ssls_conf_version": 1000, + "upstreams_conf_version": 1000 +} +--- more_headers +X-API-KEY: edd1c9f034335f136f87ad84b625c8f1 +--- error_code: 202 + + + +=== TEST 3: get config +--- config + location /t { + content_by_lua_block { + local json = require("toolkit.json") + local t = require("lib.test_admin") + local code, body = t.test('/apisix/admin/configs', + ngx.HTTP_GET, + nil, + [[{ + "consumer_groups_conf_version": 1000, + "consumers_conf_version": 1000, + "global_rules_conf_version": 1000, + "plugin_configs_conf_version": 1000, + "plugin_metadata_conf_version": 1000, + "protos_conf_version": 1000, + "routes_conf_version": 1000, + "secrets_conf_version": 1000, + "services_conf_version": 1000, + "ssls_conf_version": 1000, + "upstreams_conf_version": 1000 + }]], + { + ["X-API-KEY"] = "edd1c9f034335f136f87ad84b625c8f1" + } + ) + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 4: configure route +--- config + location /t {} # force the worker to restart by changing the configuration +--- request +PUT /apisix/admin/configs {"routes":[{"id":"r1","uri":"/r1","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"},"plugins":{"proxy-rewrite":{"uri":"/hello"}}}]} --- more_headers X-API-KEY: edd1c9f034335f136f87ad84b625c8f1 @@ -76,7 +136,7 @@ X-API-KEY: edd1c9f034335f136f87ad84b625c8f1 -=== TEST 3: test route +=== TEST 5: test route --- config location /t1 {} --- request @@ -87,11 +147,11 @@ hello world -=== TEST 4: remove route +=== TEST 6: remove route --- config location /t2 {} --- request -PUT /apisix/admin/configs?conf_version=102 +PUT /apisix/admin/configs {} --- more_headers X-API-KEY: edd1c9f034335f136f87ad84b625c8f1 @@ -99,9 +159,52 @@ X-API-KEY: edd1c9f034335f136f87ad84b625c8f1 -=== TEST 5: test non-exist route +=== TEST 7: test non-exist route --- config location /t3 {} --- request GET /r1 --- error_code: 404 + + + +=== TEST 8: route references upstream, but only updates the route +--- config + location /t6 {} +--- pipelined_requests eval +[ + "PUT /apisix/admin/configs\n" . "{\"routes_conf_version\":1060,\"upstreams_conf_version\":1060,\"routes\":[{\"id\":\"r1\",\"uri\":\"/r1\",\"upstream_id\":\"u1\",\"plugins\":{\"proxy-rewrite\":{\"uri\":\"/hello\"}}}],\"upstreams\":[{\"id\":\"u1\",\"nodes\":{\"127.0.0.1:1980\":1},\"type\":\"roundrobin\"}]}", + "PUT /apisix/admin/configs\n" . "{\"routes_conf_version\":1062,\"upstreams_conf_version\":1060,\"routes\":[{\"id\":\"r1\",\"uri\":\"/r2\",\"upstream_id\":\"u1\",\"plugins\":{\"proxy-rewrite\":{\"uri\":\"/hello\"}}}],\"upstreams\":[{\"id\":\"u1\",\"nodes\":{\"127.0.0.1:1980\":1},\"type\":\"roundrobin\"}]}" +] +--- more_headers eval +[ + "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1", + "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1\n" . "x-apisix-conf-version-routes: 100", +] +--- error_code eval +[202, 202] + + + +=== TEST 9: hit r2 +--- config + location /t3 {} +--- pipelined_requests eval +["GET /r1", "GET /r2"] +--- error_code eval +[404, 200] + + + +=== TEST 10: routes_conf_version < 1062 is not allowed +--- config + location /t {} +--- request +PUT /apisix/admin/configs +{"routes_conf_version":1,"routes":[{"id":"r1","uri":"/r2","upstream_id":"u1","plugins":{"proxy-rewrite":{"uri":"/hello"}}}]} +--- more_headers +X-API-KEY: edd1c9f034335f136f87ad84b625c8f1 +x-apisix-conf-version-routes: 100 +--- error_code: 400 +--- response_body +{"error_msg":"routes_conf_version must be greater than or equal to (1062)"}