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 2b98a717f feat(kubernetes): select clusters by
discovery_args.cluster_ids (#13991)
2b98a717f is described below
commit 2b98a717fc06a652fe5966019f71f8f885d98b70
Author: AlinsRan <[email protected]>
AuthorDate: Thu Oct 8 15:13:31 2026 +0800
feat(kubernetes): select clusters by discovery_args.cluster_ids (#13991)
---
apisix/discovery/kubernetes/init.lua | 136 +++++++-
apisix/schema_def.lua | 9 +
apisix/upstream.lua | 9 +
docs/en/latest/discovery/kubernetes.md | 29 ++
docs/zh/latest/discovery/kubernetes.md | 29 ++
t/discovery/kubernetes_cluster_ids.t | 190 +++++++++++
t/kubernetes/discovery/kubernetes5.t | 588 +++++++++++++++++++++++++++++++++
7 files changed, 988 insertions(+), 2 deletions(-)
diff --git a/apisix/discovery/kubernetes/init.lua
b/apisix/discovery/kubernetes/init.lua
index f92077923..11c8b0630 100644
--- a/apisix/discovery/kubernetes/init.lua
+++ b/apisix/discovery/kubernetes/init.lua
@@ -21,6 +21,7 @@ local ipairs = ipairs
local pairs = pairs
local string = string
local error = error
+local tostring = tostring
local is_http = ngx.config.subsystem == "http"
local process = require("ngx.process")
local core = require("apisix.core")
@@ -87,7 +88,13 @@ local function single_mode_init(conf)
end
-local function single_mode_nodes(service_name)
+local function single_mode_nodes(service_name, discovery_args)
+ if discovery_args and discovery_args.cluster_ids then
+ core.log.error("discovery_args.cluster_ids requires kubernetes
discovery ",
+ "configured with multiple clusters, service: ",
service_name)
+ return nil
+ end
+
return k8s_core.resolve_nodes(
endpoint_lrucache, service_name,
"^(.*):(.*)$", -- namespace/name:port_name
@@ -154,7 +161,86 @@ local function multiple_mode_init(confs)
end
-local function multiple_mode_nodes(service_name)
+local function merge_cluster_nodes(endpoint_dicts, endpoint_key, endpoint_port)
+ local nodes = {}
+ local seen = {}
+ for _, endpoint_dict in ipairs(endpoint_dicts) do
+ local cluster_nodes = k8s_core.create_endpoint_lrucache(endpoint_dict,
endpoint_key,
+ endpoint_port)
+ for _, node in ipairs(cluster_nodes or {}) do
+ local addr = node.host .. ":" .. tostring(node.port)
+ if not seen[addr] then
+ seen[addr] = true
+ core.table.insert(nodes, node)
+ end
+ end
+ end
+
+ return nodes
+end
+
+
+-- with cluster_ids, service_name is "namespace/name:port_name"
+local cluster_ids_service_name_pattern = [[^([^/:]+/[^/:]+):([^/:]+)$]]
+
+
+local function parse_cluster_ids_service_name(service_name)
+ local match = ngx.re.match(service_name, cluster_ids_service_name_pattern,
"jo")
+ if not match then
+ return nil, "service_name must be namespace/name:port_name when "
+ .. "discovery_args.cluster_ids is set, got: " ..
service_name
+ end
+ return match
+end
+
+
+local function selected_clusters_nodes(service_name, cluster_ids)
+ local match, err = parse_cluster_ids_service_name(service_name)
+ if not match then
+ core.log.error(err)
+ return nil
+ end
+ local endpoint_key, endpoint_port = match[1], match[2]
+
+ local endpoint_dicts = core.table.new(#cluster_ids, 0)
+ local versions = core.table.new(#cluster_ids, 0)
+ local unknown_ids
+ for _, id in ipairs(cluster_ids) do
+ local endpoint_dict = ctx[id]
+ if not endpoint_dict then
+ unknown_ids = unknown_ids or {}
+ core.table.insert(unknown_ids, id)
+ else
+ local endpoint_version = endpoint_dict:get(endpoint_key ..
"#version")
+ if endpoint_version then
+ core.table.insert(endpoint_dicts, endpoint_dict)
+ core.table.insert(versions, id .. "#" .. endpoint_version)
+ end
+ end
+ end
+
+ if unknown_ids then
+ core.log.warn("skip unknown kubernetes discovery cluster ids: ",
+ core.table.concat(unknown_ids, ", "), ", service: ",
service_name)
+ end
+
+ if #endpoint_dicts == 0 then
+ core.log.info("get empty endpoint version from selected clusters for
", service_name)
+ return nil
+ end
+
+ return endpoint_lrucache(service_name .. "#" ..
core.table.concat(cluster_ids, ","),
+ core.table.concat(versions, ","),
+ merge_cluster_nodes, endpoint_dicts,
endpoint_key, endpoint_port)
+end
+
+
+local function multiple_mode_nodes(service_name, discovery_args)
+ local cluster_ids = discovery_args and discovery_args.cluster_ids
+ if cluster_ids then
+ return selected_clusters_nodes(service_name, cluster_ids)
+ end
+
return k8s_core.resolve_nodes(
endpoint_lrucache, service_name,
"^(.*)/(.*/.*):(.*)$", -- id/namespace/name:port_name
@@ -183,6 +269,52 @@ function _M.init_worker()
end
+function _M.check_discovery_args(discovery_args, service_name, in_dp)
+ local cluster_ids = discovery_args and discovery_args.cluster_ids
+ if not cluster_ids then
+ return true
+ end
+
+ if service_name then
+ local match, err = parse_cluster_ids_service_name(service_name)
+ if not match then
+ return false, err
+ end
+ end
+
+ -- the data plane resolves the ids at runtime, where an unknown id is
skipped
+ if in_dp then
+ return true
+ end
+
+ local discovery_conf = local_conf.discovery.kubernetes
+ if #discovery_conf == 0 then
+ return false, "discovery_args.cluster_ids requires kubernetes
discovery "
+ .. "configured with multiple clusters"
+ end
+
+ local known_ids = {}
+ for _, conf in ipairs(discovery_conf) do
+ known_ids[conf.id] = true
+ end
+
+ local unknown_ids
+ for _, id in ipairs(cluster_ids) do
+ if not known_ids[id] then
+ unknown_ids = unknown_ids or {}
+ core.table.insert(unknown_ids, id)
+ end
+ end
+
+ if unknown_ids then
+ return false, "unknown kubernetes discovery cluster ids in "
+ .. "discovery_args.cluster_ids: " ..
core.table.concat(unknown_ids, ", ")
+ end
+
+ return true
+end
+
+
function _M.dump_data()
local discovery_conf = local_conf.discovery.kubernetes
local eps = {}
diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua
index 64f661f82..8b008fe67 100644
--- a/apisix/schema_def.lua
+++ b/apisix/schema_def.lua
@@ -570,6 +570,15 @@ local upstream_schema = {
description = "group name",
type = "string",
},
+ cluster_ids = {
+ description = "ids of the kubernetes discovery clusters to
get nodes from",
+ type = "array",
+ minItems = 1,
+ uniqueItems = true,
+ items = {
+ type = "string",
+ },
+ },
}
},
pass_host = {
diff --git a/apisix/upstream.lua b/apisix/upstream.lua
index 76865fc06..9ccfeaaa6 100644
--- a/apisix/upstream.lua
+++ b/apisix/upstream.lua
@@ -667,6 +667,15 @@ local function check_upstream_conf(in_dp, conf)
end
end
+ -- a discovery module may check discovery_args against its own
configuration
+ local dis = conf.discovery_type and discovery and
discovery[conf.discovery_type]
+ if dis and dis.check_discovery_args then
+ local ok, err = dis.check_discovery_args(conf.discovery_args,
conf.service_name, in_dp)
+ if not ok then
+ return false, err
+ end
+ end
+
if is_http then
if conf.pass_host == "rewrite" and
(conf.upstream_host == nil or conf.upstream_host == "")
diff --git a/docs/en/latest/discovery/kubernetes.md
b/docs/en/latest/discovery/kubernetes.md
index a2f137c39..b00e3750f 100644
--- a/docs/en/latest/discovery/kubernetes.md
+++ b/docs/en/latest/discovery/kubernetes.md
@@ -284,6 +284,35 @@ a nodes("release/default/plat-dev:port") call will get
follow result:
}
```
+### Select Clusters with `cluster_ids`
+
+In multi-cluster mode, an upstream can get its nodes from several clusters at
once by listing their `id`s in `discovery_args.cluster_ids`. The `service_name`
then uses the single-cluster pattern _[namespace]/[name]:[portName]_, without
the `id` prefix:
+
+```json
+{
+ "type": "roundrobin",
+ "discovery_type": "kubernetes",
+ "service_name": "default/plat-dev:port",
+ "discovery_args": {
+ "cluster_ids": ["release", "staging"]
+ }
+}
+```
+
+The upstream nodes are the union of the matching endpoints in the listed
clusters, and they follow the endpoint changes in those clusters. When the same
`host:port` is found in more than one listed cluster, it is used once.
+
++ `cluster_ids` is a non-empty array of unique strings. It only applies to
multi-cluster mode. The Admin API rejects `cluster_ids` when Kubernetes service
discovery is configured in single-cluster mode. If such an upstream reaches the
data plane in another way, it gets no nodes and an error is logged.
+
++ Clusters that are not listed, including clusters added to the configuration
later, never contribute nodes.
+
++ When `cluster_ids` is set, `service_name` must match
_[namespace]/[name]:[portName]_, so it cannot carry an `id` prefix. Any other
value is rejected, and the error shows the `service_name` value.
+
++ The Admin API rejects an `id` that is not defined in the Kubernetes service
discovery configuration of the APISIX instance that serves the Admin API, and
the error lists all unknown `id`s. When a configuration with an unknown `id`
still reaches the data plane, for example because it was written before a
cluster was removed from the configuration, or because it comes from a
standalone configuration file, the unknown `id`s are skipped and a warning that
lists them is logged.
+
++ When none of the listed clusters has matching endpoints, the upstream has no
valid nodes. Nodes from other clusters are never used instead.
+
+Without `cluster_ids`, the query interface described above is unchanged.
+
## Q&A
**Q: Why only support configuration token to access _Kubernetes APIServer_?**
diff --git a/docs/zh/latest/discovery/kubernetes.md
b/docs/zh/latest/discovery/kubernetes.md
index b769400c3..6ec9993ed 100644
--- a/docs/zh/latest/discovery/kubernetes.md
+++ b/docs/zh/latest/discovery/kubernetes.md
@@ -282,6 +282,35 @@ nodes("release/default/plat-dev:port") 调用会得到如下的返回值:
}
```
+### 使用 `cluster_ids` 选择集群
+
+在多集群模式下,上游可以通过 `discovery_args.cluster_ids` 列出多个集群的 `id`,同时从这些集群获取节点。此时
`service_name` 使用单集群模式的格式 _[namespace]/[name]:[portName]_,不带 `id` 前缀:
+
+```json
+{
+ "type": "roundrobin",
+ "discovery_type": "kubernetes",
+ "service_name": "default/plat-dev:port",
+ "discovery_args": {
+ "cluster_ids": ["release", "staging"]
+ }
+}
+```
+
+上游节点是所列集群中匹配 endpoints 的并集,并随这些集群中 endpoints 的变化而更新。同一个 `host:port`
出现在多个所列集群中时只使用一次。
+
++ `cluster_ids` 是非空且元素不重复的字符串数组,仅适用于多集群模式。Kubernetes 服务发现为单集群模式时,Admin API 会拒绝
`cluster_ids`。如果这样的上游通过其他方式到达数据面,它不会获得任何节点,并会记录错误日志。
+
++ 未列出的集群(包括之后新增到配置中的集群)不会提供节点。
+
++ 设置 `cluster_ids` 时,`service_name` 必须满足格式
_[namespace]/[name]:[portName]_,因此不能带 `id` 前缀。其他格式的值会被拒绝,错误信息中会给出
`service_name` 的值。
+
++ 如果 `id` 不在提供 Admin API 的 APISIX 实例的 Kubernetes 服务发现配置中,Admin API
会拒绝该配置,错误信息会列出所有未知的 `id`。如果带有未知 `id` 的配置仍然到达了数据面,例如该配置写入于某个集群从配置中移除之前,或者来自
standalone 配置文件,未知的 `id` 会被跳过,并记录一条列出这些 `id` 的警告日志。
+
++ 所列集群都没有匹配的 endpoints 时,上游没有可用节点,不会改用其他集群的节点。
+
+不设置 `cluster_ids` 时,上述查询接口的行为不变。
+
## Q&A
**Q: 为什么只支持配置 token 来访问 Kubernetes APIServer?**
diff --git a/t/discovery/kubernetes_cluster_ids.t
b/t/discovery/kubernetes_cluster_ids.t
new file mode 100644
index 000000000..9606d51b8
--- /dev/null
+++ b/t/discovery/kubernetes_cluster_ids.t
@@ -0,0 +1,190 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_shuffle();
+no_root_location();
+log_level("warn");
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ # no apiserver listens on 6446, the tests only check the Admin API
+ my $extra_yaml_config = $block->extra_yaml_config // <<_EOC_;
+discovery:
+ kubernetes:
+ - id: first
+ service:
+ schema: "http"
+ host: "127.0.0.1"
+ port: "6446"
+ client:
+ token: "fake"
+ - id: second
+ service:
+ schema: "http"
+ host: "127.0.0.1"
+ port: "6446"
+ client:
+ token: "fake"
+_EOC_
+
+ $block->set_value("extra_yaml_config", $extra_yaml_config);
+
+ if (!$block->error_log && !$block->no_error_log) {
+ $block->set_value("no_error_log", "[alert]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: validate cluster_ids in the Admin API
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local cases = {
+ {service_name = "ns/svc:p1", discovery_args = {cluster_ids =
{"first"}}},
+ {service_name = "ns/svc:p1", discovery_args = {cluster_ids =
{"second", "first"}}},
+ {service_name = "ns/svc:p1",
+ discovery_args = {cluster_ids = {"third", "first",
"fourth"}}},
+ {service_name = "first/ns/svc:p1", discovery_args =
{cluster_ids = {"first"}}},
+ {service_name = "ns/svc:p1", discovery_args = {cluster_ids =
{}}},
+ {service_name = "ns/svc:p1", discovery_args = {cluster_ids =
{"first", "first"}}},
+ {service_name = "ns/svc:p1", discovery_args = {cluster_ids =
{1}}},
+ {service_name = "ns/svc:p1", discovery_args = {cluster_ids =
"first"}},
+ {service_name = "first/ns/svc:p1"},
+ {service_name = "svc:p1", discovery_args = {cluster_ids =
{"first"}}},
+ {service_name = "/svc:p1", discovery_args = {cluster_ids =
{"first"}}},
+ {service_name = "ns/:p1", discovery_args = {cluster_ids =
{"first"}}},
+ {service_name = "ns/svc:", discovery_args = {cluster_ids =
{"first"}}},
+ {service_name = "ns/svc", discovery_args = {cluster_ids =
{"first"}}},
+ {service_name = "ns:extra/svc:p1", discovery_args =
{cluster_ids = {"first"}}},
+ {service_name = "ns/svc:p1:extra", discovery_args =
{cluster_ids = {"first"}}},
+ }
+ for _, case in ipairs(cases) do
+ case.discovery_type = "kubernetes"
+ case.type = "roundrobin"
+ local code, body = t('/apisix/admin/upstreams/1',
ngx.HTTP_PUT, case)
+ if code >= 300 then
+ ngx.say(code, " ", (body:gsub("%s+$", "")))
+ else
+ ngx.say("passed")
+ end
+ end
+ }
+ }
+--- response_body
+passed
+passed
+400 {"error_msg":"unknown kubernetes discovery cluster ids in
discovery_args.cluster_ids: third, fourth"}
+400 {"error_msg":"service_name must be namespace/name:port_name when
discovery_args.cluster_ids is set, got: first/ns/svc:p1"}
+400 {"error_msg":"invalid configuration: property \"discovery_args\"
validation failed: property \"cluster_ids\" validation failed: expect array to
have at least 1 items"}
+400 {"error_msg":"invalid configuration: property \"discovery_args\"
validation failed: property \"cluster_ids\" validation failed: expected unique
items but items 1 and 2 are equal"}
+400 {"error_msg":"invalid configuration: property \"discovery_args\"
validation failed: property \"cluster_ids\" validation failed: failed to
validate item 1: wrong type: expected string, got number"}
+400 {"error_msg":"invalid configuration: property \"discovery_args\"
validation failed: property \"cluster_ids\" validation failed: wrong type:
expected array, got string"}
+passed
+400 {"error_msg":"service_name must be namespace/name:port_name when
discovery_args.cluster_ids is set, got: svc:p1"}
+400 {"error_msg":"service_name must be namespace/name:port_name when
discovery_args.cluster_ids is set, got: /svc:p1"}
+400 {"error_msg":"service_name must be namespace/name:port_name when
discovery_args.cluster_ids is set, got: ns/:p1"}
+400 {"error_msg":"service_name must be namespace/name:port_name when
discovery_args.cluster_ids is set, got: ns/svc:"}
+400 {"error_msg":"service_name must be namespace/name:port_name when
discovery_args.cluster_ids is set, got: ns/svc"}
+400 {"error_msg":"service_name must be namespace/name:port_name when
discovery_args.cluster_ids is set, got: ns:extra/svc:p1"}
+400 {"error_msg":"service_name must be namespace/name:port_name when
discovery_args.cluster_ids is set, got: ns/svc:p1:extra"}
+
+
+
+=== TEST 2: validate cluster_ids in an inline upstream
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ for _, uri in ipairs({"/apisix/admin/routes/1",
"/apisix/admin/services/1"}) do
+ local conf = {
+ upstream = {
+ type = "roundrobin",
+ discovery_type = "kubernetes",
+ service_name = "ns/svc:p1",
+ discovery_args = {cluster_ids = {"first", "third"}},
+ },
+ }
+ if uri == "/apisix/admin/routes/1" then
+ conf.uri = "/hello"
+ end
+ local code, body = t(uri, ngx.HTTP_PUT, conf)
+ ngx.say(code, " ", (body:gsub("%s+$", "")))
+ end
+ }
+ }
+--- response_body
+400 {"error_msg":"unknown kubernetes discovery cluster ids in
discovery_args.cluster_ids: third"}
+400 {"error_msg":"unknown kubernetes discovery cluster ids in
discovery_args.cluster_ids: third"}
+
+
+
+=== TEST 3: cluster_ids of another discovery type is not checked by kubernetes
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/upstreams/1', ngx.HTTP_PUT, {
+ type = "roundrobin",
+ discovery_type = "consul",
+ service_name = "first/ns/svc:p1",
+ discovery_args = {cluster_ids = {"third"}},
+ })
+ ngx.say(code < 300 and "passed" or body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 4: cluster_ids requires multiple clusters
+--- extra_yaml_config
+discovery:
+ kubernetes:
+ service:
+ schema: "http"
+ host: "127.0.0.1"
+ port: "6446"
+ client:
+ token: "fake"
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/upstreams/1', ngx.HTTP_PUT, {
+ type = "roundrobin",
+ discovery_type = "kubernetes",
+ service_name = "ns/svc:p1",
+ discovery_args = {cluster_ids = {"first"}},
+ })
+ ngx.say(code, " ", (body:gsub("%s+$", "")))
+ }
+ }
+--- response_body
+400 {"error_msg":"discovery_args.cluster_ids requires kubernetes discovery
configured with multiple clusters"}
diff --git a/t/kubernetes/discovery/kubernetes5.t
b/t/kubernetes/discovery/kubernetes5.t
new file mode 100644
index 000000000..572494343
--- /dev/null
+++ b/t/kubernetes/discovery/kubernetes5.t
@@ -0,0 +1,588 @@
+#
+# 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.
+#
+
+BEGIN {
+ our $token_file =
"/tmp/var/run/secrets/kubernetes.io/serviceaccount/token";
+ our $token_value = eval {`cat $token_file 2>/dev/null`};
+
+ our $yaml_config = <<_EOC_;
+apisix:
+ node_listen: 1984
+deployment:
+ role: data_plane
+ role_data_plane:
+ config_provider: yaml
+discovery:
+ kubernetes:
+ - id: first
+ service:
+ host: "127.0.0.1"
+ port: "6443"
+ ssl_verify: false
+ client:
+ token_file: "/tmp/var/run/secrets/kubernetes.io/serviceaccount/token"
+ namespace_selector:
+ equal: ns-a
+ - id: second
+ service:
+ schema: "http"
+ host: "127.0.0.1"
+ port: "6445"
+ client:
+ token_file: "/tmp/var/run/secrets/kubernetes.io/serviceaccount/token"
+
+_EOC_
+
+ # no apiserver listens on 6446, so the tests below own the endpoint dicts
+ our $offline_yaml_config = <<_EOC_;
+apisix:
+ node_listen: 1984
+deployment:
+ role: data_plane
+ role_data_plane:
+ config_provider: yaml
+discovery:
+ kubernetes:
+ - id: first
+ service:
+ schema: "http"
+ host: "127.0.0.1"
+ port: "6446"
+ client:
+ token: "fake"
+ - id: second
+ service:
+ schema: "http"
+ host: "127.0.0.1"
+ port: "6446"
+ client:
+ token: "fake"
+
+_EOC_
+
+}
+
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+log_level('warn');
+no_root_location();
+no_shuffle();
+workers(4);
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ my $apisix_yaml = $block->apisix_yaml // <<_EOC_;
+routes: []
+#END
+_EOC_
+
+ $block->set_value("apisix_yaml", $apisix_yaml);
+
+ my $main_config = $block->main_config // <<_EOC_;
+env KUBERNETES_SERVICE_HOST=127.0.0.1;
+env KUBERNETES_SERVICE_PORT=6443;
+env KUBERNETES_CLIENT_TOKEN=$::token_value;
+env KUBERNETES_CLIENT_TOKEN_FILE=$::token_file;
+_EOC_
+
+ $block->set_value("main_config", $main_config);
+
+ my $config = $block->config // <<_EOC_;
+ location /queries {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local d = require("apisix.discovery.kubernetes")
+
+ ngx.sleep(1)
+
+ ngx.req.read_body()
+ local request_body = ngx.req.get_body_data()
+ local queries = core.json.decode(request_body)
+ local response_body = "{"
+ for _, query in ipairs(queries) do
+ local args
+ if query.cluster_ids then
+ args = {cluster_ids = query.cluster_ids}
+ end
+ local nodes = d.nodes(query.service_name, args)
+ if nodes == nil or #nodes == 0 then
+ response_body = response_body .. " " .. 0
+ else
+ response_body = response_body .. " " .. #nodes
+ end
+ end
+ ngx.say(response_body .. " }")
+ }
+ }
+
+ location /operators {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local core = require("apisix.core")
+ local ipairs = ipairs
+
+ ngx.req.read_body()
+ local request_body = ngx.req.get_body_data()
+ local operators = core.json.decode(request_body)
+
+ for _, op in ipairs(operators) do
+ local path = "/api/v1/namespaces/" .. op.namespace ..
"/endpoints/" .. op.name
+ local body
+ if #op.subsets == 0 then
+ body =
'[{"path":"/subsets","op":"replace","value":[]}]'
+ else
+ local t = { { op = "replace", path = "/subsets", value
= op.subsets } }
+ body = core.json.encode(t, true)
+ end
+
+ local httpc = http.new()
+ local res, err = httpc:request_uri("http://127.0.0.1:6445"
.. path, {
+ method = "PATCH",
+ headers = {
+ ["Host"] = "127.0.0.1:6445",
+ ["Content-Type"] = "application/json-patch+json",
+ },
+ body = body,
+ })
+ if not res then
+ core.log.error("operator k8s cluster error: ", err)
+ return 500
+ end
+ if res.status ~= 200 and res.status ~= 201 then
+ return res.status
+ end
+ end
+ ngx.say("DONE")
+ }
+ }
+
+_EOC_
+
+ $block->set_value("config", $config);
+
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: create endpoints
+--- yaml_config eval: $::yaml_config
+--- request
+POST /operators
+[
+ {
+ "namespace": "ns-a",
+ "name": "ep",
+ "subsets": [
+ {
+ "addresses": [{"ip": "10.0.1.1"}],
+ "ports": [{"name": "p1", "port": 5001}]
+ }
+ ]
+ },
+ {
+ "namespace": "ns-b",
+ "name": "ep",
+ "subsets": [
+ {
+ "addresses": [{"ip": "10.0.2.1"}, {"ip": "10.0.2.2"}],
+ "ports": [{"name": "p1", "port": 5001}]
+ }
+ ]
+ }
+]
+--- more_headers
+Content-type: application/json
+--- response_body
+DONE
+
+
+
+=== TEST 2: select clusters by cluster_ids
+--- yaml_config eval: $::yaml_config
+--- request
+GET /queries
+[
+ {"service_name": "ns-a/ep:p1", "cluster_ids": ["first"]},
+ {"service_name": "ns-a/ep:p1", "cluster_ids": ["second"]},
+ {"service_name": "ns-a/ep:p1", "cluster_ids": ["first", "second"]},
+ {"service_name": "ns-b/ep:p1", "cluster_ids": ["first"]},
+ {"service_name": "ns-b/ep:p1", "cluster_ids": ["second"]},
+ {"service_name": "ns-b/ep:p1", "cluster_ids": ["first", "second"]},
+ {"service_name": "ns-b/ep:p1", "cluster_ids": ["third", "second"]},
+ {"service_name": "ns-b/ep:p1", "cluster_ids": ["third"]},
+ {"service_name": "first/ns-a/ep:p1"},
+ {"service_name": "first/ns-b/ep:p1"},
+ {"service_name": "second/ns-b/ep:p1"}
+]
+--- more_headers
+Content-type: application/json
+--- response_body
+{ 1 1 1 0 2 2 2 0 1 0 2 }
+--- error_log
+skip unknown kubernetes discovery cluster ids: third, service: ns-b/ep:p1
+
+
+
+=== TEST 3: endpoint changes in the selected clusters keep refreshing
+--- yaml_config eval: $::yaml_config
+--- request eval
+[
+
+"GET /queries
+[
+ {\"service_name\": \"ns-b/ep:p1\", \"cluster_ids\": [\"first\", \"second\"]}
+]",
+
+"POST /operators
+[{\"name\":\"ep\",\"namespace\":\"ns-b\",\"subsets\":[{\"addresses\":[{\"ip\":\"10.0.2.1\"},{\"ip\":\"10.0.2.2\"},{\"ip\":\"10.0.2.3\"}],\"ports\":[{\"name\":\"p1\",\"port\":5001}]}]}]",
+
+"GET /queries
+[
+ {\"service_name\": \"ns-b/ep:p1\", \"cluster_ids\": [\"first\", \"second\"]}
+]",
+
+"POST /operators
+[{\"name\":\"ep\",\"namespace\":\"ns-b\",\"subsets\":[]}]",
+
+"GET /queries
+[
+ {\"service_name\": \"ns-b/ep:p1\", \"cluster_ids\": [\"first\", \"second\"]}
+]"
+
+]
+--- response_body eval
+[
+ "{ 2 }\n",
+ "DONE\n",
+ "{ 3 }\n",
+ "DONE\n",
+ "{ 0 }\n",
+]
+
+
+
+=== TEST 4: union of the selected clusters
+--- yaml_config eval: $::offline_yaml_config
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local function set_endpoints(id, key, endpoints, version)
+ local dict = ngx.shared["kubernetes-" .. id]
+ dict:set(key, core.json.encode(endpoints))
+ dict:set(key .. "#version", version)
+ end
+ local d = require("apisix.discovery.kubernetes")
+
+ local function show(service_name, cluster_ids)
+ local nodes = d.nodes(service_name, {cluster_ids =
cluster_ids})
+ if not nodes then
+ ngx.say("nil")
+ return
+ end
+ local addrs = {}
+ for _, node in ipairs(nodes) do
+ table.insert(addrs, node.host .. ":" .. node.port)
+ end
+ table.sort(addrs)
+ ngx.say(table.concat(addrs, " "))
+ end
+
+ set_endpoints("first", "ns/svc", {p1 = {
+ {host = "10.0.0.1", port = 80, weight = 50},
+ {host = "10.0.0.9", port = 80, weight = 50},
+ }}, "1")
+ set_endpoints("second", "ns/svc", {p1 = {
+ {host = "10.0.0.2", port = 80, weight = 50},
+ {host = "10.0.0.9", port = 80, weight = 50},
+ }}, "1")
+ set_endpoints("first", "ns/only-first", {p1 = {
+ {host = "10.0.1.1", port = 80, weight = 50},
+ }}, "1")
+
+ show("ns/svc:p1", {"first", "second"})
+ show("ns/svc:p1", {"first"})
+ show("ns/svc:p1", {"second"})
+ show("ns/only-first:p1", {"second"})
+ show("ns/only-first:p1", {"second", "first"})
+ show("second/ns/svc:p1", {"second"})
+
+ set_endpoints("second", "ns/svc", {p1 = {
+ {host = "10.0.0.2", port = 80, weight = 50},
+ {host = "10.0.0.3", port = 80, weight = 50},
+ }}, "2")
+ show("ns/svc:p1", {"first", "second"})
+ }
+ }
+--- request
+GET /t
+--- response_body
+10.0.0.1:80 10.0.0.2:80 10.0.0.9:80
+10.0.0.1:80 10.0.0.9:80
+10.0.0.2:80 10.0.0.9:80
+nil
+10.0.1.1:80
+nil
+10.0.0.1:80 10.0.0.2:80 10.0.0.3:80 10.0.0.9:80
+--- error_log
+service_name must be namespace/name:port_name when discovery_args.cluster_ids
is set, got: second/ns/svc:p1
+--- no_error_log
+skip unknown kubernetes discovery cluster ids
+
+
+
+=== TEST 5: proxy to the selected clusters only
+--- yaml_config eval: $::offline_yaml_config
+--- apisix_yaml
+routes:
+ -
+ uri: /hello
+ upstream:
+ service_name: ns/svc:p1
+ discovery_type: kubernetes
+ discovery_args:
+ cluster_ids:
+ - second
+ type: roundrobin
+ -
+ uri: /hello1
+ upstream:
+ service_name: ns/svc:p1
+ discovery_type: kubernetes
+ discovery_args:
+ cluster_ids:
+ - third
+ - fourth
+ type: roundrobin
+ -
+ uri: /hello_chunked
+ upstream:
+ service_name: ns/only-second:p1
+ discovery_type: kubernetes
+ discovery_args:
+ cluster_ids:
+ - first
+ type: roundrobin
+#END
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local function set_endpoints(id, key, endpoints, version)
+ local dict = ngx.shared["kubernetes-" .. id]
+ dict:set(key, core.json.encode(endpoints))
+ dict:set(key .. "#version", version)
+ end
+ local http = require("resty.http")
+
+ set_endpoints("first", "ns/svc", {p1 = {
+ {host = "127.0.0.1", port = 1979, weight = 50},
+ }}, "1")
+ set_endpoints("second", "ns/svc", {p1 = {
+ {host = "127.0.0.1", port = 1980, weight = 50},
+ }}, "1")
+ set_endpoints("second", "ns/only-second", {p1 = {
+ {host = "127.0.0.1", port = 1980, weight = 50},
+ }}, "1")
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port
+ for _, path in ipairs({"/hello", "/hello", "/hello", "/hello1",
"/hello_chunked"}) do
+ local httpc = http.new()
+ local res, err = httpc:request_uri(uri .. path)
+ if not res then
+ ngx.say(err)
+ return
+ end
+ ngx.say(path, " ", res.status)
+ end
+ }
+ }
+--- request
+GET /t
+--- response_body
+/hello 200
+/hello 200
+/hello 200
+/hello1 503
+/hello_chunked 503
+--- error_log
+skip unknown kubernetes discovery cluster ids: third, fourth, service:
ns/svc:p1
+
+
+
+=== TEST 6: cluster_ids requires multiple clusters
+--- yaml_config
+apisix:
+ node_listen: 1984
+deployment:
+ role: data_plane
+ role_data_plane:
+ config_provider: yaml
+discovery:
+ kubernetes:
+ service:
+ schema: "http"
+ host: "127.0.0.1"
+ port: "6446"
+ client:
+ token: "fake"
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local d = require("apisix.discovery.kubernetes")
+
+ local dict = ngx.shared["kubernetes"]
+ dict:set("ns/svc", core.json.encode({p1 = {
+ {host = "10.0.0.1", port = 80, weight = 50},
+ }}))
+ dict:set("ns/svc#version", "1")
+
+ local nodes = d.nodes("ns/svc:p1")
+ ngx.say(#nodes)
+ nodes = d.nodes("ns/svc:p1", {cluster_ids = {"first"}})
+ ngx.say(tostring(nodes))
+ }
+ }
+--- request
+GET /t
+--- response_body
+1
+nil
+--- error_log
+discovery_args.cluster_ids requires kubernetes discovery configured with
multiple clusters
+
+
+
+=== TEST 7: the data plane rejects a cluster id prefix but not an unknown id
+--- yaml_config eval: $::offline_yaml_config
+--- apisix_yaml
+upstreams:
+ -
+ id: 1
+ service_name: second/ns/svc:p1
+ discovery_type: kubernetes
+ discovery_args:
+ cluster_ids:
+ - second
+ type: roundrobin
+ -
+ id: 2
+ service_name: ns/svc:p1
+ discovery_type: kubernetes
+ discovery_args:
+ cluster_ids:
+ - second
+ - third
+ type: roundrobin
+routes:
+ -
+ uri: /hello
+ upstream_id: 1
+ -
+ uri: /hello1
+ upstream_id: 2
+#END
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local http = require("resty.http")
+
+ local dict = ngx.shared["kubernetes-second"]
+ dict:set("ns/svc", core.json.encode({p1 = {
+ {host = "127.0.0.1", port = 1980, weight = 50},
+ }}))
+ dict:set("ns/svc#version", "1")
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port
+ for _, path in ipairs({"/hello", "/hello1"}) do
+ local httpc = http.new()
+ local res, err = httpc:request_uri(uri .. path)
+ if not res then
+ ngx.say(err)
+ return
+ end
+ ngx.say(path, " ", res.status)
+ end
+ }
+ }
+--- request
+GET /t
+--- response_body
+/hello 502
+/hello1 200
+--- error_log
+service_name must be namespace/name:port_name when discovery_args.cluster_ids
is set, got: second/ns/svc:p1
+skip unknown kubernetes discovery cluster ids: third, service: ns/svc:p1
+
+
+
+=== TEST 8: stream route with cluster_ids
+--- yaml_config eval: $::offline_yaml_config
+--- apisix_yaml
+stream_routes:
+ -
+ id: 1
+ server_addr: 127.0.0.1
+ server_port: 1985
+ upstream:
+ service_name: ns/svc:p1
+ discovery_type: kubernetes
+ discovery_args:
+ cluster_ids:
+ - second
+ type: roundrobin
+#END
+--- stream_extra_init_worker_by_lua
+ local core = require("apisix.core")
+ local d = require("apisix.discovery.kubernetes")
+
+ local function set_endpoints(id, key, endpoints, version)
+ local dict = ngx.shared["kubernetes-" .. id .. "-stream"]
+ dict:set(key, core.json.encode(endpoints))
+ dict:set(key .. "#version", version)
+ end
+
+ set_endpoints("first", "ns/svc", {p1 = {
+ {host = "127.0.0.1", port = 1979, weight = 50},
+ {host = "127.0.0.1", port = 1995, weight = 50},
+ }}, "1")
+ set_endpoints("second", "ns/svc", {p1 = {
+ {host = "127.0.0.1", port = 1995, weight = 50},
+ {host = "127.0.0.2", port = 1995, weight = 50},
+ }}, "1")
+
+ local nodes = d.nodes("ns/svc:p1", {cluster_ids = {"first", "second"}})
+ local addrs = {}
+ for _, node in ipairs(nodes) do
+ table.insert(addrs, node.host .. ":" .. node.port)
+ end
+ table.sort(addrs)
+ core.log.warn("stream nodes of first and second: ", table.concat(addrs, "
"))
+--- stream_request
+m
+--- stream_response
+hello world
+--- error_log
+stream nodes of first and second: 127.0.0.1:1979 127.0.0.1:1995 127.0.0.2:1995
+--- no_error_log
+skip unknown kubernetes discovery cluster ids