diff --git a/apisix/discovery/nacos/init.lua b/apisix/discovery/nacos/init.lua index d4fec7977018..e1611d939121 100644 --- a/apisix/discovery/nacos/init.lua +++ b/apisix/discovery/nacos/init.lua @@ -21,7 +21,9 @@ local http = require('resty.http') local core = require('apisix.core') local ipairs = ipairs local pairs = pairs +local next = next local type = type +local assert = assert local math = math local math_random = math.random local ngx = ngx @@ -54,6 +56,33 @@ local function get_key(namespace_id, group_name, service_name) return namespace_id .. '.' .. group_name .. '.' .. service_name end + +local function metadata_contains(host_metadata, route_metadata) + if not host_metadata or not next(host_metadata) then + return false + end + + for k, v in pairs(route_metadata) do + local host_value = host_metadata[k] + if not host_value then + return false + end + + -- Multi-value matching: check if host_value matches any value in the array + local found = false + for _, expected_value in ipairs(v) do + if host_value == expected_value then + found = true + break + end + end + if not found then + return false + end + end + return true +end + local function request(request_uri, path, body, method, basic_auth) local url = request_uri .. path log.info('request url:', url) @@ -315,10 +344,12 @@ local function fetch_full_registry(premature) local key = get_key(namespace_id, group_name, service_info.service_name) service_names[key] = true for _, host in ipairs(data.hosts) do + assert(host.metadata == nil or type(host.metadata) == "table") local node = { host = host.ip, port = host.port, weight = host.weight or default_weight, + metadata = host.metadata, } -- docs: https://github.com/yidongnan/grpc-spring-boot-starter/pull/496 if is_grpc(scheme) and host.metadata and host.metadata.gRPC_port then @@ -355,6 +386,19 @@ function _M.nodes(service_name, discovery_args) return nil end local nodes = core.json.decode(value) + + -- Apply metadata filtering if specified + local route_metadata = discovery_args and discovery_args.metadata + if route_metadata and next(route_metadata) then + local filtered_nodes = {} + for _, node in ipairs(nodes) do + if metadata_contains(node.metadata, route_metadata) then + core.table.insert(filtered_nodes, node) + end + end + return filtered_nodes + end + return nodes end diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua index d8b62088476c..1d823e8dd03f 100644 --- a/apisix/schema_def.lua +++ b/apisix/schema_def.lua @@ -488,7 +488,18 @@ local upstream_schema = { description = "group name", type = "string", }, - } + metadata = { + description = "metadata for filtering service instances", + type = "object", + additionalProperties = { + type = "array", + items = { + type = "string" + }, + uniqueItems = true + } + }, + }, }, pass_host = { description = "mod of host passing", diff --git a/ci/pod/docker-compose.first.yml b/ci/pod/docker-compose.first.yml index d203a967ddfc..514b49326b34 100644 --- a/ci/pod/docker-compose.first.yml +++ b/ci/pod/docker-compose.first.yml @@ -178,6 +178,9 @@ services: - ci/pod/nacos/env/service.env environment: SUFFIX_NUM: 1 + METADATA_LANE: "a" + METADATA_ENV: "prod" + METADATA_VERSION: "1.0" restart: unless-stopped ports: - "18001:18001" @@ -195,6 +198,8 @@ services: - ci/pod/nacos/env/service.env environment: SUFFIX_NUM: 2 + METADATA_LANE: "b" + METADATA_ENV: "test" restart: unless-stopped ports: - "18002:18001" @@ -213,6 +218,8 @@ services: environment: SUFFIX_NUM: 1 NAMESPACE: test_ns + METADATA_LANE: "b" + METADATA_ENV: "test" restart: unless-stopped ports: - "18003:18001" diff --git a/ci/pod/nacos/service/Dockerfile b/ci/pod/nacos/service/Dockerfile index d279c74972cc..c9e2fa3f848f 100644 --- a/ci/pod/nacos/service/Dockerfile +++ b/ci/pod/nacos/service/Dockerfile @@ -25,8 +25,8 @@ ENV GROUP=${GROUP:-DEFAULT_GROUP} ADD https://raw.githubusercontent.com/api7/nacos-test-service/main/spring-nacos-1.0-SNAPSHOT.jar /app.jar -ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar",\ - "--suffix.num=${SUFFIX_NUM}","--spring.cloud.nacos.discovery.server-addr=${NACOS_ADDR}",\ - "--spring.application.name=${SERVICE_NAME}","--spring.cloud.nacos.discovery.group=${GROUP}",\ - "--spring.cloud.nacos.discovery.namespace=${NAMESPACE}"] +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] EXPOSE 18001 diff --git a/ci/pod/nacos/service/entrypoint.sh b/ci/pod/nacos/service/entrypoint.sh new file mode 100644 index 000000000000..d916d7506713 --- /dev/null +++ b/ci/pod/nacos/service/entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/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. +# + +# Build Java command with proper environment variable expansion +JAVA_ARGS=( + "-Djava.security.egd=file:/dev/./urandom" + "-jar" + "/app.jar" + "--suffix.num=${SUFFIX_NUM}" + "--spring.cloud.nacos.discovery.server-addr=${NACOS_ADDR}" + "--spring.application.name=${SERVICE_NAME}" + "--spring.cloud.nacos.discovery.group=${GROUP}" + "--spring.cloud.nacos.discovery.namespace=${NAMESPACE}" +) + +# Add metadata dynamically for all METADATA_* environment variables +for var in $(env | grep '^METADATA_' | cut -d= -f1); do + # Convert METADATA_LANE to lane, METADATA_ENV to env, etc. + metadata_key=$(echo "${var#METADATA_}" | tr '[:upper:]' '[:lower:]') + metadata_value=$(eval echo \$${var}) + + if [ -n "${metadata_value}" ]; then + JAVA_ARGS+=("--spring.cloud.nacos.discovery.metadata.${metadata_key}=${metadata_value}") + fi +done + +# Execute Java with expanded arguments +exec java "${JAVA_ARGS[@]}" diff --git a/docs/en/latest/discovery/nacos.md b/docs/en/latest/discovery/nacos.md index 5ebbcee46b49..707b731f2b73 100644 --- a/docs/en/latest/discovery/nacos.md +++ b/docs/en/latest/discovery/nacos.md @@ -132,6 +132,7 @@ $ curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: $admin_ | ------------ | ------ | ----------- | ------- | ----- | ------------------------------------------------------------ | | namespace_id | string | optional | public | | This parameter is used to specify the namespace of the corresponding service | | group_name | string | optional | DEFAULT_GROUP | | This parameter is used to specify the group of the corresponding service | +| metadata | object | optional | | | Filter service instances by metadata using containment matching | #### Specify the namespace @@ -278,3 +279,52 @@ The formatted response as below: } } ``` + +#### Metadata filtering + +APISIX supports filtering service instances based on metadata. When a route is configured with metadata conditions, only service instances whose metadata contains all the key-value pairs specified in the route's `metadata` configuration will be selected. The metadata values in the route configuration are arrays, and a service instance matches if its metadata value equals any value in the corresponding array. + +Example: If a service instance has metadata `{lane: "a", env: "prod", version: "1.0"}`, it will match routes configured with metadata `{lane: ["a"]}` or `{lane: ["a"], env: ["prod"]}`, but not routes configured with `{lane: ["b"]}` or `{lane: ["a"], region: ["us"]}`. + +Example of routing a request with metadata filtering: + +```shell +$ curl http://127.0.0.1:9180/apisix/admin/routes/5 -H "X-API-KEY: $admin_key" -X PUT -i -d ' +{ + "uri": "/nacosWithMetadata/*", + "upstream": { + "service_name": "APISIX-NACOS", + "type": "roundrobin", + "discovery_type": "nacos", + "discovery_args": { + "metadata": { + "version": ["v1"] + } + } + } +}' +``` + +This route will only route traffic to service instances that have the metadata field `version` set to `v1`. + +For multiple metadata criteria: + +```shell +$ curl http://127.0.0.1:9180/apisix/admin/routes/6 -H "X-API-KEY: $admin_key" -X PUT -i -d ' +{ + "uri": "/nacosWithMultipleMetadata/*", + "upstream": { + "service_name": "APISIX-NACOS", + "type": "roundrobin", + "discovery_type": "nacos", + "discovery_args": { + "metadata": { + "lane": ["a", "b"], + "env": ["prod"] + } + } + } +}' +``` + +This route will only route traffic to service instances that have `env: "prod"` and `lane` set to either `"a"` or `"b"` in their metadata. diff --git a/docs/zh/latest/discovery/nacos.md b/docs/zh/latest/discovery/nacos.md index 370ef17670b1..da94716e968e 100644 --- a/docs/zh/latest/discovery/nacos.md +++ b/docs/zh/latest/discovery/nacos.md @@ -132,6 +132,7 @@ $ curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: $admin_ | ------------ | ------ | ----------- | ------- | ----- | ------------------------------------------------------------ | | namespace_id | string | 可选 | public | | 服务所在的命名空间 | | group_name | string | 可选 | DEFAULT_GROUP | | 服务所在的组 | +| metadata | object | 可选 | | | 使用包含匹配方式根据元数据过滤服务实例 | #### 指定命名空间 @@ -281,3 +282,52 @@ $ curl http://127.0.0.1:9180/apisix/admin/routes/4 -H "X-API-KEY: $admin_key" -X } } ``` + +#### 使用元数据过滤服务实例 + +APISIX 支持根据元数据过滤服务实例。当路由配置了元数据条件时,只有服务实例的元数据包含路由配置中指定的所有键值对,该服务实例才会被选中。路由配置中的元数据值为数组,如果服务实例的元数据值等于数组中的任意一个值,则匹配成功。 + +举例:如果服务实例的元数据是 `{lane: "a", env: "prod", version: "1.0"}`,那么它能匹配配置了元数据 `{lane: ["a"]}` 或 `{lane: ["a"], env: ["prod"]}` 的路由,但不能匹配配置了 `{lane: ["b"]}` 或 `{lane: ["a"], region: ["us"]}` 的路由。 + +使用元数据过滤的路由配置示例: + +```shell +$ curl http://127.0.0.1:9180/apisix/admin/routes/5 -H "X-API-KEY: $admin_key" -X PUT -i -d ' +{ + "uri": "/nacosWithMetadata/*", + "upstream": { + "service_name": "APISIX-NACOS", + "type": "roundrobin", + "discovery_type": "nacos", + "discovery_args": { + "metadata": { + "version": ["v1"] + } + } + } +}' +``` + +此路由只会将流量转发到元数据字段 `version` 为 `v1` 的服务实例。 + +使用多个元数据条件的示例: + +```shell +$ curl http://127.0.0.1:9180/apisix/admin/routes/6 -H "X-API-KEY: $admin_key" -X PUT -i -d ' +{ + "uri": "/nacosWithMultipleMetadata/*", + "upstream": { + "service_name": "APISIX-NACOS", + "type": "roundrobin", + "discovery_type": "nacos", + "discovery_args": { + "metadata": { + "lane": ["a", "b"], + "env": ["prod"] + } + } + } +}' +``` + +此路由只会将流量转发到元数据中包含 `env: "prod"` 且 `lane` 为 `"a"` 或 `"b"` 的服务实例。 diff --git a/t/core/schema_def.t b/t/core/schema_def.t index da3bb51f8b26..4270d61a5e7b 100644 --- a/t/core/schema_def.t +++ b/t/core/schema_def.t @@ -237,3 +237,129 @@ passed } --- response_body passed + + + +=== TEST 5: discovery_args metadata validation +--- config + location /t { + content_by_lua_block { + local schema_def = require("apisix.schema_def") + local core = require("apisix.core") + + local upstream_schema = schema_def.upstream + + local test_cases = { + -- Valid cases + { + name = "valid metadata with multiple string values", + should_pass = true, + upstream = { + service_name = "test-service", + discovery_type = "nacos", + type = "roundrobin", + discovery_args = { + namespace_id = "test-ns", + group_name = "test-group", + metadata = { + version = {"v1"}, + env = {"prod"}, + lane = {"a"}, + } + } + } + }, + { + name = "valid metadata with empty object", + should_pass = true, + upstream = { + service_name = "test-service", + discovery_type = "nacos", + type = "roundrobin", + discovery_args = { + metadata = {} + } + } + }, + + -- Invalid cases + { + name = "invalid metadata with non-array or non-string values", + should_pass = false, + upstream = { + service_name = "test-service", + discovery_type = "nacos", + type = "roundrobin", + discovery_args = { + metadata = { + version = 123, -- should be array + env = true, -- should be array + count = 456, -- should be array + config = { port = 8080 }, -- should be array of strings + lane = {"a", 1} -- mixed types not allowed + } + } + }, + expected_error_pattern = "discovery_args.*metadata" + }, + { + name = "invalid metadata with string instead of array", + should_pass = false, + upstream = { + service_name = "test-service", + discovery_type = "nacos", + type = "roundrobin", + discovery_args = { + metadata = { + lane = "a" + } + } + }, + expected_error_pattern = "metadata.*wrong type" + }, + { + name = "invalid metadata with duplicate values in array", + should_pass = false, + upstream = { + service_name = "test-service", + discovery_type = "nacos", + type = "roundrobin", + discovery_args = { + metadata = { + lane = {"a", "b", "a"} -- duplicate "a" should fail uniqueItems validation + } + } + }, + expected_error_pattern = "expected unique items" + }, + } + + -- Execute all test cases + for i, test_case in ipairs(test_cases) do + local ok, err = core.schema.check(upstream_schema, test_case.upstream) + + if test_case.should_pass then + assert(ok, string.format("Test case %d (%s) should pass validation: %s", + i, test_case.name, err or "")) + else + assert(not ok, string.format("Test case %d (%s) should fail validation", + i, test_case.name)) + assert(err ~= nil, string.format("Test case %d (%s) should have error message", + i, test_case.name)) + + if test_case.expected_error_pattern then + assert(string.find(err, test_case.expected_error_pattern), + string.format("Test case %d (%s) error should match pattern '%s', but got: %s", + i, test_case.name, test_case.expected_error_pattern, err)) + ngx.log(ngx.INFO, string.format("Test case %d (%s) actual error: %s", i, test_case.name, err)) + end + end + end + + ngx.say("passed") + } + } +--- response_body +passed +--- no_error_log +[alert] diff --git a/t/discovery/nacos.t b/t/discovery/nacos.t index f2ebee57ea7b..030c7b65b5d8 100644 --- a/t/discovery/nacos.t +++ b/t/discovery/nacos.t @@ -1066,3 +1066,169 @@ GET /t --- response_body server 1 server 4 + + + +=== TEST 27: get APISIX-NACOS info from NACOS - metadata filtering lane=a (only server1) +--- yaml_config eval: $::yaml_config +--- apisix_yaml +routes: + - + uri: /hello + upstream: + service_name: APISIX-NACOS + discovery_type: nacos + type: roundrobin + discovery_args: + metadata: + lane: ["a"] +#END +--- pipelined_requests eval +[ + "GET /hello", + "GET /hello", + "GET /hello", + "GET /hello", + "GET /hello", +] +--- response_body_like eval +[ + qr/server 1/, + qr/server 1/, + qr/server 1/, + qr/server 1/, + qr/server 1/, +] + + + +=== TEST 28: get APISIX-NACOS info from NACOS - metadata filtering empty (load balance between server1 and server2) +--- yaml_config eval: $::yaml_config +--- apisix_yaml +routes: + - + uri: /hello + upstream: + service_name: APISIX-NACOS + discovery_type: nacos + type: roundrobin + discovery_args: + metadata: +#END +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local uri = "http://127.0.0.1:" .. ngx.var.server_port + + -- Wait for 2 seconds for APISIX initialization + ngx.sleep(2) + local httpc = http.new() + local server1_count = 0 + local server2_count = 0 + + -- Send multiple requests to test load balancing + for i = 1, 10 do + local res, err = httpc:request_uri(uri .. "/hello") + if not res then + ngx.log(ngx.ERR, "Request failed: ", err) + else + -- Clean and validate response + local clean_body = res.body:gsub("%s+$", "") + if clean_body == "server 1" then + server1_count = server1_count + 1 + elseif clean_body == "server 2" then + server2_count = server2_count + 1 + else + ngx.log(ngx.ERR, "Invalid response: ", clean_body) + end + end + end + + -- Verify that both servers were used + if server1_count > 0 and server2_count > 0 then + ngx.say("PASS") + else + ngx.say("FAIL") + end + } + } +--- request +GET /t +--- response_body +PASS + + + +=== TEST 29: get APISIX-NACOS info from NACOS - metadata filtering no match (lane=c) +--- yaml_config eval: $::yaml_config +--- apisix_yaml +routes: + - + uri: /hello + upstream: + service_name: APISIX-NACOS + discovery_type: nacos + type: roundrobin + discovery_args: + metadata: + lane: ["c"] +#END +--- request +GET /hello +--- error_code: 503 +--- error_log +no valid upstream node + + + +=== TEST 30: metadata filtering with multiple values - should match both servers (lane=a,b) +--- yaml_config eval: $::yaml_config +--- apisix_yaml +routes: + - + uri: /hello + upstream: + service_name: APISIX-NACOS + discovery_type: nacos + type: roundrobin + discovery_args: + metadata: + lane: ["a", "b"] +#END +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local uri = "http://127.0.0.1:" .. ngx.var.server_port + + -- Wait for 2 seconds for APISIX initialization + ngx.sleep(2) + local httpc = http.new() + + local server1_count = 0 + local server2_count = 0 + for i = 1, 10 do + local res, err = httpc:request_uri(uri .. "/hello", {method = "GET"}) + if res then + local clean_body = res.body:gsub("%s+$", "") + if clean_body == "server 1" then + server1_count = server1_count + 1 + elseif clean_body == "server 2" then + server2_count = server2_count + 1 + end + end + ngx.sleep(0.1) + end + + if server1_count > 0 and server2_count > 0 then + ngx.say("PASS") + else + ngx.say("FAIL") + end + } + } +--- request +GET /t +--- response_body +PASS