You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@shenyu.apache.org by li...@apache.org on 2026/10/01 07:02:55 UTC
(shenyu) branch master updated: [feat] support Kubernetes Gateway API (#6347)
This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new a250c109b4 [feat] support Kubernetes Gateway API (#6347)
a250c109b4 is described below
commit a250c109b4ea1d669fd79bcce2dabbc88bd545e4
Author: eye-gu <ey...@apache.org>
AuthorDate: Thu Oct 1 15:02:48 2026 +0800
[feat] support Kubernetes Gateway API (#6347)
---
.../workflows/integrated-test-k8s-gateway-api.yml | 183 +++++
.../shenyu-examples-http/k8s/gateway-api.yml | 53 ++
shenyu-integrated-test/pom.xml | 1 +
.../Dockerfile | 29 +
.../deploy/deploy-shenyu.yaml | 163 +++++
.../deploy/kind-config.yaml | 19 +-
.../pom.xml | 144 ++++
.../script/build_k8s_cluster.sh | 12 +-
.../script/healthcheck.sh | 20 +-
.../script/services.list | 12 +-
.../gateway/api/GatewayApiControllerBootstrap.java | 37 +
.../src/main/resources/application.yml | 179 +++++
.../test/k8s/gateway/api/DividePluginTest.java | 45 ++
shenyu-kubernetes-controller/pom.xml | 7 +
.../apache/shenyu/k8s/cache/GatewayRouteCache.java | 170 +++++
.../apache/shenyu/k8s/cache/K8sCacheReadiness.java | 85 +++
.../shenyu/k8s/common/GatewayApiConstants.java | 186 +++++
.../shenyu/k8s/common/GatewayApiCrdVerifier.java | 142 ++++
.../shenyu/k8s/common/GatewayApiCrdVersions.java | 102 +++
.../org/apache/shenyu/k8s/common/JsonFields.java | 77 +++
.../apache/shenyu/k8s/common/ListenerSupport.java | 244 +++++++
.../apache/shenyu/k8s/common/ReferenceGrants.java | 106 +++
.../shenyu/k8s/common/ShenyuMemoryConfig.java | 33 +
.../apache/shenyu/k8s/common/StatusMergePatch.java | 57 ++
.../apache/shenyu/k8s/parser/HttpRouteParser.java | 722 +++++++++++++++++++
.../k8s/reconciler/GatewayClassReconciler.java | 521 ++++++++++++++
.../shenyu/k8s/reconciler/GatewayReconciler.java | 513 ++++++++++++++
.../shenyu/k8s/reconciler/HTTPRouteReconciler.java | 652 ++++++++++++++++++
.../k8s/reconciler/HttpRouteBackendHandler.java | 120 ++++
.../k8s/reconciler/HttpRouteEndpointsHandler.java | 49 ++
.../k8s/reconciler/HttpRouteServiceHandler.java | 52 ++
.../k8s/reconciler/ReferenceGrantReconciler.java | 117 ++++
.../k8s/repository/ShenyuCacheRepository.java | 113 ++-
.../shenyu/k8s/GatewayClassReconcilerTest.java | 405 +++++++++++
.../apache/shenyu/k8s/GatewayReconcilerTest.java | 402 +++++++++++
.../apache/shenyu/k8s/HTTPRouteReconcilerTest.java | 676 ++++++++++++++++++
.../shenyu/k8s/HttpRouteBackendHandlerTest.java | 189 +++++
.../org/apache/shenyu/k8s/HttpRouteParserTest.java | 763 +++++++++++++++++++++
.../apache/shenyu/k8s/K8sCacheReadinessTest.java | 98 +++
.../shenyu/k8s/ShenyuCacheRepositoryTest.java | 169 +++++
.../k8s/common/GatewayApiCrdVerifierTest.java | 121 ++++
.../k8s/common/GatewayApiCrdVersionsTest.java | 52 ++
.../shenyu-spring-boot-starter-k8s/pom.xml | 5 +
.../starter/k8s/ControllerManagerLifecycle.java | 61 ++
.../k8s/GatewayApiControllerConfiguration.java | 489 +++++++++++++
.../k8s/IngressControllerConfiguration.java | 1 +
.../src/main/resources/META-INF/spring.factories | 3 +-
...rk.boot.autoconfigure.AutoConfiguration.imports | 1 +
48 files changed, 8378 insertions(+), 22 deletions(-)
diff --git a/.github/workflows/integrated-test-k8s-gateway-api.yml b/.github/workflows/integrated-test-k8s-gateway-api.yml
new file mode 100644
index 0000000000..28960e2e4f
--- /dev/null
+++ b/.github/workflows/integrated-test-k8s-gateway-api.yml
@@ -0,0 +1,183 @@
+# 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.
+
+name: it-k8s-gateway-api
+
+on:
+ pull_request:
+ push:
+ branches:
+ - master
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ case:
+ - shenyu-integrated-test-k8s-gateway-api-http
+ runs-on: ubuntu-latest
+ if: github.repository == 'apache/shenyu'
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: true
+
+ - uses: ./actions/paths-filter
+ id: filter
+ with:
+ filters: |
+ k8s-gateway-api:
+ - 'shenyu-integrated-test-k8s-gateway-api*/**'
+ - 'shenyu-*/**'
+ - 'pom.xml'
+ - '**/pom.xml'
+ - 'shenyu-examples/**'
+ - '!**/*.md'
+ - '!**/*.txt'
+ - '!resources/static/**'
+ - '!.asf.yaml'
+ - '!.gitignore'
+ - '!.licenserc.yaml'
+ - '!LICENSE'
+ - '!NOTICE'
+ - '!.github/ISSUE_TEMPLATE/**'
+ - '!.github/PULL_REQUEST_TEMPLATE'
+
+ - name: Clean Space
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ run: |
+ sudo rm -rf /usr/share/dotnet
+ sudo rm -rf /opt/ghc
+ sudo rm -rf "/usr/local/share/boost"
+ sudo rm -rf "$AGENT_TOOLSDIRECTORY"
+
+ - name: Cache Maven Repos
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ uses: actions/cache@v3
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+
+ - uses: actions/setup-java@v4
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ with:
+ java-version: 17
+ distribution: "temurin"
+
+ - name: Install Go
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ uses: actions/setup-go@v3
+ with:
+ go-version: 1.21.x
+
+ - name: Install k8s
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ run: |
+ go install sigs.k8s.io/kind@v0.24.0
+ curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.31.0/bin/linux/amd64/kubectl && sudo install kubectl /usr/local/bin/kubectl
+ kind create cluster --image=kindest/node:v1.31.0 --config=./shenyu-integrated-test/${{ matrix.case }}/deploy/kind-config.yaml
+
+ - name: Install mvnd
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ shell: bash
+ run: |
+ MVND_VERSION=1.0.6
+ curl -sfL https://downloads.apache.org/maven/mvnd/${MVND_VERSION}/maven-mvnd-${MVND_VERSION}-linux-amd64.zip -o mvnd.zip
+ unzip -q mvnd.zip
+ mkdir -p $HOME/.local
+ mv maven-mvnd-${MVND_VERSION}-linux-amd64 $HOME/.local/mvnd
+ echo "$HOME/.local/mvnd/bin" >> $GITHUB_PATH
+ echo "MVND_HOME=$HOME/.local/mvnd" >> $GITHUB_ENV
+
+ - name: Build with Maven
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ shell: bash
+ run: |
+ if mvnd --version > /dev/null 2>&1; then
+ echo "Using mvnd for build"
+ mvnd -B clean install -Dmaven.javadoc.skip=true -Dmaven.test.skip=true
+ else
+ echo "Falling back to maven wrapper"
+ ./mvnw -B clean install -Dmaven.javadoc.skip=true -Dmaven.test.skip=true
+ fi
+
+ - name: Build integrated tests
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ shell: bash
+ run: |
+ if mvnd --version > /dev/null 2>&1; then
+ echo "Using mvnd for build integrated tests"
+ mvnd -B clean install -Pit -DskipTests -am -f ./shenyu-integrated-test/pom.xml
+ else
+ echo "Falling back to maven wrapper for integrated tests"
+ ./mvnw -B clean install -Pit -DskipTests -am -f ./shenyu-integrated-test/pom.xml
+ fi
+
+ - name: Build examples
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ shell: bash
+ run: |
+ if mvnd --version > /dev/null 2>&1; then
+ echo "Using mvnd for build examples"
+ mvnd -B clean install -Pexample -Dmaven.javadoc.skip=true -Dmaven.test.skip=true -am -f ./shenyu-examples/pom.xml
+ else
+ echo "Falling back to maven wrapper for examples"
+ ./mvnw -B clean install -Pexample -Dmaven.javadoc.skip=true -Dmaven.test.skip=true -am -f ./shenyu-examples/pom.xml
+ fi
+
+ - name: Build k8s Cluster
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ run: bash ./shenyu-integrated-test/${{ matrix.case }}/script/build_k8s_cluster.sh
+
+ - name: Wait for k8s Cluster Start up
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ timeout-minutes: 15
+ run: |
+ bash ./shenyu-integrated-test/${{ matrix.case }}/script/healthcheck.sh
+
+ - name: Run test
+ id: test
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ shell: bash
+ run: |
+ if mvnd --version > /dev/null 2>&1; then
+ echo "Using mvnd for running tests"
+ mvnd test -Pit -f ./shenyu-integrated-test/${{ matrix.case }}/pom.xml
+ else
+ echo "Falling back to maven wrapper for tests"
+ ./mvnw test -Pit -f ./shenyu-integrated-test/${{ matrix.case }}/pom.xml
+ fi
+ continue-on-error: true
+
+ - name: Cluster Test after Healthcheck
+ if: steps.filter.outputs.k8s-gateway-api == 'true'
+ run: |
+ echo "----------kubectl get all -o wide----------"
+ kubectl get all -o wide
+ echo "----------kubectl get all -n shenyu-gateway-api -o wide----------"
+ kubectl get all -n shenyu-gateway-api -o wide
+ echo "----------kubectl get events --all-namespaces----------"
+ kubectl get events --all-namespaces
+ echo "----------kubectl logs -l app=shenyu-gateway-api-controller -n shenyu-gateway-api --tail=-1----------"
+ kubectl logs -l app=shenyu-gateway-api-controller -n shenyu-gateway-api --tail=-1
+ if [[ ${{steps.test.outcome}} == "failure" ]]; then
+ echo "Test Failed"
+ exit 1
+ else
+ echo "Test Successful"
+ exit 0
+ fi
diff --git a/shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml b/shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml
new file mode 100644
index 0000000000..e0d9ca0ba9
--- /dev/null
+++ b/shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml
@@ -0,0 +1,53 @@
+# 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.
+
+apiVersion: gateway.networking.k8s.io/v1
+kind: GatewayClass
+metadata:
+ name: shenyu
+spec:
+ controllerName: gateway.shenyu.apache.org/shenyu-controller
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+ name: shenyu-gateway
+spec:
+ gatewayClassName: shenyu
+ listeners:
+ - name: http
+ port: 9195
+ protocol: HTTP
+ allowedRoutes:
+ namespaces:
+ from: Same
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: demo-http-route
+spec:
+ parentRefs:
+ - name: shenyu-gateway
+ rules:
+ - matches:
+ - path:
+ type: PathPrefix
+ value: /order
+ backendRefs:
+ - name: shenyu-examples-http-service
+ port: 8189
diff --git a/shenyu-integrated-test/pom.xml b/shenyu-integrated-test/pom.xml
index 341ff505c1..4de30e33a3 100644
--- a/shenyu-integrated-test/pom.xml
+++ b/shenyu-integrated-test/pom.xml
@@ -47,6 +47,7 @@
<module>shenyu-integrated-test-k8s-ingress-websocket</module>
<module>shenyu-integrated-test-k8s-ingress-grpc</module>
<module>shenyu-integrated-test-k8s-ingress-sofa</module>
+ <module>shenyu-integrated-test-k8s-gateway-api-http</module>
</modules>
<properties>
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/Dockerfile b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/Dockerfile
new file mode 100644
index 0000000000..d11d2e4d81
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/Dockerfile
@@ -0,0 +1,29 @@
+# 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.
+
+FROM eclipse-temurin:17-centos7
+
+ENV APP_NAME shenyu-integrated-test-k8s-gateway-api-http
+ENV LOCAL_PATH /opt/${APP_NAME}
+
+RUN mkdir -p ${LOCAL_PATH}
+
+ADD target/${APP_NAME}.jar ${LOCAL_PATH}
+
+WORKDIR ${LOCAL_PATH}
+EXPOSE 9195
+
+CMD java -jar ${APP_NAME}.jar
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml
new file mode 100644
index 0000000000..f3adf71b66
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml
@@ -0,0 +1,163 @@
+# 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.
+
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: shenyu-gateway-api
+---
+apiVersion: v1
+automountServiceAccountToken: true
+kind: ServiceAccount
+metadata:
+ name: shenyu-gateway-api-controller
+ namespace: shenyu-gateway-api
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: shenyu-gateway-api-controller
+ namespace: shenyu-gateway-api
+ labels:
+ app: shenyu-gateway-api-controller
+ all: shenyu-gateway-api-controller
+spec:
+ replicas: 2
+ strategy:
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 0
+ selector:
+ matchLabels:
+ app: shenyu-gateway-api-controller
+ template:
+ metadata:
+ labels:
+ app: shenyu-gateway-api-controller
+ spec:
+ containers:
+ - name: shenyu-gateway-api-controller
+ image: apache/shenyu-integrated-test-k8s-gateway-api-http:latest
+ ports:
+ - containerPort: 9195
+ readinessProbe:
+ httpGet:
+ path: /actuator/health/readiness
+ port: 9195
+ initialDelaySeconds: 10
+ periodSeconds: 3
+ failureThreshold: 40
+ imagePullPolicy: IfNotPresent
+ serviceAccountName: shenyu-gateway-api-controller
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: shenyu-gateway-api-controller
+ namespace: shenyu-gateway-api
+spec:
+ minAvailable: 1
+ selector:
+ matchLabels:
+ app: shenyu-gateway-api-controller
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: shenyu-gateway-api-controller
+ namespace: shenyu-gateway-api
+spec:
+ selector:
+ app: shenyu-gateway-api-controller
+ type: NodePort
+ ports:
+ - port: 9195
+ targetPort: 9195
+ nodePort: 30095
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: shenyu-gateway-api-controller
+rules:
+# Services are watched to map a backendRef's Service port to its targetPort
+# (including named targetPorts); Endpoints (sharing name/namespace with the
+# backed Service) are watched to resolve backendRefs.
+- apiGroups:
+ - ""
+ resources:
+ - services
+ - endpoints
+ verbs:
+ - get
+ - list
+ - watch
+# patch on gatewayclasses manages the spec-defined finalizer while Gateways use the class
+- apiGroups:
+ - gateway.networking.k8s.io
+ resources:
+ - gatewayclasses
+ - gateways
+ - httproutes
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - gateway.networking.k8s.io
+ resources:
+ - gatewayclasses
+ verbs:
+ - patch
+- apiGroups:
+ - gateway.networking.k8s.io
+ resources:
+ - gatewayclasses/status
+ - gateways/status
+ - httproutes/status
+ verbs:
+ - get
+ - patch
+# Required for cross-namespace parentRef validation via ReferenceGrant
+- apiGroups:
+ - gateway.networking.k8s.io
+ resources:
+ - referencegrants
+ verbs:
+ - get
+ - list
+ - watch
+# Required to read the bundle-version annotation for the SupportedVersion condition
+- apiGroups:
+ - apiextensions.k8s.io
+ resources:
+ - customresourcedefinitions
+ verbs:
+ - get
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: shenyu-gateway-api-controller
+ namespace: shenyu-gateway-api
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: shenyu-gateway-api-controller
+subjects:
+- kind: ServiceAccount
+ name: shenyu-gateway-api-controller
+ namespace: shenyu-gateway-api
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml
similarity index 59%
copy from shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
copy to shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml
index 3eb3d33916..6b01013ef5 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml
@@ -1,9 +1,10 @@
-#
-# Licensed to the Apache Software Foundation (ASF) under one or more
-# contributor license agreements. See the NOTICE file distributed with
+# 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 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
@@ -13,6 +14,12 @@
# 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.
-#
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+nodes:
+ - role: control-plane
+ extraPortMappings:
+ - containerPort: 30095
+ hostPort: 30095
+ protocol: TCP
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/pom.xml b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/pom.xml
new file mode 100644
index 0000000000..705abdbfa7
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/pom.xml
@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ 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.
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <parent>
+ <artifactId>shenyu-integrated-test</artifactId>
+ <groupId>org.apache.shenyu</groupId>
+ <version>2.7.2-SNAPSHOT</version>
+ </parent>
+ <modelVersion>4.0.0</modelVersion>
+
+ <artifactId>shenyu-integrated-test-k8s-gateway-api-http</artifactId>
+ <name>shenyu-integrated-test-k8s-gateway-api-http</name>
+ <url>https://shenyu.apache.org</url>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-integrated-test-common</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-spring-boot-starter-plugin-divide</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-spring-boot-starter-plugin-httpclient</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-spring-boot-starter-plugin-global</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-spring-boot-starter-plugin-uri</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-spring-boot-starter-plugin-general-context</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-spring-boot-starter-k8s</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
+ </dependency>
+ </dependencies>
+
+ <profiles>
+ <profile>
+ <id>it</id>
+ <properties>
+ <docker.buildArg.APP_NAME>shenyu-integrated-test-k8s-gateway-api-http</docker.buildArg.APP_NAME>
+ <docker.image.tag.repo>apache/shenyu-integrated-test-k8s-gateway-api-http</docker.image.tag.repo>
+ <docker.image.tag.tagName>latest</docker.image.tag.tagName>
+ </properties>
+ <build>
+ <finalName>shenyu-integrated-test-k8s-gateway-api-http</finalName>
+ <plugins>
+ <plugin>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-maven-plugin</artifactId>
+ <version>${spring-boot.version}</version>
+ <executions>
+ <execution>
+ <phase>package</phase>
+ <goals>
+ <goal>repackage</goal>
+ </goals>
+ </execution>
+ </executions>
+ <configuration>
+ <mainClass>org.apache.shenyu.integrated.test.k8s.gateway.api.GatewayApiControllerBootstrap</mainClass>
+ <executable>true</executable>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>io.fabric8</groupId>
+ <artifactId>docker-maven-plugin</artifactId>
+ <version>${docker-maven-plugin.version}</version>
+ <configuration>
+ <images>
+ <image>
+ <name>apache/shenyu-integrated-test-k8s-gateway-api-http</name>
+ <build>
+ <contextDir>${project.basedir}</contextDir>
+ </build>
+ </image>
+ </images>
+ </configuration>
+ <executions>
+ <execution>
+ <id>start</id>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <configuration>
+ <skipTests>false</skipTests>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+</project>
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh
similarity index 56%
copy from shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
copy to shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh
index 3eb3d33916..4e1b5c93a9 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh
@@ -1,3 +1,5 @@
+#!/bin/bash
+set -e
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
@@ -15,4 +17,12 @@
# limitations under the License.
#
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+kind load docker-image "shenyu-examples-http:latest"
+kind load docker-image "apache/shenyu-integrated-test-k8s-gateway-api-http:latest"
+
+# Install Gateway API CRD before creating Gateway/HTTPRoute resources
+kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml
+
+kubectl apply -f ./shenyu-examples/shenyu-examples-http/k8s/shenyu-examples-http.yml
+kubectl apply -f ./shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml
+kubectl apply -f ./shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh
similarity index 67%
copy from shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
copy to shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh
index 3eb3d33916..896e784144 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh
@@ -1,3 +1,4 @@
+#!/bin/bash
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
@@ -15,4 +16,21 @@
# limitations under the License.
#
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+PRGDIR=`dirname "$0"`
+for service in `grep -v -E "^$|^#" ${PRGDIR}/services.list`
+do
+ for loop in `seq 1 30`
+ do
+ status=`curl -o /dev/null -s -w %{http_code} $service`
+ echo -e "curl $service response $status"
+
+ if [ $status -eq 200 ]; then
+ break
+ fi
+
+ sleep 2
+ done
+done
+
+sleep 20
+echo -e "\n-------------------"
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list
similarity index 66%
copy from shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
copy to shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list
index 3eb3d33916..6da1cfd997 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list
@@ -1,9 +1,10 @@
-#
-# Licensed to the Apache Software Foundation (ASF) under one or more
-# contributor license agreements. See the NOTICE file distributed with
+# 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 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
@@ -13,6 +14,5 @@
# 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.
-#
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+http://localhost:30095/actuator/health
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/java/org/apache/shenyu/integrated/test/k8s/gateway/api/GatewayApiControllerBootstrap.java b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/java/org/apache/shenyu/integrated/test/k8s/gateway/api/GatewayApiControllerBootstrap.java
new file mode 100644
index 0000000000..4f69d0f9e4
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/java/org/apache/shenyu/integrated/test/k8s/gateway/api/GatewayApiControllerBootstrap.java
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.integrated.test.k8s.gateway.api;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * The type gateway api controller integrated bootstrap.
+ */
+@SpringBootApplication
+public class GatewayApiControllerBootstrap {
+
+ /**
+ * main method of App.
+ *
+ * @param args args
+ */
+ public static void main(final String[] args) {
+ SpringApplication.run(GatewayApiControllerBootstrap.class);
+ }
+}
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/resources/application.yml b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/resources/application.yml
new file mode 100644
index 0000000000..a1c10bccb9
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/resources/application.yml
@@ -0,0 +1,179 @@
+# 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.
+
+server:
+ port: 9195
+ address: 0.0.0.0
+
+spring:
+ main:
+ allow-bean-definition-overriding: true
+ application:
+ name: shenyu-bootstrap
+ codec:
+ max-in-memory-size: 2MB
+ cloud:
+ discovery:
+ enabled: false
+ nacos:
+ discovery:
+ server-addr: 127.0.0.1:8848
+ enabled: false
+ namespace: ShenyuRegisterCenter
+
+eureka:
+ client:
+ enabled: false
+ serviceUrl:
+ defaultZone: http://localhost:8761/eureka/
+ instance:
+ prefer-ip-address: true
+
+management:
+ health:
+ redis:
+ enabled: false
+ elasticsearch:
+ enabled: false
+ endpoint:
+ health:
+ enabled: true
+ show-details: always
+ probes:
+ enabled: true
+ group:
+ readiness:
+ include: readinessState,k8sCacheReadiness
+ endpoints:
+ web:
+ exposure:
+ include: "*"
+
+shenyu:
+ k8s:
+ mode: gateway-api
+ selectorMatchCache:
+ cache:
+ enabled: false
+ initialCapacity: 10000
+ maximumSize: 10000
+ ruleMatchCache:
+ cache:
+ enabled: true
+ initialCapacity: 10000
+ maximumSize: 65536
+ netty:
+ http:
+ webServerFactoryEnabled: true
+ selectCount: 1
+ workerCount: 8
+ accessLog: false
+ serverSocketChannel:
+ soRcvBuf: 87380
+ soBackLog: 128
+ soReuseAddr: false
+ connectTimeoutMillis: 10000
+ writeBufferHighWaterMark: 65536
+ writeBufferLowWaterMark: 32768
+ writeSpinCount: 16
+ autoRead: false
+ allocType: "pooled"
+ messageSizeEstimator: 8
+ singleEventExecutorPerGroup: true
+ socketChannel:
+ soKeepAlive: false
+ soReuseAddr: false
+ soLinger: -1
+ tcpNoDelay: true
+ soRcvBuf: 87380
+ soSndBuf: 16384
+ ipTos: 0
+ allowHalfClosure: false
+ connectTimeoutMillis: 10000
+ writeBufferHighWaterMark: 65536
+ writeBufferLowWaterMark: 32768
+ writeSpinCount: 16
+ autoRead: false
+ allocType: "pooled"
+ messageSizeEstimator: 8
+ singleEventExecutorPerGroup: true
+ httpclient:
+ strategy: netty
+ register:
+ enabled: false
+ registerType: zookeeper
+ serverLists: localhost:2181
+ props:
+ cross:
+ enabled: true
+ allowedHeaders:
+ allowedMethods: "*"
+ allowedAnyOrigin: true
+ allowedExpose: ""
+ maxAge: "18000"
+ allowCredentials: true
+
+ switchConfig:
+ local: true
+ collapseSlashes: false
+ file:
+ enabled: true
+ maxSize: 10
+ health:
+ enabled: true
+ paths:
+ - /actuator
+ - /health_check
+ extPlugin:
+ path:
+ enabled: true
+ threads: 1
+ scheduleTime: 300
+ scheduleDelay: 30
+ scheduler:
+ enabled: false
+ type: fixed
+ threads: 16
+ upstreamCheck:
+ enabled: false
+ poolSize: 10
+ timeout: 3000
+ healthyThreshold: 1
+ unhealthyThreshold: 1
+ interval: 5000
+ printEnabled: true
+ printInterval: 60000
+ springCloudCache:
+ enabled: false
+ ribbon:
+ serverListRefreshInterval: 10000
+ metrics:
+ enabled: false
+ name: prometheus
+ host: 127.0.0.1
+ port: 8090
+ jmxConfig:
+ props:
+ jvm_enabled: true
+ local:
+ enabled: false
+ sha512Key: "BA3253876AED6BC22D4A6FF53D8406C6AD864195ED144AB5C87621B6C233B548BAEAE6956DF346EC8C17F5EA10F35EE3CBC514797ED7DDD3145464E2A0BAB413"
+
+logging:
+ level:
+ root: info
+ org.springframework.boot: info
+ org.apache.ibatis: info
+ org.apache.shenyu: info
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/test/java/org/apache/shenyu/integrated/test/k8s/gateway/api/DividePluginTest.java b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/test/java/org/apache/shenyu/integrated/test/k8s/gateway/api/DividePluginTest.java
new file mode 100644
index 0000000000..42b44db0b7
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/test/java/org/apache/shenyu/integrated/test/k8s/gateway/api/DividePluginTest.java
@@ -0,0 +1,45 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.integrated.test.k8s.gateway.api;
+
+import org.apache.shenyu.integratedtest.common.dto.OrderDTO;
+import org.apache.shenyu.integratedtest.common.helper.HttpHelper;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Test divide plugin in gateway api controller.
+ */
+public class DividePluginTest {
+
+ private static final HttpHelper HTTP_HELPER = HttpHelper.INSTANCE;
+
+ @BeforeAll
+ public static void setup() {
+ HTTP_HELPER.setGatewayEndpoint("http://localhost:30095");
+ }
+
+ @Test
+ public void testHelloWorld() throws Exception {
+ OrderDTO user = new OrderDTO("123", "Tom");
+ user = HttpHelper.INSTANCE.postGateway("/order/save", user, OrderDTO.class);
+ assertEquals("hello world save order", user.getName());
+ }
+}
diff --git a/shenyu-kubernetes-controller/pom.xml b/shenyu-kubernetes-controller/pom.xml
index 72a062f013..8a54b2c8c1 100644
--- a/shenyu-kubernetes-controller/pom.xml
+++ b/shenyu-kubernetes-controller/pom.xml
@@ -47,5 +47,12 @@
<groupId>io.kubernetes</groupId>
<artifactId>client-java-spring-integration</artifactId>
</dependency>
+
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-plugin-divide</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/GatewayRouteCache.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/GatewayRouteCache.java
new file mode 100644
index 0000000000..49988dd9f4
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/GatewayRouteCache.java
@@ -0,0 +1,170 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.cache;
+
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * In-memory route↔gateway/listener bindings written by the HTTPRoute reconciler, read for
+ * attachedRoutes counts and cascade cleanup. Tracked per listener because the spec defines
+ * attachedRoutes per listener.
+ */
+public final class GatewayRouteCache {
+
+ private static final GatewayRouteCache INSTANCE = new GatewayRouteCache();
+
+ private static final Map<String, List<String>> ROUTE_SELECTOR_MAP = Maps.newConcurrentMap();
+
+ /** gatewayKey → (routeKey → listener names that accepted the route). */
+ private static final Map<String, Map<String, Set<String>>> GATEWAY_ROUTE_MAP = Maps.newConcurrentMap();
+
+ private GatewayRouteCache() {
+ }
+
+ public static GatewayRouteCache getInstance() {
+ return INSTANCE;
+ }
+
+ public void putRouteSelectors(final String namespace, final String routeName,
+ final String pluginName, final List<String> selectorIds) {
+ ROUTE_SELECTOR_MAP.put(routeKey(namespace, routeName, pluginName), selectorIds);
+ }
+
+ public List<String> getRouteSelectors(final String namespace, final String routeName,
+ final String pluginName) {
+ return ROUTE_SELECTOR_MAP.get(routeKey(namespace, routeName, pluginName));
+ }
+
+ public List<String> removeRouteSelectors(final String namespace, final String routeName,
+ final String pluginName) {
+ return ROUTE_SELECTOR_MAP.remove(routeKey(namespace, routeName, pluginName));
+ }
+
+ /**
+ * Bind a route to a Gateway on the given listeners, replacing the previous binding.
+ *
+ * @param gatewayNamespace namespace of the Gateway
+ * @param gatewayName name of the Gateway
+ * @param listenerNames listeners of the Gateway that accepted the route
+ * @param routeNamespace namespace of the route
+ * @param routeName name of the route
+ */
+ public void bindRouteToGateway(final String gatewayNamespace, final String gatewayName,
+ final Set<String> listenerNames,
+ final String routeNamespace, final String routeName) {
+ String gwKey = gatewayKey(gatewayNamespace, gatewayName);
+ String rKey = routeKey(routeNamespace, routeName);
+ GATEWAY_ROUTE_MAP.computeIfAbsent(gwKey, k -> Maps.newConcurrentMap())
+ .compute(rKey, (k, listeners) -> {
+ Set<String> merged = Objects.isNull(listeners) ? Sets.newConcurrentHashSet() : listeners;
+ merged.addAll(listenerNames);
+ return merged;
+ });
+ }
+
+ /**
+ * Route keys attached through any listener, null if none.
+ *
+ * @param gatewayNamespace namespace of the Gateway
+ * @param gatewayName name of the Gateway
+ * @return route keys ("namespace/name") attached to the Gateway, null if none
+ */
+ public Set<String> getRoutesByGateway(final String gatewayNamespace, final String gatewayName) {
+ Map<String, Set<String>> routes = GATEWAY_ROUTE_MAP.get(gatewayKey(gatewayNamespace, gatewayName));
+ return Objects.isNull(routes) || routes.isEmpty() ? null : Set.copyOf(routes.keySet());
+ }
+
+ /**
+ * Route keys attached through one listener; its size is the listener's attachedRoutes.
+ *
+ * @param gatewayNamespace namespace of the Gateway
+ * @param gatewayName name of the Gateway
+ * @param listenerName name of the listener
+ * @return route keys ("namespace/name") attached through that listener, empty if none
+ */
+ public Set<String> getRoutesByListener(final String gatewayNamespace, final String gatewayName,
+ final String listenerName) {
+ Map<String, Set<String>> routes = GATEWAY_ROUTE_MAP.get(gatewayKey(gatewayNamespace, gatewayName));
+ if (Objects.isNull(routes)) {
+ return Set.of();
+ }
+ Set<String> attached = new HashSet<>();
+ routes.forEach((routeKey, listeners) -> {
+ if (listeners.contains(listenerName)) {
+ attached.add(routeKey);
+ }
+ });
+ return attached;
+ }
+
+ /**
+ * Gateway keys the route is bound to via multiple parentRefs, null if none.
+ *
+ * @param routeNamespace namespace of the route
+ * @param routeName name of the route
+ * @return gateway keys ("namespace/name") the route is bound to, null if none
+ */
+ public Set<String> getGatewaysForRoute(final String routeNamespace, final String routeName) {
+ String rKey = routeKey(routeNamespace, routeName);
+ Set<String> gateways = new HashSet<>();
+ GATEWAY_ROUTE_MAP.forEach((gwKey, routes) -> {
+ if (routes.containsKey(rKey)) {
+ gateways.add(gwKey);
+ }
+ });
+ return gateways.isEmpty() ? null : gateways;
+ }
+
+ public Set<String> removeRoutesByGateway(final String gatewayNamespace, final String gatewayName) {
+ String gwKey = gatewayKey(gatewayNamespace, gatewayName);
+ Map<String, Set<String>> routes = GATEWAY_ROUTE_MAP.remove(gwKey);
+ return Objects.isNull(routes) || routes.isEmpty() ? null : Set.copyOf(routes.keySet());
+ }
+
+ public void removeRouteGatewayBinding(final String routeNamespace, final String routeName) {
+ String rKey = routeKey(routeNamespace, routeName);
+ GATEWAY_ROUTE_MAP.forEach((gwKey, routes) -> routes.remove(rKey));
+ }
+
+ /**
+ * Clear all cached data. Used for testing.
+ */
+ public void clear() {
+ ROUTE_SELECTOR_MAP.clear();
+ GATEWAY_ROUTE_MAP.clear();
+ }
+
+ private String routeKey(final String namespace, final String name) {
+ return namespace + "/" + name;
+ }
+
+ private String routeKey(final String namespace, final String name, final String pluginName) {
+ return String.format("%s/%s-%s", namespace, name, pluginName);
+ }
+
+ private String gatewayKey(final String namespace, final String name) {
+ return namespace + "/" + name;
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/K8sCacheReadiness.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/K8sCacheReadiness.java
new file mode 100644
index 0000000000..44f22f1d96
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/K8sCacheReadiness.java
@@ -0,0 +1,85 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.cache;
+
+import io.kubernetes.client.extended.workqueue.WorkQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Readiness over informer initial-sync AND drained work queues: hasSynced() only means the
+ * LIST finished, while objects may still wait in the queues to reach {@code BaseDataCache}
+ * (minutes on large clusters). WorkQueue#length() misses items a worker already popped, so
+ * emptiness must hold on consecutive polls. Readiness latches: the local cache keeps serving
+ * the last known state during API outages, so flapping would only churn endpoints.
+ */
+public final class K8sCacheReadiness {
+
+ /** Consecutive polls observing every queue empty before the drain is trusted. */
+ private static final int REQUIRED_DRAINED_OBSERVATIONS = 2;
+
+ private final List<SharedIndexInformer<?>> informers;
+
+ private final List<WorkQueue<?>> workQueues;
+
+ private int drainedStreak;
+
+ private volatile boolean ready;
+
+ public K8sCacheReadiness(final Collection<SharedIndexInformer<?>> informers,
+ final Collection<WorkQueue<?>> workQueues) {
+ if (Objects.isNull(informers) || informers.isEmpty()) {
+ throw new IllegalArgumentException("At least one informer is required");
+ }
+ this.informers = List.copyOf(informers);
+ this.workQueues = Objects.isNull(workQueues) ? List.of() : List.copyOf(workQueues);
+ }
+
+ public boolean isReady() {
+ if (ready) {
+ return true;
+ }
+ if (informersSynced() && workQueues.stream().allMatch(queue -> queue.length() == 0)) {
+ drainedStreak++;
+ ready = drainedStreak >= REQUIRED_DRAINED_OBSERVATIONS;
+ } else {
+ drainedStreak = 0;
+ }
+ return ready;
+ }
+
+ public long pendingInformers() {
+ return informers.stream().filter(informer -> !informer.hasSynced()).count();
+ }
+
+ /**
+ * Queued items across the controller work queues.
+ *
+ * @return total number of queued items
+ */
+ public long pendingWorkItems() {
+ return workQueues.stream().mapToInt(WorkQueue::length).sum();
+ }
+
+ private boolean informersSynced() {
+ return informers.stream().allMatch(SharedIndexInformer::hasSynced);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiConstants.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiConstants.java
new file mode 100644
index 0000000000..d76530f33d
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiConstants.java
@@ -0,0 +1,186 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+
+import java.util.Objects;
+
+public final class GatewayApiConstants {
+
+ public static final String GATEWAY_API_GROUP = "gateway.networking.k8s.io";
+
+ public static final String GATEWAY_API_VERSION = "v1";
+
+ public static final String GATEWAY_KIND = "Gateway";
+
+ public static final String HTTP_ROUTE_KIND = "HTTPRoute";
+
+ public static final String SERVICE_KIND = "Service";
+
+ /** The core (legacy) API group, named by the empty string; group of Service. */
+ public static final String CORE_API_GROUP = "";
+
+ /** ResolvedRefs=False reason: a backendRef's Service has no ready endpoints. */
+ public static final String REASON_BACKEND_NOT_FOUND = "BackendNotFound";
+
+ /** ResolvedRefs=False / Accepted=False reason: a cross-namespace reference (parentRef or backendRef) is not permitted by a ReferenceGrant or listener policy. */
+ public static final String REASON_REF_NOT_PERMITTED = "RefNotPermitted";
+
+ /** ResolvedRefs=False reason: a backendRef's kind is not Service, the only supported kind. */
+ public static final String REASON_INVALID_KIND = "InvalidKind";
+
+ /** Condition type Accepted. */
+ public static final String CONDITION_ACCEPTED = "Accepted";
+
+ /** Condition type ResolvedRefs. */
+ public static final String CONDITION_RESOLVED_REFS = "ResolvedRefs";
+
+ /** Condition type Programmed. */
+ public static final String CONDITION_PROGRAMMED = "Programmed";
+
+ /** Accepted=False reason: parentRef does not resolve to an existing Gateway. */
+ public static final String REASON_NO_MATCHING_PARENT = "NoMatchingParent";
+
+ /** Accepted=False reason: no listener hostname intersects the route hostnames. */
+ public static final String REASON_NO_MATCHING_LISTENER_HOSTNAME = "NoMatchingListenerHostname";
+
+ /** Condition reason: a spec value (filter, sectionName, ...) is unsupported. */
+ public static final String REASON_UNSUPPORTED_VALUE = "UnsupportedValue";
+
+ /** Listener Accepted=False reason: protocol is not supported (only HTTP is served). */
+ public static final String REASON_UNSUPPORTED_PROTOCOL = "UnsupportedProtocol";
+
+ /** Listener Accepted=False reason: port is not served by this gateway. */
+ public static final String REASON_PORT_UNAVAILABLE = "PortUnavailable";
+
+ /** Condition reason: programmed into the data plane. */
+ public static final String REASON_PROGRAMMED = "Programmed";
+
+ /** Gateway Programmed=False reason: no listener with a supported protocol and port. */
+ public static final String REASON_LISTENERS_NOT_VALID = "ListenersNotValid";
+
+ /** Gateway Accepted=False reason: the Gateway is semantically invalid for this controller. */
+ public static final String REASON_INVALID = "Invalid";
+
+ /** The only listener protocol this controller serves. */
+ public static final String PROTOCOL_HTTP = "HTTP";
+
+ public static final String SHENYU_CONTROLLER_NAME = "gateway.shenyu.apache.org/shenyu-controller";
+
+ /** Wording shared by every Accepted=True message this controller writes; used to recognize ShenYu's own status payload on resources whose conditions carry no controllerName. */
+ public static final String ACCEPTED_BY_SHENYU_MESSAGE = "accepted by the ShenYu controller";
+
+ /** Spec-defined finalizer holding a GatewayClass alive while Gateways still use it. */
+ public static final String GATEWAY_CLASS_FINALIZER = "gateway-exists-finalizer.gateway.networking.k8s.io";
+
+ /** Condition type SupportedVersion: whether the installed CRD bundle version is supported. */
+ public static final String CONDITION_SUPPORTED_VERSION = "SupportedVersion";
+
+ /** SupportedVersion=True reason. */
+ public static final String REASON_SUPPORTED_VERSION = "SupportedVersion";
+
+ /** SupportedVersion=False reason. */
+ public static final String REASON_UNSUPPORTED_VERSION = "UnsupportedVersion";
+
+ /** GatewayClass Accepted=False reason: spec.parametersRef is set but not supported. */
+ public static final String REASON_INVALID_PARAMETERS = "InvalidParameters";
+
+ /** GatewayClass Accepted=False reason: the class is not managed by this controller. */
+ public static final String REASON_UNSUPPORTED = "Unsupported";
+
+ private GatewayApiConstants() {
+ }
+
+ /**
+ * Whether a backendRef references a core (legacy group) Service: both {@code group} and
+ * {@code kind} default to Service's core values when absent. A kind of {@code Service}
+ * with an explicit foreign group names a different resource (e.g. a custom backend
+ * implementation) and must not be resolved against core Services of the same name.
+ *
+ * @param backendRef the backendRef object
+ * @return true if the reference targets a core Service
+ */
+ public static boolean isServiceRef(final JsonObject backendRef) {
+ String kind = JsonFields.getString(backendRef, "kind");
+ if (Objects.nonNull(kind) && !SERVICE_KIND.equals(kind)) {
+ return false;
+ }
+ String group = JsonFields.getString(backendRef, "group");
+ return Objects.isNull(group) || group.isEmpty();
+ }
+
+ /**
+ * Check if a Gateway API resource has a specific condition with status "True".
+ *
+ * @param resource the dynamic kubernetes object
+ * @param conditionType the condition type to check
+ * @return true if the condition exists and is "True"
+ */
+ public static boolean isConditionTrue(final DynamicKubernetesObject resource, final String conditionType) {
+ JsonObject condition = findCondition(resource, conditionType);
+ return Objects.nonNull(condition) && "True".equals(condition.get("status").getAsString());
+ }
+
+ /**
+ * Find a resource's condition of the given type in its status.
+ *
+ * @param resource the dynamic kubernetes object
+ * @param conditionType the condition type to look for
+ * @return the condition object, or null when absent or the status is malformed
+ */
+ public static JsonObject findCondition(final DynamicKubernetesObject resource, final String conditionType) {
+ JsonObject raw = resource.getRaw();
+ JsonObject status = JsonFields.getJsonObject(raw, "status");
+ JsonArray conditions = JsonFields.getJsonArray(status, "conditions");
+ if (Objects.isNull(conditions)) {
+ return null;
+ }
+ for (JsonElement element : conditions) {
+ if (!element.isJsonObject()) {
+ continue;
+ }
+ JsonObject condition = element.getAsJsonObject();
+ if (conditionType.equals(JsonFields.getString(condition, "type"))) {
+ return condition;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Whether the resource's Accepted=True condition is ShenYu's own status payload.
+ * Gateway API conditions carry no controllerName, so on Gateways and GatewayClasses
+ * that changed ownership the only way to tell ShenYu's writing from the new
+ * controller's is the message wording this controller uses.
+ *
+ * @param resource the dynamic kubernetes object
+ * @param conditionType the condition type to check
+ * @return true if the condition is True and was written by this controller
+ */
+ public static boolean isConditionAcceptedByShenyu(final DynamicKubernetesObject resource, final String conditionType) {
+ JsonObject condition = findCondition(resource, conditionType);
+ return Objects.nonNull(condition)
+ && "True".equals(JsonFields.getString(condition, "status"))
+ && Objects.nonNull(JsonFields.getString(condition, "message"))
+ && JsonFields.getString(condition, "message").contains(ACCEPTED_BY_SHENYU_MESSAGE);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifier.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifier.java
new file mode 100644
index 0000000000..ab4ffe8fa5
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifier.java
@@ -0,0 +1,142 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.ApiResponse;
+import okhttp3.Call;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Startup probe for the Gateway API CRDs: all four resources must serve
+ * {@code gateway.networking.k8s.io/v1} (ReferenceGrant only since v1.5.0) or the controller
+ * fails fast — missing CRDs make informers 404 forever and silently degrade. Also reads
+ * each CRD's bundle-version annotation for the SupportedVersion condition; an unreadable,
+ * unannotated or unrecognized version only degrades that condition, never startup.
+ */
+public final class GatewayApiCrdVerifier {
+
+ /** Annotation stamped on every Gateway API CRD by the official release bundles. */
+ public static final String BUNDLE_VERSION_ANNOTATION = "gateway.networking.k8s.io/bundle-version";
+
+ private static final Logger LOG = LoggerFactory.getLogger(GatewayApiCrdVerifier.class);
+
+ private static final Set<String> REQUIRED_RESOURCES = Set.of(
+ "gatewayclasses", "gateways", "httproutes", "referencegrants");
+
+ private static final String CRD_API_PATH = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/";
+
+ private GatewayApiCrdVerifier() {
+ }
+
+ /**
+ * Fails fast on missing resources; returns the detected bundle versions.
+ *
+ * @param apiClient the Kubernetes API client
+ * @return the detected CRD bundle versions
+ * @throws IllegalStateException when any required resource is missing
+ */
+ public static GatewayApiCrdVersions verify(final ApiClient apiClient) {
+ String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION;
+ final JsonObject resourceList;
+ try {
+ resourceList = fetchJson(apiClient, path);
+ } catch (ApiException e) {
+ throw new IllegalStateException("Gateway API is not available at " + path + " (HTTP " + e.getCode()
+ + "). Install the Gateway API standard channel CRDs (>= v1.5.0) or disable shenyu.k8s.mode=gateway-api.", e);
+ }
+ Set<String> served = new HashSet<>();
+ JsonArray resources = JsonFields.getJsonArray(resourceList, "resources");
+ if (Objects.nonNull(resources)) {
+ for (JsonElement element : resources) {
+ if (element.isJsonObject()) {
+ String name = JsonFields.getString(element.getAsJsonObject(), "name");
+ if (Objects.nonNull(name) && !name.contains("/")) {
+ served.add(name);
+ }
+ }
+ }
+ }
+ Set<String> missing = new HashSet<>(REQUIRED_RESOURCES);
+ missing.removeAll(served);
+ if (!missing.isEmpty()) {
+ throw new IllegalStateException("Gateway API CRDs " + missing + " are not served at "
+ + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+ + ". Install the standard channel CRDs (>= v1.5.0; referencegrants serve v1 only since then)"
+ + " or disable shenyu.k8s.mode=gateway-api.");
+ }
+ LOG.info("Gateway API CRDs verified: all required resources are served at {}/{}",
+ GatewayApiConstants.GATEWAY_API_GROUP, GatewayApiConstants.GATEWAY_API_VERSION);
+ return detectBundleVersions(apiClient);
+ }
+
+ private static JsonObject fetchJson(final ApiClient apiClient, final String path) throws ApiException {
+ Map<String, String> headerParams = new HashMap<>();
+ headerParams.put("Accept", "application/json");
+ String[] authNames = apiClient.getAuthentications().keySet().toArray(new String[0]);
+ // buildCall dereferences cookieParams unconditionally, so it must be non-null
+ Call call = apiClient.buildCall(path, "GET", null, null, null, headerParams,
+ new HashMap<>(), null, authNames, null);
+ ApiResponse<JsonObject> response = apiClient.execute(call, JsonObject.class);
+ return response.getData();
+ }
+
+ /**
+ * Read the bundle-version annotation off every required CRD. Fetch failures degrade to
+ * an unsupported verdict instead of failing startup.
+ */
+ private static GatewayApiCrdVersions detectBundleVersions(final ApiClient apiClient) {
+ Set<String> detected = new TreeSet<>();
+ boolean supported = true;
+ for (String resource : new TreeSet<>(REQUIRED_RESOURCES)) {
+ String crdName = resource + "." + GatewayApiConstants.GATEWAY_API_GROUP;
+ String version = null;
+ try {
+ JsonObject crd = fetchJson(apiClient, CRD_API_PATH + crdName);
+ JsonObject metadata = JsonFields.getJsonObject(crd, "metadata");
+ JsonObject annotations = JsonFields.getJsonObject(metadata, "annotations");
+ version = JsonFields.getString(annotations, BUNDLE_VERSION_ANNOTATION);
+ } catch (ApiException e) {
+ LOG.warn("Could not read CRD {} to detect its bundle version (HTTP {}); marking CRD versions unsupported",
+ crdName, e.getCode());
+ }
+ int[] majorMinor = GatewayApiCrdVersions.majorMinor(version);
+ if (Objects.isNull(version) || Objects.isNull(majorMinor) || !GatewayApiCrdVersions.atLeastMinSupported(majorMinor)) {
+ supported = false;
+ }
+ if (Objects.nonNull(version)) {
+ detected.add(version);
+ }
+ }
+ LOG.info("Gateway API CRD bundle versions detected: {} (supported: {})", detected, supported);
+ return new GatewayApiCrdVersions(new ArrayList<>(detected), supported);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersions.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersions.java
new file mode 100644
index 0000000000..0661570320
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersions.java
@@ -0,0 +1,102 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * CRD bundle versions detected at startup with the SupportedVersion verdict: a missing
+ * annotation or unrecognized version means unsupported; the controller still serves
+ * best-effort because it only relies on the v1 GA surface.
+ */
+public final class GatewayApiCrdVersions {
+
+ /** Oldest bundle version whose standard-channel CRDs serve ReferenceGrant at v1. */
+ private static final int[] MIN_SUPPORTED = {1, 5};
+
+ private final List<String> detectedVersions;
+
+ private final boolean supported;
+
+ public GatewayApiCrdVersions(final List<String> detectedVersions, final boolean supported) {
+ this.detectedVersions = detectedVersions.stream().distinct().sorted().toList();
+ this.supported = supported;
+ }
+
+ /**
+ * Whether every required CRD carries a recognized bundle version at or above the minimum.
+ *
+ * @return true when the install is fully supported
+ */
+ public boolean isSupported() {
+ return supported;
+ }
+
+ /**
+ * Distinct detected bundle-version annotation values, sorted.
+ *
+ * @return the detected versions, possibly empty when CRDs carry no annotation
+ */
+ public List<String> detectedVersions() {
+ return detectedVersions;
+ }
+
+ /**
+ * Parse {@code v1.5.1} into (major, minor); null when not numeric.
+ *
+ * @param version CRD bundle version string, e.g. {@code v1.5.1}
+ * @return {major, minor} pair, or null when not numeric
+ */
+ public static int[] majorMinor(final String version) {
+ if (Objects.isNull(version)) {
+ return null;
+ }
+ String trimmed = version.startsWith("v") ? version.substring(1) : version;
+ String[] parts = trimmed.split("\\.");
+ if (parts.length < 2) {
+ return null;
+ }
+ try {
+ return new int[]{Integer.parseInt(parts[0]), Integer.parseInt(parts[1])};
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ /**
+ * Whether the parsed pair is at or above the minimum supported version.
+ *
+ * @param majorMinor parsed {major, minor} pair
+ * @return true when at or above the minimum supported version
+ */
+ public static boolean atLeastMinSupported(final int[] majorMinor) {
+ return Objects.nonNull(majorMinor)
+ && (majorMinor[0] > MIN_SUPPORTED[0]
+ || majorMinor[0] == MIN_SUPPORTED[0] && majorMinor[1] >= MIN_SUPPORTED[1]);
+ }
+
+ /**
+ * Human-readable form for status messages, e.g. "v1.5.1, v1.6.0" or "none annotated".
+ *
+ * @return the detected versions joined for display
+ */
+ public String describeDetected() {
+ return detectedVersions.isEmpty() ? "none annotated" : String.join(", ", detectedVersions);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/JsonFields.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/JsonFields.java
new file mode 100644
index 0000000000..2306bed6df
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/JsonFields.java
@@ -0,0 +1,77 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.util.Objects;
+
+/**
+ * Null-safe field accessors for dynamic Gateway API json, where fields may be absent,
+ * null or mistyped (foreign status is not schema-validated); null returns degrade a
+ * reconcile to a no-op instead of an infinite retry loop.
+ */
+public final class JsonFields {
+
+ private JsonFields() {
+ }
+
+ public static String getString(final JsonObject obj, final String field) {
+ if (Objects.isNull(obj) || !obj.has(field) || obj.get(field).isJsonNull()
+ || !obj.get(field).isJsonPrimitive()) {
+ return null;
+ }
+ return obj.get(field).getAsString();
+ }
+
+ /**
+ * Read an optional numeric field as Long.
+ *
+ * @param obj the object to read from
+ * @param field the field name
+ * @return the number, or null when absent or not numeric
+ */
+ public static Long getLong(final JsonObject obj, final String field) {
+ if (Objects.isNull(obj) || !obj.has(field) || obj.get(field).isJsonNull()
+ || !obj.get(field).isJsonPrimitive()) {
+ return null;
+ }
+ try {
+ return obj.get(field).getAsLong();
+ } catch (NumberFormatException | UnsupportedOperationException ex) {
+ return null;
+ }
+ }
+
+ public static JsonObject getJsonObject(final JsonObject obj, final String field) {
+ if (Objects.isNull(obj) || !obj.has(field) || obj.get(field).isJsonNull()
+ || !obj.get(field).isJsonObject()) {
+ return null;
+ }
+ return obj.getAsJsonObject(field);
+ }
+
+ public static JsonArray getJsonArray(final JsonObject obj, final String field) {
+ if (Objects.isNull(obj) || !obj.has(field) || obj.get(field).isJsonNull()
+ || !obj.get(field).isJsonArray()) {
+ return null;
+ }
+ return obj.getAsJsonArray(field);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ListenerSupport.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ListenerSupport.java
new file mode 100644
index 0000000000..8b7be0a45e
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ListenerSupport.java
@@ -0,0 +1,244 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Read-only helpers for evaluating Gateway {@code spec.listeners}: which listeners an
+ * HTTPRoute may attach to (namespace policy, kind policy, protocol support) and the
+ * route/listener hostname intersection mandated by the Gateway API attachment rules.
+ */
+public final class ListenerSupport {
+
+ private ListenerSupport() {
+ }
+
+ /**
+ * Listeners matching the optional sectionName and parentRef port; both selectors must match.
+ *
+ * @param gatewayRaw raw Gateway json
+ * @param sectionName optional sectionName
+ * @param parentPort optional parentRef port; null skips the port check
+ * @return matching listeners; empty when sectionName matches no listener
+ */
+ public static List<JsonObject> selectListeners(final JsonObject gatewayRaw, final String sectionName,
+ final Long parentPort) {
+ JsonObject spec = JsonFields.getJsonObject(gatewayRaw, "spec");
+ JsonArray listeners = JsonFields.getJsonArray(spec, "listeners");
+ List<JsonObject> result = new ArrayList<>();
+ if (Objects.isNull(listeners)) {
+ return result;
+ }
+ for (JsonElement element : listeners) {
+ if (!element.isJsonObject()) {
+ continue;
+ }
+ JsonObject listener = element.getAsJsonObject();
+ boolean nameMatches = Objects.isNull(sectionName) || sectionName.equals(nameOf(listener));
+ boolean portMatches = Objects.isNull(parentPort) || parentPort.equals(portOf(listener));
+ if (nameMatches && portMatches) {
+ result.add(listener);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Listeners selected by sectionName only.
+ *
+ * @param gatewayRaw raw Gateway json
+ * @param sectionName optional sectionName
+ * @return matching listeners; empty when sectionName matches no listener
+ */
+ public static List<JsonObject> selectListeners(final JsonObject gatewayRaw, final String sectionName) {
+ return selectListeners(gatewayRaw, sectionName, null);
+ }
+
+ /**
+ * True when the listener's port equals the served data-plane port; a portless listener cannot be confirmed served.
+ *
+ * @param listener the listener object
+ * @param servedPort the port the data plane listens on
+ * @return true if the listener's port is served
+ */
+ public static boolean servesPort(final JsonObject listener, final long servedPort) {
+ Long port = portOf(listener);
+ return Objects.nonNull(port) && port == servedPort;
+ }
+
+ public static String nameOf(final JsonObject listener) {
+ return JsonFields.getString(listener, "name");
+ }
+
+ /**
+ * Listener protocol; defaults to HTTP per the spec.
+ *
+ * @param listener the listener object
+ * @return the protocol, never null
+ */
+ public static String protocolOf(final JsonObject listener) {
+ String protocol = JsonFields.getString(listener, "protocol");
+ return Objects.isNull(protocol) ? GatewayApiConstants.PROTOCOL_HTTP : protocol;
+ }
+
+ public static String hostnameOf(final JsonObject listener) {
+ return JsonFields.getString(listener, "hostname");
+ }
+
+ /**
+ * Listener port; null when absent (foreign status is not schema-guaranteed).
+ *
+ * @param listener the listener object
+ * @return the port, or null
+ */
+ public static Long portOf(final JsonObject listener) {
+ return JsonFields.getLong(listener, "port");
+ }
+
+ /**
+ * Only plain HTTP is supported.
+ *
+ * @param listener the listener object
+ * @return true if the listener speaks plain HTTP
+ */
+ public static boolean isSupportedProtocol(final JsonObject listener) {
+ return GatewayApiConstants.PROTOCOL_HTTP.equals(protocolOf(listener));
+ }
+
+ /**
+ * Spec default is Same, from=All allows all; from=Selector is unimplemented and denies (widening would break isolation).
+ *
+ * @param listener the listener object
+ * @param routeNamespace namespace of the attaching HTTPRoute
+ * @param gatewayNamespace namespace of the Gateway
+ * @return true if the namespace policy permits the attachment
+ */
+ public static boolean allowsNamespace(final JsonObject listener, final String routeNamespace, final String gatewayNamespace) {
+ String from = fromOf(listener);
+ if (Objects.isNull(from) || "Same".equals(from)) {
+ return Objects.equals(routeNamespace, gatewayNamespace);
+ }
+ return "All".equals(from);
+ }
+
+ /**
+ * from=Selector is unsupported; distinguishable so status reports UnsupportedValue, not a permission denial.
+ *
+ * @param listener the listener json object
+ * @return true when allowedRoutes.from is the unsupported Selector value
+ */
+ public static boolean usesUnsupportedFrom(final JsonObject listener) {
+ return "Selector".equals(fromOf(listener));
+ }
+
+ private static String fromOf(final JsonObject listener) {
+ JsonObject allowedRoutes = JsonFields.getJsonObject(listener, "allowedRoutes");
+ JsonObject namespaces = JsonFields.getJsonObject(allowedRoutes, "namespaces");
+ return JsonFields.getString(namespaces, "from");
+ }
+
+ /**
+ * Absent kinds means all protocol-matching kinds, i.e. HTTPRoute for HTTP.
+ *
+ * @param listener the listener object
+ * @return true if HTTPRoute is permitted
+ */
+ public static boolean allowsKind(final JsonObject listener) {
+ JsonObject allowedRoutes = JsonFields.getJsonObject(listener, "allowedRoutes");
+ JsonArray kinds = JsonFields.getJsonArray(allowedRoutes, "kinds");
+ if (Objects.isNull(kinds) || kinds.size() == 0) {
+ return true;
+ }
+ for (JsonElement element : kinds) {
+ if (!element.isJsonObject()) {
+ continue;
+ }
+ JsonObject kind = element.getAsJsonObject();
+ String group = JsonFields.getString(kind, "group");
+ boolean groupMatches = Objects.isNull(group) || GatewayApiConstants.GATEWAY_API_GROUP.equals(group);
+ if (groupMatches && GatewayApiConstants.HTTP_ROUTE_KIND.equals(JsonFields.getString(kind, "kind"))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Route × listener hostname intersection; a null listener hostname imposes no restriction.
+ *
+ * @param listenerHostname listener hostname, may be null
+ * @param routeHostnames hostnames from the HTTPRoute spec, empty means "any host"
+ * @return effective hostnames, or null when the two sides have no overlap
+ */
+ public static List<String> intersectHostnames(final String listenerHostname, final List<String> routeHostnames) {
+ if (Objects.isNull(listenerHostname)) {
+ return new ArrayList<>(routeHostnames);
+ }
+ if (routeHostnames.isEmpty()) {
+ return List.of(listenerHostname);
+ }
+ Set<String> overlaps = new LinkedHashSet<>();
+ for (String routeHostname : routeHostnames) {
+ String overlap = overlap(routeHostname, listenerHostname);
+ if (Objects.nonNull(overlap)) {
+ overlaps.add(overlap);
+ }
+ }
+ return overlaps.isEmpty() ? null : new ArrayList<>(overlaps);
+ }
+
+ /** The more specific of two overlapping hostnames; {@code *.example.com} matches one or more labels, never the bare domain. */
+ private static String overlap(final String routeHostname, final String listenerHostname) {
+ if (routeHostname.equals(listenerHostname)) {
+ return routeHostname;
+ }
+ boolean routeWildcard = routeHostname.startsWith("*.");
+ boolean listenerWildcard = listenerHostname.startsWith("*.");
+ if (!routeWildcard && !listenerWildcard) {
+ return null;
+ }
+ if (routeWildcard && listenerWildcard) {
+ String routeSuffix = routeHostname.substring(2);
+ String listenerSuffix = listenerHostname.substring(2);
+ if (listenerSuffix.endsWith("." + routeSuffix)) {
+ return listenerHostname;
+ }
+ if (routeSuffix.endsWith("." + listenerSuffix)) {
+ return routeHostname;
+ }
+ return null;
+ }
+ if (routeWildcard) {
+ return wildcardCovers(routeHostname, listenerHostname) ? listenerHostname : null;
+ }
+ return wildcardCovers(listenerHostname, routeHostname) ? routeHostname : null;
+ }
+
+ private static boolean wildcardCovers(final String wildcard, final String hostname) {
+ return hostname.endsWith("." + wildcard.substring(2));
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ReferenceGrants.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ReferenceGrants.java
new file mode 100644
index 0000000000..576a14d766
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ReferenceGrants.java
@@ -0,0 +1,106 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * ReferenceGrant (GEP-709) matching for cross-namespace references: a grant lives in
+ * the namespace of the referenced resource and allows an HTTPRoute from one namespace
+ * to reference a resource of a given group/kind (optionally restricted by name).
+ */
+public final class ReferenceGrants {
+
+ private ReferenceGrants() {
+ }
+
+ /**
+ * Whether a grant in grantNamespace (where referenced resources' grants live) allows the reference; to.name restricts to one resource.
+ *
+ * @param grantLister lister for ReferenceGrant resources
+ * @param grantNamespace namespace of the referenced resource, where grants live
+ * @param fromNamespace namespace of the referencing HTTPRoute
+ * @param toGroup API group of the referenced resource
+ * @param toKind kind of the referenced resource
+ * @param toName name of the referenced resource, null for unrestricted grants
+ * @return true if a matching grant exists
+ */
+ public static boolean isGranted(final Lister<DynamicKubernetesObject> grantLister, final String grantNamespace,
+ final String fromNamespace, final String toGroup, final String toKind,
+ final String toName) {
+ List<DynamicKubernetesObject> grants = grantLister.namespace(grantNamespace).list();
+ if (CollectionUtils.isEmpty(grants)) {
+ return false;
+ }
+ for (DynamicKubernetesObject grant : grants) {
+ if (matches(grant, fromNamespace, toGroup, toKind, toName)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Requires a matching spec.from (HTTPRoute@fromNamespace) AND spec.to (group/kind, optionally name). */
+ private static boolean matches(final DynamicKubernetesObject grant, final String fromNamespace,
+ final String toGroup, final String toKind, final String toName) {
+ JsonObject spec = grant.getRaw().getAsJsonObject("spec");
+ if (Objects.isNull(spec)) {
+ return false;
+ }
+ boolean fromMatched = false;
+ JsonArray from = spec.has("from") ? spec.getAsJsonArray("from") : null;
+ if (Objects.nonNull(from)) {
+ for (JsonElement el : from) {
+ JsonObject fromRule = el.getAsJsonObject();
+ if (GatewayApiConstants.GATEWAY_API_GROUP.equals(JsonFields.getString(fromRule, "group"))
+ && GatewayApiConstants.HTTP_ROUTE_KIND.equals(JsonFields.getString(fromRule, "kind"))
+ && fromNamespace.equals(JsonFields.getString(fromRule, "namespace"))) {
+ fromMatched = true;
+ break;
+ }
+ }
+ }
+ if (!fromMatched) {
+ return false;
+ }
+ JsonArray to = spec.has("to") ? spec.getAsJsonArray("to") : null;
+ if (Objects.isNull(to)) {
+ return false;
+ }
+ for (JsonElement el : to) {
+ JsonObject toRule = el.getAsJsonObject();
+ if (!toGroup.equals(JsonFields.getString(toRule, "group"))
+ || !toKind.equals(JsonFields.getString(toRule, "kind"))) {
+ continue;
+ }
+ String grantedName = JsonFields.getString(toRule, "name");
+ if (Objects.isNull(grantedName) || grantedName.equals(toName)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ShenyuMemoryConfig.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ShenyuMemoryConfig.java
index 96011398de..ad48562e3f 100644
--- a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ShenyuMemoryConfig.java
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ShenyuMemoryConfig.java
@@ -33,6 +33,15 @@ public class ShenyuMemoryConfig {
private List<SslCrtAndKeyStream> tlsConfigList;
+ /** Whether every backendRef resolved to at least one ready upstream; drives ResolvedRefs. */
+ private boolean allBackendsResolved = true;
+
+ /** Reason for ResolvedRefs=False (BackendNotFound, RefNotPermitted or InvalidKind). */
+ private String unresolvedReason;
+
+ /** Unsupported filters must surface as Accepted=False/UnsupportedValue with the rule unprogrammed. */
+ private boolean hasUnsupportedFilters;
+
/**
* ShenyuMemoryConfig Constructor.
*/
@@ -92,4 +101,28 @@ public class ShenyuMemoryConfig {
public void setTlsConfigList(final List<SslCrtAndKeyStream> tlsConfigList) {
this.tlsConfigList = tlsConfigList;
}
+
+ public boolean isAllBackendsResolved() {
+ return allBackendsResolved;
+ }
+
+ public void setAllBackendsResolved(final boolean allBackendsResolved) {
+ this.allBackendsResolved = allBackendsResolved;
+ }
+
+ public String getUnresolvedReason() {
+ return unresolvedReason;
+ }
+
+ public void setUnresolvedReason(final String unresolvedReason) {
+ this.unresolvedReason = unresolvedReason;
+ }
+
+ public boolean isHasUnsupportedFilters() {
+ return hasUnsupportedFilters;
+ }
+
+ public void setHasUnsupportedFilters(final boolean hasUnsupportedFilters) {
+ this.hasUnsupportedFilters = hasUnsupportedFilters;
+ }
}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/StatusMergePatch.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/StatusMergePatch.java
new file mode 100644
index 0000000000..eb27b73868
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/StatusMergePatch.java
@@ -0,0 +1,57 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonObject;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import okhttp3.Call;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Merge-patch through the {@link ApiClient}'s request pipeline: a raw okhttp call would
+ * miss per-request header auth (static kubeconfig tokens) and only work for client-bound auth.
+ */
+public final class StatusMergePatch {
+
+ private static final String MERGE_PATCH_CONTENT_TYPE = "application/merge-patch+json";
+
+ private StatusMergePatch() {
+ }
+
+ /**
+ * Merge-patch the resource at the given path.
+ *
+ * @param apiClient the Kubernetes API client providing transport and authentication
+ * @param path the /status subresource path
+ * @param body the patch body
+ * @throws ApiException when the API server rejects the patch
+ */
+ public static void patch(final ApiClient apiClient, final String path, final JsonObject body) throws ApiException {
+ Map<String, String> headerParams = new HashMap<>();
+ headerParams.put("Accept", "application/json");
+ headerParams.put("Content-Type", MERGE_PATCH_CONTENT_TYPE);
+ String[] authNames = apiClient.getAuthentications().keySet().toArray(new String[0]);
+ // buildCall dereferences cookieParams unconditionally, so it must be non-null
+ Call call = apiClient.buildCall(path, "PATCH", null, null, body, headerParams,
+ new HashMap<>(), null, authNames, null);
+ apiClient.execute(call);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/parser/HttpRouteParser.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/parser/HttpRouteParser.java
new file mode 100644
index 0000000000..c2e3a80e63
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/parser/HttpRouteParser.java
@@ -0,0 +1,722 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.parser;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.custom.IntOrString;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.models.CoreV1EndpointPort;
+import io.kubernetes.client.openapi.models.V1EndpointAddress;
+import io.kubernetes.client.openapi.models.V1EndpointSubset;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ServicePort;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
+import org.apache.shenyu.common.dto.convert.selector.DivideUpstream;
+import org.apache.shenyu.common.enums.LoadBalanceEnum;
+import org.apache.shenyu.common.enums.MatchModeEnum;
+import org.apache.shenyu.common.enums.OperatorEnum;
+import org.apache.shenyu.common.enums.ParamTypeEnum;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.common.enums.SelectorTypeEnum;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.IngressConfiguration;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.apache.shenyu.k8s.common.ReferenceGrants;
+import org.apache.shenyu.k8s.common.ShenyuMemoryConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+/**
+ * Parses an HTTPRoute into ShenYu divide selectors/rules; pure, no cache or data-plane
+ * side effects. Match precedence follows the spec (hostname, path, method presence, header
+ * count, query count, first rule) and is encoded in the selector sort; because the data
+ * plane groups matches by condition count BEFORE comparing sort
+ * ({@code AbstractShenyuPlugin#manyMatchSelector}), every selector's condition list is
+ * padded to a fixed length with always-true duplicates so the sort alone decides. The
+ * traffic share of invalid weighted backendRefs goes to a loopback fail-target upstream:
+ * the connection is refused instantly, so the request fails with a 500 instead of
+ * re-flowing onto healthy backends.
+ */
+public class HttpRouteParser {
+
+ private static final Logger LOG = LoggerFactory.getLogger(HttpRouteParser.class);
+
+ private static final String ID_PREFIX = "gwapi-";
+
+ private static final String NO_HOSTNAME_PLACEHOLDER = "_";
+
+ /** Base of the packed precedence sort (hostname 3b, path 11b, method 1b, headers 4b, query 4b, rule index 4b); lower sort wins. */
+ private static final int SORT_PRECEDENCE_BASE = 1 << 28;
+
+ /** Condition-list length selectors are padded to, defeating the data plane's count-first tie-breaking. */
+ private static final int CONDITION_COUNT_FLOOR = 16;
+
+ /** Loopback port 1 has no listener in the pod: connections are refused instantly, failing the invalid share with a 500. */
+ private static final String FAIL_TARGET_URL = "127.0.0.1:1";
+
+ private final Lister<V1Endpoints> endpointsLister;
+
+ private final Lister<V1Service> serviceLister;
+
+ private final Lister<DynamicKubernetesObject> referenceGrantLister;
+
+ public HttpRouteParser(final Lister<V1Endpoints> endpointsLister,
+ final Lister<V1Service> serviceLister,
+ final Lister<DynamicKubernetesObject> referenceGrantLister) {
+ this.endpointsLister = endpointsLister;
+ this.serviceLister = serviceLister;
+ this.referenceGrantLister = referenceGrantLister;
+ }
+
+ /**
+ * Parse the HTTPRoute into a ShenYu config snapshot.
+ * @param httpRoute the route object
+ * @param hostnames effective hostnames (route × listener intersection); empty means "any host"
+ * @return the parsed config, never null
+ */
+ public ShenyuMemoryConfig parse(final DynamicKubernetesObject httpRoute, final List<String> hostnames) {
+ ShenyuMemoryConfig res = new ShenyuMemoryConfig();
+ String namespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace();
+ String routeName = httpRoute.getMetadata().getName();
+ List<IngressConfiguration> routeConfigList = new ArrayList<>();
+ res.setRouteConfigList(routeConfigList);
+
+ JsonObject spec = JsonFields.getJsonObject(httpRoute.getRaw(), "spec");
+ if (Objects.nonNull(spec)) {
+ JsonArray rules = JsonFields.getJsonArray(spec, "rules");
+ ResolveState resolveState = new ResolveState();
+ for (int ruleIndex = 0; Objects.nonNull(rules) && ruleIndex < rules.size(); ruleIndex++) {
+ if (rules.get(ruleIndex).isJsonObject()) {
+ processRule(rules.get(ruleIndex).getAsJsonObject(), hostnames, namespace, routeName, ruleIndex,
+ routeConfigList, resolveState);
+ }
+ }
+ res.setAllBackendsResolved(!resolveState.anyUnresolved);
+ res.setUnresolvedReason(resolveState.reason);
+ res.setHasUnsupportedFilters(resolveState.unsupportedFilters);
+ }
+ return res;
+ }
+
+ private void processRule(final JsonObject rule, final List<String> hostnames, final String namespace,
+ final String routeName, final int ruleIndex,
+ final List<IngressConfiguration> routeConfigList,
+ final ResolveState resolveState) {
+ // Unsupported filters must surface as Accepted=False/UnsupportedValue, never apply partially.
+ JsonArray filters = JsonFields.getJsonArray(rule, "filters");
+ if (Objects.nonNull(filters) && !filters.isEmpty()) {
+ resolveState.unsupportedFilters = true;
+ LOG.warn("HTTPRoute {}/{} rule {} declares filters which are not supported; the rule is not programmed",
+ namespace, routeName, ruleIndex);
+ return;
+ }
+
+ // Without backendRefs there is no ShenYu equivalent; leave requests unmatched.
+ JsonArray backendRefs = JsonFields.getJsonArray(rule, "backendRefs");
+ if (Objects.isNull(backendRefs) || backendRefs.isEmpty()) {
+ return;
+ }
+
+ BackendResolveResult result = parseBackendRefs(backendRefs, namespace, routeName);
+ if (result.unresolvedCount > 0) {
+ resolveState.anyUnresolved = true;
+ if (Objects.isNull(resolveState.reason)) {
+ resolveState.reason = result.unresolvedReason;
+ }
+ LOG.warn("HTTPRoute {}/{} rule {} has {} unresolved backendRef(s)",
+ namespace, routeName, ruleIndex, result.unresolvedCount);
+ }
+
+ // All backends valid with weight 0: removed from rotation, nothing to program.
+ if (result.upstreams.isEmpty()) {
+ return;
+ }
+ emitRuleSelectors(rule, hostnames, namespace, routeName, ruleIndex, routeConfigList, result.upstreams);
+ }
+
+ /**
+ * Fan the rule out into selectors/rules: one selector per (hostname, match) pair, all
+ * sharing the rule's upstream list.
+ */
+ private void emitRuleSelectors(final JsonObject rule, final List<String> hostnames, final String namespace,
+ final String routeName, final int ruleIndex,
+ final List<IngressConfiguration> routeConfigList,
+ final List<DivideUpstream> upstreamList) {
+ // One selector per hostname: AND semantics cannot express "any of these hostnames".
+ JsonArray matches = JsonFields.getJsonArray(rule, "matches");
+ if (Objects.nonNull(matches) && !matches.isEmpty()) {
+ for (int matchIndex = 0; matchIndex < matches.size(); matchIndex++) {
+ if (!matches.get(matchIndex).isJsonObject()) {
+ continue;
+ }
+ JsonObject match = matches.get(matchIndex).getAsJsonObject();
+ List<ConditionData> matchConditions = new ArrayList<>();
+ appendMatchConditions(matchConditions, match);
+ if (hostnames.isEmpty()) {
+ addSelectorRule(routeConfigList, namespace, routeName, ruleIndex, null,
+ matchIndex, selectorSort(null, match, ruleIndex), matchConditions, upstreamList);
+ } else {
+ for (String hostname : hostnames) {
+ addSelectorRule(routeConfigList, namespace, routeName, ruleIndex,
+ hostname, matchIndex, selectorSort(hostname, match, ruleIndex),
+ composeConditions(hostname, matchConditions), upstreamList);
+ }
+ }
+ }
+ } else {
+ // Spec: a rule without matches matches everything, like PathPrefix /
+ JsonObject noMatch = new JsonObject();
+ if (hostnames.isEmpty()) {
+ addSelectorRule(routeConfigList, namespace, routeName, ruleIndex, null,
+ 0, selectorSort(null, noMatch, ruleIndex), new ArrayList<>(), upstreamList);
+ } else {
+ for (String hostname : hostnames) {
+ addSelectorRule(routeConfigList, namespace, routeName, ruleIndex,
+ hostname, 0, selectorSort(hostname, noMatch, ruleIndex),
+ composeConditions(hostname, new ArrayList<>()), upstreamList);
+ }
+ }
+ }
+ }
+
+ private void addSelectorRule(final List<IngressConfiguration> routeConfigList,
+ final String namespace, final String routeName, final int ruleIndex,
+ final String hostname, final int matchIndex, final int sort,
+ final List<ConditionData> conditions, final List<DivideUpstream> upstreamList) {
+ // A CUSTOM_FLOW selector with no conditions never matches; match-all fills the gap.
+ if (conditions.isEmpty()) {
+ conditions.add(matchAllCondition());
+ }
+ String selectorId = deterministicSelectorId(namespace, routeName, ruleIndex, hostname, matchIndex);
+ String ruleId = deterministicRuleId(selectorId, matchIndex);
+ String hostComponent = Objects.isNull(hostname) ? "" : "-" + hostname;
+ String selectorName = routeName + "-rule-" + ruleIndex + hostComponent + "-m" + matchIndex;
+ // Only the selector is padded; the rule's trie cache would just bloat with duplicates.
+ SelectorData selectorData = buildSelectorData(selectorId, selectorName, sort,
+ padToConditionFloor(conditions), upstreamList);
+ RuleData ruleData = buildRuleData(ruleId, selectorId, selectorName, conditions);
+ routeConfigList.add(new IngressConfiguration(selectorData, List.of(ruleData), null));
+ }
+
+ /** Pad to the fixed condition count so the precedence-encoded sort alone decides (see class javadoc). */
+ private List<ConditionData> padToConditionFloor(final List<ConditionData> conditions) {
+ if (conditions.size() >= CONDITION_COUNT_FLOOR) {
+ return conditions;
+ }
+ List<ConditionData> padded = new ArrayList<>(conditions);
+ while (padded.size() < CONDITION_COUNT_FLOOR) {
+ padded.add(matchAllCondition());
+ }
+ return padded;
+ }
+
+ /** Every request path starts with '/', so this condition matches all requests. */
+ private ConditionData matchAllCondition() {
+ ConditionData condition = new ConditionData();
+ condition.setParamType(ParamTypeEnum.URI.getName());
+ condition.setOperator(OperatorEnum.STARTS_WITH.getAlias());
+ condition.setParamValue("/");
+ return condition;
+ }
+
+ private List<ConditionData> composeConditions(final String hostname,
+ final List<ConditionData> matchConditions) {
+ List<ConditionData> conditions = new ArrayList<>();
+ conditions.add(buildHostnameCondition(hostname));
+ conditions.addAll(matchConditions);
+ return conditions;
+ }
+
+ /** Deterministic ID: a resync upserts instead of delete-then-create on the data plane. */
+ private String deterministicSelectorId(final String namespace, final String routeName, final int ruleIndex,
+ final String hostname, final int matchIndex) {
+ String hostComponent = Objects.isNull(hostname) ? NO_HOSTNAME_PLACEHOLDER : hostname;
+ String key = namespace + "/" + routeName + "/r" + ruleIndex + "/h" + hostComponent + "/m" + matchIndex;
+ return ID_PREFIX + UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /** Derive a deterministic rule ID from its parent selector ID; stays under varchar(128). */
+ private String deterministicRuleId(final String selectorId, final int matchIndex) {
+ return selectorId + "/rule-m" + matchIndex;
+ }
+
+ /** Exact hostnames use EQ; a wildcard is a multi-label suffix match, expressible only as REGEX. */
+ private ConditionData buildHostnameCondition(final String hostname) {
+ ConditionData condition = new ConditionData();
+ condition.setParamType(ParamTypeEnum.DOMAIN.getName());
+ if (hostname.startsWith("*.")) {
+ String suffix = hostname.substring(2).replace(".", "\\.");
+ condition.setOperator(OperatorEnum.REGEX.getAlias());
+ condition.setParamValue("^([^.]+\\.)+" + suffix + "$");
+ } else {
+ condition.setOperator(OperatorEnum.EQ.getAlias());
+ condition.setParamValue(hostname);
+ }
+ return condition;
+ }
+
+ private SelectorData buildSelectorData(final String selectorId, final String selectorName, final int sort,
+ final List<ConditionData> conditions, final List<DivideUpstream> upstreamList) {
+ return SelectorData.builder()
+ .id(selectorId)
+ .pluginId(String.valueOf(PluginEnum.DIVIDE.getCode()))
+ .pluginName(PluginEnum.DIVIDE.getName())
+ .name(selectorName)
+ .sort(sort)
+ .matchMode(MatchModeEnum.AND.getCode())
+ .type(SelectorTypeEnum.CUSTOM_FLOW.getCode())
+ .enabled(true)
+ .logged(false)
+ .continued(true)
+ .conditionList(conditions)
+ .handle(GsonUtils.getInstance().toJson(upstreamList))
+ .build();
+ }
+
+ private RuleData buildRuleData(final String ruleId, final String selectorId,
+ final String selectorName, final List<ConditionData> conditions) {
+ DivideRuleHandle divideRuleHandle = new DivideRuleHandle();
+ divideRuleHandle.setLoadBalance(LoadBalanceEnum.RANDOM.getName());
+ divideRuleHandle.setRetry(3);
+ divideRuleHandle.setTimeout(3000L);
+
+ return RuleData.builder()
+ .id(ruleId)
+ .selectorId(selectorId)
+ .name(selectorName)
+ .pluginName(PluginEnum.DIVIDE.getName())
+ .sort(1)
+ .matchMode(MatchModeEnum.AND.getCode())
+ .conditionDataList(conditions)
+ .handle(GsonUtils.getInstance().toJson(divideRuleHandle))
+ .loged(false)
+ .enabled(true)
+ .build();
+ }
+
+ /** Resolve backendRefs to upstream addresses; non-Service kinds, unauthorized cross-namespace refs and missing Endpoints stay unresolved (ResolvedRefs=False). */
+ private BackendResolveResult parseBackendRefs(final JsonArray backendRefs, final String namespace,
+ final String routeName) {
+ List<ResolvedBackend> backends = new ArrayList<>();
+ int unresolvedCount = 0;
+ int invalidWeightedShare = 0;
+ String unresolvedReason = null;
+ for (JsonElement element : backendRefs) {
+ if (!element.isJsonObject()) {
+ continue;
+ }
+ BackendRefOutcome outcome = resolveBackendRef(element.getAsJsonObject(), namespace, routeName);
+ if (Objects.nonNull(outcome.unresolvedReason)) {
+ unresolvedCount++;
+ invalidWeightedShare += Math.max(0, outcome.declaredWeight);
+ if (Objects.isNull(unresolvedReason)) {
+ unresolvedReason = outcome.unresolvedReason;
+ }
+ continue;
+ }
+ backends.add(new ResolvedBackend(outcome.declaredWeight, outcome.urls));
+ }
+ // One fail-target entry with exactly the invalid share keeps the split proportional.
+ if (invalidWeightedShare > 0) {
+ backends.add(new ResolvedBackend(invalidWeightedShare, List.of(FAIL_TARGET_URL)));
+ }
+ return new BackendResolveResult(buildUpstreams(backends), unresolvedCount, unresolvedReason);
+ }
+
+ /** Spread declared weights over endpoints with one common scale factor, so aggregate weights stay proportional regardless of replica counts. */
+ private List<DivideUpstream> buildUpstreams(final List<ResolvedBackend> backends) {
+ long scale = 1;
+ for (ResolvedBackend backend : backends) {
+ if (!backend.urls.isEmpty()) {
+ scale = Math.max(scale, divideRoundingUp(backend.urls.size(), backend.declaredWeight));
+ }
+ }
+ List<DivideUpstream> upstreams = new ArrayList<>();
+ for (ResolvedBackend backend : backends) {
+ if (backend.urls.isEmpty()) {
+ continue;
+ }
+ int perEndpointWeight = Math.max(1, (int) Math.min(Integer.MAX_VALUE,
+ Math.round(backend.declaredWeight * (double) scale / backend.urls.size())));
+ for (String url : backend.urls) {
+ DivideUpstream upstream = new DivideUpstream();
+ upstream.setUpstreamUrl(url);
+ upstream.setWeight(perEndpointWeight);
+ upstream.setProtocol("http://");
+ upstream.setWarmup(0);
+ upstream.setStatus(true);
+ upstream.setUpstreamHost("");
+ // Constant timestamp keeps the handle json byte-identical for the unchanged-check.
+ upstream.setTimestamp(0L);
+ upstreams.add(upstream);
+ }
+ }
+ return upstreams;
+ }
+
+ private long divideRoundingUp(final long dividend, final long divisor) {
+ return (dividend + divisor - 1) / divisor;
+ }
+
+ /** Resolve one backendRef into upstream URLs or the Gateway API reason of its failure. */
+ private BackendRefOutcome resolveBackendRef(final JsonObject backendRef, final String namespace,
+ final String routeName) {
+ String serviceName = JsonFields.getString(backendRef, "name");
+ if (Objects.isNull(serviceName)) {
+ return BackendRefOutcome.ok(List.of(), 0);
+ }
+ // An omitted weight defaults to 1
+ int weight = backendRef.has("weight") && backendRef.get("weight").isJsonPrimitive()
+ ? backendRef.get("weight").getAsInt() : 1;
+ String backendNamespace = JsonFields.getString(backendRef, "namespace");
+ if (Objects.isNull(backendNamespace)) {
+ backendNamespace = namespace;
+ }
+ if (!GatewayApiConstants.isServiceRef(backendRef)) {
+ LOG.warn("HTTPRoute {}/{} backendRef to group '{}' kind '{}' is not supported, only core Service",
+ namespace, routeName, JsonFields.getString(backendRef, "group"),
+ JsonFields.getString(backendRef, "kind"));
+ return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_INVALID_KIND, weight);
+ }
+ if (!backendNamespace.equals(namespace)
+ && !ReferenceGrants.isGranted(referenceGrantLister, backendNamespace, namespace,
+ GatewayApiConstants.CORE_API_GROUP, GatewayApiConstants.SERVICE_KIND, serviceName)) {
+ LOG.warn("HTTPRoute {}/{} backendRef to Service {}/{} is not permitted by a ReferenceGrant",
+ namespace, routeName, backendNamespace, serviceName);
+ return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_REF_NOT_PERMITTED, weight);
+ }
+ V1Endpoints v1Endpoints = endpointsLister.namespace(backendNamespace).get(serviceName);
+ if (Objects.isNull(v1Endpoints) || CollectionUtils.isEmpty(v1Endpoints.getSubsets())) {
+ LOG.warn("Cannot find endpoints for service {}/{}", backendNamespace, serviceName);
+ return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_BACKEND_NOT_FOUND, weight);
+ }
+ List<String> readyIps = new ArrayList<>();
+ Set<Long> endpointPorts = new LinkedHashSet<>();
+ Map<String, Long> endpointPortsByName = new HashMap<>();
+ for (V1EndpointSubset subset : v1Endpoints.getSubsets()) {
+ if (Objects.nonNull(subset.getPorts())) {
+ for (CoreV1EndpointPort endpointPort : subset.getPorts()) {
+ if (Objects.nonNull(endpointPort.getPort())) {
+ endpointPorts.add(endpointPort.getPort().longValue());
+ if (Objects.nonNull(endpointPort.getName())) {
+ endpointPortsByName.putIfAbsent(endpointPort.getName(), endpointPort.getPort().longValue());
+ }
+ }
+ }
+ }
+ if (CollectionUtils.isEmpty(subset.getAddresses())) {
+ continue;
+ }
+ for (V1EndpointAddress address : subset.getAddresses()) {
+ if (Objects.nonNull(address.getIp())) {
+ readyIps.add(address.getIp());
+ }
+ }
+ }
+ // Endpoints existed but yielded no ready address → treat as unresolved
+ if (readyIps.isEmpty()) {
+ return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_BACKEND_NOT_FOUND, weight);
+ }
+ V1Service service = serviceLister.namespace(backendNamespace).get(serviceName);
+ Long targetPort = resolveTargetPort(service, endpointPorts, endpointPortsByName,
+ JsonFields.getLong(backendRef, "port"), namespace, routeName, backendNamespace, serviceName);
+ if (Objects.isNull(targetPort)) {
+ return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_BACKEND_NOT_FOUND, weight);
+ }
+ // Spec: weight 0 removes the backend from rotation.
+ if (weight == 0) {
+ return BackendRefOutcome.ok(List.of(), 0);
+ }
+ List<String> urls = new ArrayList<>();
+ for (String ip : readyIps) {
+ urls.add(ip + ":" + targetPort);
+ }
+ return BackendRefOutcome.ok(urls, weight);
+ }
+
+ /**
+ * Map the backendRef port to the pod port via the Service spec (targetPort numeric or
+ * named, resolved against Endpoints); without a cached Service, fall back to a
+ * single-endpoint-port heuristic and report ambiguity as BackendNotFound.
+ */
+ private Long resolveTargetPort(final V1Service service, final Set<Long> endpointPorts,
+ final Map<String, Long> endpointPortsByName, final Long servicePort,
+ final String namespace, final String routeName,
+ final String backendNamespace, final String serviceName) {
+ List<V1ServicePort> servicePorts = Objects.isNull(service) || Objects.isNull(service.getSpec())
+ || Objects.isNull(service.getSpec().getPorts()) ? List.of() : service.getSpec().getPorts();
+ if (!servicePorts.isEmpty()) {
+ V1ServicePort selected = selectServicePort(servicePorts, servicePort);
+ if (Objects.isNull(selected)) {
+ LOG.warn("HTTPRoute {}/{} backendRef to Service {}/{}: no service port matches {}",
+ namespace, routeName, backendNamespace, serviceName,
+ Objects.isNull(servicePort) ? "the multiple ports of the service" : servicePort);
+ return null;
+ }
+ IntOrString targetPort = selected.getTargetPort();
+ if (Objects.nonNull(targetPort) && targetPort.isInteger()) {
+ return targetPort.getIntValue().longValue();
+ }
+ if (Objects.nonNull(targetPort)) {
+ Long resolved = endpointPortsByName.get(targetPort.getStrValue());
+ if (Objects.nonNull(resolved)) {
+ return resolved;
+ }
+ LOG.warn("HTTPRoute {}/{} backendRef to Service {}/{}: named targetPort '{}' not found in endpoints",
+ namespace, routeName, backendNamespace, serviceName, targetPort.getStrValue());
+ return null;
+ }
+ return Objects.isNull(selected.getPort()) ? null : selected.getPort().longValue();
+ }
+ if (endpointPorts.size() == 1) {
+ return endpointPorts.iterator().next();
+ }
+ if (Objects.nonNull(servicePort) && (endpointPorts.isEmpty() || endpointPorts.contains(servicePort))) {
+ return servicePort;
+ }
+ LOG.warn("HTTPRoute {}/{} backendRef to Service {}/{}: cannot map service port {} to an endpoint port {}",
+ namespace, routeName, backendNamespace, serviceName, servicePort, endpointPorts);
+ return null;
+ }
+
+ /** The Service port entry a backendRef port selects; required unless the Service has exactly one port. */
+ private V1ServicePort selectServicePort(final List<V1ServicePort> servicePorts, final Long servicePort) {
+ if (Objects.nonNull(servicePort)) {
+ for (V1ServicePort port : servicePorts) {
+ if (Objects.nonNull(port.getPort()) && port.getPort().longValue() == servicePort) {
+ return port;
+ }
+ }
+ return null;
+ }
+ return servicePorts.size() == 1 ? servicePorts.get(0) : null;
+ }
+
+ private void appendMatchConditions(final List<ConditionData> conditions, final JsonObject match) {
+ JsonObject path = JsonFields.getJsonObject(match, "path");
+ String pathValue = JsonFields.getString(path, "value");
+ if (Objects.nonNull(pathValue)) {
+ ConditionData pathCondition = new ConditionData();
+ pathCondition.setParamType(ParamTypeEnum.URI.getName());
+ String pathType = JsonFields.getString(path, "type");
+ if (Objects.isNull(pathType) || "PathPrefix".equals(pathType)) {
+ // Prefixes match on element boundaries (/foo ≠ /foobar); raw startsWith cannot, hence regex.
+ pathCondition.setOperator(OperatorEnum.REGEX.getAlias());
+ pathCondition.setParamValue(prefixRegex(pathValue));
+ } else {
+ pathCondition.setOperator(mapPathType(pathType));
+ pathCondition.setParamValue(pathValue);
+ }
+ conditions.add(pathCondition);
+ }
+
+ String method = JsonFields.getString(match, "method");
+ if (Objects.nonNull(method)) {
+ ConditionData methodCondition = new ConditionData();
+ methodCondition.setParamType(ParamTypeEnum.REQUEST_METHOD.getName());
+ methodCondition.setOperator(OperatorEnum.EQ.getAlias());
+ methodCondition.setParamValue(method);
+ conditions.add(methodCondition);
+ }
+
+ JsonArray headers = JsonFields.getJsonArray(match, "headers");
+ if (Objects.nonNull(headers)) {
+ for (JsonElement headerElement : headers) {
+ if (!headerElement.isJsonObject()) {
+ continue;
+ }
+ JsonObject header = headerElement.getAsJsonObject();
+ ConditionData headerCondition = new ConditionData();
+ headerCondition.setParamType(ParamTypeEnum.HEADER.getName());
+ headerCondition.setOperator(exactOrRegex(JsonFields.getString(header, "type")));
+ headerCondition.setParamName(JsonFields.getString(header, "name"));
+ headerCondition.setParamValue(JsonFields.getString(header, "value"));
+ conditions.add(headerCondition);
+ }
+ }
+
+ JsonArray queryParams = JsonFields.getJsonArray(match, "queryParams");
+ if (Objects.nonNull(queryParams)) {
+ for (JsonElement queryElement : queryParams) {
+ if (!queryElement.isJsonObject()) {
+ continue;
+ }
+ JsonObject queryParam = queryElement.getAsJsonObject();
+ ConditionData queryCondition = new ConditionData();
+ queryCondition.setParamType(ParamTypeEnum.QUERY.getName());
+ queryCondition.setOperator(exactOrRegex(JsonFields.getString(queryParam, "type")));
+ queryCondition.setParamName(JsonFields.getString(queryParam, "name"));
+ queryCondition.setParamValue(JsonFields.getString(queryParam, "value"));
+ conditions.add(queryCondition);
+ }
+ }
+ }
+
+ /** Anchored full-match regex for a prefix: {@code /foo} → {@code ^\Q/foo\E(/.*)?$}; root {@code /} is the catch-all. */
+ private String prefixRegex(final String prefix) {
+ String stripped = prefix.length() > 1 && prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix;
+ if ("/".equals(stripped)) {
+ return "^/.*$";
+ }
+ return "^" + Pattern.quote(stripped) + "(/.*)?$";
+ }
+
+ /** Full spec precedence packed into the sort, rule index as the final tie-break; lower sort wins. */
+ private int selectorSort(final String hostname, final JsonObject match, final int ruleIndex) {
+ int score = hostnameScore(hostname) << 24
+ | pathScore(match) << 13
+ | (Objects.isNull(JsonFields.getString(match, "method")) ? 0 : 1) << 12
+ | matchCount(match, "headers") << 8
+ | matchCount(match, "queryParams") << 4
+ | 15 - Math.min(ruleIndex, 15);
+ return SORT_PRECEDENCE_BASE - score;
+ }
+
+ /** Exact beats wildcard, more wildcard suffix labels beat fewer; no hostname sorts below both. */
+ private int hostnameScore(final String hostname) {
+ if (Objects.isNull(hostname)) {
+ return 0;
+ }
+ if (hostname.startsWith("*.")) {
+ return Math.min(hostname.substring(2).split("\\.").length, 6);
+ }
+ return 7;
+ }
+
+ /** Exact beats prefix, longer prefix beats shorter; the 11-bit field covers the CRD's 1024-char path maximum. */
+ private int pathScore(final JsonObject match) {
+ JsonObject path = JsonFields.getJsonObject(match, "path");
+ String pathValue = JsonFields.getString(path, "value");
+ if (Objects.isNull(pathValue)) {
+ return 0;
+ }
+ String pathType = JsonFields.getString(path, "type");
+ if ("Exact".equals(pathType)) {
+ return 2047;
+ }
+ if ("RegularExpression".equals(pathType)) {
+ return 1;
+ }
+ return 2 + Math.min(pathValue.length() - 1, 2044);
+ }
+
+ /** Number of header/query matches (4 bits each, capped), higher is more specific. */
+ private int matchCount(final JsonObject match, final String field) {
+ JsonArray array = JsonFields.getJsonArray(match, field);
+ return Objects.isNull(array) ? 0 : Math.min(array.size(), 15);
+ }
+
+ private String mapPathType(final String pathType) {
+ if ("Exact".equals(pathType)) {
+ return OperatorEnum.EQ.getAlias();
+ }
+ if ("RegularExpression".equals(pathType)) {
+ return OperatorEnum.REGEX.getAlias();
+ }
+ return OperatorEnum.STARTS_WITH.getAlias();
+ }
+
+ /** Regex must map to REGEX: the MATCH judge compares by substring containment, not regex semantics. */
+ private String exactOrRegex(final String matchType) {
+ return "RegularExpression".equals(matchType) ? OperatorEnum.REGEX.getAlias() : OperatorEnum.EQ.getAlias();
+ }
+
+ /** One backendRef outcome: URLs with declared weight, or the failure reason. */
+ private static final class BackendRefOutcome {
+
+ private final List<String> urls;
+
+ private final int declaredWeight;
+
+ private final String unresolvedReason;
+
+ private BackendRefOutcome(final List<String> urls, final int declaredWeight, final String unresolvedReason) {
+ this.urls = urls;
+ this.declaredWeight = declaredWeight;
+ this.unresolvedReason = unresolvedReason;
+ }
+
+ static BackendRefOutcome ok(final List<String> urls, final int declaredWeight) {
+ return new BackendRefOutcome(urls, declaredWeight, null);
+ }
+
+ static BackendRefOutcome unresolved(final String reason, final int declaredWeight) {
+ return new BackendRefOutcome(List.of(), declaredWeight, reason);
+ }
+ }
+
+ /** One resolved backendRef: its declared weight and the pod addresses it fans out to. */
+ private static final class ResolvedBackend {
+
+ private final int declaredWeight;
+
+ private final List<String> urls;
+
+ ResolvedBackend(final int declaredWeight, final List<String> urls) {
+ this.declaredWeight = declaredWeight;
+ this.urls = urls;
+ }
+ }
+
+ /** Rule-level result; non-zero unresolvedCount means the reconciler reports ResolvedRefs=False. */
+ private static final class BackendResolveResult {
+
+ private final List<DivideUpstream> upstreams;
+
+ private final int unresolvedCount;
+
+ private final String unresolvedReason;
+
+ BackendResolveResult(final List<DivideUpstream> upstreams, final int unresolvedCount,
+ final String unresolvedReason) {
+ this.upstreams = upstreams;
+ this.unresolvedCount = unresolvedCount;
+ this.unresolvedReason = unresolvedReason;
+ }
+ }
+
+ /** Backend resolution failures across rules; the first reason becomes the route-level one. */
+ private static final class ResolveState {
+
+ private boolean anyUnresolved;
+
+ private String reason;
+
+ private boolean unsupportedFilters;
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayClassReconciler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayClassReconciler.java
new file mode 100644
index 0000000000..775cbdae4c
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayClassReconciler.java
@@ -0,0 +1,521 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.GatewayApiCrdVersions;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.apache.shenyu.k8s.common.StatusMergePatch;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Accepts GatewayClasses whose controllerName matches ShenYu's; reports the
+ * SupportedVersion condition and supportedFeatures, keeps the spec-defined finalizer while
+ * Gateways use the class, rejects parametersRef classes (ShenYu has no class-level
+ * parameters) and re-queues served Gateways on deletion or ownership loss.
+ */
+public class GatewayClassReconciler implements Reconciler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(GatewayClassReconciler.class);
+
+ private static final String GATEWAY_CLASS_KIND = "GatewayClass";
+
+ private static final String GATEWAYCLASSES_RESOURCE = "gatewayclasses";
+
+ /** Official conformance FeatureName strings; sorted ascending (spec) and limited to implemented capabilities. */
+ private static final List<String> SUPPORTED_FEATURES = List.of("Gateway", "HTTPRoute", "ReferenceGrant");
+
+ private final Lister<DynamicKubernetesObject> gatewayClassLister;
+
+ private final Lister<DynamicKubernetesObject> gatewayLister;
+
+ private final RateLimitingQueue<Request> gatewayWorkQueue;
+
+ private final ApiClient apiClient;
+
+ private final GatewayApiCrdVersions crdVersions;
+
+ public GatewayClassReconciler(final SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer,
+ final SharedIndexInformer<DynamicKubernetesObject> gatewayInformer,
+ final RateLimitingQueue<Request> gatewayWorkQueue,
+ final ApiClient apiClient,
+ final GatewayApiCrdVersions crdVersions) {
+ this.gatewayClassLister = new Lister<>(gatewayClassInformer.getIndexer());
+ this.gatewayLister = new Lister<>(gatewayInformer.getIndexer());
+ this.gatewayWorkQueue = gatewayWorkQueue;
+ this.apiClient = apiClient;
+ this.crdVersions = crdVersions;
+ }
+
+ @Override
+ public Result reconcile(final Request request) {
+ LOG.info("Starting to reconcile GatewayClass {}", request.getName());
+ try {
+ DynamicKubernetesObject gatewayClass = gatewayClassLister.get(request.getName());
+
+ if (Objects.isNull(gatewayClass)) {
+ LOG.info("GatewayClass {} deleted, re-queuing affected Gateways", request.getName());
+ requeueAffectedGateways(request.getName());
+ return new Result(false);
+ }
+
+ if (isDeleting(gatewayClass)) {
+ // No status writes on a deleting object; drain Gateways, then release the finalizer.
+ requeueAffectedGateways(request.getName());
+ reconcileFinalizer(gatewayClass, false);
+ return new Result(false);
+ }
+
+ if (!isShenyuGatewayClass(gatewayClass)) {
+ boolean wasAcceptedByShenyu = GatewayApiConstants.isConditionAcceptedByShenyu(gatewayClass, "Accepted");
+ boolean anyGatewayRequeued = requeuePreviouslyServedGateways(request.getName());
+ if (wasAcceptedByShenyu || anyGatewayRequeued) {
+ LOG.info("GatewayClass {} is no longer managed by ShenYu, re-queuing affected Gateways", request.getName());
+ }
+ if (wasAcceptedByShenyu) {
+ updateGatewayClassRejectedStatus(gatewayClass, GatewayApiConstants.REASON_UNSUPPORTED,
+ "GatewayClass is not managed by the ShenYu controller");
+ }
+ // Our finalizer must not outlive the last Gateway once another controller owns the class.
+ reconcileFinalizer(gatewayClass, false);
+ return new Result(false);
+ }
+
+ if (hasParametersRef(gatewayClass)) {
+ // Reject instead of silently ignoring class-level parameters we cannot honor.
+ boolean wasAcceptedByShenyu = GatewayApiConstants.isConditionAcceptedByShenyu(gatewayClass, "Accepted");
+ updateGatewayClassRejectedStatus(gatewayClass, GatewayApiConstants.REASON_INVALID_PARAMETERS,
+ "GatewayClass parametersRef is not supported by the ShenYu controller");
+ if (wasAcceptedByShenyu) {
+ requeueAffectedGateways(request.getName());
+ }
+ reconcileFinalizer(gatewayClass, false);
+ return new Result(false);
+ }
+
+ // Requeue only on the Accepted transition; plain resyncs would waste a cluster scan.
+ boolean wasAccepted = GatewayApiConstants.isConditionTrue(gatewayClass, "Accepted");
+ updateGatewayClassAcceptedStatus(gatewayClass);
+ if (!wasAccepted) {
+ requeueAffectedGateways(request.getName());
+ }
+ reconcileFinalizer(gatewayClass, true);
+ LOG.debug("GatewayClass {} reconciled successfully", request.getName());
+ return new Result(false);
+ } catch (Exception e) {
+ LOG.error("Error reconciling GatewayClass {}, will retry", request.getName(), e);
+ return new Result(true);
+ }
+ }
+
+ /**
+ * Whether the class's controllerName matches ShenYu's.
+ *
+ * @param gatewayClass the GatewayClass dynamic object
+ * @return true if the GatewayClass's controllerName matches ShenYu's controller name
+ */
+ public static boolean isShenyuGatewayClass(final DynamicKubernetesObject gatewayClass) {
+ if (Objects.isNull(gatewayClass)) {
+ return false;
+ }
+ JsonObject spec = gatewayClass.getRaw().getAsJsonObject("spec");
+ if (Objects.isNull(spec) || !spec.has("controllerName") || spec.get("controllerName").isJsonNull()) {
+ return false;
+ }
+ String controllerName = spec.get("controllerName").getAsString();
+ return GatewayApiConstants.SHENYU_CONTROLLER_NAME.equals(controllerName);
+ }
+
+ /**
+ * Whether the Gateway's class is ShenYu-owned and not rejected; shared by the Gateway and
+ * HTTPRoute reconcilers. Accepted=False puts the Gateway out of scope; an absent Accepted is
+ * treated optimistically for the startup race (Gateway reconciled before the class status
+ * patch reaches the cache) and enforced by the periodic resync.
+ *
+ * @param gateway the Gateway dynamic object
+ * @param gatewayClassLister lister for GatewayClass (cluster-scoped)
+ * @return true if the Gateway's class is owned by ShenYu
+ */
+ public static boolean isShenyuGateway(final DynamicKubernetesObject gateway,
+ final Lister<DynamicKubernetesObject> gatewayClassLister) {
+ if (Objects.isNull(gateway)) {
+ return false;
+ }
+ JsonObject spec = gateway.getRaw().getAsJsonObject("spec");
+ if (Objects.isNull(spec) || !spec.has("gatewayClassName") || spec.get("gatewayClassName").isJsonNull()) {
+ return false;
+ }
+ String gatewayClassName = spec.get("gatewayClassName").getAsString();
+ DynamicKubernetesObject gatewayClass = gatewayClassLister.get(gatewayClassName);
+ if (!isShenyuGatewayClass(gatewayClass)) {
+ return false;
+ }
+ JsonObject rejected = GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED);
+ return Objects.isNull(rejected) || !"False".equals(JsonFields.getString(rejected, "status"));
+ }
+
+ /** Whether the class carries a spec.parametersRef ShenYu cannot honor. */
+ private static boolean hasParametersRef(final DynamicKubernetesObject gatewayClass) {
+ JsonObject spec = JsonFields.getJsonObject(gatewayClass.getRaw(), "spec");
+ JsonObject parametersRef = JsonFields.getJsonObject(spec, "parametersRef");
+ return Objects.nonNull(parametersRef);
+ }
+
+ private static boolean isDeleting(final DynamicKubernetesObject gatewayClass) {
+ JsonObject metadata = JsonFields.getJsonObject(gatewayClass.getRaw(), "metadata");
+ return Objects.nonNull(JsonFields.getString(metadata, "deletionTimestamp"));
+ }
+
+ /** Re-queue Gateways referencing this class (Accepted transition, deletion cascade). */
+ private void requeueAffectedGateways(final String gatewayClassName) {
+ for (DynamicKubernetesObject gateway : gatewayLister.list()) {
+ if (referencesGatewayClass(gateway, gatewayClassName)) {
+ String ns = Objects.requireNonNull(gateway.getMetadata()).getNamespace();
+ String name = gateway.getMetadata().getName();
+ gatewayWorkQueue.add(new Request(ns, name));
+ LOG.info("Re-queued Gateway {}/{} due to GatewayClass {} change", ns, name, gatewayClassName);
+ }
+ }
+ }
+
+ /**
+ * Re-queue only previously served Gateways on ownership loss; the rest belong to the new controller.
+ *
+ * @param gatewayClassName name of the GatewayClass
+ * @return whether any Gateway was re-queued
+ */
+ private boolean requeuePreviouslyServedGateways(final String gatewayClassName) {
+ boolean anyRequeued = false;
+ for (DynamicKubernetesObject gateway : gatewayLister.list()) {
+ if (!referencesGatewayClass(gateway, gatewayClassName)) {
+ continue;
+ }
+ String ns = Objects.requireNonNull(gateway.getMetadata()).getNamespace();
+ String name = gateway.getMetadata().getName();
+ boolean servedByShenyu = CollectionUtils.isNotEmpty(GatewayRouteCache.getInstance().getRoutesByGateway(ns, name))
+ || GatewayApiConstants.isConditionAcceptedByShenyu(gateway, GatewayApiConstants.CONDITION_ACCEPTED);
+ if (!servedByShenyu) {
+ continue;
+ }
+ gatewayWorkQueue.add(new Request(ns, name));
+ LOG.info("Re-queued Gateway {}/{} after GatewayClass {} ownership loss", ns, name, gatewayClassName);
+ anyRequeued = true;
+ }
+ return anyRequeued;
+ }
+
+ private boolean referencesGatewayClass(final DynamicKubernetesObject gateway, final String gatewayClassName) {
+ JsonObject spec = gateway.getRaw().getAsJsonObject("spec");
+ if (Objects.isNull(spec) || !spec.has("gatewayClassName") || spec.get("gatewayClassName").isJsonNull()) {
+ return false;
+ }
+ return gatewayClassName.equals(spec.get("gatewayClassName").getAsString());
+ }
+
+ /** Finalizer present while an accepted class has Gateways, removed once none remain (also releases a deleting class). */
+ private void reconcileFinalizer(final DynamicKubernetesObject gatewayClass, final boolean acceptedByShenyu) {
+ final String name = gatewayClass.getMetadata().getName();
+ JsonArray existing = finalizersOf(gatewayClass);
+ boolean hasOurs = containsFinalizer(existing);
+ boolean anyGateway = anyGatewayReferences(name);
+ try {
+ if (anyGateway && !hasOurs && acceptedByShenyu) {
+ JsonArray desired = new JsonArray();
+ for (JsonElement element : existing) {
+ desired.add(element);
+ }
+ desired.add(GatewayApiConstants.GATEWAY_CLASS_FINALIZER);
+ patchGatewayClassFinalizers(name, desired);
+ LOG.info("Added finalizer to GatewayClass {} in use", name);
+ } else if (!anyGateway && hasOurs) {
+ JsonArray desired = new JsonArray();
+ for (JsonElement element : existing) {
+ if (!GatewayApiConstants.GATEWAY_CLASS_FINALIZER.equals(getAsStringOrNull(element))) {
+ desired.add(element);
+ }
+ }
+ patchGatewayClassFinalizers(name, desired);
+ LOG.info("Removed finalizer from GatewayClass {} no longer in use", name);
+ }
+ } catch (Exception e) {
+ LOG.warn("Failed to reconcile GatewayClass {} finalizer, will retry on next resync", name, e);
+ }
+ }
+
+ private static JsonArray finalizersOf(final DynamicKubernetesObject gatewayClass) {
+ JsonObject metadata = JsonFields.getJsonObject(gatewayClass.getRaw(), "metadata");
+ JsonArray finalizers = JsonFields.getJsonArray(metadata, "finalizers");
+ return Objects.isNull(finalizers) ? new JsonArray() : finalizers;
+ }
+
+ private static boolean containsFinalizer(final JsonArray finalizers) {
+ for (JsonElement element : finalizers) {
+ if (GatewayApiConstants.GATEWAY_CLASS_FINALIZER.equals(getAsStringOrNull(element))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String getAsStringOrNull(final JsonElement element) {
+ return Objects.nonNull(element) && element.isJsonPrimitive() ? element.getAsString() : null;
+ }
+
+ private boolean anyGatewayReferences(final String gatewayClassName) {
+ for (DynamicKubernetesObject gateway : gatewayLister.list()) {
+ if (referencesGatewayClass(gateway, gatewayClassName)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Merge-patch the main resource (not the /status subresource) to set metadata.finalizers. */
+ private void patchGatewayClassFinalizers(final String name, final JsonArray finalizers) throws ApiException {
+ JsonObject body = new JsonObject();
+ body.addProperty("kind", GATEWAY_CLASS_KIND);
+ body.addProperty("apiVersion", GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION);
+
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("name", name);
+ metadata.add("finalizers", finalizers);
+ body.add("metadata", metadata);
+
+ String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+ + "/" + GATEWAYCLASSES_RESOURCE + "/" + name;
+
+ StatusMergePatch.patch(apiClient, path, body);
+ }
+
+ /** Patch Accepted=True + SupportedVersion + supportedFeatures; skipped while the existing status already matches at the current generation. */
+ private void updateGatewayClassAcceptedStatus(final DynamicKubernetesObject gatewayClass) {
+ Long generation = JsonFields.getLong(JsonFields.getJsonObject(gatewayClass.getRaw(), "metadata"), "generation");
+ if (acceptedStatusUpToDate(gatewayClass, generation)) {
+ return;
+ }
+ try {
+ final String name = gatewayClass.getMetadata().getName();
+
+ JsonObject accepted = new JsonObject();
+ accepted.addProperty("type", GatewayApiConstants.CONDITION_ACCEPTED);
+ accepted.addProperty("status", "True");
+ accepted.addProperty("reason", GatewayApiConstants.CONDITION_ACCEPTED);
+ accepted.addProperty("message", "GatewayClass has been accepted by the ShenYu controller");
+ if (Objects.nonNull(generation)) {
+ accepted.addProperty("observedGeneration", generation);
+ }
+ accepted.addProperty("lastTransitionTime", Instant.now().toString());
+ preserveTransitionTime(GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED), accepted);
+
+ boolean supported = crdVersions.isSupported();
+ JsonObject supportedVersion = new JsonObject();
+ supportedVersion.addProperty("type", GatewayApiConstants.CONDITION_SUPPORTED_VERSION);
+ supportedVersion.addProperty("status", supported ? "True" : "False");
+ supportedVersion.addProperty("reason", supported
+ ? GatewayApiConstants.REASON_SUPPORTED_VERSION : GatewayApiConstants.REASON_UNSUPPORTED_VERSION);
+ supportedVersion.addProperty("message", "detected Gateway API CRD bundle version(s): "
+ + crdVersions.describeDetected() + "; supported: >= v1.5.0");
+ if (Objects.nonNull(generation)) {
+ supportedVersion.addProperty("observedGeneration", generation);
+ }
+ supportedVersion.addProperty("lastTransitionTime", Instant.now().toString());
+ preserveTransitionTime(GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_SUPPORTED_VERSION), supportedVersion);
+
+ JsonArray conditions = buildGatewayClassStatusConditions(gatewayClass, accepted, supportedVersion);
+
+ JsonObject statusObj = new JsonObject();
+ statusObj.add("conditions", conditions);
+ statusObj.add("supportedFeatures", buildSupportedFeatures());
+
+ patchGatewayClassStatus(name, statusObj);
+ LOG.info("Updated GatewayClass {} status to Accepted=True", name);
+ } catch (Exception e) {
+ LOG.warn("Failed to update GatewayClass status, will retry on next resync", e);
+ }
+ }
+
+ /** Accepted=False with the given reason; skipped when already saying the same at the current generation. */
+ private void updateGatewayClassRejectedStatus(final DynamicKubernetesObject gatewayClass, final String reason,
+ final String message) {
+ Long generation = JsonFields.getLong(JsonFields.getJsonObject(gatewayClass.getRaw(), "metadata"), "generation");
+ JsonObject existing = GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED);
+ if (Objects.nonNull(existing)
+ && "False".equals(JsonFields.getString(existing, "status"))
+ && reason.equals(JsonFields.getString(existing, "reason"))
+ && observedGenerationUpToDate(existing, generation)) {
+ return;
+ }
+ try {
+ final String name = gatewayClass.getMetadata().getName();
+
+ JsonObject condition = new JsonObject();
+ condition.addProperty("type", GatewayApiConstants.CONDITION_ACCEPTED);
+ condition.addProperty("status", "False");
+ condition.addProperty("reason", reason);
+ condition.addProperty("message", message);
+ if (Objects.nonNull(generation)) {
+ condition.addProperty("observedGeneration", generation);
+ }
+ condition.addProperty("lastTransitionTime", Instant.now().toString());
+
+ JsonArray conditions = buildGatewayClassStatusConditions(gatewayClass, condition);
+
+ JsonObject statusObj = new JsonObject();
+ statusObj.add("conditions", conditions);
+
+ patchGatewayClassStatus(name, statusObj);
+ LOG.info("Updated GatewayClass {} status to Accepted=False ({})", name, reason);
+ } catch (Exception e) {
+ LOG.warn("Failed to update GatewayClass status, will retry on next resync", e);
+ }
+ }
+
+ private void patchGatewayClassStatus(final String name, final JsonObject statusObj) throws ApiException {
+ JsonObject body = new JsonObject();
+ body.add("status", statusObj);
+ body.addProperty("kind", GATEWAY_CLASS_KIND);
+ body.addProperty("apiVersion", GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION);
+
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("name", name);
+ body.add("metadata", metadata);
+
+ String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+ + "/" + GATEWAYCLASSES_RESOURCE + "/" + name + "/status";
+
+ StatusMergePatch.patch(apiClient, path, body);
+ }
+
+ private boolean acceptedStatusUpToDate(final DynamicKubernetesObject gatewayClass, final Long generation) {
+ JsonObject existingAccepted = GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED);
+ if (!GatewayApiConstants.isConditionTrue(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED)
+ || !observedGenerationUpToDate(existingAccepted, generation)) {
+ return false;
+ }
+ JsonObject existingVersion = GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_SUPPORTED_VERSION);
+ boolean supported = crdVersions.isSupported();
+ if (Objects.isNull(existingVersion)
+ || !(supported ? "True" : "False").equals(JsonFields.getString(existingVersion, "status"))
+ || !supportedVersionReason(supported).equals(JsonFields.getString(existingVersion, "reason"))) {
+ return false;
+ }
+ return supportedFeaturesMatch(gatewayClass);
+ }
+
+ private String supportedVersionReason(final boolean supported) {
+ return supported ? GatewayApiConstants.REASON_SUPPORTED_VERSION : GatewayApiConstants.REASON_UNSUPPORTED_VERSION;
+ }
+
+ private boolean supportedFeaturesMatch(final DynamicKubernetesObject gatewayClass) {
+ JsonObject status = JsonFields.getJsonObject(gatewayClass.getRaw(), "status");
+ JsonArray existing = JsonFields.getJsonArray(status, "supportedFeatures");
+ if (Objects.isNull(existing) || existing.size() != SUPPORTED_FEATURES.size()) {
+ return false;
+ }
+ List<String> names = new ArrayList<>();
+ for (JsonElement element : existing) {
+ JsonObject feature = element.isJsonObject() ? element.getAsJsonObject() : null;
+ String name = Objects.isNull(feature) ? null : JsonFields.getString(feature, "name");
+ if (Objects.isNull(name)) {
+ return false;
+ }
+ names.add(name);
+ }
+ return names.equals(SUPPORTED_FEATURES);
+ }
+
+ private static JsonArray buildSupportedFeatures() {
+ JsonArray features = new JsonArray();
+ for (String name : SUPPORTED_FEATURES) {
+ JsonObject feature = new JsonObject();
+ feature.addProperty("name", name);
+ features.add(feature);
+ }
+ return features;
+ }
+
+ private boolean observedGenerationUpToDate(final JsonObject existingCondition, final Long generation) {
+ if (Objects.isNull(generation)) {
+ return true;
+ }
+ return Objects.nonNull(existingCondition)
+ && generation.equals(JsonFields.getLong(existingCondition, "observedGeneration"));
+ }
+
+ /** Keep lastTransitionTime when type and status are unchanged; a generation refresh is not a transition. */
+ private void preserveTransitionTime(final JsonObject existingCondition, final JsonObject desiredCondition) {
+ if (Objects.isNull(existingCondition)
+ || !Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+ || !Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))) {
+ return;
+ }
+ String existingTime = JsonFields.getString(existingCondition, "lastTransitionTime");
+ if (Objects.nonNull(existingTime)) {
+ desiredCondition.addProperty("lastTransitionTime", existingTime);
+ }
+ }
+
+ /** Own conditions plus foreign ones: merge-patch replaces arrays wholesale. */
+ private JsonArray buildGatewayClassStatusConditions(final DynamicKubernetesObject gatewayClass,
+ final JsonObject... ownConditions) {
+ Set<String> ownTypes = new HashSet<>();
+ JsonArray conditions = new JsonArray();
+ for (JsonObject ownCondition : ownConditions) {
+ conditions.add(ownCondition);
+ ownTypes.add(JsonFields.getString(ownCondition, "type"));
+ }
+
+ JsonObject raw = gatewayClass.getRaw();
+ if (raw.has("status") && !raw.get("status").isJsonNull()) {
+ JsonObject status = raw.getAsJsonObject("status");
+ if (status.has("conditions") && !status.get("conditions").isJsonNull()) {
+ for (JsonElement el : status.getAsJsonArray("conditions")) {
+ JsonObject existing = el.getAsJsonObject();
+ String existingType = existing.has("type") ? existing.get("type").getAsString() : null;
+ // Drop our stale entries of the same types; keep everything else.
+ if (!ownTypes.contains(existingType)) {
+ conditions.add(existing);
+ }
+ }
+ }
+ }
+ return conditions;
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayReconciler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayReconciler.java
new file mode 100644
index 0000000000..2a69560f61
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayReconciler.java
@@ -0,0 +1,513 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.apache.shenyu.k8s.common.ListenerSupport;
+import org.apache.shenyu.k8s.common.StatusMergePatch;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Reconciler for Gateway resources. Reports Accepted/Programmed plus per-listener status
+ * (supportedKinds, attachedRoutes, Accepted/Programmed); a listener is usable only for
+ * plain HTTP on the served port ({@code server.port}), everything else gets the
+ * spec-defined rejection reason.
+ */
+public class GatewayReconciler implements Reconciler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(GatewayReconciler.class);
+
+ private final Lister<DynamicKubernetesObject> gatewayLister;
+
+ private final Lister<DynamicKubernetesObject> gatewayClassLister;
+
+ private final Lister<DynamicKubernetesObject> httpRouteLister;
+
+ private final ShenyuCacheRepository shenyuCacheRepository;
+
+ private final RateLimitingQueue<Request> httpRouteWorkQueue;
+
+ private final RateLimitingQueue<Request> gatewayClassWorkQueue;
+
+ private final ApiClient apiClient;
+
+ /** The port the embedded ShenYu data plane actually listens on ({@code server.port}). */
+ private final int servedPort;
+
+ public GatewayReconciler(final SharedIndexInformer<DynamicKubernetesObject> gatewayInformer,
+ final SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer,
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+ final ShenyuCacheRepository shenyuCacheRepository,
+ final RateLimitingQueue<Request> httpRouteWorkQueue,
+ final RateLimitingQueue<Request> gatewayClassWorkQueue,
+ final ApiClient apiClient,
+ final int servedPort) {
+ this.gatewayLister = new Lister<>(gatewayInformer.getIndexer());
+ this.gatewayClassLister = new Lister<>(gatewayClassInformer.getIndexer());
+ this.httpRouteLister = new Lister<>(httpRouteInformer.getIndexer());
+ this.shenyuCacheRepository = shenyuCacheRepository;
+ this.httpRouteWorkQueue = httpRouteWorkQueue;
+ this.gatewayClassWorkQueue = gatewayClassWorkQueue;
+ this.apiClient = apiClient;
+ this.servedPort = servedPort;
+ }
+
+ @Override
+ public Result reconcile(final Request request) {
+ LOG.debug("Starting to reconcile gateway {}", request);
+ try {
+ DynamicKubernetesObject gateway = gatewayLister.namespace(request.getNamespace()).get(request.getName());
+
+ if (Objects.isNull(gateway)) {
+ LOG.info("Gateway {} deleted, cleaning associated routes", request);
+ deleteAssociatedRoutes(request.getNamespace(), request.getName());
+ // The class name is unrecoverable after deletion; re-queue all so the drained one drops its finalizer.
+ requeueAllGatewayClasses();
+ return new Result(false);
+ }
+
+ if (!GatewayClassReconciler.isShenyuGateway(gateway, gatewayClassLister)) {
+ if (!previouslyServedByShenyu(gateway, request.getNamespace(), request.getName())) {
+ LOG.debug("Gateway {} is not managed by ShenYu, skipping", request);
+ return new Result(false);
+ }
+ LOG.info("Gateway {} is no longer managed by ShenYu, cleaning associated routes", request);
+ deleteAssociatedRoutes(request.getNamespace(), request.getName());
+ // Accepted must not stay True; the flip also re-triggers recovery on class restore.
+ updateGatewayNotAcceptedStatus(gateway);
+ requeueAllGatewayClasses();
+ return new Result(false);
+ }
+
+ boolean wasAccepted = GatewayApiConstants.isConditionTrue(gateway, GatewayApiConstants.CONDITION_ACCEPTED);
+ Long generation = generationOf(gateway);
+ boolean generationObserved = observedGenerationUpToDate(
+ GatewayApiConstants.findCondition(gateway, GatewayApiConstants.CONDITION_ACCEPTED), generation);
+ JsonObject desiredStatus = buildAcceptedStatus(gateway);
+ preserveTransitionTimes(gateway, desiredStatus);
+ if (!gatewayStatusMatches(gateway, desiredStatus)) {
+ patchGatewayStatus(gateway, desiredStatus);
+ }
+ if (!wasAccepted || !generationObserved) {
+ requeueAffectedHTTPRoutes(request.getNamespace(), request.getName());
+ // Re-queue the class so its finalizer is added without waiting for its resync.
+ enqueueGatewayClass(gateway);
+ }
+
+ LOG.debug("Gateway {} reconciled successfully", request);
+ return new Result(false);
+ } catch (Exception e) {
+ LOG.error("Error reconciling gateway {}, will retry", request, e);
+ return new Result(true);
+ }
+ }
+
+ /** Re-queue bound routes on the Accepted transition and unobserved generations; covers routes created before acceptance. */
+ private void requeueAffectedHTTPRoutes(final String gatewayNamespace, final String gatewayName) {
+ for (DynamicKubernetesObject route : httpRouteLister.list()) {
+ enqueueIfBound(route, gatewayNamespace, gatewayName);
+ }
+ }
+
+ /** Re-queue the Gateway's own class so usage-driven finalizer updates are immediate. */
+ private void enqueueGatewayClass(final DynamicKubernetesObject gateway) {
+ JsonObject spec = JsonFields.getJsonObject(gateway.getRaw(), "spec");
+ String className = JsonFields.getString(spec, "gatewayClassName");
+ if (Objects.nonNull(className)) {
+ gatewayClassWorkQueue.add(new Request("", className));
+ }
+ }
+
+ /** Re-queue every class on Gateway deletion/ownership change, where the affected class is unknowable. */
+ private void requeueAllGatewayClasses() {
+ for (DynamicKubernetesObject gatewayClass : gatewayClassLister.list()) {
+ gatewayClassWorkQueue.add(new Request("", Objects.requireNonNull(gatewayClass.getMetadata()).getName()));
+ }
+ }
+
+ private void enqueueIfBound(final DynamicKubernetesObject route, final String gatewayNamespace,
+ final String gatewayName) {
+ if (!isBoundToGateway(route, gatewayNamespace, gatewayName)) {
+ return;
+ }
+ httpRouteWorkQueue.add(new Request(route.getMetadata().getNamespace(), route.getMetadata().getName()));
+ LOG.info("Re-queued HTTPRoute {}/{} due to Gateway {}/{} acceptance",
+ route.getMetadata().getNamespace(), route.getMetadata().getName(), gatewayNamespace, gatewayName);
+ }
+
+ private boolean isBoundToGateway(final DynamicKubernetesObject httpRoute,
+ final String gatewayNamespace, final String gatewayName) {
+ JsonObject spec = JsonFields.getJsonObject(httpRoute.getRaw(), "spec");
+ JsonArray parentRefs = Objects.isNull(spec) ? null : JsonFields.getJsonArray(spec, "parentRefs");
+ if (Objects.isNull(parentRefs)) {
+ return false;
+ }
+ String routeNamespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace();
+ for (JsonElement element : parentRefs) {
+ JsonObject parentRef = element.getAsJsonObject();
+ String parentName = JsonFields.getString(parentRef, "name");
+ String parentNamespace = Optional.ofNullable(JsonFields.getString(parentRef, "namespace"))
+ .orElse(routeNamespace);
+ if (gatewayNamespace.equals(parentNamespace) && gatewayName.equals(parentName)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Clean up config of bound routes; routes still attached to another ShenYu Gateway keep theirs. */
+ private void deleteAssociatedRoutes(final String gatewayNamespace, final String gatewayName) {
+ GatewayRouteCache cache = GatewayRouteCache.getInstance();
+ Set<String> routeKeys = cache.getRoutesByGateway(gatewayNamespace, gatewayName);
+ if (CollectionUtils.isEmpty(routeKeys)) {
+ return;
+ }
+ cache.removeRoutesByGateway(gatewayNamespace, gatewayName);
+ for (String routeKey : routeKeys) {
+ String[] parts = routeKey.split("/", 2);
+ if (parts.length != 2) {
+ continue;
+ }
+ String routeNamespace = parts[0];
+ String routeName = parts[1];
+ if (CollectionUtils.isNotEmpty(cache.getGatewaysForRoute(routeNamespace, routeName))) {
+ LOG.info("Route {}/{} still served by another ShenYu Gateway, keeping its config",
+ routeNamespace, routeName);
+ httpRouteWorkQueue.add(new Request(routeNamespace, routeName));
+ continue;
+ }
+ deleteRouteConfig(routeNamespace, routeName, cache);
+ LOG.info("Deleted ShenYu config for route {}/{} due to Gateway removal", routeNamespace, routeName);
+ }
+ }
+
+ private void deleteRouteConfig(final String routeNamespace, final String routeName,
+ final GatewayRouteCache cache) {
+ List<String> selectorIds = cache.removeRouteSelectors(routeNamespace, routeName, PluginEnum.DIVIDE.getName());
+ if (CollectionUtils.isEmpty(selectorIds)) {
+ return;
+ }
+ for (String selectorId : selectorIds) {
+ shenyuCacheRepository.deleteSelectorWithRules(PluginEnum.DIVIDE.getName(), selectorId);
+ }
+ }
+
+ /** Previously served = live route bindings or our own Accepted=True payload (zero-route Gateways). */
+ private boolean previouslyServedByShenyu(final DynamicKubernetesObject gateway, final String namespace, final String name) {
+ return CollectionUtils.isNotEmpty(GatewayRouteCache.getInstance().getRoutesByGateway(namespace, name))
+ || GatewayApiConstants.isConditionAcceptedByShenyu(gateway, GatewayApiConstants.CONDITION_ACCEPTED);
+ }
+
+ private boolean observedGenerationUpToDate(final JsonObject existingCondition, final Long generation) {
+ if (Objects.isNull(generation)) {
+ return true;
+ }
+ return Objects.nonNull(existingCondition)
+ && generation.equals(JsonFields.getLong(existingCondition, "observedGeneration"));
+ }
+
+ /** Keep lastTransitionTime of unchanged conditions, per spec. */
+ private void preserveTransitionTimes(final DynamicKubernetesObject gateway, final JsonObject desiredStatus) {
+ JsonObject existingStatus = JsonFields.getJsonObject(gateway.getRaw(), "status");
+ JsonArray existingConditions = JsonFields.getJsonArray(existingStatus, "conditions");
+ if (Objects.nonNull(existingConditions)) {
+ preserveTransitionTimes(existingConditions, desiredStatus.getAsJsonArray("conditions"));
+ }
+ JsonArray existingListeners = JsonFields.getJsonArray(existingStatus, "listeners");
+ JsonArray desiredListeners = desiredStatus.getAsJsonArray("listeners");
+ if (Objects.isNull(existingListeners) || Objects.isNull(desiredListeners)) {
+ return;
+ }
+ for (JsonElement desiredElement : desiredListeners) {
+ JsonObject desiredListener = desiredElement.getAsJsonObject();
+ JsonArray existingListenerConditions = JsonFields.getJsonArray(
+ findListenerStatus(existingListeners, JsonFields.getString(desiredListener, "name")), "conditions");
+ if (Objects.nonNull(existingListenerConditions)) {
+ preserveTransitionTimes(existingListenerConditions, desiredListener.getAsJsonArray("conditions"));
+ }
+ }
+ }
+
+ private void preserveTransitionTimes(final JsonArray existingConditions, final JsonArray desiredConditions) {
+ for (JsonElement desiredElement : desiredConditions) {
+ JsonObject desiredCondition = desiredElement.getAsJsonObject();
+ for (JsonElement existingElement : existingConditions) {
+ if (!existingElement.isJsonObject()) {
+ continue;
+ }
+ JsonObject existingCondition = existingElement.getAsJsonObject();
+ String existingTime = JsonFields.getString(existingCondition, "lastTransitionTime");
+ if (Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+ && Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))
+ && Objects.nonNull(existingTime)) {
+ desiredCondition.addProperty("lastTransitionTime", existingTime);
+ break;
+ }
+ }
+ }
+ }
+
+ /** Patch Accepted=False on a Gateway the controller no longer manages; no-op when already False. */
+ private void updateGatewayNotAcceptedStatus(final DynamicKubernetesObject gateway) {
+ if (isAcceptedCondition(gateway, "False")) {
+ return;
+ }
+ JsonObject condition = new JsonObject();
+ condition.addProperty("type", GatewayApiConstants.CONDITION_ACCEPTED);
+ condition.addProperty("status", "False");
+ condition.addProperty("reason", GatewayApiConstants.REASON_INVALID);
+ condition.addProperty("message", "GatewayClass is missing, rejected, or not managed by the ShenYu controller");
+ Long generation = generationOf(gateway);
+ if (Objects.nonNull(generation)) {
+ condition.addProperty("observedGeneration", generation);
+ }
+ condition.addProperty("lastTransitionTime", Instant.now().toString());
+
+ JsonArray conditions = new JsonArray();
+ conditions.add(condition);
+ JsonObject statusObj = new JsonObject();
+ statusObj.add("conditions", conditions);
+ patchGatewayStatus(gateway, statusObj);
+ }
+
+ private boolean isAcceptedCondition(final DynamicKubernetesObject gateway, final String status) {
+ JsonObject statusObj = JsonFields.getJsonObject(gateway.getRaw(), "status");
+ JsonArray conditions = JsonFields.getJsonArray(statusObj, "conditions");
+ if (Objects.isNull(conditions)) {
+ return false;
+ }
+ for (JsonElement el : conditions) {
+ if (!el.isJsonObject()) {
+ continue;
+ }
+ JsonObject cond = el.getAsJsonObject();
+ if (GatewayApiConstants.CONDITION_ACCEPTED.equals(JsonFields.getString(cond, "type"))
+ && status.equals(JsonFields.getString(cond, "status"))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Desired status of an accepted Gateway; attachedRoutes counts only routes bound to that listener. */
+ private JsonObject buildAcceptedStatus(final DynamicKubernetesObject gateway) {
+ Long generation = generationOf(gateway);
+ String namespace = gateway.getMetadata().getNamespace();
+ String name = gateway.getMetadata().getName();
+ GatewayRouteCache cache = GatewayRouteCache.getInstance();
+
+ List<JsonObject> listeners = ListenerSupport.selectListeners(gateway.getRaw(), null);
+ JsonArray listenerStatuses = new JsonArray();
+ boolean anyUsableListener = false;
+ for (JsonObject listener : listeners) {
+ boolean protocolOk = ListenerSupport.isSupportedProtocol(listener);
+ Long port = ListenerSupport.portOf(listener);
+ boolean portOk = Objects.nonNull(port) && port == servedPort;
+ boolean usable = protocolOk && portOk;
+ anyUsableListener |= usable;
+
+ JsonObject listenerStatus = new JsonObject();
+ listenerStatus.addProperty("name", ListenerSupport.nameOf(listener));
+ JsonObject kind = new JsonObject();
+ kind.addProperty("group", GatewayApiConstants.GATEWAY_API_GROUP);
+ kind.addProperty("kind", GatewayApiConstants.HTTP_ROUTE_KIND);
+ JsonArray supportedKinds = new JsonArray();
+ supportedKinds.add(kind);
+ listenerStatus.add("supportedKinds", supportedKinds);
+ Set<String> listenerRoutes = cache.getRoutesByListener(namespace, name, ListenerSupport.nameOf(listener));
+ listenerStatus.addProperty("attachedRoutes", Objects.isNull(listenerRoutes) ? 0 : listenerRoutes.size());
+
+ JsonArray listenerConditions = new JsonArray();
+ if (usable) {
+ listenerConditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "True",
+ GatewayApiConstants.CONDITION_ACCEPTED, "Listener is accepted", generation));
+ listenerConditions.add(buildCondition(GatewayApiConstants.CONDITION_PROGRAMMED, "True",
+ GatewayApiConstants.REASON_PROGRAMMED, "Listener is programmed", generation));
+ } else {
+ String reason = protocolOk ? GatewayApiConstants.REASON_PORT_UNAVAILABLE
+ : GatewayApiConstants.REASON_UNSUPPORTED_PROTOCOL;
+ String message = protocolOk
+ ? "listener port " + port + " is not served by this gateway (serving " + servedPort + ")"
+ : "listener protocol " + ListenerSupport.protocolOf(listener) + " is not supported, only HTTP";
+ listenerConditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "False", reason, message, generation));
+ listenerConditions.add(buildCondition(GatewayApiConstants.CONDITION_PROGRAMMED, "False",
+ "Pending", "Listener is not programmed", generation));
+ }
+ listenerStatus.add("conditions", listenerConditions);
+ listenerStatuses.add(listenerStatus);
+ }
+
+ JsonArray conditions = new JsonArray();
+ conditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "True",
+ GatewayApiConstants.CONDITION_ACCEPTED, "Gateway has been accepted by the ShenYu controller", generation));
+ if (anyUsableListener) {
+ conditions.add(buildCondition(GatewayApiConstants.CONDITION_PROGRAMMED, "True",
+ GatewayApiConstants.REASON_PROGRAMMED, "Gateway is programmed into the embedded ShenYu data plane", generation));
+ } else {
+ conditions.add(buildCondition(GatewayApiConstants.CONDITION_PROGRAMMED, "False",
+ GatewayApiConstants.REASON_LISTENERS_NOT_VALID, "No listener with a supported protocol (HTTP) and a served port",
+ generation));
+ }
+
+ JsonObject status = new JsonObject();
+ status.add("conditions", conditions);
+ status.add("listeners", listenerStatuses);
+ return status;
+ }
+
+ /** Whether the existing status matches desired (ignoring timestamps); keeps the steady state patch-free. */
+ private boolean gatewayStatusMatches(final DynamicKubernetesObject gateway, final JsonObject desiredStatus) {
+ JsonObject existingStatus = JsonFields.getJsonObject(gateway.getRaw(), "status");
+ if (Objects.isNull(existingStatus)) {
+ return false;
+ }
+ if (!conditionsMatch(JsonFields.getJsonArray(existingStatus, "conditions"),
+ desiredStatus.getAsJsonArray("conditions"))) {
+ return false;
+ }
+ JsonArray existingListeners = JsonFields.getJsonArray(existingStatus, "listeners");
+ JsonArray desiredListeners = desiredStatus.getAsJsonArray("listeners");
+ if (Objects.isNull(existingListeners) || existingListeners.size() != desiredListeners.size()) {
+ return false;
+ }
+ for (JsonElement desiredElement : desiredListeners) {
+ JsonObject desiredListener = desiredElement.getAsJsonObject();
+ JsonObject existingListener = findListenerStatus(existingListeners, JsonFields.getString(desiredListener, "name"));
+ if (Objects.isNull(existingListener)
+ || !Objects.equals(JsonFields.getLong(existingListener, "attachedRoutes"),
+ JsonFields.getLong(desiredListener, "attachedRoutes"))
+ || !conditionsMatch(JsonFields.getJsonArray(existingListener, "conditions"),
+ desiredListener.getAsJsonArray("conditions"))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private JsonObject findListenerStatus(final JsonArray listeners, final String name) {
+ for (JsonElement element : listeners) {
+ if (!element.isJsonObject()) {
+ continue;
+ }
+ JsonObject listener = element.getAsJsonObject();
+ if (Objects.equals(JsonFields.getString(listener, "name"), name)) {
+ return listener;
+ }
+ }
+ return null;
+ }
+
+ /** Compare by (type, status, observedGeneration) and reason for False; ignore timestamps. */
+ private boolean conditionsMatch(final JsonArray existing, final JsonArray desired) {
+ if (Objects.isNull(existing) || Objects.isNull(desired) || existing.size() < desired.size()) {
+ return false;
+ }
+ for (JsonElement desiredElement : desired) {
+ JsonObject desiredCondition = desiredElement.getAsJsonObject();
+ boolean found = false;
+ for (JsonElement existingElement : existing) {
+ if (!existingElement.isJsonObject()) {
+ continue;
+ }
+ JsonObject existingCondition = existingElement.getAsJsonObject();
+ boolean reasonMatters = "False".equals(JsonFields.getString(desiredCondition, "status"));
+ boolean reasonMatches = !reasonMatters || Objects.equals(
+ JsonFields.getString(existingCondition, "reason"), JsonFields.getString(desiredCondition, "reason"));
+ if (Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+ && Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))
+ && Objects.equals(JsonFields.getLong(existingCondition, "observedGeneration"),
+ JsonFields.getLong(desiredCondition, "observedGeneration"))
+ && reasonMatches) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static JsonObject buildCondition(final String type, final String status, final String reason,
+ final String message, final Long observedGeneration) {
+ JsonObject condition = new JsonObject();
+ condition.addProperty("type", type);
+ condition.addProperty("status", status);
+ condition.addProperty("reason", reason);
+ condition.addProperty("message", message);
+ if (Objects.nonNull(observedGeneration)) {
+ condition.addProperty("observedGeneration", observedGeneration);
+ }
+ condition.addProperty("lastTransitionTime", Instant.now().toString());
+ return condition;
+ }
+
+ private static Long generationOf(final DynamicKubernetesObject gateway) {
+ return JsonFields.getLong(JsonFields.getJsonObject(gateway.getRaw(), "metadata"), "generation");
+ }
+
+ /** Merge-patch the Gateway /status subresource, preserving conditions owned by other controllers. */
+ private void patchGatewayStatus(final DynamicKubernetesObject gateway, final JsonObject statusObj) {
+ try {
+ final String namespace = gateway.getMetadata().getNamespace();
+ final String name = gateway.getMetadata().getName();
+
+ JsonObject body = new JsonObject();
+ body.add("status", statusObj);
+ body.addProperty("kind", GatewayApiConstants.GATEWAY_KIND);
+ body.addProperty("apiVersion", GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION);
+
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("name", name);
+ metadata.addProperty("namespace", namespace);
+ body.add("metadata", metadata);
+
+ String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+ + "/namespaces/" + namespace + "/gateways/" + name + "/status";
+
+ StatusMergePatch.patch(apiClient, path, body);
+ LOG.info("Updated Gateway {}/{} status", namespace, name);
+ } catch (Exception e) {
+ LOG.warn("Failed to update Gateway status, will retry on next resync", e);
+ }
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HTTPRouteReconciler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HTTPRouteReconciler.java
new file mode 100644
index 0000000000..ab3b5c2485
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HTTPRouteReconciler.java
@@ -0,0 +1,652 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.IngressConfiguration;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.apache.shenyu.k8s.common.ListenerSupport;
+import org.apache.shenyu.k8s.common.ShenyuMemoryConfig;
+import org.apache.shenyu.k8s.common.StatusMergePatch;
+import org.apache.shenyu.k8s.parser.HttpRouteParser;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Reconciler for HTTPRoute resources. Every parentRef is evaluated and reported in
+ * status.parents with spec-defined rejection reasons, except parentRefs of foreign
+ * controllers. Cross-namespace attachment is authorized by allowedRoutes only;
+ * ReferenceGrant governs cross-namespace backendRefs (validated by {@link HttpRouteParser}).
+ */
+public class HTTPRouteReconciler implements Reconciler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(HTTPRouteReconciler.class);
+
+ private final Lister<DynamicKubernetesObject> httpRouteLister;
+
+ private final Lister<DynamicKubernetesObject> gatewayLister;
+
+ private final Lister<DynamicKubernetesObject> gatewayClassLister;
+
+ private final HttpRouteParser httpRouteParser;
+
+ private final ShenyuCacheRepository shenyuCacheRepository;
+
+ private final ApiClient apiClient;
+
+ /** The port the embedded ShenYu data plane actually listens on ({@code server.port}); route attachment is allowed only on listeners the gateway really serves. */
+ private final int servedPort;
+
+ public HTTPRouteReconciler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+ final SharedIndexInformer<DynamicKubernetesObject> gatewayInformer,
+ final SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer,
+ final HttpRouteParser httpRouteParser,
+ final ShenyuCacheRepository shenyuCacheRepository,
+ final ApiClient apiClient,
+ final int servedPort) {
+ this.httpRouteLister = new Lister<>(httpRouteInformer.getIndexer());
+ this.gatewayLister = new Lister<>(gatewayInformer.getIndexer());
+ this.gatewayClassLister = new Lister<>(gatewayClassInformer.getIndexer());
+ this.httpRouteParser = httpRouteParser;
+ this.shenyuCacheRepository = shenyuCacheRepository;
+ this.apiClient = apiClient;
+ this.servedPort = servedPort;
+ }
+
+ @Override
+ public Result reconcile(final Request request) {
+ String namespace = request.getNamespace();
+ String routeName = request.getName();
+ LOG.debug("Starting to reconcile HTTPRoute {}/{}", namespace, routeName);
+
+ DynamicKubernetesObject httpRoute = httpRouteLister.namespace(namespace).get(routeName);
+ if (Objects.isNull(httpRoute)) {
+ deleteConfig(namespace, routeName);
+ return new Result(false);
+ }
+
+ List<ParentDecision> decisions = evaluateParents(httpRoute);
+ if (decisions.isEmpty()) {
+ // Not attached to any ShenYu Gateway: drop config and our status entries.
+ deleteConfig(namespace, routeName);
+ removeShenyuParentStatus(httpRoute, namespace, routeName);
+ return new Result(false);
+ }
+
+ List<ParentDecision> accepted = new ArrayList<>();
+ for (ParentDecision decision : decisions) {
+ if (decision.accepted) {
+ accepted.add(decision);
+ }
+ }
+
+ ShenyuMemoryConfig config = null;
+ if (accepted.isEmpty()) {
+ // All ShenYu parents rejected the attachment: clean up but still report status.
+ deleteConfig(namespace, routeName);
+ } else {
+ config = httpRouteParser.parse(httpRoute, effectiveHostnames(accepted));
+ if (config.isHasUnsupportedFilters()) {
+ deleteConfig(namespace, routeName);
+ } else {
+ GatewayRouteCache cache = GatewayRouteCache.getInstance();
+ List<String> newSelectorIds = selectorIdsOf(config);
+ // read the previous snapshot before putRouteSelectors overwrites it
+ List<String> oldSelectorIds = Objects.requireNonNullElse(
+ cache.getRouteSelectors(namespace, routeName, PluginEnum.DIVIDE.getName()), List.of());
+ applyConfig(config, namespace, routeName);
+ deleteStaleSelectors(namespace, routeName, oldSelectorIds, newSelectorIds);
+ cache.putRouteSelectors(namespace, routeName, PluginEnum.DIVIDE.getName(), newSelectorIds);
+ rebindGateways(cache, namespace, routeName, accepted);
+ }
+ }
+
+ updateHTTPRouteStatus(httpRoute, decisions, config, namespace, routeName);
+ return new Result(false);
+ }
+
+ private List<String> selectorIdsOf(final ShenyuMemoryConfig config) {
+ List<String> ids = new ArrayList<>();
+ for (IngressConfiguration rc : config.getRouteConfigList()) {
+ ids.add(rc.getSelectorData().getId());
+ }
+ return ids;
+ }
+
+ /** Union of per-parent hostname intersections; one accepting parent with no hostnames makes the route host-agnostic overall. */
+ private List<String> effectiveHostnames(final List<ParentDecision> accepted) {
+ Set<String> union = new LinkedHashSet<>();
+ for (ParentDecision decision : accepted) {
+ if (decision.hostnames.isEmpty()) {
+ return List.of();
+ }
+ union.addAll(decision.hostnames);
+ }
+ return new ArrayList<>(union);
+ }
+
+ /** Rebuild route→gateway listener bindings from accepted parents; they drive attachedRoutes counts. */
+ private void rebindGateways(final GatewayRouteCache cache, final String namespace, final String routeName,
+ final List<ParentDecision> accepted) {
+ cache.removeRouteGatewayBinding(namespace, routeName);
+ for (ParentDecision decision : accepted) {
+ cache.bindRouteToGateway(decision.parentNamespace, decision.parentName, decision.listenerNames,
+ namespace, routeName);
+ }
+ }
+
+ /** Evaluate every parentRef; foreign kinds/groups and other controllers' Gateways are skipped silently. */
+ private List<ParentDecision> evaluateParents(final DynamicKubernetesObject httpRoute) {
+ String routeNamespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace();
+ JsonObject spec = JsonFields.getJsonObject(httpRoute.getRaw(), "spec");
+ JsonArray parentRefs = JsonFields.getJsonArray(spec, "parentRefs");
+ List<String> routeHostnames = readStringList(JsonFields.getJsonArray(spec, "hostnames"));
+ List<ParentDecision> decisions = new ArrayList<>();
+ if (Objects.isNull(parentRefs)) {
+ return decisions;
+ }
+ for (JsonElement element : parentRefs) {
+ if (!element.isJsonObject()) {
+ continue;
+ }
+ JsonObject parentRef = element.getAsJsonObject();
+ String group = JsonFields.getString(parentRef, "group");
+ String kind = JsonFields.getString(parentRef, "kind");
+ if ((Objects.nonNull(group) && !GatewayApiConstants.GATEWAY_API_GROUP.equals(group))
+ || (Objects.nonNull(kind) && !GatewayApiConstants.GATEWAY_KIND.equals(kind))) {
+ continue;
+ }
+ String parentName = JsonFields.getString(parentRef, "name");
+ if (Objects.isNull(parentName)) {
+ continue;
+ }
+ String parentNamespace = JsonFields.getString(parentRef, "namespace");
+ if (Objects.isNull(parentNamespace)) {
+ parentNamespace = routeNamespace;
+ }
+ DynamicKubernetesObject gateway = gatewayLister.namespace(parentNamespace).get(parentName);
+ if (Objects.isNull(gateway)) {
+ decisions.add(ParentDecision.rejected(parentRef, parentNamespace, parentName,
+ GatewayApiConstants.REASON_NO_MATCHING_PARENT,
+ "Gateway " + parentNamespace + "/" + parentName + " does not exist"));
+ continue;
+ }
+ if (!isShenyuGateway(gateway)) {
+ continue;
+ }
+ // allowedRoutes authorizes cross-ns parentRefs; ReferenceGrant never applies to parentRefs per spec.
+ String sectionName = JsonFields.getString(parentRef, "sectionName");
+ Long parentPort = JsonFields.getLong(parentRef, "port");
+ decisions.add(evaluateListeners(gateway, sectionName, parentPort, routeNamespace, parentNamespace,
+ parentName, routeHostnames, parentRef));
+ }
+ return decisions;
+ }
+
+ /** Attach when at least one selected listener is usable, allowed and hostname-intersecting. */
+ private ParentDecision evaluateListeners(final DynamicKubernetesObject gateway, final String sectionName,
+ final Long parentPort, final String routeNamespace,
+ final String parentNamespace, final String parentName,
+ final List<String> routeHostnames, final JsonObject parentRef) {
+ List<JsonObject> selected = ListenerSupport.selectListeners(gateway.getRaw(), sectionName, parentPort);
+ if (selected.isEmpty()) {
+ String message;
+ if (Objects.nonNull(sectionName)) {
+ message = "no listener named '" + sectionName + "' on Gateway " + parentNamespace + "/" + parentName;
+ if (Objects.nonNull(parentPort)) {
+ message = message + " serving port " + parentPort;
+ }
+ } else if (Objects.nonNull(parentPort)) {
+ message = "Gateway " + parentNamespace + "/" + parentName + " has no listener on port " + parentPort;
+ } else {
+ message = "Gateway " + parentNamespace + "/" + parentName + " has no listeners";
+ }
+ return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+ GatewayApiConstants.REASON_UNSUPPORTED_VALUE, message);
+ }
+ Set<String> effective = new LinkedHashSet<>();
+ Set<String> matchedListeners = new LinkedHashSet<>();
+ boolean anyMatched = false;
+ boolean unsupportedFrom = false;
+ boolean notPermitted = false;
+ boolean hostnameMismatch = false;
+ for (JsonObject listener : selected) {
+ // Must mirror the Gateway reconciler's listener usability or status and data plane diverge.
+ if (!ListenerSupport.isSupportedProtocol(listener) || !ListenerSupport.servesPort(listener, servedPort)) {
+ continue;
+ }
+ if (ListenerSupport.usesUnsupportedFrom(listener)) {
+ unsupportedFrom = true;
+ continue;
+ }
+ if (!ListenerSupport.allowsNamespace(listener, routeNamespace, parentNamespace)
+ || !ListenerSupport.allowsKind(listener)) {
+ notPermitted = true;
+ continue;
+ }
+ List<String> intersect = ListenerSupport.intersectHostnames(ListenerSupport.hostnameOf(listener), routeHostnames);
+ if (Objects.isNull(intersect)) {
+ hostnameMismatch = true;
+ continue;
+ }
+ // an empty intersection is still a match: no hostnames on either side means "any host"
+ anyMatched = true;
+ matchedListeners.add(ListenerSupport.nameOf(listener));
+ effective.addAll(intersect);
+ }
+ if (anyMatched) {
+ return ParentDecision.accepted(parentRef, parentNamespace, parentName, new ArrayList<>(effective),
+ matchedListeners);
+ }
+ if (unsupportedFrom) {
+ return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+ GatewayApiConstants.REASON_UNSUPPORTED_VALUE,
+ "allowedRoutes with from=Selector is not supported by the ShenYu controller");
+ }
+ if (notPermitted) {
+ return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+ GatewayApiConstants.REASON_REF_NOT_PERMITTED,
+ "listener allowedRoutes policy does not permit routes from namespace " + routeNamespace);
+ }
+ if (hostnameMismatch) {
+ return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+ GatewayApiConstants.REASON_NO_MATCHING_LISTENER_HOSTNAME,
+ "route hostnames do not intersect any listener hostname");
+ }
+ return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+ GatewayApiConstants.REASON_UNSUPPORTED_VALUE,
+ "no listener with a supported protocol (HTTP) on a served port (serving " + servedPort + ")");
+ }
+
+ private List<String> readStringList(final JsonArray array) {
+ List<String> result = new ArrayList<>();
+ if (Objects.nonNull(array)) {
+ for (JsonElement element : array) {
+ if (element.isJsonPrimitive()) {
+ result.add(element.getAsString());
+ }
+ }
+ }
+ return result;
+ }
+
+ private boolean isShenyuGateway(final DynamicKubernetesObject gateway) {
+ return GatewayClassReconciler.isShenyuGateway(gateway, gatewayClassLister);
+ }
+
+ private void applyConfig(final ShenyuMemoryConfig config, final String namespace, final String routeName) {
+ for (IngressConfiguration routeConfig : config.getRouteConfigList()) {
+ shenyuCacheRepository.saveOrUpdateSelectorData(routeConfig.getSelectorData());
+ if (CollectionUtils.isNotEmpty(routeConfig.getRuleDataList())) {
+ shenyuCacheRepository.saveOrUpdateRuleData(routeConfig.getRuleDataList().get(0));
+ }
+ }
+ LOG.debug("HTTPRoute {}/{}: applied {} selector(s)", namespace, routeName, config.getRouteConfigList().size());
+ }
+
+ /** Delete selectors gone from the current parse; runs after the new config applies so a live interval never goes unmatched. */
+ private void deleteStaleSelectors(final String namespace, final String routeName,
+ final List<String> oldSelectorIds, final List<String> newSelectorIds) {
+ Set<String> stale = new LinkedHashSet<>(oldSelectorIds);
+ stale.removeAll(newSelectorIds);
+ for (String selectorId : stale) {
+ shenyuCacheRepository.deleteSelectorWithRules(PluginEnum.DIVIDE.getName(), selectorId);
+ LOG.info("HTTPRoute {}/{}: removed stale divide selector {}", namespace, routeName, selectorId);
+ }
+ }
+
+ private void deleteConfig(final String namespace, final String routeName) {
+ GatewayRouteCache cache = GatewayRouteCache.getInstance();
+ List<String> selectorIds = cache.removeRouteSelectors(namespace, routeName, PluginEnum.DIVIDE.getName());
+ if (Objects.nonNull(selectorIds)) {
+ for (String selectorId : selectorIds) {
+ shenyuCacheRepository.deleteSelectorWithRules(PluginEnum.DIVIDE.getName(), selectorId);
+ LOG.info("HTTPRoute {}/{}: deleted divide selector {}", namespace, routeName, selectorId);
+ }
+ }
+ cache.removeRouteGatewayBinding(namespace, routeName);
+ }
+
+ private void updateHTTPRouteStatus(final DynamicKubernetesObject httpRoute, final List<ParentDecision> decisions,
+ final ShenyuMemoryConfig config, final String namespace, final String routeName) {
+ Long generation = JsonFields.getLong(JsonFields.getJsonObject(httpRoute.getRaw(), "metadata"), "generation");
+ JsonArray desiredParents = new JsonArray();
+ for (ParentDecision decision : decisions) {
+ desiredParents.add(buildParentStatus(decision, config, generation));
+ }
+
+ JsonObject raw = httpRoute.getRaw();
+ JsonObject currentStatus = JsonFields.getJsonObject(raw, "status");
+ if (Objects.nonNull(currentStatus)) {
+ JsonArray existingParents = JsonFields.getJsonArray(currentStatus, "parents");
+ retainForeignParentEntries(existingParents, desiredParents);
+ if (existingStatusMatches(existingParents, desiredParents)) {
+ return;
+ }
+ preserveTransitionTimes(existingParents, desiredParents);
+ }
+
+ JsonObject status = new JsonObject();
+ status.add("parents", desiredParents);
+ sendStatusPatch(httpRoute, namespace, routeName, status);
+ }
+
+ private JsonObject buildParentStatus(final ParentDecision decision, final ShenyuMemoryConfig config,
+ final Long generation) {
+ JsonObject parentRefStatus = new JsonObject();
+ parentRefStatus.addProperty("group", GatewayApiConstants.GATEWAY_API_GROUP);
+ parentRefStatus.addProperty("kind", GatewayApiConstants.GATEWAY_KIND);
+ parentRefStatus.addProperty("namespace", decision.parentNamespace);
+ parentRefStatus.addProperty("name", decision.parentName);
+ String sectionName = JsonFields.getString(decision.parentRef, "sectionName");
+ if (Objects.nonNull(sectionName)) {
+ parentRefStatus.addProperty("sectionName", sectionName);
+ }
+ Long parentPort = JsonFields.getLong(decision.parentRef, "port");
+ if (Objects.nonNull(parentPort)) {
+ parentRefStatus.addProperty("port", parentPort);
+ }
+
+ JsonObject parent = new JsonObject();
+ parent.add("parentRef", parentRefStatus);
+ parent.addProperty("controllerName", GatewayApiConstants.SHENYU_CONTROLLER_NAME);
+ parent.add("conditions", buildStatusConditions(decision, config, generation));
+ return parent;
+ }
+
+ private JsonArray buildStatusConditions(final ParentDecision decision, final ShenyuMemoryConfig config,
+ final Long generation) {
+ JsonArray conditions = new JsonArray();
+
+ boolean unsupportedFilters = Objects.nonNull(config) && config.isHasUnsupportedFilters();
+ if (!decision.accepted) {
+ conditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "False", decision.reason,
+ decision.message, generation));
+ } else if (unsupportedFilters) {
+ conditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "False",
+ GatewayApiConstants.REASON_UNSUPPORTED_VALUE,
+ "route rules declare filters which are not supported by ShenYu", generation));
+ } else {
+ conditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "True", "Accepted",
+ "HTTPRoute is accepted by the ShenYu Gateway", generation));
+ }
+
+ boolean resolved = Objects.isNull(config) || config.isAllBackendsResolved();
+ if (resolved) {
+ conditions.add(buildCondition(GatewayApiConstants.CONDITION_RESOLVED_REFS, "True", "ResolvedRefs",
+ "All references have been resolved", generation));
+ } else {
+ String reason = Objects.nonNull(config.getUnresolvedReason())
+ ? config.getUnresolvedReason() : GatewayApiConstants.REASON_BACKEND_NOT_FOUND;
+ conditions.add(buildCondition(GatewayApiConstants.CONDITION_RESOLVED_REFS, "False", reason,
+ "Some services were not found or not permitted", generation));
+ }
+ return conditions;
+ }
+
+ private static JsonObject buildCondition(final String type, final String status, final String reason,
+ final String message, final Long observedGeneration) {
+ JsonObject condition = new JsonObject();
+ condition.addProperty("type", type);
+ condition.addProperty("status", status);
+ condition.addProperty("reason", reason);
+ condition.addProperty("message", message);
+ if (Objects.nonNull(observedGeneration)) {
+ condition.addProperty("observedGeneration", observedGeneration);
+ }
+ condition.addProperty("lastTransitionTime", java.time.Instant.now().toString());
+ return condition;
+ }
+
+ /** No-op patches must be skipped: each bumps resourceVersion and re-enqueues the route forever. */
+ private boolean existingStatusMatches(final JsonArray existingParents, final JsonArray desiredParents) {
+ if (Objects.isNull(existingParents) || existingParents.size() != desiredParents.size()) {
+ return false;
+ }
+ List<JsonObject> unmatched = new ArrayList<>();
+ for (JsonElement element : existingParents) {
+ if (element.isJsonObject()) {
+ unmatched.add(element.getAsJsonObject());
+ }
+ }
+ for (JsonElement desiredElement : desiredParents) {
+ JsonObject desired = desiredElement.getAsJsonObject();
+ boolean found = false;
+ for (int i = 0; i < unmatched.size(); i++) {
+ JsonObject existing = unmatched.get(i);
+ if (sameParentRef(JsonFields.getJsonObject(existing, "parentRef"),
+ JsonFields.getJsonObject(desired, "parentRef"))
+ && Objects.equals(JsonFields.getString(desired, "controllerName"),
+ JsonFields.getString(existing, "controllerName"))
+ && conditionsMatch(JsonFields.getJsonArray(existing, "conditions"),
+ JsonFields.getJsonArray(desired, "conditions"))) {
+ unmatched.remove(i);
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean sameParentRef(final JsonObject existing, final JsonObject desired) {
+ if (Objects.isNull(existing) || Objects.isNull(desired)) {
+ return false;
+ }
+ return Objects.equals(JsonFields.getString(existing, "namespace"), JsonFields.getString(desired, "namespace"))
+ && Objects.equals(JsonFields.getString(existing, "name"), JsonFields.getString(desired, "name"))
+ && Objects.equals(JsonFields.getString(existing, "sectionName"), JsonFields.getString(desired, "sectionName"))
+ && Objects.equals(JsonFields.getLong(existing, "port"), JsonFields.getLong(desired, "port"));
+ }
+
+ private boolean conditionsMatch(final JsonArray existing, final JsonArray desired) {
+ if (Objects.isNull(existing) || Objects.isNull(desired) || existing.size() != desired.size()) {
+ return false;
+ }
+ for (JsonElement desiredElement : desired) {
+ JsonObject desiredCondition = desiredElement.getAsJsonObject();
+ boolean found = false;
+ for (JsonElement existingElement : existing) {
+ JsonObject existingCondition = existingElement.getAsJsonObject();
+ boolean reasonMatters = "False".equals(JsonFields.getString(desiredCondition, "status"));
+ boolean reasonMatches = !reasonMatters || Objects.equals(
+ JsonFields.getString(existingCondition, "reason"), JsonFields.getString(desiredCondition, "reason"));
+ if (Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+ && Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))
+ && Objects.equals(JsonFields.getLong(existingCondition, "observedGeneration"),
+ JsonFields.getLong(desiredCondition, "observedGeneration"))
+ && reasonMatches) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Keep lastTransitionTime of unchanged conditions; it advances only on real transitions. */
+ private void preserveTransitionTimes(final JsonArray existingParents, final JsonArray desiredParents) {
+ for (JsonElement desiredElement : desiredParents) {
+ JsonObject desired = desiredElement.getAsJsonObject();
+ JsonObject desiredRef = JsonFields.getJsonObject(desired, "parentRef");
+ JsonArray desiredConditions = JsonFields.getJsonArray(desired, "conditions");
+ for (JsonElement existingElement : existingParents) {
+ JsonObject existing = existingElement.getAsJsonObject();
+ if (!GatewayApiConstants.SHENYU_CONTROLLER_NAME.equals(JsonFields.getString(existing, "controllerName"))) {
+ continue;
+ }
+ if (!sameParentRef(JsonFields.getJsonObject(existing, "parentRef"), desiredRef)) {
+ continue;
+ }
+ JsonArray existingConditions = JsonFields.getJsonArray(existing, "conditions");
+ if (Objects.isNull(existingConditions) || Objects.isNull(desiredConditions)) {
+ continue;
+ }
+ for (JsonElement desiredConditionElement : desiredConditions) {
+ JsonObject desiredCondition = desiredConditionElement.getAsJsonObject();
+ for (JsonElement existingConditionElement : existingConditions) {
+ JsonObject existingCondition = existingConditionElement.getAsJsonObject();
+ if (Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+ && Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))
+ && Objects.nonNull(JsonFields.getString(existingCondition, "lastTransitionTime"))) {
+ desiredCondition.addProperty("lastTransitionTime",
+ JsonFields.getString(existingCondition, "lastTransitionTime"));
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /** Foreign parents must ride in the patch body: merge patch replaces the array wholesale. */
+ private void retainForeignParentEntries(final JsonArray existingParents, final JsonArray desiredParents) {
+ if (Objects.isNull(existingParents)) {
+ return;
+ }
+ for (JsonElement element : existingParents) {
+ if (!element.isJsonObject()) {
+ continue;
+ }
+ JsonObject parent = element.getAsJsonObject();
+ if (!GatewayApiConstants.SHENYU_CONTROLLER_NAME.equals(JsonFields.getString(parent, "controllerName"))) {
+ desiredParents.add(element);
+ }
+ }
+ }
+
+ /** Remove ShenYu's parents entries, preserving other controllers'. */
+ private void removeShenyuParentStatus(final DynamicKubernetesObject httpRoute, final String namespace,
+ final String routeName) {
+ JsonObject status = JsonFields.getJsonObject(httpRoute.getRaw(), "status");
+ JsonArray existingParents = JsonFields.getJsonArray(status, "parents");
+ if (Objects.isNull(existingParents)) {
+ return;
+ }
+ JsonArray kept = new JsonArray();
+ boolean hasShenyuEntry = false;
+ for (JsonElement element : existingParents) {
+ if (!element.isJsonObject()) {
+ continue;
+ }
+ JsonObject parent = element.getAsJsonObject();
+ if (GatewayApiConstants.SHENYU_CONTROLLER_NAME.equals(JsonFields.getString(parent, "controllerName"))) {
+ hasShenyuEntry = true;
+ } else {
+ kept.add(parent);
+ }
+ }
+ if (!hasShenyuEntry) {
+ return;
+ }
+ JsonObject newStatus = new JsonObject();
+ newStatus.add("parents", kept);
+ sendStatusPatch(httpRoute, namespace, routeName, newStatus);
+ }
+
+ private void sendStatusPatch(final DynamicKubernetesObject httpRoute, final String namespace,
+ final String routeName, final JsonObject status) {
+ JsonObject patch = new JsonObject();
+ patch.addProperty("apiVersion", GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION);
+ patch.addProperty("kind", GatewayApiConstants.HTTP_ROUTE_KIND);
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("namespace", namespace);
+ metadata.addProperty("name", routeName);
+ patch.add("metadata", metadata);
+ patch.add("status", status);
+
+ String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+ + "/namespaces/" + namespace + "/httproutes/" + routeName + "/status";
+ try {
+ StatusMergePatch.patch(apiClient, path, patch);
+ LOG.debug("Updated status of HTTPRoute {}/{}", namespace, routeName);
+ } catch (ApiException e) {
+ LOG.warn("Failed to update status of HTTPRoute {}/{}: {}", namespace, routeName, e.getMessage());
+ }
+ }
+
+ /** One parentRef verdict: acceptance with hostnames/listeners, or rejection with a spec reason. */
+ private static final class ParentDecision {
+
+ private final JsonObject parentRef;
+
+ private final String parentNamespace;
+
+ private final String parentName;
+
+ private final boolean accepted;
+
+ private final String reason;
+
+ private final String message;
+
+ private final List<String> hostnames;
+
+ private final Set<String> listenerNames;
+
+ private ParentDecision(final JsonObject parentRef, final String parentNamespace, final String parentName,
+ final boolean accepted, final String reason, final String message,
+ final List<String> hostnames, final Set<String> listenerNames) {
+ this.parentRef = parentRef;
+ this.parentNamespace = parentNamespace;
+ this.parentName = parentName;
+ this.accepted = accepted;
+ this.reason = reason;
+ this.message = message;
+ this.hostnames = hostnames;
+ this.listenerNames = listenerNames;
+ }
+
+ static ParentDecision accepted(final JsonObject parentRef, final String parentNamespace, final String parentName,
+ final List<String> hostnames, final Set<String> listenerNames) {
+ return new ParentDecision(parentRef, parentNamespace, parentName, true, null, null, hostnames, listenerNames);
+ }
+
+ static ParentDecision rejected(final JsonObject parentRef, final String parentNamespace, final String parentName,
+ final String reason, final String message) {
+ return new ParentDecision(parentRef, parentNamespace, parentName, false, reason, message, List.of(), Set.of());
+ }
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteBackendHandler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteBackendHandler.java
new file mode 100644
index 0000000000..cb76a107df
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteBackendHandler.java
@@ -0,0 +1,120 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.JsonFields;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Shared machinery of the backend event handlers (Endpoints, Service): both resolve
+ * affected HTTPRoutes through the informer index on referenced Service ("namespace/name",
+ * shared with the Endpoints object) instead of scanning the cache. The queue deduplicates
+ * and the reconciler re-checks binding, so extra enqueues are harmless.
+ */
+abstract class HttpRouteBackendHandler {
+
+ /** Index key: the Services ("namespace/name") each route's backendRefs reference. */
+ static final String BACKEND_SERVICE_INDEX = "backendService";
+
+ private final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer;
+
+ private final RateLimitingQueue<Request> httpRouteWorkQueue;
+
+ HttpRouteBackendHandler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+ final RateLimitingQueue<Request> httpRouteWorkQueue) {
+ this.httpRouteInformer = httpRouteInformer;
+ this.httpRouteWorkQueue = httpRouteWorkQueue;
+ // Indexers must be registered before the informer starts; the handler beans are
+ // created (and depended on by the controller manager) before informers run. Both
+ // backend handlers share one index, so whichever registers first wins.
+ if (!httpRouteInformer.getIndexer().getIndexers().containsKey(BACKEND_SERVICE_INDEX)) {
+ this.httpRouteInformer.addIndexers(Map.of(BACKEND_SERVICE_INDEX, HttpRouteBackendHandler::indexBackendServices));
+ }
+ }
+
+ static List<String> indexBackendServices(final DynamicKubernetesObject route) {
+ if (Objects.isNull(route.getMetadata())) {
+ return Collections.emptyList();
+ }
+ String routeNamespace = route.getMetadata().getNamespace();
+ JsonObject spec = JsonFields.getJsonObject(route.getRaw(), "spec");
+ JsonElement rulesElement = Objects.isNull(spec) ? null : JsonFields.getJsonArray(spec, "rules");
+ if (Objects.isNull(rulesElement)) {
+ return Collections.emptyList();
+ }
+ List<String> keys = new ArrayList<>();
+ for (JsonElement ruleElement : rulesElement.getAsJsonArray()) {
+ JsonObject rule = ruleElement.getAsJsonObject();
+ JsonElement refsElement = JsonFields.getJsonArray(rule, "backendRefs");
+ if (Objects.isNull(refsElement)) {
+ continue;
+ }
+ for (JsonElement refElement : refsElement.getAsJsonArray()) {
+ JsonObject ref = refElement.getAsJsonObject();
+ if (!GatewayApiConstants.isServiceRef(ref)) {
+ continue;
+ }
+ String name = JsonFields.getString(ref, "name");
+ String namespace = JsonFields.getString(ref, "namespace");
+ if (Objects.isNull(name)) {
+ continue;
+ }
+ keys.add((Objects.isNull(namespace) ? routeNamespace : namespace) + "/" + name);
+ }
+ }
+ return keys;
+ }
+
+ /** Null-safe accessors so each event callback stays a one-liner. */
+ static String namespaceOf(final V1ObjectMeta metadata) {
+ return Objects.isNull(metadata) ? null : metadata.getNamespace();
+ }
+
+ static String nameOf(final V1ObjectMeta metadata) {
+ return Objects.isNull(metadata) ? null : metadata.getName();
+ }
+
+ /**
+ * Enqueue every HTTPRoute whose backendRefs target the Service {@code namespace/name}.
+ * Null coordinates (malformed event object) enqueue nothing.
+ */
+ final void enqueueAffectedRoutes(final String namespace, final String name) {
+ if (Objects.isNull(namespace) || Objects.isNull(name)) {
+ return;
+ }
+ for (DynamicKubernetesObject route : httpRouteInformer.getIndexer().byIndex(BACKEND_SERVICE_INDEX, namespace + "/" + name)) {
+ if (Objects.isNull(route.getMetadata())) {
+ continue;
+ }
+ httpRouteWorkQueue.add(new Request(route.getMetadata().getNamespace(), route.getMetadata().getName()));
+ }
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteEndpointsHandler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteEndpointsHandler.java
new file mode 100644
index 0000000000..c27638c8e9
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteEndpointsHandler.java
@@ -0,0 +1,49 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.ResourceEventHandler;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+
+/** Bridges Endpoints events to HTTPRoute reconciliation; see {@link HttpRouteBackendHandler}. */
+public final class HttpRouteEndpointsHandler extends HttpRouteBackendHandler implements ResourceEventHandler<V1Endpoints> {
+
+ public HttpRouteEndpointsHandler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+ final RateLimitingQueue<Request> httpRouteWorkQueue) {
+ super(httpRouteInformer, httpRouteWorkQueue);
+ }
+
+ @Override
+ public void onAdd(final V1Endpoints endpoints) {
+ enqueueAffectedRoutes(namespaceOf(endpoints.getMetadata()), nameOf(endpoints.getMetadata()));
+ }
+
+ @Override
+ public void onUpdate(final V1Endpoints oldEndpoints, final V1Endpoints newEndpoints) {
+ enqueueAffectedRoutes(namespaceOf(newEndpoints.getMetadata()), nameOf(newEndpoints.getMetadata()));
+ }
+
+ @Override
+ public void onDelete(final V1Endpoints endpoints, final boolean unknownState) {
+ enqueueAffectedRoutes(namespaceOf(endpoints.getMetadata()), nameOf(endpoints.getMetadata()));
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteServiceHandler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteServiceHandler.java
new file mode 100644
index 0000000000..f40dcf11f0
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteServiceHandler.java
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.ResourceEventHandler;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+
+/**
+ * Bridges Service events to route reconciliation: port/targetPort edits never touch
+ * Endpoints, so without this trigger routes keep the stale pod port until the resync.
+ */
+public final class HttpRouteServiceHandler extends HttpRouteBackendHandler implements ResourceEventHandler<V1Service> {
+
+ public HttpRouteServiceHandler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+ final RateLimitingQueue<Request> httpRouteWorkQueue) {
+ super(httpRouteInformer, httpRouteWorkQueue);
+ }
+
+ @Override
+ public void onAdd(final V1Service service) {
+ enqueueAffectedRoutes(namespaceOf(service.getMetadata()), nameOf(service.getMetadata()));
+ }
+
+ @Override
+ public void onUpdate(final V1Service oldService, final V1Service newService) {
+ enqueueAffectedRoutes(namespaceOf(newService.getMetadata()), nameOf(newService.getMetadata()));
+ }
+
+ @Override
+ public void onDelete(final V1Service service, final boolean unknownState) {
+ enqueueAffectedRoutes(namespaceOf(service.getMetadata()), nameOf(service.getMetadata()));
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/ReferenceGrantReconciler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/ReferenceGrantReconciler.java
new file mode 100644
index 0000000000..184e025c57
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/ReferenceGrantReconciler.java
@@ -0,0 +1,117 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Objects;
+
+/**
+ * Re-queues every HTTPRoute referencing the grant's namespace, so a revoked grant stops
+ * unauthorized traffic immediately instead of at the next route resync.
+ */
+public class ReferenceGrantReconciler implements Reconciler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ReferenceGrantReconciler.class);
+
+ private final Lister<DynamicKubernetesObject> httpRouteLister;
+
+ private final RateLimitingQueue<Request> httpRouteWorkQueue;
+
+ public ReferenceGrantReconciler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+ final RateLimitingQueue<Request> httpRouteWorkQueue) {
+ this.httpRouteLister = new Lister<>(httpRouteInformer.getIndexer());
+ this.httpRouteWorkQueue = httpRouteWorkQueue;
+ }
+
+ @Override
+ public Result reconcile(final Request request) {
+ String grantNamespace = request.getNamespace();
+ LOG.debug("ReferenceGrant {}/{} changed, re-queuing routes referencing namespace {}",
+ grantNamespace, request.getName(), grantNamespace);
+ try {
+ for (DynamicKubernetesObject route : httpRouteLister.list()) {
+ String routeNamespace = Objects.requireNonNull(route.getMetadata()).getNamespace();
+ // Same-namespace references never consult grants
+ if (grantNamespace.equals(routeNamespace)) {
+ continue;
+ }
+ if (referencesNamespace(route, grantNamespace)) {
+ httpRouteWorkQueue.add(new Request(routeNamespace, route.getMetadata().getName()));
+ LOG.info("Re-queued HTTPRoute {}/{} due to ReferenceGrant change in namespace {}",
+ routeNamespace, route.getMetadata().getName(), grantNamespace);
+ }
+ }
+ return new Result(false);
+ } catch (Exception e) {
+ LOG.error("Error reconciling ReferenceGrant {}, will retry", request, e);
+ return new Result(true);
+ }
+ }
+
+ /** Cross-namespace parentRef or backendRef into the namespace: exactly what a grant there permits or denies. */
+ private boolean referencesNamespace(final DynamicKubernetesObject route, final String targetNamespace) {
+ JsonObject spec = JsonFields.getJsonObject(route.getRaw(), "spec");
+ if (Objects.isNull(spec)) {
+ return false;
+ }
+ if (referencesNamespace(JsonFields.getJsonArray(spec, "parentRefs"), targetNamespace)) {
+ return true;
+ }
+ JsonArray rules = JsonFields.getJsonArray(spec, "rules");
+ if (Objects.isNull(rules)) {
+ return false;
+ }
+ for (JsonElement ruleElement : rules) {
+ if (!ruleElement.isJsonObject()) {
+ continue;
+ }
+ if (referencesNamespace(JsonFields.getJsonArray(ruleElement.getAsJsonObject(), "backendRefs"), targetNamespace)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean referencesNamespace(final JsonArray refs, final String targetNamespace) {
+ if (Objects.isNull(refs)) {
+ return false;
+ }
+ for (JsonElement element : refs) {
+ if (!element.isJsonObject()) {
+ continue;
+ }
+ if (targetNamespace.equals(JsonFields.getString(element.getAsJsonObject(), "namespace"))) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/repository/ShenyuCacheRepository.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/repository/ShenyuCacheRepository.java
index e56d71f3c1..51e5f1f6e5 100644
--- a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/repository/ShenyuCacheRepository.java
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/repository/ShenyuCacheRepository.java
@@ -26,6 +26,8 @@ import org.apache.shenyu.common.dto.RuleData;
import org.apache.shenyu.common.dto.SelectorData;
import org.apache.shenyu.common.dto.convert.selector.DivideUpstream;
import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
import org.apache.shenyu.plugin.base.cache.BaseDataCache;
import org.apache.shenyu.plugin.base.cache.CommonDiscoveryUpstreamDataSubscriber;
import org.apache.shenyu.plugin.base.cache.CommonPluginDataSubscriber;
@@ -36,10 +38,13 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Timestamp;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
+import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -114,22 +119,92 @@ public class ShenyuCacheRepository {
}
/**
- * Save or update SelectorData by SelectorData.
+ * Save or update SelectorData by SelectorData. Idempotent: a selector whose cached
+ * content is already equal (including the upstream list in its handle) is skipped, so
+ * the periodic informer resync does not churn the data plane with no-op updates.
*
* @param selectorData SelectorData
*/
public void saveOrUpdateSelectorData(final SelectorData selectorData) {
- subscriber.onSelectorSubscribe(selectorData);
+ List<DiscoveryUpstreamData> upstreamDataList = new ArrayList<>(convert(selectorData.getPluginName(), selectorData.getHandle()));
+ Set<String> newKeys = upstreamDataList.stream().map(ShenyuCacheRepository::upstreamKey).collect(Collectors.toSet());
+ List<Upstream> cachedUpstreams = UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selectorData.getId());
+ Set<String> cachedKeys = CollectionUtils.isEmpty(cachedUpstreams) ? Collections.emptySet()
+ : cachedUpstreams.stream().map(ShenyuCacheRepository::upstreamKey).collect(Collectors.toSet());
+ if (CollectionUtils.isNotEmpty(cachedUpstreams)) {
+ for (Upstream cached : cachedUpstreams) {
+ if (!newKeys.contains(upstreamKey(cached))) {
+ upstreamDataList.add(buildOfflineUpstreamData(cached.getUrl(), cached.getProtocol()));
+ }
+ }
+ }
+ boolean upstreamsChanged = !cachedKeys.equals(newKeys);
+ SelectorData cachedSelector = findSelectorData(selectorData.getPluginName(), selectorData.getId());
+ boolean selectorChanged = !selectorData.equals(cachedSelector);
+ if (!selectorChanged && !upstreamsChanged) {
+ return;
+ }
DiscoverySyncData discoverySyncData = new DiscoverySyncData();
discoverySyncData.setSelectorName(selectorData.getName());
discoverySyncData.setSelectorId(selectorData.getId());
discoverySyncData.setPluginName(selectorData.getPluginName());
- discoverySyncData.setUpstreamDataList(convert(selectorData.getPluginName(), selectorData.getHandle()));
+ discoverySyncData.setUpstreamDataList(upstreamDataList);
saveOrUpdateDiscoveryUpstreamData(discoverySyncData);
+ if (upstreamsChanged) {
+ LOG.info("Resolved {} upstream(s) for selector {}", newKeys.size(), selectorData.getId());
+ }
+ if (selectorChanged) {
+ subscriber.onSelectorSubscribe(selectorData);
+ LOG.info("Published divide selector {} for HTTPRoute coordinates {}", selectorData.getId(), selectorData.getName());
+ }
+ }
+
+ /**
+ * Upstream identity key, matching {@link UpstreamCacheManager}'s merge key: protocol and
+ * URL together. Comparing URL alone would leave a stale entry routable when the protocol
+ * of an address changes (http:// → https://), since neither submit nor the offline loop
+ * would evict the old-protocol entry.
+ */
+ private static String upstreamKey(final String protocol, final String url) {
+ return Objects.requireNonNullElse(protocol, "") + "_" + Objects.requireNonNullElse(url, "");
+ }
+
+ private static String upstreamKey(final DiscoveryUpstreamData upstreamData) {
+ return upstreamKey(upstreamData.getProtocol(), upstreamData.getUrl());
+ }
+
+ private static String upstreamKey(final Upstream upstream) {
+ return upstreamKey(upstream.getProtocol(), upstream.getUrl());
+ }
+
+ /**
+ * Find a cached SelectorData by plugin name and selector id.
+ *
+ * @param pluginName plugin name
+ * @param selectorId selector id
+ * @return the cached selector, or null
+ */
+ public SelectorData findSelectorData(final String pluginName, final String selectorId) {
+ List<SelectorData> selectors = BaseDataCache.getInstance().obtainSelectorData(pluginName);
+ if (CollectionUtils.isEmpty(selectors)) {
+ return null;
+ }
+ return selectors.stream()
+ .filter(selector -> selector.getId().equals(selectorId))
+ .findFirst().orElse(null);
+ }
+
+ private DiscoveryUpstreamData buildOfflineUpstreamData(final String url, final String protocol) {
+ DiscoveryUpstreamData upstreamData = new DiscoveryUpstreamData();
+ upstreamData.setUrl(url);
+ upstreamData.setProtocol(protocol);
+ // status 1 marks the entry offline, which is how submit evicts it from the cache
+ upstreamData.setStatus(1);
+ upstreamData.setDateUpdated(new Timestamp(System.currentTimeMillis()));
+ return upstreamData;
}
private List<DiscoveryUpstreamData> convert(final String pluginName, final String handle) {
- LOG.info("saveOrUpdateSelectorData convert handle={}", handle);
List<DivideUpstream> divideUpstreams = GsonUtils.getInstance().fromList(handle, DivideUpstream.class);
if (CollectionUtils.isEmpty(divideUpstreams)) {
return Collections.emptyList();
@@ -169,22 +244,46 @@ public class ShenyuCacheRepository {
subscriber.unSelectorSubscribe(SelectorData.builder().pluginName(pluginName).id(selectorId).build());
}
+ /**
+ * Delete a selector together with all rules still attached to it. findRuleDataList
+ * exposes the cache's mutable internal list, so iterate over a copy to avoid
+ * ConcurrentModificationException.
+ *
+ * @param pluginName plugin name
+ * @param selectorId selector id
+ */
+ public void deleteSelectorWithRules(final String pluginName, final String selectorId) {
+ List<RuleData> rules = findRuleDataList(selectorId);
+ if (CollectionUtils.isNotEmpty(rules)) {
+ for (RuleData rule : new ArrayList<>(rules)) {
+ deleteRuleData(pluginName, selectorId, rule.getId());
+ }
+ }
+ deleteSelectorData(pluginName, selectorId);
+ }
+
/**
* Find RuleData list by selector id.
*
* @param selectorId selector id
- * @return RuleData list
+ * @return RuleData list, never null; empty when the selector has no cached rules yet
*/
public List<RuleData> findRuleDataList(final String selectorId) {
- return BaseDataCache.getInstance().obtainRuleData(selectorId);
+ return Optional.ofNullable(BaseDataCache.getInstance().obtainRuleData(selectorId)).orElse(Collections.emptyList());
}
/**
- * Save or update RuleData by RuleData.
+ * Save or update RuleData by RuleData. Idempotent like the selector path: an unchanged
+ * rule is skipped so the periodic resync does not churn the data plane.
*
* @param ruleData RuleData
*/
public void saveOrUpdateRuleData(final RuleData ruleData) {
+ boolean unchanged = findRuleDataList(ruleData.getSelectorId()).stream()
+ .anyMatch(cached -> cached.getId().equals(ruleData.getId()) && cached.equals(ruleData));
+ if (unchanged) {
+ return;
+ }
subscriber.onRuleSubscribe(ruleData);
}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayClassReconcilerTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayClassReconcilerTest.java
new file mode 100644
index 0000000000..daf332236a
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayClassReconcilerTest.java
@@ -0,0 +1,405 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.common.GatewayApiCrdVersions;
+import org.apache.shenyu.k8s.reconciler.GatewayClassReconciler;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * GatewayClass Reconciler Test: acceptance (with SupportedVersion and supportedFeatures
+ * reporting), parametersRef rejection, the spec-defined finalizer lifecycle, foreign
+ * classes, and the ownership-loss transition (controllerName re-pointed away from ShenYu)
+ * which must re-queue previously served Gateways immediately instead of waiting for their
+ * resync.
+ */
+public final class GatewayClassReconcilerTest {
+
+ private static final String SHENYU_CONTROLLER = "gateway.shenyu.apache.org/shenyu-controller";
+
+ private static final String FINALIZER = "gateway-exists-finalizer.gateway.networking.k8s.io";
+
+ private static final String STATUS_PATH_SUFFIX = "/status";
+
+ private RateLimitingQueue<Request> gatewayWorkQueue;
+
+ private ApiClient reconcilerApi;
+
+ private ArgumentCaptor<String> pathCaptor;
+
+ private ArgumentCaptor<Object> bodyCaptor;
+
+ @BeforeEach
+ public void setUp() {
+ GatewayRouteCache.getInstance().clear();
+ }
+
+ /** A ShenYu GatewayClass without Accepted status gets Accepted=True patched together with the SupportedVersion condition and the supportedFeatures list. */
+ @Test
+ public void testShenYuGatewayClassGetsAcceptedStatus() throws Exception {
+ GatewayClassReconciler reconciler = reconciler(
+ gatewayClass("shenyu", SHENYU_CONTROLLER, null), null);
+
+ Result result = reconciler.reconcile(new Request("", "shenyu"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(reconcilerApi).execute(any(okhttp3.Call.class));
+
+ JsonObject status = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status");
+ Assertions.assertEquals(2, status.getAsJsonArray("conditions").size());
+ JsonObject accepted = condition(status, "Accepted");
+ Assertions.assertEquals("True", accepted.get("status").getAsString());
+ JsonObject supportedVersion = condition(status, "SupportedVersion");
+ Assertions.assertEquals("True", supportedVersion.get("status").getAsString());
+ Assertions.assertEquals("SupportedVersion", supportedVersion.get("reason").getAsString());
+ Assertions.assertEquals(3, status.getAsJsonArray("supportedFeatures").size());
+ Assertions.assertEquals("Gateway", featureName(status, 0));
+ Assertions.assertEquals("HTTPRoute", featureName(status, 1));
+ Assertions.assertEquals("ReferenceGrant", featureName(status, 2));
+ }
+
+ /** Unsupported CRD bundle versions still accept the class (best effort) but flip the SupportedVersion condition to False with the standard reason. */
+ @Test
+ public void testUnsupportedCrdVersionsFlipSupportedVersion() throws Exception {
+ GatewayClassReconciler reconciler = reconciler(
+ gatewayClass("shenyu", SHENYU_CONTROLLER, null), null,
+ new GatewayApiCrdVersions(List.of("main"), false));
+
+ Result result = reconciler.reconcile(new Request("", "shenyu"));
+ Assertions.assertEquals(new Result(false), result);
+
+ JsonObject status = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status");
+ Assertions.assertEquals("True", condition(status, "Accepted").get("status").getAsString());
+ Assertions.assertEquals("False", condition(status, "SupportedVersion").get("status").getAsString());
+ Assertions.assertEquals("UnsupportedVersion", condition(status, "SupportedVersion").get("reason").getAsString());
+ }
+
+ /** Steady state: an already-reported status (Accepted=True at the current generation, matching SupportedVersion and supportedFeatures) produces no patch at all. */
+ @Test
+ public void testSteadyStateStatusIsNotPatchedAgain() throws Exception {
+ GatewayClassReconciler reconciler = reconciler(
+ gatewayClass("shenyu", SHENYU_CONTROLLER, steadyStatus()), null);
+
+ Result result = reconciler.reconcile(new Request("", "shenyu"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(reconcilerApi, never()).execute(any(okhttp3.Call.class));
+ }
+
+ /** A class with a parametersRef is rejected with Accepted=False/InvalidParameters and its never-served Gateways are left alone. */
+ @Test
+ public void testParametersRefClassIsRejected() throws Exception {
+ final DynamicKubernetesObject gatewayClass = gatewayClass("shenyu", SHENYU_CONTROLLER, null);
+ JsonObject parametersRef = new JsonObject();
+ parametersRef.addProperty("group", "gateway.shenyu.apache.org");
+ parametersRef.addProperty("kind", "ShenyuClassConfig");
+ parametersRef.addProperty("name", "cfg");
+ gatewayClass.getRaw().getAsJsonObject("spec").add("parametersRef", parametersRef);
+
+ GatewayClassReconciler reconciler = reconciler(gatewayClass, null);
+
+ Result result = reconciler.reconcile(new Request("", "shenyu"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(gatewayWorkQueue, never()).add(any(Request.class));
+ verify(reconcilerApi).execute(any(okhttp3.Call.class));
+
+ JsonObject status = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status");
+ JsonObject rejected = condition(status, "Accepted");
+ Assertions.assertEquals("False", rejected.get("status").getAsString());
+ Assertions.assertEquals("InvalidParameters", rejected.get("reason").getAsString());
+ }
+
+ /** Ownership loss: the class was accepted by ShenYu (our Accepted=True payload) and its controllerName moved to another controller. */
+ @Test
+ public void testOwnershipLossRequeuesServedGatewaysAndDowngradesStatus() throws Exception {
+ JsonObject accepted = new JsonObject();
+ accepted.addProperty("type", "Accepted");
+ accepted.addProperty("status", "True");
+ accepted.addProperty("reason", "Accepted");
+ accepted.addProperty("message", "GatewayClass has been accepted by the ShenYu controller");
+ JsonArray conditions = new JsonArray();
+ conditions.add(accepted);
+ JsonObject status = new JsonObject();
+ status.add("conditions", conditions);
+
+ GatewayRouteCache cache = GatewayRouteCache.getInstance();
+ cache.bindRouteToGateway("mockedNamespace", "shenyu-gateway", Set.of("http"),
+ "mockedNamespace", "test-route");
+
+ GatewayClassReconciler reconciler = reconciler(
+ gatewayClass("shenyu", "example.com/other-controller", status),
+ gateway("mockedNamespace", "shenyu-gateway", "shenyu"));
+
+ Result result = reconciler.reconcile(new Request("", "shenyu"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(gatewayWorkQueue).add(new Request("mockedNamespace", "shenyu-gateway"));
+ verify(reconcilerApi).execute(any(okhttp3.Call.class));
+
+ JsonObject patched = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status");
+ Assertions.assertEquals("False", condition(patched, "Accepted").get("status").getAsString());
+ Assertions.assertEquals("Unsupported", condition(patched, "Accepted").get("reason").getAsString());
+ }
+
+ /** A foreign class ShenYu never served (no bindings, no ShenYu-written status) must be skipped entirely: re-queuing its Gateways or patching its status would fight the controller that owns it. */
+ @Test
+ public void testForeignGatewayClassNeverServedIsSkipped() throws Exception {
+ GatewayClassReconciler reconciler = reconciler(
+ gatewayClass("other-class", "example.com/other-controller", null),
+ gateway("mockedNamespace", "some-gateway", "other-class"));
+
+ Result result = reconciler.reconcile(new Request("", "other-class"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(gatewayWorkQueue, never()).add(any(Request.class));
+ verify(reconcilerApi, never()).execute(any(okhttp3.Call.class));
+ }
+
+ /** An accepted class used by a Gateway gains the spec-defined finalizer through a patch on the main resource (not the /status subresource). */
+ @Test
+ public void testFinalizerAddedWhenGatewayUsesClass() throws Exception {
+ GatewayClassReconciler reconciler = reconciler(
+ gatewayClass("shenyu", SHENYU_CONTROLLER, null),
+ gateway("mockedNamespace", "shenyu-gateway", "shenyu"));
+
+ Result result = reconciler.reconcile(new Request("", "shenyu"));
+ Assertions.assertEquals(new Result(false), result);
+
+ boolean finalizerPatchFound = false;
+ for (int i = 0; i < pathCaptor.getAllValues().size(); i++) {
+ String path = pathCaptor.getAllValues().get(i);
+ if (!path.endsWith(STATUS_PATH_SUFFIX)) {
+ finalizerPatchFound = true;
+ JsonArray finalizers = ((JsonObject) bodyCaptor.getAllValues().get(i))
+ .getAsJsonObject("metadata").getAsJsonArray("finalizers");
+ Assertions.assertEquals(1, finalizers.size());
+ Assertions.assertEquals(FINALIZER, finalizers.get(0).getAsString());
+ }
+ }
+ Assertions.assertTrue(finalizerPatchFound, "a finalizer patch on the main resource is expected");
+ }
+
+ /** The finalizer is removed once no Gateway references the class anymore. */
+ @Test
+ public void testFinalizerRemovedWhenNoGatewayUsesClass() throws Exception {
+ DynamicKubernetesObject gatewayClass = gatewayClass("shenyu", SHENYU_CONTROLLER, null);
+ JsonArray finalizers = new JsonArray();
+ finalizers.add(FINALIZER);
+ gatewayClass.getRaw().getAsJsonObject("metadata").add("finalizers", finalizers);
+
+ GatewayClassReconciler reconciler = reconciler(gatewayClass, null);
+
+ Result result = reconciler.reconcile(new Request("", "shenyu"));
+ Assertions.assertEquals(new Result(false), result);
+
+ boolean removalPatchFound = false;
+ for (int i = 0; i < pathCaptor.getAllValues().size(); i++) {
+ if (!pathCaptor.getAllValues().get(i).endsWith(STATUS_PATH_SUFFIX)) {
+ removalPatchFound = true;
+ JsonArray patched = ((JsonObject) bodyCaptor.getAllValues().get(i))
+ .getAsJsonObject("metadata").getAsJsonArray("finalizers");
+ Assertions.assertEquals(0, patched.size());
+ }
+ }
+ Assertions.assertTrue(removalPatchFound, "a finalizer removal patch is expected");
+ }
+
+ /** A deleting class gets no status writes, keeps the finalizer while Gateways still reference it, and is released once they are gone. */
+ @Test
+ public void testDeletingClassKeepsFinalizerWhileInUseAndReleasesAfterDrain() throws Exception {
+ DynamicKubernetesObject inUse = deletingClass();
+ GatewayClassReconciler reconciler = reconciler(inUse,
+ gateway("mockedNamespace", "shenyu-gateway", "shenyu"));
+ Assertions.assertEquals(new Result(false), reconciler.reconcile(new Request("", "shenyu")));
+ // no status patch, no finalizer change: the class is still in use
+ verify(reconcilerApi, never()).execute(any(okhttp3.Call.class));
+ verify(gatewayWorkQueue).add(new Request("mockedNamespace", "shenyu-gateway"));
+
+ DynamicKubernetesObject drained = deletingClass();
+ reconciler = reconciler(drained, null);
+ Assertions.assertEquals(new Result(false), reconciler.reconcile(new Request("", "shenyu")));
+ verify(reconcilerApi, times(1)).execute(any(okhttp3.Call.class));
+ Assertions.assertFalse(pathCaptor.getValue().endsWith(STATUS_PATH_SUFFIX));
+ JsonArray patched = ((JsonObject) bodyCaptor.getValue())
+ .getAsJsonObject("metadata").getAsJsonArray("finalizers");
+ Assertions.assertEquals(0, patched.size());
+ }
+
+ private DynamicKubernetesObject deletingClass() {
+ DynamicKubernetesObject gatewayClass = gatewayClass("shenyu", SHENYU_CONTROLLER, null);
+ JsonArray finalizers = new JsonArray();
+ finalizers.add(FINALIZER);
+ gatewayClass.getRaw().getAsJsonObject("metadata").add("finalizers", finalizers);
+ gatewayClass.getRaw().getAsJsonObject("metadata").addProperty("deletionTimestamp", "2026-01-01T00:00:00Z");
+ return gatewayClass;
+ }
+
+ private static JsonObject condition(final JsonObject status, final String type) {
+ JsonArray conditions = status.getAsJsonArray("conditions");
+ for (JsonElement element : conditions) {
+ if (type.equals(element.getAsJsonObject().get("type").getAsString())) {
+ return element.getAsJsonObject();
+ }
+ }
+ throw new AssertionError("condition " + type + " not found");
+ }
+
+ private static String featureName(final JsonObject status, final int index) {
+ return status.getAsJsonArray("supportedFeatures").get(index).getAsJsonObject()
+ .get("name").getAsString();
+ }
+
+ /** The full status an accepted, fully reported class carries in steady state. */
+ private static JsonObject steadyStatus() {
+ JsonObject accepted = new JsonObject();
+ accepted.addProperty("type", "Accepted");
+ accepted.addProperty("status", "True");
+ accepted.addProperty("reason", "Accepted");
+ accepted.addProperty("message", "GatewayClass has been accepted by the ShenYu controller");
+ accepted.addProperty("observedGeneration", 1L);
+ accepted.addProperty("lastTransitionTime", "2026-01-01T00:00:00Z");
+ JsonObject supportedVersion = new JsonObject();
+ supportedVersion.addProperty("type", "SupportedVersion");
+ supportedVersion.addProperty("status", "True");
+ supportedVersion.addProperty("reason", "SupportedVersion");
+ supportedVersion.addProperty("message", "detected Gateway API CRD bundle version(s): v1.5.1; supported: >= v1.5.0");
+ supportedVersion.addProperty("observedGeneration", 1L);
+ supportedVersion.addProperty("lastTransitionTime", "2026-01-01T00:00:00Z");
+ JsonArray conditions = new JsonArray();
+ conditions.add(accepted);
+ conditions.add(supportedVersion);
+
+ JsonArray supportedFeatures = new JsonArray();
+ for (String name : List.of("Gateway", "HTTPRoute", "ReferenceGrant")) {
+ JsonObject feature = new JsonObject();
+ feature.addProperty("name", name);
+ supportedFeatures.add(feature);
+ }
+
+ JsonObject status = new JsonObject();
+ status.add("conditions", conditions);
+ status.add("supportedFeatures", supportedFeatures);
+ return status;
+ }
+
+ private GatewayClassReconciler reconciler(final DynamicKubernetesObject gatewayClass,
+ final DynamicKubernetesObject gateway) {
+ return reconciler(gatewayClass, gateway, new GatewayApiCrdVersions(List.of("v1.5.1"), true));
+ }
+
+ private GatewayClassReconciler reconciler(final DynamicKubernetesObject gatewayClass,
+ final DynamicKubernetesObject gateway,
+ final GatewayApiCrdVersions crdVersions) {
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayClassIndexer = mock(Indexer.class);
+ when(gatewayClassIndexer.getByKey("shenyu")).thenReturn(gatewayClass);
+ when(gatewayClassIndexer.getByKey("other-class")).thenReturn(gatewayClass);
+ when(gatewayClassInformer.getIndexer()).thenReturn(gatewayClassIndexer);
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ if (Objects.nonNull(gateway)) {
+ when(gatewayIndexer.list()).thenReturn(List.of(gateway));
+ } else {
+ when(gatewayIndexer.list()).thenReturn(List.of());
+ }
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ gatewayWorkQueue = mock(RateLimitingQueue.class);
+ reconcilerApi = mock(ApiClient.class);
+ pathCaptor = ArgumentCaptor.forClass(String.class);
+ bodyCaptor = ArgumentCaptor.forClass(Object.class);
+ try {
+ when(reconcilerApi.getAuthentications()).thenReturn(Map.of());
+ when(reconcilerApi.buildCall(pathCaptor.capture(), any(), any(), any(), bodyCaptor.capture(),
+ any(), any(), any(), any(), any()))
+ .thenReturn(mock(okhttp3.Call.class));
+ } catch (ApiException e) {
+ throw new IllegalStateException(e);
+ }
+ return new GatewayClassReconciler(gatewayClassInformer, gatewayInformer, gatewayWorkQueue, reconcilerApi, crdVersions);
+ }
+
+ private DynamicKubernetesObject gatewayClass(final String name, final String controllerName,
+ final JsonObject status) {
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("name", name);
+ metadata.addProperty("generation", 1L);
+
+ JsonObject spec = new JsonObject();
+ spec.addProperty("controllerName", controllerName);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "GatewayClass");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ if (Objects.nonNull(status)) {
+ raw.add("status", status);
+ }
+ return new DynamicKubernetesObject(raw);
+ }
+
+ private DynamicKubernetesObject gateway(final String namespace, final String name,
+ final String gatewayClassName) {
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("namespace", namespace);
+ metadata.addProperty("name", name);
+
+ JsonObject listener = new JsonObject();
+ listener.addProperty("name", "http");
+ listener.addProperty("protocol", "HTTP");
+ listener.addProperty("port", 9195);
+ JsonArray listeners = new JsonArray();
+ listeners.add(listener);
+
+ JsonObject spec = new JsonObject();
+ spec.addProperty("gatewayClassName", gatewayClassName);
+ spec.add("listeners", listeners);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "Gateway");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayReconcilerTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayReconcilerTest.java
new file mode 100644
index 0000000000..ba1f85c85a
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayReconcilerTest.java
@@ -0,0 +1,402 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.reconciler.GatewayReconciler;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Set;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Gateway Reconciler Test.
+ */
+public final class GatewayReconcilerTest {
+
+ @BeforeEach
+ public void setUp() {
+ GatewayRouteCache.getInstance().clear();
+ }
+
+ /** Test ShenYu Gateway creation. */
+ @Test
+ public void testReconcileShenYuGatewayCreation() throws Exception {
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+ "mockedNamespace", "shenyu-gateway", "testService", 8189);
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+ when(httpRouteIndexer.list()).thenReturn(List.of(httpRoute));
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+ RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+ ApiClient apiClient = mockApiClientWithStatusPatch();
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+ httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+ Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(httpRouteWorkQueue).add(new Request("mockedNamespace", "test-route"));
+ // the accept transition re-queues the class so its finalizer is added immediately
+ verify(gatewayClassWorkQueue).add(new Request("", "shenyu"));
+ verify(apiClient).execute(any(okhttp3.Call.class));
+ }
+
+ /** Test Gateway deletion: should cascade delete ShenYu config for associated routes. */
+ @Test
+ public void testReconcileGatewayDeletion() {
+ // gateway not found in indexer → treated as deletion
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(null);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ GatewayRouteCache cache = GatewayRouteCache.getInstance();
+ cache.bindRouteToGateway("mockedNamespace", "shenyu-gateway", Set.of("http"), "mockedNamespace", "test-route");
+ cache.putRouteSelectors("mockedNamespace", "test-route", "divide", List.of("sel-1"));
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+
+ RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+ RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+ ApiClient apiClient = mock(ApiClient.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+ httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+ Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(shenyuCacheRepository).deleteSelectorWithRules("divide", "sel-1");
+ // deletion re-queues every class: the one losing its last Gateway must drop its finalizer
+ verify(gatewayClassWorkQueue).add(new Request("", "shenyu"));
+ verify(gatewayClassWorkQueue).add(new Request("", "other-class"));
+ }
+
+ /**
+ * A Gateway whose class carries Accepted=False (ShenYu rejected it) is out of scope: previously served config is
+ * cleaned up and the Gateway status is downgraded with the standard Invalid reason.
+ */
+ @Test
+ public void testRejectedGatewayClassMakesGatewayOutOfScope() throws Exception {
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ GatewayRouteCache cache = GatewayRouteCache.getInstance();
+ cache.bindRouteToGateway("mockedNamespace", "shenyu-gateway", Set.of("http"), "mockedNamespace", "test-route");
+ cache.putRouteSelectors("mockedNamespace", "test-route", "divide", List.of("sel-1"));
+
+ final ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ final RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+ final RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+ ApiClient apiClient = mockApiClientWithStatusPatch();
+ ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+ when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+ .thenReturn(mock(okhttp3.Call.class));
+
+ final DynamicKubernetesObject rejectedClass = buildGatewayClass("shenyu", "gateway.shenyu.apache.org/shenyu-controller");
+ JsonObject rejected = new JsonObject();
+ rejected.addProperty("type", "Accepted");
+ rejected.addProperty("status", "False");
+ rejected.addProperty("reason", "InvalidParameters");
+ JsonObject status = new JsonObject();
+ status.add("conditions", new JsonArray());
+ status.getAsJsonArray("conditions").add(rejected);
+ rejectedClass.getRaw().add("status", status);
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer(rejectedClass);
+ GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+ httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+ Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(shenyuCacheRepository).deleteSelectorWithRules("divide", "sel-1");
+
+ JsonObject body = (JsonObject) bodyCaptor.getValue();
+ JsonObject downgraded = body.getAsJsonObject("status").getAsJsonArray("conditions").get(0).getAsJsonObject();
+ Assertions.assertEquals("False", downgraded.get("status").getAsString());
+ Assertions.assertEquals("Invalid", downgraded.get("reason").getAsString());
+ }
+
+ /**
+ * Test that status update is skipped when the Gateway status already reflects the full desired steady state:
+ * Accepted=True and Programmed=True conditions plus a per-listener status entry with the current attachedRoutes
+ * count.
+ */
+ @Test
+ public void testReconcileGatewayAlreadyAccepted() throws Exception {
+ JsonObject statusObj = new JsonObject();
+ statusObj.add("conditions", buildConditions("True", "True"));
+ JsonObject listenerStatus = new JsonObject();
+ listenerStatus.addProperty("name", "http");
+ listenerStatus.addProperty("attachedRoutes", 0);
+ listenerStatus.add("conditions", buildConditions("True", "True"));
+ JsonArray listeners = new JsonArray();
+ listeners.add(listenerStatus);
+ statusObj.add("listeners", listeners);
+
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ gateway.getRaw().add("status", statusObj);
+
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+ RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+ ApiClient apiClient = mock(ApiClient.class);
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+ httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+ Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(apiClient, never()).execute(any(okhttp3.Call.class));
+ }
+
+ /** attachedRoutes is defined per listener: a route bound through only one listener of a two-listener Gateway must be counted on that listener alone, not on both. */
+ @Test
+ public void testAttachedRoutesAreCountedPerListener() throws Exception {
+ final DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ JsonObject secondListener = new JsonObject();
+ secondListener.addProperty("name", "http2");
+ secondListener.addProperty("protocol", "HTTP");
+ secondListener.addProperty("port", 9195);
+ gateway.getRaw().getAsJsonObject("spec").getAsJsonArray("listeners").add(secondListener);
+
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ GatewayRouteCache.getInstance().bindRouteToGateway("mockedNamespace", "shenyu-gateway",
+ Set.of("http"), "mockedNamespace", "test-route");
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+ RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+ ApiClient apiClient = mock(ApiClient.class);
+ when(apiClient.getAuthentications()).thenReturn(Map.of());
+ ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+ when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+ .thenReturn(mock(okhttp3.Call.class));
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+ httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+ Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+ Assertions.assertEquals(new Result(false), result);
+
+ JsonArray listeners = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status").getAsJsonArray("listeners");
+ Assertions.assertEquals(2, listeners.size());
+ for (JsonElement element : listeners) {
+ JsonObject listenerStatus = element.getAsJsonObject();
+ String name = listenerStatus.get("name").getAsString();
+ int attached = listenerStatus.get("attachedRoutes").getAsInt();
+ if ("http".equals(name)) {
+ Assertions.assertEquals(1, attached, "listener the route attached to must count it");
+ } else {
+ Assertions.assertEquals(0, attached, "unrelated listener must not count the route");
+ }
+ }
+ }
+
+ private JsonArray buildConditions(final String acceptedStatus, final String programmedStatus) {
+ JsonObject accepted = new JsonObject();
+ accepted.addProperty("type", "Accepted");
+ accepted.addProperty("status", acceptedStatus);
+ JsonObject programmed = new JsonObject();
+ programmed.addProperty("type", "Programmed");
+ programmed.addProperty("status", programmedStatus);
+ JsonArray conditions = new JsonArray();
+ conditions.add(accepted);
+ conditions.add(programmed);
+ return conditions;
+ }
+
+ /**
+ * Mock ApiClient whose status-patch pipeline succeeds: authentications resolve empty
+ * and buildCall returns a mock call accepted by execute.
+ */
+ private ApiClient mockApiClientWithStatusPatch() throws ApiException {
+ ApiClient apiClient = mock(ApiClient.class);
+ when(apiClient.getAuthentications()).thenReturn(Map.of());
+ when(apiClient.buildCall(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+ .thenReturn(mock(okhttp3.Call.class));
+ return apiClient;
+ }
+
+ private DynamicKubernetesObject buildGateway(final String namespace, final String name,
+ final String gatewayClassName) {
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("namespace", namespace);
+ metadata.addProperty("name", name);
+
+ // a single HTTP listener on the served port; allowedRoutes defaults to Same-namespace
+ JsonObject listener = new JsonObject();
+ listener.addProperty("name", "http");
+ listener.addProperty("protocol", "HTTP");
+ listener.addProperty("port", 9195);
+ JsonArray listeners = new JsonArray();
+ listeners.add(listener);
+
+ JsonObject spec = new JsonObject();
+ spec.addProperty("gatewayClassName", gatewayClassName);
+ spec.add("listeners", listeners);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "Gateway");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+
+ private DynamicKubernetesObject buildGatewayClass(final String name, final String controllerName) {
+ // GatewayClass is cluster-scoped, no namespace
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("name", name);
+
+ JsonObject spec = new JsonObject();
+ spec.addProperty("controllerName", controllerName);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "GatewayClass");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+
+ /**
+ * Build a mocked gatewayClass informer backed by an Indexer. Lister.get(name) for a
+ * cluster-scoped resource resolves to Indexer.getByKey(name), so gateway classes are
+ * stubbed via getByKey. "shenyu" is ShenYu-owned; "other-class" is non-ShenYu.
+ */
+ private SharedIndexInformer<DynamicKubernetesObject> mockGatewayClassInformer() {
+ return mockGatewayClassInformer(buildGatewayClass("shenyu", "gateway.shenyu.apache.org/shenyu-controller"));
+ }
+
+ /**
+ * Same as {@link #mockGatewayClassInformer()} but with a caller-provided "shenyu"
+ * class, e.g. one already carrying a rejection status.
+ */
+ private SharedIndexInformer<DynamicKubernetesObject> mockGatewayClassInformer(
+ final DynamicKubernetesObject shenyuClass) {
+ SharedIndexInformer<DynamicKubernetesObject> informer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+ DynamicKubernetesObject otherClass = buildGatewayClass("other-class", "example.com/other-controller");
+ when(indexer.getByKey("shenyu")).thenReturn(shenyuClass);
+ when(indexer.getByKey("other-class")).thenReturn(otherClass);
+ // requeueAllGatewayClasses iterates every class after a Gateway deletion
+ when(indexer.list()).thenReturn(List.of(shenyuClass, otherClass));
+ when(informer.getIndexer()).thenReturn(indexer);
+ return informer;
+ }
+
+ private DynamicKubernetesObject buildHTTPRoute(final String routeNamespace, final String routeName,
+ final String gatewayNamespace, final String gatewayName,
+ final String serviceName, final int port) {
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("namespace", routeNamespace);
+ metadata.addProperty("name", routeName);
+
+ JsonObject parentRef = new JsonObject();
+ parentRef.addProperty("name", gatewayName);
+ parentRef.addProperty("namespace", gatewayNamespace);
+ JsonArray parentRefs = new JsonArray();
+ parentRefs.add(parentRef);
+
+ JsonObject backendRef = new JsonObject();
+ backendRef.addProperty("name", serviceName);
+ backendRef.addProperty("port", port);
+ JsonArray backendRefs = new JsonArray();
+ backendRefs.add(backendRef);
+
+ JsonObject rule = new JsonObject();
+ rule.add("backendRefs", backendRefs);
+ JsonArray rules = new JsonArray();
+ rules.add(rule);
+
+ JsonObject spec = new JsonObject();
+ spec.add("parentRefs", parentRefs);
+ spec.add("rules", rules);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "HTTPRoute");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HTTPRouteReconcilerTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HTTPRouteReconcilerTest.java
new file mode 100644
index 0000000000..4fc5d89f28
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HTTPRouteReconcilerTest.java
@@ -0,0 +1,676 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.models.V1EndpointAddress;
+import io.kubernetes.client.openapi.models.V1EndpointSubsetBuilder;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1EndpointsBuilder;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.parser.HttpRouteParser;
+import org.apache.shenyu.k8s.reconciler.HTTPRouteReconciler;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Set;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** HTTPRoute Reconciler Test. */
+public final class HTTPRouteReconcilerTest {
+
+ @BeforeEach
+ public void setUp() {
+ GatewayRouteCache.getInstance().clear();
+ }
+
+ /** Test HTTPRoute bound to a ShenYu Gateway: should create selector and rule. */
+ @Test
+ public void testReconcileBoundHTTPRoute() throws Exception {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+ .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+ .build();
+ when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+ "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ ApiClient apiClient = mockApiClientWithStatusPatch();
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+ Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(shenyuCacheRepository).saveOrUpdateSelectorData(any());
+ verify(shenyuCacheRepository).saveOrUpdateRuleData(any());
+ verify(apiClient).execute(any(okhttp3.Call.class));
+ }
+
+ /** Regression: when the route status already carries Accepted=True and ResolvedRefs=True (capitalized, per K8s condition convention), the reconciler must skip the status patch. */
+ @Test
+ public void testReconcileSkipsStatusPatchWhenAlreadySet() throws Exception {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints mockedEndpoints = new V1EndpointsBuilder()
+ .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("192.168.0.1")).build())
+ .build();
+ when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+ "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+ JsonObject accepted = new JsonObject();
+ accepted.addProperty("type", "Accepted");
+ accepted.addProperty("status", "True");
+ JsonObject resolvedRefs = new JsonObject();
+ resolvedRefs.addProperty("type", "ResolvedRefs");
+ resolvedRefs.addProperty("status", "True");
+ JsonArray conditions = new JsonArray();
+ conditions.add(accepted);
+ conditions.add(resolvedRefs);
+ JsonObject parentRef = new JsonObject();
+ parentRef.addProperty("group", "gateway.networking.k8s.io");
+ parentRef.addProperty("kind", "Gateway");
+ parentRef.addProperty("namespace", "mockedNamespace");
+ parentRef.addProperty("name", "shenyu-gateway");
+ JsonObject parent = new JsonObject();
+ parent.add("parentRef", parentRef);
+ parent.addProperty("controllerName", "gateway.shenyu.apache.org/shenyu-controller");
+ parent.add("conditions", conditions);
+ JsonArray parents = new JsonArray();
+ parents.add(parent);
+ JsonObject status = new JsonObject();
+ status.add("parents", parents);
+ httpRoute.getRaw().add("status", status);
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ ApiClient apiClient = mock(ApiClient.class);
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+ Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(shenyuCacheRepository).saveOrUpdateSelectorData(any());
+ verify(apiClient, never()).execute(any(okhttp3.Call.class));
+ }
+
+ /**
+ * A parentRef carrying a port must only attach through listeners on that port: with the
+ * sectionName selecting a listener whose port differs from the parentRef port, the
+ * attachment is rejected instead of silently going through the named listener.
+ */
+ @Test
+ public void testParentRefPortMustMatchSelectedListener() throws Exception {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+ .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+ .build();
+ when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister,
+ new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+ "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+ // parentRef selects the listener by name but demands port 443; the listener serves 9195
+ httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("parentRefs")
+ .get(0).getAsJsonObject().addProperty("port", 443);
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ ApiClient apiClient = mockApiClientWithStatusPatch();
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+ Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(shenyuCacheRepository, never()).saveOrUpdateSelectorData(any());
+ }
+
+ /** Test HTTPRoute deletion: should clean up selector and rule data. */
+ @Test
+ public void testReconcileHTTPRouteDeletion() {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+ // httpRoute not found in indexer → treated as deletion
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(null);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ ApiClient apiClient = mock(ApiClient.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+ Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+ Assertions.assertEquals(new Result(false), result);
+ // No exception should be thrown; deleteConfig handles empty cache gracefully
+ }
+
+ /** Idempotent reconcile: re-reconciling an unchanged HTTPRoute must NOT delete any selector, because the deterministic IDs are stable across parses. */
+ @Test
+ public void testReconcileIsIdempotentOnResync() throws Exception {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+ .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+ .build();
+ when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+ "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ ApiClient apiClient = mockApiClientWithStatusPatch();
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+ httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+ httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+
+ // The reconciler deletes through deleteSelectorWithRules only, so that is the
+ // call an unwanted delete-then-recreate would go through
+ verify(shenyuCacheRepository, never()).deleteSelectorWithRules(any(), any());
+ }
+
+ /**
+ * Cross-namespace parentRef authorized by the listener's allowedRoutes alone: per the
+ * Gateway API spec a ReferenceGrant does not apply to a Route's parentRef, so the
+ * attachment must be accepted without any grant existing in the Gateway's namespace.
+ */
+ @Test
+ public void testReconcileCrossNamespaceHTTPRouteAcceptedByAllowedRoutes() throws Exception {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+ .withNewMetadata().withNamespace("route-ns").withName("testService").endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+ .build();
+ when(endpointsIndexer.getByKey("route-ns/testService")).thenReturn(mockedEndpoints);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = allowAllNamespaces(buildGateway("gw-ns", "shenyu-gateway", "shenyu"));
+ when(gatewayIndexer.getByKey("gw-ns/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute("route-ns", "test-route",
+ "gw-ns", "shenyu-gateway", "testService", 8189, "/**");
+ when(httpRouteIndexer.getByKey("route-ns/test-route")).thenReturn(httpRoute);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ ApiClient apiClient = mockApiClientWithStatusPatch();
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+ Result result = httpRouteReconciler.reconcile(new Request("route-ns", "test-route"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(shenyuCacheRepository).saveOrUpdateSelectorData(any());
+ }
+
+ /**
+ * A listener with allowedRoutes from=Selector is an unsupported value, not a permission denial:
+ * the route must be rejected with Accepted=False/UnsupportedValue (never RefNotPermitted, which
+ * would suggest a ReferenceGrant could fix it) and nothing may be programmed.
+ */
+ @Test
+ public void testSelectorAllowedRoutesReportsUnsupportedValue() throws Exception {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+ final SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ JsonObject namespaces = new JsonObject();
+ namespaces.addProperty("from", "Selector");
+ JsonObject allowedRoutes = new JsonObject();
+ allowedRoutes.add("namespaces", namespaces);
+ gateway.getRaw().getAsJsonObject("spec").getAsJsonArray("listeners")
+ .get(0).getAsJsonObject().add("allowedRoutes", allowedRoutes);
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+ "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+ ApiClient apiClient = mock(ApiClient.class);
+ when(apiClient.getAuthentications()).thenReturn(Map.of());
+ ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+ when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+ .thenReturn(mock(okhttp3.Call.class));
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+ Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(shenyuCacheRepository, never()).saveOrUpdateSelectorData(any());
+ JsonArray parents = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status").getAsJsonArray("parents");
+ JsonArray conditions = parents.get(0).getAsJsonObject().getAsJsonArray("conditions");
+ boolean rejectedWithUnsupportedValue = false;
+ for (JsonElement element : conditions) {
+ JsonObject condition = element.getAsJsonObject();
+ if ("Accepted".equals(condition.get("type").getAsString())
+ && "False".equals(condition.get("status").getAsString())
+ && "UnsupportedValue".equals(condition.get("reason").getAsString())) {
+ rejectedWithUnsupportedValue = true;
+ }
+ }
+ Assertions.assertTrue(rejectedWithUnsupportedValue, "Accepted=False/UnsupportedValue not reported");
+ }
+
+ /**
+ * A rule with filters (unsupported) must reject the whole route: nothing is programmed,
+ * config from a previous valid spec of this route is dropped, and the status patch
+ * reports Accepted=False with the spec-defined reason UnsupportedValue.
+ */
+ @Test
+ public void testReconcileUnsupportedFiltersProgramsNothing() throws Exception {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+ .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+ .build();
+ when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+ "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+ JsonObject filter = new JsonObject();
+ filter.addProperty("type", "RequestHeaderModifier");
+ JsonArray filters = new JsonArray();
+ filters.add(filter);
+ httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules")
+ .get(0).getAsJsonObject().add("filters", filters);
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ // config programmed by a previous, valid spec of this route
+ GatewayRouteCache cache = GatewayRouteCache.getInstance();
+ cache.putRouteSelectors("mockedNamespace", "test-route", "divide", List.of("sel-1"));
+
+ ApiClient apiClient = mock(ApiClient.class);
+ when(apiClient.getAuthentications()).thenReturn(Map.of());
+ ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+ when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+ .thenReturn(mock(okhttp3.Call.class));
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+ Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(shenyuCacheRepository, never()).saveOrUpdateSelectorData(any());
+ verify(shenyuCacheRepository).deleteSelectorWithRules("divide", "sel-1");
+
+ JsonArray parents = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status").getAsJsonArray("parents");
+ JsonArray conditions = parents.get(0).getAsJsonObject().getAsJsonArray("conditions");
+ boolean rejectedWithUnsupportedValue = false;
+ for (JsonElement element : conditions) {
+ JsonObject condition = element.getAsJsonObject();
+ if ("Accepted".equals(condition.get("type").getAsString())
+ && "False".equals(condition.get("status").getAsString())
+ && "UnsupportedValue".equals(condition.get("reason").getAsString())) {
+ rejectedWithUnsupportedValue = true;
+ }
+ }
+ Assertions.assertTrue(rejectedWithUnsupportedValue, "Accepted=False/UnsupportedValue not reported");
+ }
+
+ /** Status entries owned by other controllers must survive ShenYu's status patch: the patch body carries them over, because the merge patch replaces the parents array wholesale. */
+ @Test
+ public void testStatusPatchPreservesForeignControllerEntries() throws Exception {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+ .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+ .build();
+ when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+ when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+ "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+ JsonObject foreignRef = new JsonObject();
+ foreignRef.addProperty("group", "gateway.networking.k8s.io");
+ foreignRef.addProperty("kind", "Gateway");
+ foreignRef.addProperty("namespace", "mockedNamespace");
+ foreignRef.addProperty("name", "other-gateway");
+ JsonObject foreignParent = new JsonObject();
+ foreignParent.add("parentRef", foreignRef);
+ foreignParent.addProperty("controllerName", "example.net/other-gateway-controller");
+ foreignParent.add("conditions", new JsonArray());
+ JsonArray existingParents = new JsonArray();
+ existingParents.add(foreignParent);
+ JsonObject status = new JsonObject();
+ status.add("parents", existingParents);
+ httpRoute.getRaw().add("status", status);
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ ApiClient apiClient = mock(ApiClient.class);
+ when(apiClient.getAuthentications()).thenReturn(Map.of());
+ ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+ when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+ .thenReturn(mock(okhttp3.Call.class));
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+ httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+
+ verify(apiClient).execute(any(okhttp3.Call.class));
+ JsonArray patchedParents = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status").getAsJsonArray("parents");
+ Assertions.assertEquals(2, patchedParents.size(), "patch must carry the foreign entry plus ShenYu's own");
+ boolean foreignKept = false;
+ boolean shenyuPresent = false;
+ for (JsonElement element : patchedParents) {
+ String controllerName = element.getAsJsonObject().get("controllerName").getAsString();
+ if ("example.net/other-gateway-controller".equals(controllerName)) {
+ foreignKept = true;
+ }
+ if ("gateway.shenyu.apache.org/shenyu-controller".equals(controllerName)) {
+ shenyuPresent = true;
+ }
+ }
+ Assertions.assertTrue(foreignKept, "foreign controller entry was dropped by the patch");
+ Assertions.assertTrue(shenyuPresent, "ShenYu's own entry missing from the patch");
+ }
+
+ /** Mock ApiClient whose status-patch pipeline succeeds: authentications resolve empty and buildCall returns a mock call accepted by execute. */
+ private ApiClient mockApiClientWithStatusPatch() throws ApiException {
+ ApiClient apiClient = mock(ApiClient.class);
+ when(apiClient.getAuthentications()).thenReturn(Map.of());
+ when(apiClient.buildCall(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+ .thenReturn(mock(okhttp3.Call.class));
+ return apiClient;
+ }
+
+ /**
+ * A previously bound HTTPRoute that is no longer bound to any ShenYu Gateway (grant
+ * removed, GatewayClass re-pointed, listener removed) must have its programmed
+ * selectors deleted and its ShenYu status entries dropped, instead of being skipped
+ * with stale config left behind.
+ */
+ @Test
+ public void testReconcileUnboundHTTPRouteCleansUpPreviouslyAppliedConfig() throws Exception {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+ final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+ DynamicKubernetesObject otherGateway = buildGateway("mockedNamespace", "other-gateway", "other-class");
+ when(gatewayIndexer.getByKey("mockedNamespace/other-gateway")).thenReturn(otherGateway);
+ when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+ final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+ final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+ "mockedNamespace", "other-gateway", "testService", 8189, "/**");
+ JsonObject shenyuParent = new JsonObject();
+ shenyuParent.addProperty("controllerName", "gateway.shenyu.apache.org/shenyu-controller");
+ JsonArray parents = new JsonArray();
+ parents.add(shenyuParent);
+ JsonObject status = new JsonObject();
+ status.add("parents", parents);
+ httpRoute.getRaw().add("status", status);
+ when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+ when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+ GatewayRouteCache cache = GatewayRouteCache.getInstance();
+ cache.bindRouteToGateway("mockedNamespace", "other-gateway", Set.of("http"), "mockedNamespace", "test-route");
+ cache.putRouteSelectors("mockedNamespace", "test-route", "divide", List.of("sel-1"));
+
+ ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+ ApiClient apiClient = mockApiClientWithStatusPatch();
+
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+ HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+ gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+ Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+ Assertions.assertEquals(new Result(false), result);
+ verify(shenyuCacheRepository).deleteSelectorWithRules("divide", "sel-1");
+ verify(apiClient).execute(any(okhttp3.Call.class));
+ }
+
+ private DynamicKubernetesObject buildGateway(final String namespace, final String name,
+ final String gatewayClassName) {
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("namespace", namespace);
+ metadata.addProperty("name", name);
+
+ // one HTTP listener; allowedRoutes defaults to Same-namespace per the spec
+ JsonObject listener = new JsonObject();
+ listener.addProperty("name", "http");
+ listener.addProperty("protocol", "HTTP");
+ listener.addProperty("port", 9195);
+ JsonArray listeners = new JsonArray();
+ listeners.add(listener);
+
+ JsonObject spec = new JsonObject();
+ spec.addProperty("gatewayClassName", gatewayClassName);
+ spec.add("listeners", listeners);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "Gateway");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+
+ /** Loosen the gateway's listener to accept routes from all namespaces. */
+ private DynamicKubernetesObject allowAllNamespaces(final DynamicKubernetesObject gateway) {
+ JsonObject namespaces = new JsonObject();
+ namespaces.addProperty("from", "All");
+ JsonObject allowedRoutes = new JsonObject();
+ allowedRoutes.add("namespaces", namespaces);
+ gateway.getRaw().getAsJsonObject("spec").getAsJsonArray("listeners")
+ .get(0).getAsJsonObject().add("allowedRoutes", allowedRoutes);
+ return gateway;
+ }
+
+ private DynamicKubernetesObject buildGatewayClass(final String name, final String controllerName) {
+ // GatewayClass is cluster-scoped, no namespace
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("name", name);
+
+ JsonObject spec = new JsonObject();
+ spec.addProperty("controllerName", controllerName);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "GatewayClass");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+
+ /** Build a mocked gatewayClass informer backed by an Indexer. */
+ private SharedIndexInformer<DynamicKubernetesObject> mockGatewayClassInformer() {
+ SharedIndexInformer<DynamicKubernetesObject> informer = mock(SharedIndexInformer.class);
+ Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+ DynamicKubernetesObject shenyuClass = buildGatewayClass("shenyu", "gateway.shenyu.apache.org/shenyu-controller");
+ when(indexer.getByKey("shenyu")).thenReturn(shenyuClass);
+ DynamicKubernetesObject otherClass = buildGatewayClass("other-class", "example.com/other-controller");
+ when(indexer.getByKey("other-class")).thenReturn(otherClass);
+ when(informer.getIndexer()).thenReturn(indexer);
+ return informer;
+ }
+
+ private DynamicKubernetesObject buildHTTPRoute(final String routeNamespace, final String routeName,
+ final String gatewayNamespace, final String gatewayName,
+ final String serviceName, final int port,
+ final String pathValue) {
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("namespace", routeNamespace);
+ metadata.addProperty("name", routeName);
+
+ JsonObject parentRef = new JsonObject();
+ parentRef.addProperty("name", gatewayName);
+ parentRef.addProperty("namespace", gatewayNamespace);
+ JsonArray parentRefs = new JsonArray();
+ parentRefs.add(parentRef);
+
+ JsonObject backendRef = new JsonObject();
+ backendRef.addProperty("name", serviceName);
+ backendRef.addProperty("port", port);
+ JsonArray backendRefs = new JsonArray();
+ backendRefs.add(backendRef);
+
+ JsonObject pathMatch = new JsonObject();
+ pathMatch.addProperty("type", "PathPrefix");
+ pathMatch.addProperty("value", pathValue);
+ JsonObject match = new JsonObject();
+ match.add("path", pathMatch);
+ JsonArray matches = new JsonArray();
+ matches.add(match);
+
+ JsonObject rule = new JsonObject();
+ rule.add("backendRefs", backendRefs);
+ rule.add("matches", matches);
+ JsonArray rules = new JsonArray();
+ rules.add(rule);
+
+ JsonObject spec = new JsonObject();
+ spec.add("parentRefs", parentRefs);
+ spec.add("rules", rules);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "HTTPRoute");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteBackendHandlerTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteBackendHandlerTest.java
new file mode 100644
index 0000000000..d31399b3fc
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteBackendHandlerTest.java
@@ -0,0 +1,189 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1EndpointsBuilder;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ServiceBuilder;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.reconciler.HttpRouteEndpointsHandler;
+import org.apache.shenyu.k8s.reconciler.HttpRouteServiceHandler;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The Service and Endpoints event handlers must both enqueue the HTTPRoutes referencing
+ * the changed backend through the shared backend-service index: a Service port or
+ * targetPort edit does not touch Endpoints, so without the Service handler routes keep
+ * the stale pod port until the periodic resync.
+ */
+public final class HttpRouteBackendHandlerTest {
+
+ private static final String NAMESPACE = "app-ns";
+
+ private static final String SERVICE_NAME = "backend-svc";
+
+ @SuppressWarnings("unchecked")
+ private SharedIndexInformer<DynamicKubernetesObject> mockRouteInformer(final Indexer<DynamicKubernetesObject> indexer) {
+ SharedIndexInformer<DynamicKubernetesObject> informer = mock(SharedIndexInformer.class);
+ when(informer.getIndexer()).thenReturn(indexer);
+ return informer;
+ }
+
+ /** A Service update must enqueue the routes whose backendRefs target the Service, resolved through the backend-service index. */
+ @Test
+ public void testServiceUpdateEnqueuesReferencingRoutes() {
+ Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+ when(indexer.getIndexers()).thenReturn(new HashMap<>(Map.of()));
+ when(indexer.byIndex("backendService", NAMESPACE + "/" + SERVICE_NAME))
+ .thenReturn(List.of(buildRoute("app-ns", "test-route", NAMESPACE, SERVICE_NAME)));
+ SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+ RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+ new HttpRouteServiceHandler(informer, queue).onUpdate(buildService(), buildService());
+
+ verify(queue).add(new Request("app-ns", "test-route"));
+ }
+
+ /** Endpoints events keep working through the same shared index. */
+ @Test
+ public void testEndpointsUpdateEnqueuesReferencingRoutes() {
+ Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+ when(indexer.getIndexers()).thenReturn(new HashMap<>(Map.of()));
+ when(indexer.byIndex("backendService", NAMESPACE + "/" + SERVICE_NAME))
+ .thenReturn(List.of(buildRoute("app-ns", "test-route", NAMESPACE, SERVICE_NAME)));
+ SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+ RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+ new HttpRouteEndpointsHandler(informer, queue).onUpdate(buildEndpoints(), buildEndpoints());
+
+ verify(queue).add(new Request("app-ns", "test-route"));
+ }
+
+ /** Both handlers share one informer index: the second registration must be a no-op. */
+ @Test
+ public void testSharedIndexRegisteredOnlyOnce() {
+ Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+ Map<String, Object> indexers = new HashMap<>();
+ when(indexer.getIndexers()).thenAnswer(inv -> new HashMap<>(indexers));
+ when(indexer.byIndex(anyString(), anyString())).thenReturn(List.of());
+ SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+ RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+ new HttpRouteEndpointsHandler(informer, queue);
+ // after the first handler registered the shared index it is already present
+ indexers.put("backendService", new Object());
+ new HttpRouteServiceHandler(informer, queue);
+
+ // the registration goes through the informer; the second handler must not repeat it
+ verify(informer, times(1)).addIndexers(any());
+ }
+
+ /** An event whose Service is referenced by no route enqueues nothing. */
+ @Test
+ public void testUnreferencedServiceEnqueuesNothing() {
+ Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+ when(indexer.getIndexers()).thenReturn(new HashMap<>(Map.of()));
+ when(indexer.byIndex(anyString(), anyString())).thenReturn(List.of());
+ SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+ RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+ new HttpRouteServiceHandler(informer, queue).onDelete(buildService(), false);
+
+ verify(queue, never()).add(any(Request.class));
+ }
+
+ /** Cross-namespace resolution: a route referencing Service {@code other-ns/svc} by explicit namespace is found when that Service changes. */
+ @Test
+ public void testCrossNamespaceBackendServiceResolved() {
+ Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+ when(indexer.getIndexers()).thenReturn(new HashMap<>(Map.of()));
+ when(indexer.byIndex("backendService", "other-ns/" + SERVICE_NAME))
+ .thenReturn(List.of(buildRoute("app-ns", "test-route", "other-ns", SERVICE_NAME)));
+ SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+ RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+ V1Service otherNamespaceService = new V1ServiceBuilder()
+ .withNewMetadata().withNamespace("other-ns").withName(SERVICE_NAME).endMetadata().build();
+ new HttpRouteServiceHandler(informer, queue).onAdd(otherNamespaceService);
+
+ ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
+ verify(queue).add(captor.capture());
+ Assertions.assertEquals("app-ns", captor.getValue().getNamespace());
+ Assertions.assertEquals("test-route", captor.getValue().getName());
+ }
+
+ private V1Service buildService() {
+ return new V1ServiceBuilder()
+ .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata().build();
+ }
+
+ private V1Endpoints buildEndpoints() {
+ return new V1EndpointsBuilder()
+ .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata().build();
+ }
+
+ private DynamicKubernetesObject buildRoute(final String routeNamespace, final String routeName,
+ final String serviceNamespace, final String serviceName) {
+ JsonObject backendRef = new JsonObject();
+ backendRef.addProperty("name", serviceName);
+ backendRef.addProperty("namespace", serviceNamespace);
+ backendRef.addProperty("port", 8080);
+ JsonArray backendRefs = new JsonArray();
+ backendRefs.add(backendRef);
+
+ JsonObject rule = new JsonObject();
+ rule.add("backendRefs", backendRefs);
+ JsonArray rules = new JsonArray();
+ rules.add(rule);
+
+ JsonObject spec = new JsonObject();
+ spec.add("rules", rules);
+
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("namespace", routeNamespace);
+ metadata.addProperty("name", routeName);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "HTTPRoute");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteParserTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteParserTest.java
new file mode 100644
index 0000000000..5660d023f9
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteParserTest.java
@@ -0,0 +1,763 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.models.V1EndpointAddress;
+import io.kubernetes.client.openapi.models.V1EndpointSubsetBuilder;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1EndpointsBuilder;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ServiceBuilder;
+import io.kubernetes.client.openapi.models.V1ServicePortBuilder;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.selector.DivideUpstream;
+import org.apache.shenyu.common.enums.OperatorEnum;
+import org.apache.shenyu.common.enums.ParamTypeEnum;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.k8s.common.IngressConfiguration;
+import org.apache.shenyu.k8s.common.ShenyuMemoryConfig;
+import org.apache.shenyu.k8s.parser.HttpRouteParser;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Objects;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** HttpRouteParser Test. */
+public final class HttpRouteParserTest {
+
+ private static final String NAMESPACE = "test-ns";
+
+ private static final String SERVICE_NAME = "test-service";
+
+ private static final int SERVICE_PORT = 8189;
+
+ /**
+ * Test parse with path prefix match: the spec requires element-boundary matching
+ * ({@code /api} matches {@code /api} and {@code /api/x} but not {@code /apix}), so the
+ * prefix maps to an anchored regex, not a raw startsWith. Longer prefixes must sort
+ * lower (higher precedence) than shorter ones and any exact path must outrank a prefix.
+ */
+ @Test
+ public void testParseWithPathPrefix() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/api", "PathPrefix", null, null);
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ List<SelectorData> selectors = extractSelectors(config);
+ List<RuleData> rules = extractRules(config);
+ Assertions.assertEquals(1, selectors.size());
+ Assertions.assertEquals(1, rules.size());
+
+ SelectorData selector = config.getRouteConfigList().get(0).getSelectorData();
+ ConditionData pathCondition = firstRealCondition(selector.getConditionList());
+ Assertions.assertEquals(ParamTypeEnum.URI.getName(), pathCondition.getParamType());
+ Assertions.assertEquals(OperatorEnum.REGEX.getAlias(), pathCondition.getOperator());
+ Assertions.assertEquals("^\\Q/api\\E(/.*)?$", pathCondition.getParamValue());
+ // precedence score = pathScore("/api" prefix) = 2 + 3 = 5; lower sort wins
+ Assertions.assertEquals((1 << 28) - (5 << 13) - 15, selector.getSort());
+ }
+
+ /** Test parse with exact path match: EQ operator and the highest path precedence, outranking any prefix/regex/no-path match per the spec precedence rules. */
+ @Test
+ public void testParseWithExactPath() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/api/v1/test", "Exact", null, null);
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ SelectorData selector = config.getRouteConfigList().get(0).getSelectorData();
+ ConditionData pathCondition = firstRealCondition(selector.getConditionList());
+ Assertions.assertEquals(OperatorEnum.EQ.getAlias(), pathCondition.getOperator());
+ Assertions.assertEquals((1 << 28) - (2047 << 13) - 15, selector.getSort());
+ }
+
+ /** A rule without matches is the catch-all of the spec (equivalent to PathPrefix /). */
+ @Test
+ public void testParseWithRuleWithoutMatchesGetsMatchAllCondition() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/api", "PathPrefix", null, null);
+ httpRoute.getRaw().getAsJsonObject("spec")
+ .getAsJsonArray("rules").get(0).getAsJsonObject()
+ .remove("matches");
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ Assertions.assertEquals(1, config.getRouteConfigList().size());
+ SelectorData selector = config.getRouteConfigList().get(0).getSelectorData();
+ for (ConditionData condition : selector.getConditionList()) {
+ Assertions.assertEquals(ParamTypeEnum.URI.getName(), condition.getParamType());
+ Assertions.assertEquals(OperatorEnum.STARTS_WITH.getAlias(), condition.getOperator());
+ Assertions.assertEquals("/", condition.getParamValue());
+ }
+ // the rule's conditions stay unpadded: exactly the one match-all
+ Assertions.assertEquals(1, config.getRouteConfigList().get(0).getRuleDataList().get(0)
+ .getConditionDataList().size());
+ }
+
+ /** Every generated selector is padded to one fixed condition count so the data plane's condition-count-first tie-breaking cannot override the precedence-encoded sort. */
+ @Test
+ public void testSelectorConditionListsPaddedToFloor() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRouteWithHostnames(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", new String[]{"example.com", "api.example.com"});
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of("example.com", "api.example.com"));
+
+ for (IngressConfiguration routeConfig : config.getRouteConfigList()) {
+ Assertions.assertEquals(16, routeConfig.getSelectorData().getConditionList().size(),
+ "selector condition lists must all be padded to the fixed floor");
+ }
+ }
+
+ /**
+ * Gateway API precedence: for equal paths, a match carrying an HTTP method outranks a
+ * match with more header conditions — the sort must encode this independently of the
+ * raw condition count (which the padding equalizes anyway).
+ */
+ @Test
+ public void testMethodPresenceOutranksHeaderCount() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ final HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/api", "PathPrefix", "X-Header", "test-value");
+ final JsonArray matches = httpRoute.getRaw().getAsJsonObject("spec")
+ .getAsJsonArray("rules").get(0).getAsJsonObject()
+ .getAsJsonArray("matches");
+ JsonObject secondHeader = new JsonObject();
+ secondHeader.addProperty("name", "X-Other");
+ secondHeader.addProperty("value", "other");
+ secondHeader.addProperty("type", "Exact");
+ matches.get(0).getAsJsonObject().getAsJsonArray("headers").add(secondHeader);
+ JsonObject methodMatchPath = new JsonObject();
+ methodMatchPath.addProperty("type", "PathPrefix");
+ methodMatchPath.addProperty("value", "/api");
+ JsonObject methodMatch = new JsonObject();
+ methodMatch.add("path", methodMatchPath);
+ methodMatch.addProperty("method", "GET");
+ matches.add(methodMatch);
+
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+ List<SelectorData> selectors = extractSelectors(config);
+ Assertions.assertEquals(2, selectors.size());
+ SelectorData methodSelector = selectors.stream()
+ .filter(s -> s.getConditionList().stream().anyMatch(c -> ParamTypeEnum.REQUEST_METHOD.getName().equals(c.getParamType())))
+ .findFirst().orElseThrow();
+ SelectorData headerSelector = selectors.stream()
+ .filter(s -> s.getConditionList().stream().filter(c -> ParamTypeEnum.HEADER.getName().equals(c.getParamType())).count() == 2)
+ .findFirst().orElseThrow();
+ // lower sort wins on the data plane
+ Assertions.assertTrue(methodSelector.getSort() < headerSelector.getSort(),
+ "a method match must outrank a two-header match for equal paths");
+ }
+
+ @Test
+ public void testFirstRuleWinsFullPrecedenceTie() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/api", "PathPrefix", null, null);
+ httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules")
+ .add(httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules")
+ .get(0).getAsJsonObject().deepCopy());
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+ List<SelectorData> selectors = extractSelectors(config);
+ Assertions.assertEquals(2, selectors.size());
+ Assertions.assertTrue(selectors.get(0).getSort() < selectors.get(1).getSort(),
+ "the first rule must win a full precedence tie");
+ }
+
+ @Test
+ public void testLongerPrefixOutranksBeyondTwelveCharacters() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/verylongapiprefix", "PathPrefix", null, null);
+ JsonObject longerRule = httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules")
+ .get(0).getAsJsonObject().deepCopy();
+ longerRule.getAsJsonArray("matches").get(0).getAsJsonObject()
+ .getAsJsonObject("path").addProperty("value", "/verylongapiprefix/more");
+ httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules").add(longerRule);
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+ List<SelectorData> selectors = extractSelectors(config);
+ Assertions.assertEquals(2, selectors.size());
+ Assertions.assertTrue(selectors.get(1).getSort() < selectors.get(0).getSort(),
+ "the longer prefix must outrank the shorter one beyond the old 12-char cap");
+ }
+
+ /** Test parse with hostname conditions. Each hostname should generate a separate selector+rule (one hostname per selector). */
+ @Test
+ public void testParseWithHostnames() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRouteWithHostnames(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", new String[]{"example.com", "api.example.com"});
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of("example.com", "api.example.com"));
+
+ List<IngressConfiguration> routeConfigs = config.getRouteConfigList();
+ Assertions.assertEquals(2, routeConfigs.size());
+
+ for (IngressConfiguration routeConfig : routeConfigs) {
+ List<ConditionData> conditions = routeConfig.getSelectorData().getConditionList();
+ long hostConditions = conditions.stream()
+ .filter(c -> ParamTypeEnum.DOMAIN.getName().equals(c.getParamType()))
+ .count();
+ Assertions.assertEquals(1, hostConditions);
+
+ long pathConditions = conditions.stream()
+ .filter(c -> ParamTypeEnum.URI.getName().equals(c.getParamType()))
+ .filter(c -> !isPadding(c))
+ .count();
+ Assertions.assertEquals(1, pathConditions);
+ }
+ }
+
+ /**
+ * Test that exact hostnames use EQ and wildcard hostnames use REGEX with an anchored
+ * suffix-match regex: per the Gateway API spec {@code *.example.com} matches
+ * {@code test.example.com} and {@code foo.test.example.com} but not {@code example.com}.
+ */
+ @Test
+ public void testParseWithWildcardHostname() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRouteWithHostnames(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", new String[]{"*.example.com"});
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of("*.example.com"));
+
+ List<ConditionData> domainConditions = config.getRouteConfigList().stream()
+ .flatMap(rc -> rc.getSelectorData().getConditionList().stream())
+ .filter(c -> ParamTypeEnum.DOMAIN.getName().equals(c.getParamType()))
+ .toList();
+ Assertions.assertEquals(1, domainConditions.size());
+
+ ConditionData wildcardCondition = domainConditions.get(0);
+ Assertions.assertEquals(OperatorEnum.REGEX.getAlias(), wildcardCondition.getOperator());
+ Assertions.assertEquals("^([^.]+\\.)+example\\.com$", wildcardCondition.getParamValue());
+ }
+
+ /** Test parse with header match. */
+ @Test
+ public void testParseWithHeaderMatch() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", "X-Custom-Header", "test-value");
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ List<ConditionData> conditions = config.getRouteConfigList().get(0).getSelectorData().getConditionList();
+ long headerConditions = conditions.stream()
+ .filter(c -> ParamTypeEnum.HEADER.getName().equals(c.getParamType()))
+ .count();
+ Assertions.assertEquals(1, headerConditions);
+
+ ConditionData headerCondition = conditions.stream()
+ .filter(c -> ParamTypeEnum.HEADER.getName().equals(c.getParamType()))
+ .findFirst().orElse(null);
+ Assertions.assertNotNull(headerCondition);
+ Assertions.assertEquals("X-Custom-Header", headerCondition.getParamName());
+ Assertions.assertEquals("test-value", headerCondition.getParamValue());
+ Assertions.assertEquals(OperatorEnum.EQ.getAlias(), headerCondition.getOperator());
+ }
+
+ /** A method match must produce a req_method/EQ condition instead of being silently dropped: dropping it would widen the selector to match requests of any method. */
+ @Test
+ public void testParseWithMethodMatch() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", null, null);
+ httpRoute.getRaw().getAsJsonObject("spec")
+ .getAsJsonArray("rules").get(0).getAsJsonObject()
+ .getAsJsonArray("matches").get(0).getAsJsonObject()
+ .addProperty("method", "GET");
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ ConditionData methodCondition = config.getRouteConfigList().get(0).getSelectorData().getConditionList().stream()
+ .filter(c -> ParamTypeEnum.REQUEST_METHOD.getName().equals(c.getParamType()))
+ .findFirst().orElse(null);
+ Assertions.assertNotNull(methodCondition);
+ Assertions.assertEquals(OperatorEnum.EQ.getAlias(), methodCondition.getOperator());
+ Assertions.assertEquals("GET", methodCondition.getParamValue());
+ }
+
+ /** Test parse with query param match. */
+ @Test
+ public void testParseWithQueryParam() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRouteWithQueryParams(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", "debug", "true", "Exact");
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ List<ConditionData> conditions = config.getRouteConfigList().get(0).getSelectorData().getConditionList();
+ long queryConditions = conditions.stream()
+ .filter(c -> ParamTypeEnum.QUERY.getName().equals(c.getParamType()))
+ .count();
+ Assertions.assertEquals(1, queryConditions);
+
+ ConditionData queryCondition = conditions.stream()
+ .filter(c -> ParamTypeEnum.QUERY.getName().equals(c.getParamType()))
+ .findFirst().orElse(null);
+ Assertions.assertNotNull(queryCondition);
+ Assertions.assertEquals("debug", queryCondition.getParamName());
+ Assertions.assertEquals("true", queryCondition.getParamValue());
+ }
+
+ /** A backendRef with an explicit weight mixed with one that omits it must resolve to the spec default 1 (9:1), not to an arbitrary parser default like 100 (9:100). */
+ @Test
+ public void testMixedExplicitAndDefaultBackendRefWeights() {
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", null, null);
+ JsonArray backendRefs = httpRoute.getRaw().getAsJsonObject("spec")
+ .getAsJsonArray("rules").get(0).getAsJsonObject().getAsJsonArray("backendRefs");
+ backendRefs.get(0).getAsJsonObject().addProperty("weight", 9);
+ JsonObject unweighted = new JsonObject();
+ unweighted.addProperty("name", SERVICE_NAME);
+ unweighted.addProperty("port", 8080);
+ backendRefs.add(unweighted);
+
+ HttpRouteParser parser = new HttpRouteParser(mockEndpointsLister(), mockServiceLister(), mockReferenceGrantLister());
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ List<SelectorData> selectors = extractSelectors(config);
+ Assertions.assertEquals(1, selectors.size());
+
+ List<DivideUpstream> upstreams = GsonUtils.getInstance()
+ .fromList(selectors.get(0).getHandle(), DivideUpstream.class);
+ // Each backendRef fans out to the 2 addresses of the mocked Endpoints. The common
+ // scale factor is 2 (lifts the weight-1 backend's per-endpoint share to 1), so the
+ // per-endpoint weights are 9 and 1 and the backend totals keep the declared 9:1
+ // ratio (18:2) regardless of replica counts.
+ Assertions.assertEquals(4, upstreams.size());
+ Assertions.assertEquals(9, upstreams.get(0).getWeight());
+ Assertions.assertEquals(9, upstreams.get(1).getWeight());
+ Assertions.assertEquals(1, upstreams.get(2).getWeight());
+ Assertions.assertEquals(1, upstreams.get(3).getWeight());
+ }
+
+ /** The root prefix {@code /} is the spec's catch-all and must match every absolute path. */
+ @Test
+ public void testParseWithRootPathPrefixMatchesEveryPath() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/", "PathPrefix", null, null);
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ ConditionData pathCondition = config.getRouteConfigList().get(0).getSelectorData().getConditionList().get(0);
+ Assertions.assertEquals(OperatorEnum.REGEX.getAlias(), pathCondition.getOperator());
+ Assertions.assertEquals("^/.*$", pathCondition.getParamValue());
+ Assertions.assertTrue(java.util.regex.Pattern.compile(pathCondition.getParamValue()).matcher("/order").matches(),
+ "root prefix must match ordinary paths like /order");
+ }
+
+ /** A backendRef whose kind is Service but whose group names a foreign API group must be reported InvalidKind, not resolved against an unrelated core Service of the same name. */
+ @Test
+ public void testBackendRefWithForeignGroupIsInvalidKind() {
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", null, null);
+ httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules").get(0).getAsJsonObject()
+ .getAsJsonArray("backendRefs").get(0).getAsJsonObject().addProperty("group", "example.com");
+
+ HttpRouteParser parser = new HttpRouteParser(mockEndpointsLister(), mockServiceLister(), mockReferenceGrantLister());
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ Assertions.assertFalse(config.isAllBackendsResolved());
+ Assertions.assertEquals("InvalidKind", config.getUnresolvedReason());
+ }
+
+ /**
+ * The spec's 500-for-invalid-share rule: a rule mixing one valid and one invalid
+ * (default weight 1 each) backendRef keeps the valid upstreams routable and adds a
+ * fail-target upstream carrying exactly the invalid share — here 50/50, per the spec's
+ * "two equal backends, one invalid, 50 percent must receive a 500" example.
+ */
+ @Test
+ public void testInvalidWeightedShareBecomesFailTargetNotFailClosed() {
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", null, null);
+ JsonObject brokenBackend = new JsonObject();
+ brokenBackend.addProperty("name", "missing-service");
+ brokenBackend.addProperty("port", 8080);
+ httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules").get(0).getAsJsonObject()
+ .getAsJsonArray("backendRefs").add(brokenBackend);
+
+ // first backend resolves to 10.0.0.1/2, second has no endpoints
+ HttpRouteParser parser = new HttpRouteParser(mockEndpointsLister(), mockServiceLister(), mockReferenceGrantLister());
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ Assertions.assertFalse(config.isAllBackendsResolved());
+ List<SelectorData> selectors = extractSelectors(config);
+ Assertions.assertEquals(1, selectors.size(), "mixed rule must still program its selector");
+ List<DivideUpstream> upstreams = GsonUtils.getInstance()
+ .fromList(selectors.get(0).getHandle(), DivideUpstream.class);
+ Assertions.assertEquals(3, upstreams.size());
+
+ List<DivideUpstream> valid = upstreams.stream()
+ .filter(u -> !"127.0.0.1:1".equals(u.getUpstreamUrl())).toList();
+ List<DivideUpstream> failTargets = upstreams.stream()
+ .filter(u -> "127.0.0.1:1".equals(u.getUpstreamUrl())).toList();
+ Assertions.assertEquals(2, valid.size(), "valid backends must stay routable");
+ Assertions.assertEquals(1, failTargets.size(), "the invalid share must map to one fail target");
+
+ int validWeight = valid.stream().mapToInt(DivideUpstream::getWeight).sum();
+ Assertions.assertEquals(validWeight, failTargets.get(0).getWeight(),
+ "equal weights (1:1) must split traffic 50/50 between valid and fail target");
+ }
+
+ /**
+ * When every weighted backendRef is invalid the fail target carries the whole traffic:
+ * all matching requests receive a 500 (spec: all-invalid rule with no filters MUST
+ * return 500) instead of the selector silently matching nothing.
+ */
+ @Test
+ public void testAllBackendsInvalidRoutesWholeShareToFailTarget() {
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", null, null);
+ httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules").get(0).getAsJsonObject()
+ .getAsJsonArray("backendRefs").get(0).getAsJsonObject()
+ .addProperty("name", "missing-service");
+
+ HttpRouteParser parser = new HttpRouteParser(mockEndpointsLister(), mockServiceLister(), mockReferenceGrantLister());
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ Assertions.assertFalse(config.isAllBackendsResolved());
+ List<DivideUpstream> upstreams = GsonUtils.getInstance()
+ .fromList(extractSelectors(config).get(0).getHandle(), DivideUpstream.class);
+ Assertions.assertEquals(1, upstreams.size());
+ Assertions.assertEquals("127.0.0.1:1", upstreams.get(0).getUpstreamUrl());
+ }
+
+ /** A multi-port Service is resolved through its spec: the backendRef port selects spec.ports[], whose numeric targetPort is the pod port. */
+ @Test
+ public void testServiceTargetPortMapping() {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints endpoints = new V1EndpointsBuilder()
+ .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder()
+ .withAddresses(new V1EndpointAddress().ip("10.0.0.1"))
+ .addNewPort().withName("web").withPort(8080).and()
+ .addNewPort().withName("tls").withPort(8443).and()
+ .build())
+ .build();
+ when(endpointsIndexer.getByKey(NAMESPACE + "/" + SERVICE_NAME)).thenReturn(endpoints);
+
+ Indexer<V1Service> serviceIndexer = mock(Indexer.class);
+ V1Service service = new V1ServiceBuilder()
+ .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata()
+ .withNewSpec()
+ .addToPorts(new V1ServicePortBuilder().withName("http").withPort(80)
+ .withNewTargetPort(8080).build())
+ .addToPorts(new V1ServicePortBuilder().withName("https").withPort(443)
+ .withNewTargetPort(8443).build())
+ .endSpec()
+ .build();
+ when(serviceIndexer.getByKey(NAMESPACE + "/" + SERVICE_NAME)).thenReturn(service);
+
+ HttpRouteParser parser = new HttpRouteParser(new Lister<>(endpointsIndexer),
+ new Lister<>(serviceIndexer), mockReferenceGrantLister());
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, 80,
+ "/**", "PathPrefix", null, null);
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ Assertions.assertTrue(config.isAllBackendsResolved());
+ List<DivideUpstream> upstreams = GsonUtils.getInstance()
+ .fromList(extractSelectors(config).get(0).getHandle(), DivideUpstream.class);
+ Assertions.assertEquals(1, upstreams.size());
+ Assertions.assertEquals("10.0.0.1:8080", upstreams.get(0).getUpstreamUrl(),
+ "service port 80 must map to its targetPort 8080");
+ }
+
+ /** Deterministic ID: parsing the same HTTPRoute twice must yield identical selector/rule IDs. */
+ @Test
+ public void testSelectorAndRuleIdsAreStableAcrossParses() {
+ Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+ DynamicKubernetesObject httpRoute = buildHTTPRouteWithHostnames(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/**", "PathPrefix", new String[]{"example.com", "api.example.com"});
+
+ ShenyuMemoryConfig first = parser.parse(httpRoute, List.of("example.com", "api.example.com"));
+ List<String> firstSelectorIds = first.getRouteConfigList().stream()
+ .map(rc -> rc.getSelectorData().getId()).toList();
+ List<String> firstRuleIds = first.getRouteConfigList().stream()
+ .flatMap(rc -> rc.getRuleDataList().stream()).map(r -> r.getId()).toList();
+
+ ShenyuMemoryConfig second = parser.parse(httpRoute, List.of("example.com", "api.example.com"));
+ List<String> secondSelectorIds = second.getRouteConfigList().stream()
+ .map(rc -> rc.getSelectorData().getId()).toList();
+ List<String> secondRuleIds = second.getRouteConfigList().stream()
+ .flatMap(rc -> rc.getRuleDataList().stream()).map(r -> r.getId()).toList();
+
+ Assertions.assertEquals(firstSelectorIds, secondSelectorIds, "selector IDs must be deterministic");
+ Assertions.assertEquals(firstRuleIds, secondRuleIds, "rule IDs must be deterministic");
+ }
+
+ private Lister<V1Endpoints> mockEndpointsLister() {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+ .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder()
+ .withAddresses(new V1EndpointAddress().ip("10.0.0.1"), new V1EndpointAddress().ip("10.0.0.2"))
+ .build())
+ .build();
+ when(endpointsIndexer.getByKey(NAMESPACE + "/" + SERVICE_NAME)).thenReturn(mockedEndpoints);
+ return new Lister<>(endpointsIndexer);
+ }
+
+ /** Empty Service lister: the parser falls back to the Endpoints-only port heuristic, which is what the pre-existing parser tests exercise. */
+ private Lister<V1Service> mockServiceLister() {
+ return new Lister<>(mock(Indexer.class));
+ }
+
+ /** Empty ReferenceGrant lister: same-namespace backendRefs never consult grants, so all existing parser tests are unaffected by grant matching. */
+ private Lister<DynamicKubernetesObject> mockReferenceGrantLister() {
+ return new Lister<>(mock(Indexer.class));
+ }
+
+ /** Cross-namespace backendRef WITH a matching ReferenceGrant (to Service in the core group) resolves normally. */
+ @Test
+ public void testCrossNamespaceBackendRefWithGrantResolves() {
+ Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+ V1Endpoints endpoints = new V1EndpointsBuilder()
+ .withNewMetadata().withNamespace("other-ns").withName(SERVICE_NAME).endMetadata()
+ .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("10.0.0.1")).build())
+ .build();
+ when(endpointsIndexer.getByKey("other-ns/" + SERVICE_NAME)).thenReturn(endpoints);
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+
+ Indexer<DynamicKubernetesObject> grantIndexer = mock(Indexer.class);
+ when(grantIndexer.byIndex("namespace", "other-ns")).thenReturn(List.of(buildServiceGrant("other-ns", NAMESPACE, null)));
+ HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), new Lister<>(grantIndexer));
+
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+ NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+ "/api/**", "PathPrefix", null, null);
+ JsonObject backendRef = httpRoute.getRaw().getAsJsonObject("spec")
+ .getAsJsonArray("rules").get(0).getAsJsonObject()
+ .getAsJsonArray("backendRefs").get(0).getAsJsonObject();
+ backendRef.addProperty("namespace", "other-ns");
+
+ ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+ Assertions.assertTrue(config.isAllBackendsResolved());
+ Assertions.assertEquals(1, extractSelectors(config).size());
+ }
+
+ /**
+ * Build a ReferenceGrant allowing an HTTPRoute from {@code fromNamespace} to
+ * reference a Service (core group, group="") in {@code namespace}, optionally
+ * restricted to {@code toName}.
+ */
+ private DynamicKubernetesObject buildServiceGrant(final String namespace, final String fromNamespace,
+ final String toName) {
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("namespace", namespace);
+ metadata.addProperty("name", "allow-services");
+
+ JsonObject from = new JsonObject();
+ from.addProperty("group", "gateway.networking.k8s.io");
+ from.addProperty("kind", "HTTPRoute");
+ from.addProperty("namespace", fromNamespace);
+ JsonArray fromArray = new JsonArray();
+ fromArray.add(from);
+
+ JsonObject to = new JsonObject();
+ to.addProperty("group", "");
+ to.addProperty("kind", "Service");
+ if (Objects.nonNull(toName)) {
+ to.addProperty("name", toName);
+ }
+ JsonArray toArray = new JsonArray();
+ toArray.add(to);
+
+ JsonObject spec = new JsonObject();
+ spec.add("from", fromArray);
+ spec.add("to", toArray);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1beta1");
+ raw.addProperty("kind", "ReferenceGrant");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+
+ private List<SelectorData> extractSelectors(final ShenyuMemoryConfig config) {
+ return config.getRouteConfigList().stream().map(r -> r.getSelectorData()).toList();
+ }
+
+ /** Padding conditions appended to reach the fixed selector condition count. */
+ private boolean isPadding(final ConditionData condition) {
+ return ParamTypeEnum.URI.getName().equals(condition.getParamType())
+ && OperatorEnum.STARTS_WITH.getAlias().equals(condition.getOperator())
+ && "/".equals(condition.getParamValue());
+ }
+
+ /** Real (non-padding) conditions come first; padding is only appended at the end. */
+ private ConditionData firstRealCondition(final List<ConditionData> conditions) {
+ return conditions.stream().filter(c -> !isPadding(c)).findFirst().orElseThrow();
+ }
+
+ private List<RuleData> extractRules(final ShenyuMemoryConfig config) {
+ return config.getRouteConfigList().stream().flatMap(r -> r.getRuleDataList().stream()).toList();
+ }
+
+ private DynamicKubernetesObject buildHTTPRoute(final String routeNamespace, final String routeName,
+ final String gatewayNamespace, final String gatewayName,
+ final String serviceName, final int port,
+ final String pathValue, final String pathType,
+ final String headerName, final String headerValue) {
+ JsonObject metadata = new JsonObject();
+ metadata.addProperty("namespace", routeNamespace);
+ metadata.addProperty("name", routeName);
+
+ JsonObject parentRef = new JsonObject();
+ parentRef.addProperty("name", gatewayName);
+ parentRef.addProperty("namespace", gatewayNamespace);
+ JsonArray parentRefs = new JsonArray();
+ parentRefs.add(parentRef);
+
+ JsonObject backendRef = new JsonObject();
+ backendRef.addProperty("name", serviceName);
+ backendRef.addProperty("port", port);
+ JsonArray backendRefs = new JsonArray();
+ backendRefs.add(backendRef);
+
+ JsonObject pathMatch = new JsonObject();
+ pathMatch.addProperty("type", pathType);
+ pathMatch.addProperty("value", pathValue);
+ JsonObject match = new JsonObject();
+ match.add("path", pathMatch);
+
+ if (Objects.nonNull(headerName) && Objects.nonNull(headerValue)) {
+ JsonObject header = new JsonObject();
+ header.addProperty("name", headerName);
+ header.addProperty("value", headerValue);
+ header.addProperty("type", "Exact");
+ JsonArray headers = new JsonArray();
+ headers.add(header);
+ match.add("headers", headers);
+ }
+
+ JsonArray matches = new JsonArray();
+ matches.add(match);
+
+ JsonObject rule = new JsonObject();
+ rule.add("backendRefs", backendRefs);
+ rule.add("matches", matches);
+ JsonArray rules = new JsonArray();
+ rules.add(rule);
+
+ JsonObject spec = new JsonObject();
+ spec.add("parentRefs", parentRefs);
+ spec.add("rules", rules);
+
+ JsonObject raw = new JsonObject();
+ raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+ raw.addProperty("kind", "HTTPRoute");
+ raw.add("metadata", metadata);
+ raw.add("spec", spec);
+ return new DynamicKubernetesObject(raw);
+ }
+
+ private DynamicKubernetesObject buildHTTPRouteWithHostnames(final String routeNamespace, final String routeName,
+ final String gatewayNamespace, final String gatewayName,
+ final String serviceName, final int port,
+ final String pathValue, final String pathType,
+ final String[] hostnames) {
+ DynamicKubernetesObject httpRoute = buildHTTPRoute(routeNamespace, routeName,
+ gatewayNamespace, gatewayName, serviceName, port, pathValue, pathType, null, null);
+
+ JsonArray hostnameArray = new JsonArray();
+ for (String hostname : hostnames) {
+ hostnameArray.add(hostname);
+ }
+ httpRoute.getRaw().getAsJsonObject("spec").add("hostnames", hostnameArray);
+ return httpRoute;
+ }
+
+ private DynamicKubernetesObject buildHTTPRouteWithQueryParams(final String routeNamespace, final String routeName,
+ final String gatewayNamespace, final String gatewayName,
+ final String serviceName, final int port,
+ final String pathValue, final String pathType,
+ final String queryName, final String queryValue,
+ final String queryType) {
+ JsonObject queryParam = new JsonObject();
+ queryParam.addProperty("name", queryName);
+ queryParam.addProperty("value", queryValue);
+ queryParam.addProperty("type", queryType);
+ JsonArray queryParams = new JsonArray();
+ queryParams.add(queryParam);
+
+ final DynamicKubernetesObject httpRoute = buildHTTPRoute(routeNamespace, routeName,
+ gatewayNamespace, gatewayName, serviceName, port, pathValue, pathType, null, null);
+ JsonArray rules = httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules");
+ JsonObject firstRule = rules.get(0).getAsJsonObject();
+ JsonObject firstMatch = firstRule.getAsJsonArray("matches").get(0).getAsJsonObject();
+ firstMatch.add("queryParams", queryParams);
+
+ return httpRoute;
+ }
+
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/K8sCacheReadinessTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/K8sCacheReadinessTest.java
new file mode 100644
index 0000000000..d1bedb265f
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/K8sCacheReadinessTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s;
+
+import io.kubernetes.client.extended.workqueue.WorkQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import org.apache.shenyu.k8s.cache.K8sCacheReadiness;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Readiness must wait for the initial reconciliation backlog to drain in addition to the
+ * informer sync, and must observe the queues empty twice in a row to close the race of an
+ * item popped but not yet reconciled.
+ */
+public final class K8sCacheReadinessTest {
+
+ @Test
+ public void testNotReadyWhileInformerUnsynced() {
+ SharedIndexInformer<?> informer = mock(SharedIndexInformer.class);
+ when(informer.hasSynced()).thenReturn(false);
+ K8sCacheReadiness readiness = new K8sCacheReadiness(List.of(informer), List.of());
+
+ Assertions.assertFalse(readiness.isReady());
+ Assertions.assertEquals(1L, readiness.pendingInformers());
+ }
+
+ @Test
+ public void testNotReadyWhileWorkQueuesHoldItems() {
+ K8sCacheReadiness readiness = readiness(true, 3);
+
+ Assertions.assertFalse(readiness.isReady());
+ Assertions.assertEquals(3L, readiness.pendingWorkItems());
+ }
+
+ @Test
+ public void testReadyOnlyAfterRepeatedDrainedObservations() {
+ SharedIndexInformer<?> informer = syncedInformer();
+ WorkQueue<?> queue = queue(0);
+ K8sCacheReadiness readiness = new K8sCacheReadiness(List.of(informer), List.of(queue));
+
+ Assertions.assertFalse(readiness.isReady(), "a single drained observation must not flip readiness");
+ Assertions.assertTrue(readiness.isReady(), "the second consecutive drained observation must");
+ // readiness latches: a later refill (e.g. resync) does not flip it back
+ when(queue.length()).thenReturn(5);
+ Assertions.assertTrue(readiness.isReady());
+ }
+
+ @Test
+ public void testRefillResetsTheDrainedStreak() {
+ SharedIndexInformer<?> informer = syncedInformer();
+ WorkQueue<?> queue = queue(0);
+ K8sCacheReadiness readiness = new K8sCacheReadiness(List.of(informer), List.of(queue));
+
+ Assertions.assertFalse(readiness.isReady());
+ when(queue.length()).thenReturn(2);
+ Assertions.assertFalse(readiness.isReady());
+ when(queue.length()).thenReturn(0);
+ Assertions.assertFalse(readiness.isReady(), "streak was reset by the refill");
+ Assertions.assertTrue(readiness.isReady());
+ }
+
+ private K8sCacheReadiness readiness(final boolean synced, final int queueLength) {
+ return new K8sCacheReadiness(List.of(syncedInformer()), List.of(queue(queueLength)));
+ }
+
+ private SharedIndexInformer<?> syncedInformer() {
+ SharedIndexInformer<?> informer = mock(SharedIndexInformer.class);
+ when(informer.hasSynced()).thenReturn(true);
+ return informer;
+ }
+
+ private WorkQueue<?> queue(final int length) {
+ WorkQueue<?> queue = mock(WorkQueue.class);
+ when(queue.length()).thenReturn(length);
+ return queue;
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/ShenyuCacheRepositoryTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/ShenyuCacheRepositoryTest.java
new file mode 100644
index 0000000000..04712cb7e2
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/ShenyuCacheRepositoryTest.java
@@ -0,0 +1,169 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s;
+
+import org.apache.shenyu.common.config.ShenyuConfig;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.apache.shenyu.plugin.base.cache.BaseDataCache;
+import org.apache.shenyu.plugin.base.cache.CommonDiscoveryUpstreamDataSubscriber;
+import org.apache.shenyu.plugin.base.cache.CommonPluginDataSubscriber;
+import org.apache.shenyu.plugin.divide.handler.DivideUpstreamDataHandler;
+import org.apache.shenyu.plugin.global.subsciber.MetaDataCacheSubscriber;
+import org.apache.shenyu.sync.data.api.MetaDataSubscriber;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Regression tests for upstream snapshot replacement in ShenyuCacheRepository.
+ *
+ * <p>UpstreamCacheManager.submit merges by URL (removal only for entries explicitly marked
+ * offline), so a full-snapshot save whose upstream set shrank must first drop the cached
+ * list, otherwise a scaled-down backend keeps receiving traffic forever.
+ */
+public final class ShenyuCacheRepositoryTest {
+
+ private static final String SELECTOR_ID = "gwapi-upstream-snapshot-test";
+
+ private final UpstreamCacheManager upstreamCacheManager = UpstreamCacheManager.getInstance();
+
+ @AfterEach
+ public void tearDown() {
+ upstreamCacheManager.removeByKey(SELECTOR_ID);
+ }
+
+ private ShenyuCacheRepository repository() {
+ CommonDiscoveryUpstreamDataSubscriber discoverySubscriber =
+ new CommonDiscoveryUpstreamDataSubscriber(List.of(new DivideUpstreamDataHandler()));
+ return new ShenyuCacheRepository(Mockito.mock(CommonPluginDataSubscriber.class), discoverySubscriber,
+ Mockito.mock(MetaDataSubscriber.class), Mockito.mock(MetaDataCacheSubscriber.class));
+ }
+
+ private Upstream upstream(final String url) {
+ return Upstream.builder().url(url).weight(50).warmup(0).protocol("http://")
+ .healthCheckEnabled(false).status(true).build();
+ }
+
+ private SelectorData selector(final String handle) {
+ return SelectorData.builder()
+ .id(SELECTOR_ID)
+ .name("demo-route")
+ .pluginName(PluginEnum.DIVIDE.getName())
+ .handle(handle)
+ .build();
+ }
+
+ private String handleOf(final String... urls) {
+ StringBuilder handle = new StringBuilder("[");
+ for (int i = 0; i < urls.length; i++) {
+ if (i > 0) {
+ handle.append(",");
+ }
+ handle.append("{\"weight\":50,\"warmup\":0,\"protocol\":\"http://\",\"upstreamHost\":\"\",")
+ .append("\"upstreamUrl\":\"").append(urls[i]).append("\",\"status\":true,\"timestamp\":0}");
+ }
+ return handle.append("]").toString();
+ }
+
+ @Test
+ public void testScaledDownUpstreamIsRemovedFromCache() {
+ upstreamCacheManager.submit(SELECTOR_ID, List.of(upstream("10.0.0.1:8189"), upstream("10.0.0.2:8189")));
+ Assertions.assertEquals(2, upstreamCacheManager.findUpstreamListBySelectorId(SELECTOR_ID).size());
+
+ repository().saveOrUpdateSelectorData(selector(handleOf("10.0.0.1:8189")));
+
+ List<Upstream> after = upstreamCacheManager.findUpstreamListBySelectorId(SELECTOR_ID);
+ Assertions.assertEquals(1, after.size(), "scaled-down upstream must be evicted from the cache");
+ Assertions.assertEquals("10.0.0.1:8189", after.get(0).getUrl());
+ }
+
+ @Test
+ public void testUnchangedSnapshotKeepsUpstreams() {
+ upstreamCacheManager.submit(SELECTOR_ID, List.of(upstream("10.0.0.1:8189")));
+
+ repository().saveOrUpdateSelectorData(selector(handleOf("10.0.0.1:8189")));
+
+ List<Upstream> after = upstreamCacheManager.findUpstreamListBySelectorId(SELECTOR_ID);
+ Assertions.assertEquals(1, after.size());
+ Assertions.assertEquals("10.0.0.1:8189", after.get(0).getUrl());
+ }
+
+ /**
+ * deleteSelectorWithRules must remove every rule and then the selector even though findRuleDataList exposes
+ * BaseDataCache's mutable internal list that each deleteRuleData call mutates — iterating over a copy is what
+ * keeps this from throwing ConcurrentModificationException halfway through the cleanup.
+ */
+ @Test
+ public void testDeleteSelectorWithRulesClearsRulesAndSelector() {
+ CommonPluginDataSubscriber pluginSubscriber = new CommonPluginDataSubscriber(
+ new ArrayList<>(), Mockito.mock(org.springframework.context.ApplicationEventPublisher.class),
+ new ShenyuConfig.SelectorMatchCache(), new ShenyuConfig.RuleMatchCache());
+ ShenyuCacheRepository repository = new ShenyuCacheRepository(pluginSubscriber,
+ Mockito.mock(CommonDiscoveryUpstreamDataSubscriber.class),
+ Mockito.mock(MetaDataSubscriber.class), Mockito.mock(MetaDataCacheSubscriber.class));
+ String selectorId = "gwapi-cascade-delete-test";
+ BaseDataCache.getInstance().cacheRuleData(RuleData.builder()
+ .id("rule-1").selectorId(selectorId).pluginName(PluginEnum.DIVIDE.getName()).sort(1).build());
+ BaseDataCache.getInstance().cacheRuleData(RuleData.builder()
+ .id("rule-2").selectorId(selectorId).pluginName(PluginEnum.DIVIDE.getName()).sort(2).build());
+
+ repository.deleteSelectorWithRules(PluginEnum.DIVIDE.getName(), selectorId);
+
+ Assertions.assertNull(BaseDataCache.getInstance().obtainRuleData(selectorId));
+ }
+
+ /**
+ * BaseDataCache.obtainRuleData returns null for a selector with no cached rules, and a first reconcile writes a
+ * rule before any rule of that selector is cached, so saveOrUpdateRuleData must tolerate the empty case instead
+ * of throwing NPE — that NPE aborted the Kubernetes reconciler loops and left the gateway without routing data.
+ */
+ @Test
+ public void testSaveOrUpdateRuleDataWithUncachedSelectorSubscribesRule() {
+ CommonPluginDataSubscriber pluginSubscriber = new CommonPluginDataSubscriber(
+ new ArrayList<>(), Mockito.mock(org.springframework.context.ApplicationEventPublisher.class),
+ new ShenyuConfig.SelectorMatchCache(), new ShenyuConfig.RuleMatchCache());
+ ShenyuCacheRepository repository = new ShenyuCacheRepository(pluginSubscriber,
+ Mockito.mock(CommonDiscoveryUpstreamDataSubscriber.class),
+ Mockito.mock(MetaDataSubscriber.class), Mockito.mock(MetaDataCacheSubscriber.class));
+ String selectorId = "gwapi-rule-first-write-test";
+
+ Assertions.assertDoesNotThrow(() -> repository.saveOrUpdateRuleData(RuleData.builder()
+ .id("rule-1").selectorId(selectorId).pluginName(PluginEnum.DIVIDE.getName()).sort(1).build()));
+
+ Assertions.assertEquals(1, repository.findRuleDataList(selectorId).size());
+ BaseDataCache.getInstance().removeRuleDataBySelectorId(selectorId);
+ }
+
+ /** Reconcilers iterate findRuleDataList directly (e.g. */
+ @Test
+ public void testFindRuleDataListForUnknownSelectorIsEmpty() {
+ List<RuleData> rules = repository().findRuleDataList("gwapi-no-such-selector");
+
+ Assertions.assertNotNull(rules);
+ Assertions.assertTrue(rules.isEmpty());
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifierTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifierTest.java
new file mode 100644
index 0000000000..77010898f0
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifierTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import java.util.Objects;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiResponse;
+import okhttp3.Call;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * CRD verification: fail-fast on missing discovery entries, and bundle-version detection
+ * feeding the SupportedVersion condition (missing annotation means unsupported).
+ */
+public final class GatewayApiCrdVerifierTest {
+
+ @Test
+ public void testSupportedBundleVersionsDetected() throws Exception {
+ ApiClient apiClient = mockApiClient(discovery(true), crd("v1.5.1"));
+
+ GatewayApiCrdVersions versions = GatewayApiCrdVerifier.verify(apiClient);
+
+ Assertions.assertTrue(versions.isSupported());
+ Assertions.assertEquals(java.util.List.of("v1.5.1"), versions.detectedVersions());
+ }
+
+ @Test
+ public void testMissingBundleVersionAnnotationIsUnsupported() throws Exception {
+ ApiClient apiClient = mockApiClient(discovery(true), crd(null));
+
+ GatewayApiCrdVersions versions = GatewayApiCrdVerifier.verify(apiClient);
+
+ Assertions.assertFalse(versions.isSupported());
+ Assertions.assertTrue(versions.detectedVersions().isEmpty());
+ }
+
+ @Test
+ public void testMissingRequiredResourceFailsFast() throws Exception {
+ ApiClient apiClient = mockApiClient(discovery(false), crd("v1.5.1"));
+
+ Assertions.assertThrows(IllegalStateException.class, () -> GatewayApiCrdVerifier.verify(apiClient));
+ }
+
+ private JsonObject discovery(final boolean complete) {
+ JsonArray resources = new JsonArray();
+ resources.add(resource("gatewayclasses"));
+ resources.add(resource("gateways"));
+ resources.add(resource("httproutes"));
+ if (complete) {
+ resources.add(resource("referencegrants"));
+ }
+ JsonObject discovery = new JsonObject();
+ discovery.add("resources", resources);
+ return discovery;
+ }
+
+ private JsonObject resource(final String name) {
+ JsonObject resource = new JsonObject();
+ resource.addProperty("name", name);
+ resource.addProperty("verbs", "get");
+ return resource;
+ }
+
+ /** A CRD object carrying (or missing) the bundle-version annotation. */
+ private JsonObject crd(final String bundleVersion) {
+ JsonObject metadata = new JsonObject();
+ if (Objects.nonNull(bundleVersion)) {
+ JsonObject annotations = new JsonObject();
+ annotations.addProperty(GatewayApiCrdVerifier.BUNDLE_VERSION_ANNOTATION, bundleVersion);
+ metadata.add("annotations", annotations);
+ }
+ JsonObject crd = new JsonObject();
+ crd.add("metadata", metadata);
+ return crd;
+ }
+
+ /**
+ * The first API call is the discovery document; every later call returns the same CRD
+ * object for all four required CRDs.
+ */
+ @SuppressWarnings("unchecked")
+ private ApiClient mockApiClient(final JsonObject discovery, final JsonObject crd) throws Exception {
+ ApiClient apiClient = mock(ApiClient.class);
+ when(apiClient.getAuthentications()).thenReturn(Map.of());
+ when(apiClient.buildCall(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+ .thenReturn(mock(Call.class));
+
+ ApiResponse<JsonObject> discoveryResponse = mock(ApiResponse.class);
+ when(discoveryResponse.getData()).thenReturn(discovery);
+ ApiResponse<JsonObject> crdResponse = mock(ApiResponse.class);
+ when(crdResponse.getData()).thenReturn(crd);
+ when(apiClient.<JsonObject>execute(any(Call.class), eq(JsonObject.class)))
+ .thenReturn(discoveryResponse, crdResponse, crdResponse, crdResponse, crdResponse);
+ return apiClient;
+ }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersionsTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersionsTest.java
new file mode 100644
index 0000000000..cce4d4a7ad
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersionsTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Bundle version parsing and the minimum-version verdict feeding the SupportedVersion
+ * condition.
+ */
+public final class GatewayApiCrdVersionsTest {
+
+ @Test
+ public void testMajorMinorParsing() {
+ Assertions.assertArrayEquals(new int[]{1, 5}, GatewayApiCrdVersions.majorMinor("v1.5.1"));
+ Assertions.assertArrayEquals(new int[]{1, 6}, GatewayApiCrdVersions.majorMinor("1.6.0-rc2"));
+ Assertions.assertNull(GatewayApiCrdVersions.majorMinor("main"));
+ Assertions.assertNull(GatewayApiCrdVersions.majorMinor(null));
+ Assertions.assertNull(GatewayApiCrdVersions.majorMinor("v1"));
+ }
+
+ @Test
+ public void testAtLeastMinSupported() {
+ Assertions.assertTrue(GatewayApiCrdVersions.atLeastMinSupported(new int[]{1, 5}));
+ Assertions.assertTrue(GatewayApiCrdVersions.atLeastMinSupported(new int[]{2, 0}));
+ Assertions.assertFalse(GatewayApiCrdVersions.atLeastMinSupported(new int[]{1, 4}));
+ Assertions.assertFalse(GatewayApiCrdVersions.atLeastMinSupported(null));
+ }
+
+ @Test
+ public void testDescribeDetected() {
+ Assertions.assertEquals("none annotated", new GatewayApiCrdVersions(java.util.List.of(), false).describeDetected());
+ Assertions.assertEquals("v1.5.1, v1.6.0",
+ new GatewayApiCrdVersions(java.util.List.of("v1.6.0", "v1.5.1"), true).describeDetected());
+ }
+}
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/pom.xml b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/pom.xml
index fd16891e11..1789cc837c 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/pom.xml
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/pom.xml
@@ -31,5 +31,10 @@
<artifactId>shenyu-kubernetes-controller</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-actuator</artifactId>
+ <optional>true</optional>
+ </dependency>
</dependencies>
</project>
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/ControllerManagerLifecycle.java b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/ControllerManagerLifecycle.java
new file mode 100644
index 0000000000..6842548d08
--- /dev/null
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/ControllerManagerLifecycle.java
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.springboot.starter.k8s;
+
+import io.kubernetes.client.extended.controller.ControllerManager;
+import org.springframework.context.SmartLifecycle;
+
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Starts and stops the {@link ControllerManager}s through the Spring lifecycle instead of
+ * submitting them as a side effect of bean creation, so controllers only start once the
+ * context is fully refreshed and shut down cleanly on close.
+ */
+final class ControllerManagerLifecycle implements SmartLifecycle {
+
+ private final List<ControllerManager> controllerManagers;
+
+ private final ExecutorService executorService;
+
+ private volatile boolean running;
+
+ ControllerManagerLifecycle(final List<ControllerManager> controllerManagers,
+ final ExecutorService executorService) {
+ this.controllerManagers = controllerManagers;
+ this.executorService = executorService;
+ }
+
+ @Override
+ public void start() {
+ controllerManagers.forEach(executorService::submit);
+ running = true;
+ }
+
+ @Override
+ public void stop() {
+ controllerManagers.forEach(ControllerManager::shutdown);
+ running = false;
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+}
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/GatewayApiControllerConfiguration.java b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/GatewayApiControllerConfiguration.java
new file mode 100644
index 0000000000..fe899bc7fa
--- /dev/null
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/GatewayApiControllerConfiguration.java
@@ -0,0 +1,489 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.springboot.starter.k8s;
+
+import io.kubernetes.client.extended.controller.Controller;
+import io.kubernetes.client.extended.controller.ControllerManager;
+import io.kubernetes.client.extended.controller.DefaultController;
+import io.kubernetes.client.extended.controller.builder.ControllerBuilder;
+import io.kubernetes.client.extended.controller.builder.DefaultControllerBuilder;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.workqueue.DefaultRateLimitingQueue;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.extended.workqueue.WorkQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.SharedInformerFactory;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1EndpointsList;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ServiceList;
+import io.kubernetes.client.util.generic.GenericKubernetesApi;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.common.enums.PluginRoleEnum;
+import org.apache.shenyu.k8s.cache.K8sCacheReadiness;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.GatewayApiCrdVersions;
+import org.apache.shenyu.k8s.common.GatewayApiCrdVerifier;
+import org.apache.shenyu.k8s.parser.HttpRouteParser;
+import org.apache.shenyu.k8s.reconciler.GatewayClassReconciler;
+import org.apache.shenyu.k8s.reconciler.GatewayReconciler;
+import org.apache.shenyu.k8s.reconciler.HTTPRouteReconciler;
+import org.apache.shenyu.k8s.reconciler.HttpRouteEndpointsHandler;
+import org.apache.shenyu.k8s.reconciler.HttpRouteServiceHandler;
+import org.apache.shenyu.k8s.reconciler.ReferenceGrantReconciler;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.apache.shenyu.plugin.base.cache.CommonDiscoveryUpstreamDataSubscriber;
+import org.apache.shenyu.plugin.base.cache.CommonPluginDataSubscriber;
+import org.apache.shenyu.plugin.global.subsciber.MetaDataCacheSubscriber;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.actuate.health.HealthIndicator;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.core.env.Environment;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Spring Boot auto-configuration for the Kubernetes Gateway API controller mode: the
+ * bootstrap embeds the controller and writes parsed selector/rule config directly into the
+ * in-process {@code BaseDataCache}. Multiple replicas need no leader election —
+ * reconciliation is idempotent — but deployments MUST gate readiness on
+ * {@code k8sCacheReadiness} so a cold pod receives no traffic before its informers sync.
+ */
+@Configuration
+@ConditionalOnProperty(name = "shenyu.k8s.mode", havingValue = "gateway-api")
+public class GatewayApiControllerConfiguration {
+
+ /** Resync re-drives watch gaps (e.g. a grant added after ResolvedRefs=False); withResyncPeriod is a no-op in client-java. */
+ private static final long RESYNC_PERIOD_MILLIS = Duration.ofMinutes(1).toMillis();
+
+ private static final int DEFAULT_SERVER_PORT = 9195;
+
+ /**
+ * One factory per resource type: DynamicKubernetesObject class keys collide in a shared factory.
+ *
+ * @param apiClient the Kubernetes API client
+ * @return the GatewayClass SharedInformerFactory
+ */
+ @Bean("gatewayclass-shared-informer-factory")
+ public SharedInformerFactory gatewayClassSharedInformerFactory(final ApiClient apiClient) {
+ SharedInformerFactory factory = new SharedInformerFactory(apiClient);
+ DynamicKubernetesApi gatewayClassApi = new DynamicKubernetesApi(
+ GatewayApiConstants.GATEWAY_API_GROUP,
+ GatewayApiConstants.GATEWAY_API_VERSION,
+ "gatewayclasses",
+ apiClient);
+ factory.sharedIndexInformerFor(gatewayClassApi, DynamicKubernetesObject.class, RESYNC_PERIOD_MILLIS);
+ return factory;
+ }
+
+ @Bean("gateway-shared-informer-factory")
+ public SharedInformerFactory gatewaySharedInformerFactory(final ApiClient apiClient) {
+ SharedInformerFactory factory = new SharedInformerFactory(apiClient);
+ DynamicKubernetesApi gatewayApi = new DynamicKubernetesApi(
+ GatewayApiConstants.GATEWAY_API_GROUP,
+ GatewayApiConstants.GATEWAY_API_VERSION,
+ "gateways",
+ apiClient);
+ factory.sharedIndexInformerFor(gatewayApi, DynamicKubernetesObject.class, RESYNC_PERIOD_MILLIS);
+ return factory;
+ }
+
+ /**
+ * HTTPRoute, Service and Endpoints informers; Services map backendRef ports to named targetPorts.
+ *
+ * @param apiClient the Kubernetes API client
+ * @return the HTTPRoute, Service and Endpoints SharedInformerFactory
+ */
+ @Bean("httproute-shared-informer-factory")
+ public SharedInformerFactory httpRouteSharedInformerFactory(final ApiClient apiClient) {
+ SharedInformerFactory factory = new SharedInformerFactory(apiClient);
+ DynamicKubernetesApi httpRouteApi = new DynamicKubernetesApi(
+ GatewayApiConstants.GATEWAY_API_GROUP,
+ GatewayApiConstants.GATEWAY_API_VERSION,
+ "httproutes",
+ apiClient);
+ factory.sharedIndexInformerFor(httpRouteApi, DynamicKubernetesObject.class, RESYNC_PERIOD_MILLIS);
+
+ GenericKubernetesApi<V1Service, V1ServiceList> serviceApi = new GenericKubernetesApi<>(V1Service.class,
+ V1ServiceList.class, "", "v1", "services", apiClient);
+ factory.sharedIndexInformerFor(serviceApi, V1Service.class, RESYNC_PERIOD_MILLIS);
+
+ GenericKubernetesApi<V1Endpoints, V1EndpointsList> endpointsApi = new GenericKubernetesApi<>(V1Endpoints.class,
+ V1EndpointsList.class, "", "v1", "endpoints", apiClient);
+ factory.sharedIndexInformerFor(endpointsApi, V1Endpoints.class, RESYNC_PERIOD_MILLIS);
+ return factory;
+ }
+
+ @Bean("referencegrant-shared-informer-factory")
+ public SharedInformerFactory referenceGrantSharedInformerFactory(final ApiClient apiClient) {
+ SharedInformerFactory factory = new SharedInformerFactory(apiClient);
+ DynamicKubernetesApi referenceGrantApi = new DynamicKubernetesApi(
+ GatewayApiConstants.GATEWAY_API_GROUP,
+ GatewayApiConstants.GATEWAY_API_VERSION,
+ "referencegrants",
+ apiClient);
+ factory.sharedIndexInformerFor(referenceGrantApi, DynamicKubernetesObject.class, 0);
+ return factory;
+ }
+
+ @Bean(destroyMethod = "shutdown")
+ public ExecutorService controllerExecutorService() {
+ return Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "shenyu-k8s-controller");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+
+ @Bean("gatewayclass-controller-manager")
+ public ControllerManager gatewayClassControllerManager(
+ @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+ @Qualifier("gatewayclass-controller") final Controller gatewayClassController) {
+ return new ControllerManager(gatewayClassFactory, gatewayClassController);
+ }
+
+ @Bean("gateway-controller-manager")
+ public ControllerManager gatewayControllerManager(
+ @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+ @Qualifier("gateway-controller") final Controller gatewayController) {
+ return new ControllerManager(gatewayFactory, gatewayController);
+ }
+
+ @Bean("httproute-controller-manager")
+ @DependsOn({"httpRouteEndpointsHandler", "httpRouteServiceHandler"})
+ public ControllerManager httpRouteControllerManager(
+ @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+ @Qualifier("httproute-controller") final Controller httpRouteController) {
+ return new ControllerManager(httpRouteFactory, httpRouteController);
+ }
+
+ /**
+ * Re-queues HTTPRoutes referencing the grant's namespace so a revoked grant stops traffic immediately.
+ *
+ * @param referenceGrantFactory the ReferenceGrant SharedInformerFactory
+ * @param httpRouteFactory the HTTPRoute SharedInformerFactory
+ * @param httpRouteWorkQueue the HTTPRoute controller work queue
+ * @return the ReferenceGrant controller
+ */
+ @Bean("referencegrant-controller")
+ public Controller referenceGrantController(
+ @Qualifier("referencegrant-shared-informer-factory") final SharedInformerFactory referenceGrantFactory,
+ @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+ @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+ DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(referenceGrantFactory);
+ builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q)
+ .build());
+ builder.withWorkerCount(1);
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+ httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ Reconciler reconciler = new ReferenceGrantReconciler(httpRouteInformer, httpRouteWorkQueue);
+ return builder.withReconciler(reconciler).withName("referenceGrantController").build();
+ }
+
+ @Bean("referencegrant-controller-manager")
+ public ControllerManager referenceGrantControllerManager(
+ @Qualifier("referencegrant-shared-informer-factory") final SharedInformerFactory referenceGrantFactory,
+ @Qualifier("referencegrant-controller") final Controller referenceGrantController) {
+ return new ControllerManager(referenceGrantFactory, referenceGrantController);
+ }
+
+ /**
+ * Fails fast when required CRDs are missing; the result feeds the SupportedVersion condition.
+ *
+ * @param apiClient the Kubernetes API client
+ * @return the detected CRD bundle versions
+ */
+ @Bean
+ public GatewayApiCrdVersions gatewayApiCrdVersions(final ApiClient apiClient) {
+ return GatewayApiCrdVerifier.verify(apiClient);
+ }
+
+ @Bean
+ public SmartLifecycle k8sControllerLifecycle(final List<ControllerManager> controllerManagers,
+ final ExecutorService controllerExecutorService) {
+ return new ControllerManagerLifecycle(controllerManagers, controllerExecutorService);
+ }
+
+ @Bean("gatewayclass-controller")
+ public Controller gatewayClassController(
+ @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+ @Qualifier("gatewayclass-work-queue") final RateLimitingQueue<Request> gatewayClassWorkQueue,
+ final GatewayClassReconciler gatewayClassReconciler) {
+ DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(gatewayClassFactory)
+ .withWorkQueue(gatewayClassWorkQueue);
+ builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q)
+ .build());
+ builder.withWorkerCount(1);
+ return builder.withReconciler(gatewayClassReconciler).withName("gatewayClassController").build();
+ }
+
+ @Bean("gateway-controller")
+ public Controller gatewayController(
+ @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+ final GatewayReconciler gatewayReconciler) {
+ DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(gatewayFactory);
+ builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q)
+ .build());
+ builder.withWorkerCount(2);
+ return builder.withReconciler(gatewayReconciler).withName("gatewayController").build();
+ }
+
+ /**
+ * Also fed by the Gateway reconciler on accept/delete so finalizer updates are immediate.
+ *
+ * @param controllerExecutorService the shared controller executor service
+ * @return the GatewayClass work queue
+ */
+ @Bean("gatewayclass-work-queue")
+ public RateLimitingQueue<Request> gatewayClassWorkQueue(final ExecutorService controllerExecutorService) {
+ return new DefaultRateLimitingQueue<>(controllerExecutorService);
+ }
+
+ @Bean("httproute-work-queue")
+ public RateLimitingQueue<Request> httpRouteWorkQueue(final ExecutorService controllerExecutorService) {
+ return new DefaultRateLimitingQueue<>(controllerExecutorService);
+ }
+
+ @Bean("httproute-controller")
+ public Controller httpRouteController(
+ @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+ final HTTPRouteReconciler httpRouteReconciler,
+ @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+ DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(httpRouteFactory)
+ .withWorkQueue(httpRouteWorkQueue);
+ builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q)
+ .build());
+ builder.withWorkerCount(2);
+ return builder.withReconciler(httpRouteReconciler).withName("httpRouteController").build();
+ }
+
+ /**
+ * Enqueues routes whose backend Service Endpoints changed; a manager dependency so indexers register first.
+ *
+ * @param httpRouteFactory the HTTPRoute, Service and Endpoints SharedInformerFactory
+ * @param httpRouteWorkQueue the HTTPRoute controller work queue
+ * @return the registered Endpoints event handler
+ */
+ @Bean
+ public HttpRouteEndpointsHandler httpRouteEndpointsHandler(
+ @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+ @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+ SharedIndexInformer<V1Endpoints> endpointsInformer =
+ httpRouteFactory.getExistingSharedIndexInformer(V1Endpoints.class);
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+ httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ HttpRouteEndpointsHandler handler = new HttpRouteEndpointsHandler(httpRouteInformer, httpRouteWorkQueue);
+ endpointsInformer.addEventHandler(handler);
+ return handler;
+ }
+
+ /**
+ * Service port/targetPort edits do not touch Endpoints, so they need their own trigger.
+ *
+ * @param httpRouteFactory the HTTPRoute, Service and Endpoints SharedInformerFactory
+ * @param httpRouteWorkQueue the HTTPRoute controller work queue
+ * @return the registered Service event handler
+ */
+ @Bean
+ public HttpRouteServiceHandler httpRouteServiceHandler(
+ @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+ @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+ SharedIndexInformer<V1Service> serviceInformer =
+ httpRouteFactory.getExistingSharedIndexInformer(V1Service.class);
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+ httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ HttpRouteServiceHandler handler = new HttpRouteServiceHandler(httpRouteInformer, httpRouteWorkQueue);
+ serviceInformer.addEventHandler(handler);
+ return handler;
+ }
+
+ @Bean
+ public GatewayClassReconciler gatewayClassReconciler(
+ @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+ @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+ @Qualifier("gateway-controller") final Controller gatewayController,
+ final ApiClient apiClient,
+ final GatewayApiCrdVersions gatewayApiCrdVersions) {
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer =
+ gatewayClassFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer =
+ gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ RateLimitingQueue<Request> gatewayWorkQueue = ((DefaultController) gatewayController).getWorkQueue();
+ return new GatewayClassReconciler(gatewayClassInformer, gatewayInformer, gatewayWorkQueue, apiClient, gatewayApiCrdVersions);
+ }
+
+ @Bean
+ public GatewayReconciler gatewayReconciler(
+ @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+ @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+ @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+ @Qualifier("httproute-controller") final Controller httpRouteController,
+ @Qualifier("gatewayclass-work-queue") final RateLimitingQueue<Request> gatewayClassWorkQueue,
+ final ShenyuCacheRepository shenyuCacheRepository,
+ final ApiClient apiClient,
+ final Environment environment) {
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer =
+ gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer =
+ gatewayClassFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+ httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ RateLimitingQueue<Request> httpRouteWorkQueue = ((DefaultController) httpRouteController).getWorkQueue();
+ int servedPort = environment.getProperty("server.port", Integer.class, DEFAULT_SERVER_PORT);
+ return new GatewayReconciler(gatewayInformer, gatewayClassInformer, httpRouteInformer,
+ shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, servedPort);
+ }
+
+ @Bean
+ public HTTPRouteReconciler httpRouteReconciler(
+ @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+ @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+ @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+ final HttpRouteParser httpRouteParser,
+ final ShenyuCacheRepository shenyuCacheRepository,
+ final ApiClient apiClient,
+ final Environment environment) {
+ SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+ httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayInformer =
+ gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer =
+ gatewayClassFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ int servedPort = environment.getProperty("server.port", Integer.class, DEFAULT_SERVER_PORT);
+ return new HTTPRouteReconciler(httpRouteInformer, gatewayInformer, gatewayClassInformer,
+ httpRouteParser, shenyuCacheRepository, apiClient, servedPort);
+ }
+
+ @Bean
+ public HttpRouteParser httpRouteParser(
+ @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+ @Qualifier("referencegrant-shared-informer-factory") final SharedInformerFactory referenceGrantFactory) {
+ SharedIndexInformer<V1Service> serviceInformer =
+ httpRouteFactory.getExistingSharedIndexInformer(V1Service.class);
+ SharedIndexInformer<V1Endpoints> endpointsInformer =
+ httpRouteFactory.getExistingSharedIndexInformer(V1Endpoints.class);
+ SharedIndexInformer<DynamicKubernetesObject> referenceGrantInformer =
+ referenceGrantFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+ Lister<V1Service> serviceLister = new Lister<>(serviceInformer.getIndexer());
+ Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsInformer.getIndexer());
+ Lister<DynamicKubernetesObject> referenceGrantLister = new Lister<>(referenceGrantInformer.getIndexer());
+ return new HttpRouteParser(endpointsLister, serviceLister, referenceGrantLister);
+ }
+
+ @Bean
+ public ShenyuCacheRepository shenyuCacheRepository(final CommonPluginDataSubscriber pluginDataSubscriber,
+ final CommonDiscoveryUpstreamDataSubscriber discoveryUpstreamDataSubscriber,
+ final MetaDataCacheSubscriber metaDataSubscriber) {
+ ShenyuCacheRepository repository = new ShenyuCacheRepository(pluginDataSubscriber, discoveryUpstreamDataSubscriber,
+ metaDataSubscriber, metaDataSubscriber);
+ enablePlugin(repository, PluginEnum.GLOBAL, null);
+ enablePlugin(repository, PluginEnum.URI, null);
+ enablePlugin(repository, PluginEnum.NETTY_HTTP_CLIENT, null);
+ enablePlugin(repository, PluginEnum.DIVIDE, "{multiSelectorHandle: 1, multiRuleHandle:0}");
+ return repository;
+ }
+
+ /**
+ * Readiness needs informer sync AND drained work queues; the grant queue matters because its reconcile re-queues routes.
+ *
+ * @param gatewayClassFactory the GatewayClass SharedInformerFactory
+ * @param gatewayFactory the Gateway SharedInformerFactory
+ * @param httpRouteFactory the HTTPRoute, Service and Endpoints SharedInformerFactory
+ * @param referenceGrantFactory the ReferenceGrant SharedInformerFactory
+ * @param gatewayClassController the GatewayClass controller (for its work queue)
+ * @param gatewayController the Gateway controller (for its work queue)
+ * @param referenceGrantController the ReferenceGrant controller (for its work queue)
+ * @param httpRouteWorkQueue the HTTPRoute controller work queue
+ * @return readiness aggregator over all registered informers and work queues
+ */
+ @Bean
+ public K8sCacheReadiness k8sCacheReadiness(
+ @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+ @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+ @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+ @Qualifier("referencegrant-shared-informer-factory") final SharedInformerFactory referenceGrantFactory,
+ @Qualifier("gatewayclass-controller") final Controller gatewayClassController,
+ @Qualifier("gateway-controller") final Controller gatewayController,
+ @Qualifier("referencegrant-controller") final Controller referenceGrantController,
+ @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+ List<SharedIndexInformer<?>> informers = new ArrayList<>();
+ informers.add(gatewayClassFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class));
+ informers.add(gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class));
+ informers.add(httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class));
+ informers.add(httpRouteFactory.getExistingSharedIndexInformer(V1Service.class));
+ informers.add(httpRouteFactory.getExistingSharedIndexInformer(V1Endpoints.class));
+ informers.add(referenceGrantFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class));
+ if (informers.stream().anyMatch(Objects::isNull)) {
+ throw new IllegalStateException("Expected informer not registered; informer factory wiring is inconsistent");
+ }
+ List<WorkQueue<?>> workQueues = List.of(
+ ((DefaultController) gatewayClassController).getWorkQueue(),
+ ((DefaultController) gatewayController).getWorkQueue(),
+ ((DefaultController) referenceGrantController).getWorkQueue(),
+ httpRouteWorkQueue);
+ return new K8sCacheReadiness(informers, workQueues);
+ }
+
+ private void enablePlugin(final ShenyuCacheRepository shenyuCacheRepository, final PluginEnum pluginEnum, final String config) {
+ PluginData pluginData = PluginData.builder()
+ .id(String.valueOf(pluginEnum.getCode()))
+ .name(pluginEnum.getName())
+ .config(config)
+ .role(PluginRoleEnum.SYS.getName())
+ .enabled(true)
+ .sort(pluginEnum.getCode())
+ .build();
+ shenyuCacheRepository.saveOrUpdatePluginData(pluginData);
+ }
+
+ /**
+ * Isolated from the CGLIB-proxied outer class (resolving an actuator @Bean without
+ * actuator fails hard); repeats the mode condition because a nested @Configuration is
+ * an independent scan candidate.
+ */
+ @Configuration
+ @ConditionalOnProperty(name = "shenyu.k8s.mode", havingValue = "gateway-api")
+ @ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator")
+ static class HealthIndicatorConfiguration {
+
+ @Bean
+ public HealthIndicator k8sCacheReadinessHealthIndicator(final K8sCacheReadiness k8sCacheReadiness) {
+ return () -> k8sCacheReadiness.isReady()
+ ? Health.up().withDetail("pendingInformers", 0L).withDetail("pendingWorkItems", 0L).build()
+ : Health.down().withDetail("pendingInformers", k8sCacheReadiness.pendingInformers())
+ .withDetail("pendingWorkItems", k8sCacheReadiness.pendingWorkItems()).build();
+ }
+ }
+}
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/IngressControllerConfiguration.java b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/IngressControllerConfiguration.java
index 0a6ad99abd..fe16e76d8b 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/IngressControllerConfiguration.java
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/IngressControllerConfiguration.java
@@ -66,6 +66,7 @@ import java.util.concurrent.Executors;
* The type shenyu ingress controller configuration.
*/
@Configuration
+@ConditionalOnProperty(name = "shenyu.k8s.mode", havingValue = "ingress", matchIfMissing = true)
public class IngressControllerConfiguration {
/**
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring.factories b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring.factories
index 6899440f01..a386224c21 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring.factories
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring.factories
@@ -1,3 +1,4 @@
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration,\
+org.apache.shenyu.springboot.starter.k8s.GatewayApiControllerConfiguration
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 3eb3d33916..b641d56b70 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -16,3 +16,4 @@
#
org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+org.apache.shenyu.springboot.starter.k8s.GatewayApiControllerConfiguration