From 5dff4f5589d8f92f17db7d35ebce87ef5c506077 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Mon, 18 May 2026 16:29:45 +0800 Subject: [PATCH 1/5] [feat] support Kubernetes Gateway API --- .../integrated-test-k8s-gateway-api.yml | 208 +++++++++ .../shenyu-examples-http/k8s/gateway-api.yml | 55 +++ shenyu-integrated-test/pom.xml | 1 + .../Dockerfile | 29 ++ .../deploy/deploy-shenyu.yaml | 117 +++++ .../deploy/kind-config.yaml | 37 ++ .../pom.xml | 144 ++++++ .../script/build_k8s_cluster.sh | 28 ++ .../script/healthcheck.sh | 36 ++ .../script/services.list | 19 + .../api/GatewayApiControllerBootstrap.java | 37 ++ .../src/main/resources/application.yml | 179 +++++++ .../k8s/gateway/api/DividePluginTest.java | 45 ++ .../shenyu/k8s/cache/GatewayRouteCache.java | 131 ++++++ .../k8s/common/GatewayApiConstants.java | 71 +++ .../shenyu/k8s/parser/HttpRouteParser.java | 304 ++++++++++++ .../reconciler/GatewayClassReconciler.java | 194 ++++++++ .../k8s/reconciler/GatewayReconciler.java | 271 +++++++++++ .../k8s/reconciler/HTTPRouteReconciler.java | 358 ++++++++++++++ .../shenyu/k8s/GatewayReconcilerTest.java | 289 ++++++++++++ .../shenyu/k8s/HTTPRouteReconcilerTest.java | 245 ++++++++++ .../shenyu/k8s/HttpRouteParserTest.java | 439 ++++++++++++++++++ .../GatewayApiControllerConfiguration.java | 261 +++++++++++ .../k8s/IngressControllerConfiguration.java | 3 +- .../main/resources/META-INF/spring.factories | 3 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 + 26 files changed, 3503 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/integrated-test-k8s-gateway-api.yml create mode 100644 shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/Dockerfile create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/pom.xml create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/java/org/apache/shenyu/integrated/test/k8s/gateway/api/GatewayApiControllerBootstrap.java create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/resources/application.yml create mode 100644 shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/test/java/org/apache/shenyu/integrated/test/k8s/gateway/api/DividePluginTest.java create mode 100644 shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/GatewayRouteCache.java create mode 100644 shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiConstants.java create mode 100644 shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/parser/HttpRouteParser.java create mode 100644 shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayClassReconciler.java create mode 100644 shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayReconciler.java create mode 100644 shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HTTPRouteReconciler.java create mode 100644 shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayReconcilerTest.java create mode 100644 shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HTTPRouteReconcilerTest.java create mode 100644 shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteParserTest.java create mode 100644 shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/GatewayApiControllerConfiguration.java 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 000000000000..a37e6507fd8f --- /dev/null +++ b/.github/workflows/integrated-test-k8s-gateway-api.yml @@ -0,0 +1,208 @@ +# 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.17.x + + - name: Install k8s + if: steps.filter.outputs.k8s-gateway-api == 'true' + run: | + go install sigs.k8s.io/kind@v0.14.0 + curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.24.14/bin/linux/amd64/kubectl && sudo install kubectl /usr/local/bin/kubectl + kind create cluster --image=kindest/node:v1.21.1 --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.2 + if [[ "${{ runner.os }}" == "Windows" ]]; then + curl -sL https://downloads.apache.org/maven/mvnd/${MVND_VERSION}/maven-mvnd-${MVND_VERSION}-windows-amd64.zip -o mvnd.zip + unzip -q mvnd.zip + mkdir -p $HOME/.local + mv maven-mvnd-${MVND_VERSION}-windows-amd64 $HOME/.local/mvnd + echo "$HOME/.local/mvnd/bin" >> $GITHUB_PATH + echo "MVND_HOME=$HOME/.local/mvnd" >> $GITHUB_ENV + else + curl -sL 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 + fi + + - 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" + if [[ "${{ runner.os }}" == "Windows" ]]; then + ./mvnw.cmd -B clean install -Dmaven.javadoc.skip=true -Dmaven.test.skip=true + else + ./mvnw -B clean install -Dmaven.javadoc.skip=true -Dmaven.test.skip=true + fi + 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" + if [[ "${{ runner.os }}" == "Windows" ]]; then + ./mvnw.cmd -B clean install -Pit -DskipTests -am -f ./shenyu-integrated-test/pom.xml + else + ./mvnw -B clean install -Pit -DskipTests -am -f ./shenyu-integrated-test/pom.xml + fi + 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" + if [[ "${{ runner.os }}" == "Windows" ]]; then + ./mvnw.cmd -B clean install -Pexample -Dmaven.javadoc.skip=true -Dmaven.test.skip=true -am -f ./shenyu-examples/pom.xml + else + ./mvnw -B clean install -Pexample -Dmaven.javadoc.skip=true -Dmaven.test.skip=true -am -f ./shenyu-examples/pom.xml + fi + 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" + if [[ "${{ runner.os }}" == "Windows" ]]; then + ./mvnw.cmd test -Pit -f ./shenyu-integrated-test/${{ matrix.case }}/pom.xml + else + ./mvnw test -Pit -f ./shenyu-integrated-test/${{ matrix.case }}/pom.xml + fi + 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 000000000000..e21d98e8db53 --- /dev/null +++ b/shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml @@ -0,0 +1,55 @@ +# 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 + annotations: + shenyu.apache.org/loadbalancer: p2c +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 d8de022b9c86..189f5c03e13a 100644 --- a/shenyu-integrated-test/pom.xml +++ b/shenyu-integrated-test/pom.xml @@ -47,6 +47,7 @@ shenyu-integrated-test-k8s-ingress-websocket shenyu-integrated-test-k8s-ingress-grpc shenyu-integrated-test-k8s-ingress-sofa + shenyu-integrated-test-k8s-gateway-api-http 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 000000000000..d11d2e4d8141 --- /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 000000000000..d1350a43f7b4 --- /dev/null +++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml @@ -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. + +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: 1 + 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 + env: + - name: KUBECONFIG + value: '/root/.kube/config' + imagePullPolicy: IfNotPresent + serviceAccountName: 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: +- apiGroups: + - "" + resources: + - namespaces + - services + - endpoints + - secrets + - pods + verbs: + - get + - list + - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - gatewayclasses + - gatewayclasses/status + - gateways + - gateways/status + - httproutes + - httproutes/status + verbs: + - get + - list + - watch + - update + - patch +--- +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-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml new file mode 100644 index 000000000000..69a62b9b9047 --- /dev/null +++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml @@ -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. + +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + kubeadmConfigPatches: + - | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + node-labels: "ingress-ready=true" + extraPortMappings: + - containerPort: 80 + hostPort: 80 + protocol: TCP + - containerPort: 443 + hostPort: 443 + protocol: TCP + - 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 000000000000..5731375e734f --- /dev/null +++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/pom.xml @@ -0,0 +1,144 @@ + + + + + shenyu-integrated-test + org.apache.shenyu + 2.7.1-SNAPSHOT + + 4.0.0 + + shenyu-integrated-test-k8s-gateway-api-http + shenyu-integrated-test-k8s-gateway-api-http + https://shenyu.apache.org + + + + org.apache.shenyu + shenyu-integrated-test-common + ${project.version} + + + + org.apache.shenyu + shenyu-spring-boot-starter-plugin-divide + ${project.version} + + + + org.apache.shenyu + shenyu-spring-boot-starter-plugin-httpclient + ${project.version} + + + + org.apache.shenyu + shenyu-spring-boot-starter-plugin-global + ${project.version} + + + + org.apache.shenyu + shenyu-spring-boot-starter-plugin-uri + ${project.version} + + + + org.apache.shenyu + shenyu-spring-boot-starter-plugin-general-context + ${project.version} + + + + org.apache.shenyu + shenyu-spring-boot-starter-k8s + ${project.version} + + + + com.google.code.gson + gson + + + + + + it + + shenyu-integrated-test-k8s-gateway-api-http + apache/shenyu-integrated-test-k8s-gateway-api-http + latest + + + shenyu-integrated-test-k8s-gateway-api-http + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + package + + repackage + + + + + org.apache.shenyu.integrated.test.k8s.gateway.api.GatewayApiControllerBootstrap + true + + + + io.fabric8 + docker-maven-plugin + ${docker-maven-plugin.version} + + + + apache/shenyu-integrated-test-k8s-gateway-api-http + + ${project.basedir} + + + + + + + start + + build + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + + + diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh new file mode 100644 index 000000000000..4e1b5c93a964 --- /dev/null +++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -e +# +# 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. +# + +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-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh new file mode 100644 index 000000000000..896e78414458 --- /dev/null +++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list new file mode 100644 index 000000000000..732451b7dd99 --- /dev/null +++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list @@ -0,0 +1,19 @@ +# 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. + +http://localhost:31189/actuator/health +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 000000000000..4f69d0f9e436 --- /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 000000000000..acbc371cd2ee --- /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 + 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 + sni: + enabled: false + mod: k8s + defaultK8sSecretNamespace: shenyu-gateway-api + defaultK8sSecretName: default-cert + 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 000000000000..42b44db0b7b3 --- /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/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 000000000000..d9a77e5dbe2d --- /dev/null +++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/GatewayRouteCache.java @@ -0,0 +1,131 @@ +/* + * 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 java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; + +public final class GatewayRouteCache { + + private static final GatewayRouteCache INSTANCE = new GatewayRouteCache(); + + private static final Map> ROUTE_SELECTOR_MAP = Maps.newConcurrentMap(); + + private static final Map> GATEWAY_ROUTE_MAP = Maps.newConcurrentMap(); + + private static final Map ROUTE_GATEWAY_MAP = Maps.newConcurrentMap(); + + private static final AtomicLong GROWING_ID = new AtomicLong(10000); + + private GatewayRouteCache() { + } + + public static GatewayRouteCache getInstance() { + return INSTANCE; + } + + public void putRouteSelectors(final String namespace, final String routeName, + final String pluginName, final List selectorIds) { + ROUTE_SELECTOR_MAP.put(routeKey(namespace, routeName, pluginName), selectorIds); + } + + public void addRouteSelector(final String namespace, final String routeName, + final String pluginName, final String selectorId) { + ROUTE_SELECTOR_MAP.computeIfAbsent(routeKey(namespace, routeName, pluginName), + k -> new CopyOnWriteArrayList<>()).add(selectorId); + } + + public List getRouteSelectors(final String namespace, final String routeName, + final String pluginName) { + return ROUTE_SELECTOR_MAP.get(routeKey(namespace, routeName, pluginName)); + } + + public List removeRouteSelectors(final String namespace, final String routeName, + final String pluginName) { + return ROUTE_SELECTOR_MAP.remove(routeKey(namespace, routeName, pluginName)); + } + + public void bindRouteToGateway(final String gatewayNamespace, final String gatewayName, + final String routeNamespace, final String routeName) { + String gwKey = gatewayKey(gatewayNamespace, gatewayName); + GATEWAY_ROUTE_MAP.computeIfAbsent(gwKey, k -> new CopyOnWriteArrayList<>()).add(routeKey(routeNamespace, routeName)); + ROUTE_GATEWAY_MAP.put(routeKey(routeNamespace, routeName), gwKey); + } + + public List getRoutesByGateway(final String gatewayNamespace, final String gatewayName) { + return GATEWAY_ROUTE_MAP.get(gatewayKey(gatewayNamespace, gatewayName)); + } + + public List removeRoutesByGateway(final String gatewayNamespace, final String gatewayName) { + String gwKey = gatewayKey(gatewayNamespace, gatewayName); + List routes = GATEWAY_ROUTE_MAP.remove(gwKey); + if (Objects.nonNull(routes)) { + routes.forEach(ROUTE_GATEWAY_MAP::remove); + } + return routes; + } + + public String getGatewayForRoute(final String routeNamespace, final String routeName) { + return ROUTE_GATEWAY_MAP.get(routeKey(routeNamespace, routeName)); + } + + public void removeRouteGatewayBinding(final String routeNamespace, final String routeName) { + String routeKey = routeKey(routeNamespace, routeName); + String gwKey = ROUTE_GATEWAY_MAP.remove(routeKey); + if (Objects.nonNull(gwKey)) { + List routes = GATEWAY_ROUTE_MAP.get(gwKey); + if (Objects.nonNull(routes)) { + routes.remove(routeKey); + } + } + } + + public String generateSelectorId() { + return String.valueOf(GROWING_ID.getAndIncrement()); + } + + public String generateRuleId() { + return String.valueOf(GROWING_ID.getAndIncrement()); + } + + /** + * Clear all cached data. Used for testing. + */ + public void clear() { + ROUTE_SELECTOR_MAP.clear(); + GATEWAY_ROUTE_MAP.clear(); + ROUTE_GATEWAY_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/common/GatewayApiConstants.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiConstants.java new file mode 100644 index 000000000000..c2be76628aa4 --- /dev/null +++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiConstants.java @@ -0,0 +1,71 @@ +/* + * 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; + +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 GRPC_ROUTE_KIND = "GRPCRoute"; + + public static final String SHENYU_GATEWAY_CLASS_NAME = "shenyu"; + + public static final String SHENYU_CONTROLLER_NAME = "gateway.shenyu.apache.org/shenyu-controller"; + + private GatewayApiConstants() { + } + + /** + * 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 raw = resource.getRaw(); + if (!raw.has("status") || raw.get("status").isJsonNull()) { + return false; + } + JsonObject status = raw.getAsJsonObject("status"); + if (!status.has("conditions") || status.get("conditions").isJsonNull()) { + return false; + } + JsonArray conditions = status.getAsJsonArray("conditions"); + for (JsonElement element : conditions) { + JsonObject condition = element.getAsJsonObject(); + String type = condition.has("type") ? condition.get("type").getAsString() : null; + String value = condition.has("status") ? condition.get("status").getAsString() : null; + if (conditionType.equals(type) && "True".equals(value)) { + return true; + } + } + return false; + } +} 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 000000000000..e5e9c085bf23 --- /dev/null +++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/parser/HttpRouteParser.java @@ -0,0 +1,304 @@ +/* + * 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.informer.cache.Lister; +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.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.cache.GatewayRouteCache; +import org.apache.shenyu.k8s.common.IngressConfiguration; +import org.apache.shenyu.k8s.common.ShenyuMemoryConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class HttpRouteParser { + + private static final Logger LOG = LoggerFactory.getLogger(HttpRouteParser.class); + + private final Lister endpointsLister; + + public HttpRouteParser(final Lister endpointsLister) { + this.endpointsLister = endpointsLister; + } + + public ShenyuMemoryConfig parse(final DynamicKubernetesObject httpRoute) { + ShenyuMemoryConfig res = new ShenyuMemoryConfig(); + String namespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace(); + String routeName = httpRoute.getMetadata().getName(); + + JsonObject raw = httpRoute.getRaw(); + JsonObject spec = raw.getAsJsonObject("spec"); + if (Objects.isNull(spec)) { + return res; + } + + JsonArray hostnames = spec.getAsJsonArray("hostnames"); + JsonArray rules = spec.getAsJsonArray("rules"); + if (Objects.isNull(rules) || rules.isEmpty()) { + return res; + } + + GatewayRouteCache cache = GatewayRouteCache.getInstance(); + List routeConfigList = new ArrayList<>(); + + for (int ruleIndex = 0; ruleIndex < rules.size(); ruleIndex++) { + processRule(rules.get(ruleIndex).getAsJsonObject(), hostnames, namespace, routeName, ruleIndex, cache, routeConfigList); + } + + res.setRouteConfigList(routeConfigList); + return res; + } + + private void processRule(final JsonObject rule, final JsonArray hostnames, final String namespace, + final String routeName, final int ruleIndex, final GatewayRouteCache cache, + final List routeConfigList) { + JsonArray backendRefs = rule.getAsJsonArray("backendRefs"); + if (Objects.isNull(backendRefs) || backendRefs.isEmpty()) { + return; + } + + List upstreamList = parseBackendRefs(backendRefs, namespace); + + // Build a list of individual hostname conditions. + // Each hostname generates a separate selector+rule to avoid AND logic contradiction + // (a request can only match one hostname at a time). + List hostnameConditions = new ArrayList<>(); + if (Objects.nonNull(hostnames) && !hostnames.isEmpty()) { + for (JsonElement hostname : hostnames) { + ConditionData hostCondition = new ConditionData(); + hostCondition.setParamType(ParamTypeEnum.DOMAIN.getName()); + hostCondition.setOperator(OperatorEnum.EQ.getAlias()); + hostCondition.setParamValue(hostname.getAsString()); + hostnameConditions.add(hostCondition); + } + } + + JsonArray matches = rule.getAsJsonArray("matches"); + if (Objects.nonNull(matches) && !matches.isEmpty()) { + for (JsonElement matchElement : matches) { + JsonObject match = matchElement.getAsJsonObject(); + List matchConditions = new ArrayList<>(); + appendMatchConditions(matchConditions, match); + + if (hostnameConditions.isEmpty()) { + // No hostname: one selector+rule for this match + String selectorId = cache.generateSelectorId(); + String selectorName = routeName + "-rule-" + ruleIndex; + SelectorData selectorData = buildSelectorData(selectorId, selectorName, matchConditions, upstreamList); + RuleData ruleData = buildRuleData(cache.generateRuleId(), selectorId, selectorName, matchConditions); + cache.addRouteSelector(namespace, routeName, PluginEnum.DIVIDE.getName(), selectorId); + routeConfigList.add(new IngressConfiguration(selectorData, List.of(ruleData), null)); + } else { + // One selector+rule per hostname to keep AND semantics correct + for (ConditionData hostCondition : hostnameConditions) { + List conditions = new ArrayList<>(); + conditions.add(hostCondition); + conditions.addAll(matchConditions); + + String selectorId = cache.generateSelectorId(); + String selectorName = routeName + "-rule-" + ruleIndex; + SelectorData selectorData = buildSelectorData(selectorId, selectorName, conditions, upstreamList); + RuleData ruleData = buildRuleData(cache.generateRuleId(), selectorId, selectorName, conditions); + cache.addRouteSelector(namespace, routeName, PluginEnum.DIVIDE.getName(), selectorId); + routeConfigList.add(new IngressConfiguration(selectorData, List.of(ruleData), null)); + } + } + } + } else { + if (hostnameConditions.isEmpty()) { + String selectorId = cache.generateSelectorId(); + String selectorName = routeName + "-rule-" + ruleIndex; + SelectorData selectorData = buildSelectorData(selectorId, selectorName, new ArrayList<>(), upstreamList); + RuleData ruleData = buildRuleData(cache.generateRuleId(), selectorId, selectorName, new ArrayList<>()); + cache.addRouteSelector(namespace, routeName, PluginEnum.DIVIDE.getName(), selectorId); + routeConfigList.add(new IngressConfiguration(selectorData, List.of(ruleData), null)); + } else { + for (ConditionData hostCondition : hostnameConditions) { + String selectorId = cache.generateSelectorId(); + String selectorName = routeName + "-rule-" + ruleIndex; + List conditions = new ArrayList<>(); + conditions.add(hostCondition); + SelectorData selectorData = buildSelectorData(selectorId, selectorName, conditions, upstreamList); + RuleData ruleData = buildRuleData(cache.generateRuleId(), selectorId, selectorName, conditions); + cache.addRouteSelector(namespace, routeName, PluginEnum.DIVIDE.getName(), selectorId); + routeConfigList.add(new IngressConfiguration(selectorData, List.of(ruleData), null)); + } + } + } + } + + private SelectorData buildSelectorData(final String selectorId, final String selectorName, + final List conditions, final List upstreamList) { + return SelectorData.builder() + .id(selectorId) + .pluginId(String.valueOf(PluginEnum.DIVIDE.getCode())) + .pluginName(PluginEnum.DIVIDE.getName()) + .name(selectorName) + .sort(1) + .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 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(); + } + + private List parseBackendRefs(final JsonArray backendRefs, final String namespace) { + List upstreamList = new ArrayList<>(); + for (JsonElement element : backendRefs) { + JsonObject backendRef = element.getAsJsonObject(); + String serviceName = getStringField(backendRef, "name"); + int weight = backendRef.has("weight") ? backendRef.get("weight").getAsInt() : 100; + Integer port = backendRef.has("port") ? backendRef.get("port").getAsInt() : null; + + if (Objects.isNull(serviceName)) { + continue; + } + + V1Endpoints v1Endpoints = endpointsLister.namespace(namespace).get(serviceName); + if (Objects.isNull(v1Endpoints) || CollectionUtils.isEmpty(v1Endpoints.getSubsets())) { + LOG.warn("Cannot find endpoints for service {}/{}", namespace, serviceName); + continue; + } + + for (V1EndpointSubset subset : v1Endpoints.getSubsets()) { + if (CollectionUtils.isEmpty(subset.getAddresses())) { + continue; + } + for (V1EndpointAddress address : subset.getAddresses()) { + String ip = address.getIp(); + if (Objects.nonNull(ip)) { + DivideUpstream upstream = new DivideUpstream(); + upstream.setUpstreamUrl(Objects.nonNull(port) ? ip + ":" + port : ip); + upstream.setWeight(weight); + upstream.setProtocol("http://"); + upstream.setWarmup(0); + upstream.setStatus(true); + upstream.setUpstreamHost(""); + upstreamList.add(upstream); + } + } + } + } + return upstreamList; + } + + private void appendMatchConditions(final List conditions, final JsonObject match) { + JsonObject path = match.getAsJsonObject("path"); + if (Objects.nonNull(path) && path.has("value")) { + ConditionData pathCondition = new ConditionData(); + pathCondition.setParamType(ParamTypeEnum.URI.getName()); + pathCondition.setOperator(mapPathType(getStringField(path, "type"))); + pathCondition.setParamValue(path.get("value").getAsString()); + conditions.add(pathCondition); + } + + JsonArray headers = match.getAsJsonArray("headers"); + if (Objects.nonNull(headers)) { + for (JsonElement headerElement : headers) { + JsonObject header = headerElement.getAsJsonObject(); + ConditionData headerCondition = new ConditionData(); + headerCondition.setParamType(ParamTypeEnum.HEADER.getName()); + headerCondition.setOperator("Exact".equals(getStringField(header, "type")) + ? OperatorEnum.EQ.getAlias() : OperatorEnum.MATCH.getAlias()); + headerCondition.setParamName(getStringField(header, "name")); + headerCondition.setParamValue(getStringField(header, "value")); + conditions.add(headerCondition); + } + } + + JsonArray queryParams = match.getAsJsonArray("queryParams"); + if (Objects.nonNull(queryParams)) { + for (JsonElement queryElement : queryParams) { + JsonObject queryParam = queryElement.getAsJsonObject(); + ConditionData queryCondition = new ConditionData(); + queryCondition.setParamType(ParamTypeEnum.QUERY.getName()); + queryCondition.setOperator("Exact".equals(getStringField(queryParam, "type")) + ? OperatorEnum.EQ.getAlias() : OperatorEnum.MATCH.getAlias()); + queryCondition.setParamName(getStringField(queryParam, "name")); + queryCondition.setParamValue(getStringField(queryParam, "value")); + conditions.add(queryCondition); + } + } + } + + private String mapPathType(final String pathType) { + if ("Exact".equals(pathType)) { + return OperatorEnum.EQ.getAlias(); + } + if ("PathPrefix".equals(pathType)) { + return OperatorEnum.STARTS_WITH.getAlias(); + } + if ("RegularExpression".equals(pathType)) { + return OperatorEnum.MATCH.getAlias(); + } + return OperatorEnum.STARTS_WITH.getAlias(); + } + + private String getStringField(final JsonObject obj, final String field) { + if (Objects.isNull(obj) || !obj.has(field) || obj.get(field).isJsonNull()) { + return null; + } + return obj.get(field).getAsString(); + } +} 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 000000000000..fd471f85a158 --- /dev/null +++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayClassReconciler.java @@ -0,0 +1,194 @@ +/* + * 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.Gson; +import com.google.gson.JsonArray; +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.shenyu.k8s.common.GatewayApiConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.Objects; + +/** + * Reconciler for GatewayClass resources. + * GatewayClass is a cluster-scoped resource that defines which controller manages gateways of this class. + * This reconciler watches GatewayClass objects and: + *
    + *
  • Accepts GatewayClasses whose spec.controllerName matches ShenYu's controller name
  • + *
  • Updates GatewayClass status with Accepted=True condition
  • + *
  • On deletion, re-queues associated Gateways for cleanup
  • + *
+ */ +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"; + + private final Lister gatewayClassLister; + + private final Lister gatewayLister; + + private final RateLimitingQueue gatewayWorkQueue; + + private final ApiClient apiClient; + + public GatewayClassReconciler(final SharedIndexInformer gatewayClassInformer, + final SharedIndexInformer gatewayInformer, + final RateLimitingQueue gatewayWorkQueue, + final ApiClient apiClient) { + this.gatewayClassLister = new Lister<>(gatewayClassInformer.getIndexer()); + this.gatewayLister = new Lister<>(gatewayInformer.getIndexer()); + this.gatewayWorkQueue = gatewayWorkQueue; + this.apiClient = apiClient; + } + + @Override + public Result reconcile(final Request request) { + LOG.info("Starting to reconcile GatewayClass {}", request.getName()); + try { + // GatewayClass is cluster-scoped, no namespace + 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 (!isShenyuGatewayClass(gatewayClass)) { + LOG.info("GatewayClass {} is not managed by ShenYu, skipping", request.getName()); + return new Result(false); + } + + updateGatewayClassAcceptedStatus(gatewayClass); + LOG.info("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); + } + } + + /** + * Check if the given GatewayClass is managed by ShenYu by comparing spec.controllerName. + * + * @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) { + 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); + } + + /** + * When a ShenYu GatewayClass is deleted, find all Gateways that reference this class + * and re-queue them for reconciliation (which will handle cleanup). + */ + 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 {} deletion", ns, name, gatewayClassName); + } + } + } + + 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()); + } + + /** + * Update GatewayClass status with Accepted=True condition. + * GatewayClass is cluster-scoped, so the API path has no namespace segment. + */ + private void updateGatewayClassAcceptedStatus(final DynamicKubernetesObject gatewayClass) { + if (GatewayApiConstants.isConditionTrue(gatewayClass, "Accepted")) { + return; + } + try { + final String name = gatewayClass.getMetadata().getName(); + + JsonObject condition = new JsonObject(); + condition.addProperty("type", "Accepted"); + condition.addProperty("status", "True"); + condition.addProperty("reason", "Accepted"); + condition.addProperty("message", "GatewayClass has been accepted by the ShenYu controller"); + condition.addProperty("lastTransitionTime", Instant.now().toString()); + + JsonArray conditions = new JsonArray(); + conditions.add(condition); + + JsonObject statusObj = new JsonObject(); + statusObj.add("conditions", conditions); + + 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 patchBody = new Gson().toJson(body); + String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION + + "/" + GATEWAYCLASSES_RESOURCE + "/" + name + "/status"; + + okhttp3.Request httpRequest = new okhttp3.Request.Builder() + .url(apiClient.getBasePath() + path) + .patch(okhttp3.RequestBody.create(patchBody, okhttp3.MediaType.parse("application/merge-patch+json"))) + .build(); + + try (okhttp3.Response response = apiClient.getHttpClient().newCall(httpRequest).execute()) { + if (response.isSuccessful()) { + LOG.info("Updated GatewayClass {} status to Accepted=True", name); + } else { + String responseBody = Objects.nonNull(response.body()) ? response.body().string() : "empty"; + LOG.warn("Failed to update GatewayClass {} status: {} - {}", name, response.code(), responseBody); + } + } + } catch (Exception e) { + LOG.warn("Failed to update GatewayClass status, will retry on next resync", e); + } + } + +} 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 000000000000..9779ea3ac167 --- /dev/null +++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayReconciler.java @@ -0,0 +1,271 @@ +/* + * 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.Gson; +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.dto.RuleData; +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.repository.ShenyuCacheRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class GatewayReconciler implements Reconciler { + + private static final Logger LOG = LoggerFactory.getLogger(GatewayReconciler.class); + + private final Lister gatewayLister; + + private final Lister httpRouteLister; + + private final ShenyuCacheRepository shenyuCacheRepository; + + private final RateLimitingQueue httpRouteWorkQueue; + + private final ApiClient apiClient; + + public GatewayReconciler(final SharedIndexInformer gatewayInformer, + final SharedIndexInformer httpRouteInformer, + final ShenyuCacheRepository shenyuCacheRepository, + final RateLimitingQueue httpRouteWorkQueue, + final ApiClient apiClient) { + this.gatewayLister = new Lister<>(gatewayInformer.getIndexer()); + this.httpRouteLister = new Lister<>(httpRouteInformer.getIndexer()); + this.shenyuCacheRepository = shenyuCacheRepository; + this.httpRouteWorkQueue = httpRouteWorkQueue; + this.apiClient = apiClient; + } + + @Override + public Result reconcile(final Request request) { + LOG.info("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()); + return new Result(false); + } + + if (!isShenyuGateway(gateway)) { + LOG.info("Gateway {} is not managed by ShenYu, skipping", request); + return new Result(false); + } + + updateGatewayAcceptedStatus(gateway); + + // Re-queue HTTPRoutes that reference this Gateway but haven't been applied yet + requeueAffectedHTTPRoutes(request.getNamespace(), request.getName()); + + LOG.info("Gateway {} reconciled successfully", request); + return new Result(false); + } catch (Exception e) { + LOG.error("Error reconciling gateway {}, will retry", request, e); + return new Result(true); + } + } + + /** + * When a ShenYu Gateway is created/updated, find HTTPRoutes whose parentRefs reference + * this Gateway and add them to the HTTPRoute controller's work queue for re-reconciliation. + * This handles the case where an HTTPRoute was created before the Gateway existed. + * Also handles cross-namespace references where HTTPRoute's parentRef specifies a different namespace. + */ + private void requeueAffectedHTTPRoutes(final String gatewayNamespace, final String gatewayName) { + // Search in the gateway's namespace (same-namespace reference) + List localRoutes = httpRouteLister.namespace(gatewayNamespace).list(); + for (DynamicKubernetesObject route : localRoutes) { + if (isBoundToGateway(route, gatewayNamespace, gatewayName)) { + Request req = new Request(route.getMetadata().getNamespace(), route.getMetadata().getName()); + httpRouteWorkQueue.add(req); + LOG.info("Re-queued HTTPRoute {}/{} due to Gateway {}/{} reconciliation", + route.getMetadata().getNamespace(), route.getMetadata().getName(), + gatewayNamespace, gatewayName); + } + } + // Also search all namespaces for cross-namespace references + for (DynamicKubernetesObject route : httpRouteLister.list()) { + String routeNamespace = Objects.requireNonNull(route.getMetadata()).getNamespace(); + if (routeNamespace.equals(gatewayNamespace)) { + // Already handled in local routes search above + continue; + } + if (isBoundToGateway(route, gatewayNamespace, gatewayName)) { + Request req = new Request(route.getMetadata().getNamespace(), route.getMetadata().getName()); + httpRouteWorkQueue.add(req); + LOG.info("Re-queued cross-namespace HTTPRoute {}/{} due to Gateway {}/{} reconciliation", + route.getMetadata().getNamespace(), route.getMetadata().getName(), + gatewayNamespace, gatewayName); + } + } + } + + private boolean isBoundToGateway(final DynamicKubernetesObject httpRoute, + final String gatewayNamespace, final String gatewayName) { + JsonObject spec = httpRoute.getRaw().getAsJsonObject("spec"); + if (Objects.isNull(spec) || !spec.has("parentRefs")) { + return false; + } + JsonArray parentRefs = spec.getAsJsonArray("parentRefs"); + if (Objects.isNull(parentRefs)) { + return false; + } + String routeNamespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace(); + for (JsonElement element : parentRefs) { + JsonObject parentRef = element.getAsJsonObject(); + String parentName = parentRef.has("name") ? parentRef.get("name").getAsString() : null; + String parentNamespace = parentRef.has("namespace") ? parentRef.get("namespace").getAsString() : routeNamespace; + if (gatewayNamespace.equals(parentNamespace) && gatewayName.equals(parentName)) { + return true; + } + } + return false; + } + + /** + * When a Gateway is deleted, cascade delete ShenYu config (selectors/rules) for all associated routes. + */ + private void deleteAssociatedRoutes(final String gatewayNamespace, final String gatewayName) { + GatewayRouteCache cache = GatewayRouteCache.getInstance(); + List routeKeys = cache.getRoutesByGateway(gatewayNamespace, gatewayName); + if (CollectionUtils.isEmpty(routeKeys)) { + return; + } + // Copy to avoid ConcurrentModificationException: removeRouteGatewayBinding modifies the same list + List routeKeysCopy = new ArrayList<>(routeKeys); + // Remove gateway-route bindings first to prevent concurrent access issues + cache.removeRoutesByGateway(gatewayNamespace, gatewayName); + for (String routeKey : routeKeysCopy) { + String[] parts = routeKey.split("/", 2); + if (parts.length != 2) { + continue; + } + String routeNamespace = parts[0]; + String routeName = parts[1]; + List selectorIds = cache.removeRouteSelectors(routeNamespace, routeName, PluginEnum.DIVIDE.getName()); + if (CollectionUtils.isNotEmpty(selectorIds)) { + for (String selectorId : selectorIds) { + List rules = shenyuCacheRepository.findRuleDataList(selectorId); + if (CollectionUtils.isNotEmpty(rules)) { + for (RuleData rule : rules) { + shenyuCacheRepository.deleteRuleData(PluginEnum.DIVIDE.getName(), selectorId, rule.getId()); + } + } + shenyuCacheRepository.deleteSelectorData(PluginEnum.DIVIDE.getName(), selectorId); + } + } + LOG.info("Deleted ShenYu config for route {}/{} due to Gateway deletion", routeNamespace, routeName); + } + } + + /** + * Check if the given Gateway object is managed by ShenYu. + * + * @param gateway the Gateway dynamic object + * @return true if the Gateway's gatewayClassName matches ShenYu + */ + public static boolean isShenyuGateway(final DynamicKubernetesObject gateway) { + JsonObject spec = gateway.getRaw().getAsJsonObject("spec"); + if (Objects.isNull(spec)) { + return false; + } + if (!spec.has("gatewayClassName") || spec.get("gatewayClassName").isJsonNull()) { + return false; + } + String gatewayClassName = spec.get("gatewayClassName").getAsString(); + return GatewayApiConstants.SHENYU_GATEWAY_CLASS_NAME.equals(gatewayClassName); + } + + /** + * Update Gateway status with Accepted=True condition. + * Uses strategic merge patch on the /status subresource to avoid Gson JsonElement serialization issues + * with the default updateStatus implementation. + */ + private void updateGatewayAcceptedStatus(final DynamicKubernetesObject gateway) { + if (GatewayApiConstants.isConditionTrue(gateway, "Accepted")) { + return; + } + try { + final String namespace = gateway.getMetadata().getNamespace(); + final String name = gateway.getMetadata().getName(); + + JsonObject condition = new JsonObject(); + condition.addProperty("type", "Accepted"); + condition.addProperty("status", "True"); + condition.addProperty("reason", "Accepted"); + condition.addProperty("message", "Gateway has been accepted by the ShenYu controller"); + condition.addProperty("lastTransitionTime", Instant.now().toString()); + + JsonArray conditions = new JsonArray(); + conditions.add(condition); + + JsonObject statusObj = new JsonObject(); + statusObj.add("conditions", conditions); + + JsonObject body = new JsonObject(); + body.add("status", statusObj); + body.addProperty("kind", "Gateway"); + 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 patchBody = new Gson().toJson(body); + String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION + + "/namespaces/" + namespace + "/gateways/" + name + "/status"; + + okhttp3.Request request = new okhttp3.Request.Builder() + .url(apiClient.getBasePath() + path) + .patch(okhttp3.RequestBody.create(patchBody, okhttp3.MediaType.parse("application/merge-patch+json"))) + .build(); + + try (okhttp3.Response response = apiClient.getHttpClient().newCall(request).execute()) { + if (response.isSuccessful()) { + LOG.info("Updated Gateway {}/{} status to Accepted=True", namespace, name); + } else { + String responseBody = Objects.nonNull(response.body()) ? response.body().string() : "empty"; + LOG.warn("Failed to update Gateway {}/{} status: {} - {}", namespace, name, response.code(), responseBody); + } + } + } 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 000000000000..1545ce3cd0a3 --- /dev/null +++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HTTPRouteReconciler.java @@ -0,0 +1,358 @@ +/* + * 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.Gson; +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.util.generic.dynamic.DynamicKubernetesObject; +import org.apache.commons.collections4.CollectionUtils; +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.k8s.cache.GatewayRouteCache; +import org.apache.shenyu.k8s.common.GatewayApiConstants; +import org.apache.shenyu.k8s.common.IngressConfiguration; +import org.apache.shenyu.k8s.common.ShenyuMemoryConfig; +import org.apache.shenyu.k8s.parser.HttpRouteParser; +import org.apache.shenyu.k8s.repository.ShenyuCacheRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class HTTPRouteReconciler implements Reconciler { + + private static final Logger LOG = LoggerFactory.getLogger(HTTPRouteReconciler.class); + + private final Lister httpRouteLister; + + private final Lister gatewayLister; + + private final HttpRouteParser httpRouteParser; + + private final ShenyuCacheRepository shenyuCacheRepository; + + private final ApiClient apiClient; + + public HTTPRouteReconciler(final SharedIndexInformer httpRouteInformer, + final SharedIndexInformer gatewayInformer, + final HttpRouteParser httpRouteParser, + final ShenyuCacheRepository shenyuCacheRepository, + final ApiClient apiClient) { + this.httpRouteLister = new Lister<>(httpRouteInformer.getIndexer()); + this.gatewayLister = new Lister<>(gatewayInformer.getIndexer()); + this.httpRouteParser = httpRouteParser; + this.shenyuCacheRepository = shenyuCacheRepository; + this.apiClient = apiClient; + } + + @Override + public Result reconcile(final Request request) { + LOG.info("Starting to reconcile HTTPRoute {}", request); + try { + String namespace = request.getNamespace(); + String routeName = request.getName(); + DynamicKubernetesObject httpRoute = httpRouteLister.namespace(namespace).get(routeName); + + if (Objects.isNull(httpRoute)) { + deleteConfig(namespace, routeName); + return new Result(false); + } + + if (!isBoundToShenyuGateway(httpRoute)) { + LOG.info("HTTPRoute {} is not bound to a ShenYu Gateway, skipping", request); + return new Result(false); + } + + deleteConfig(namespace, routeName); + + ShenyuMemoryConfig config = httpRouteParser.parse(httpRoute); + applyConfig(config); + + bindToGateway(httpRoute); + + updateHTTPRouteStatus(httpRoute); + + LOG.info("HTTPRoute {} reconciled successfully", request); + return new Result(false); + } catch (Exception e) { + LOG.error("Error reconciling HTTPRoute {}, will retry", request, e); + return new Result(true); + } + } + + private boolean isBoundToShenyuGateway(final DynamicKubernetesObject httpRoute) { + JsonObject spec = httpRoute.getRaw().getAsJsonObject("spec"); + if (Objects.isNull(spec) || !spec.has("parentRefs")) { + return false; + } + JsonArray parentRefs = spec.getAsJsonArray("parentRefs"); + if (Objects.isNull(parentRefs)) { + return false; + } + String routeNamespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace(); + for (JsonElement element : parentRefs) { + JsonObject parentRef = element.getAsJsonObject(); + String parentName = parentRef.has("name") ? parentRef.get("name").getAsString() : null; + String parentNamespace = parentRef.has("namespace") ? parentRef.get("namespace").getAsString() : routeNamespace; + String sectionName = parentRef.has("sectionName") ? parentRef.get("sectionName").getAsString() : null; + if (Objects.isNull(parentName)) { + continue; + } + DynamicKubernetesObject gateway = gatewayLister.namespace(parentNamespace).get(parentName); + if (Objects.nonNull(gateway) && GatewayReconciler.isShenyuGateway(gateway)) { + // If sectionName is specified, verify the Gateway has a matching listener + if (Objects.nonNull(sectionName) && !hasMatchingListener(gateway, sectionName)) { + LOG.info("HTTPRoute references sectionName '{}' but Gateway {}/{} has no matching listener", sectionName, parentNamespace, parentName); + continue; + } + return true; + } + } + return false; + } + + private boolean hasMatchingListener(final DynamicKubernetesObject gateway, final String sectionName) { + JsonObject spec = gateway.getRaw().getAsJsonObject("spec"); + if (Objects.isNull(spec) || !spec.has("listeners")) { + return false; + } + JsonArray listeners = spec.getAsJsonArray("listeners"); + if (Objects.isNull(listeners)) { + return false; + } + for (JsonElement listenerElement : listeners) { + JsonObject listener = listenerElement.getAsJsonObject(); + if (listener.has("name") && sectionName.equals(listener.get("name").getAsString())) { + return true; + } + } + return false; + } + + private void deleteConfig(final String namespace, final String routeName) { + GatewayRouteCache cache = GatewayRouteCache.getInstance(); + List selectorIds = cache.removeRouteSelectors(namespace, routeName, PluginEnum.DIVIDE.getName()); + if (CollectionUtils.isNotEmpty(selectorIds)) { + for (String selectorId : selectorIds) { + List rules = shenyuCacheRepository.findRuleDataList(selectorId); + if (CollectionUtils.isNotEmpty(rules)) { + for (RuleData rule : new ArrayList<>(rules)) { + shenyuCacheRepository.deleteRuleData(PluginEnum.DIVIDE.getName(), selectorId, rule.getId()); + } + } + shenyuCacheRepository.deleteSelectorData(PluginEnum.DIVIDE.getName(), selectorId); + } + } + cache.removeRouteGatewayBinding(namespace, routeName); + } + + private void applyConfig(final ShenyuMemoryConfig config) { + List routeConfigs = config.getRouteConfigList(); + if (CollectionUtils.isEmpty(routeConfigs)) { + return; + } + for (IngressConfiguration routeConfig : routeConfigs) { + SelectorData selectorData = routeConfig.getSelectorData(); + shenyuCacheRepository.saveOrUpdateSelectorData(selectorData); + for (RuleData ruleData : routeConfig.getRuleDataList()) { + shenyuCacheRepository.saveOrUpdateRuleData(ruleData); + } + } + } + + private void bindToGateway(final DynamicKubernetesObject httpRoute) { + JsonObject spec = httpRoute.getRaw().getAsJsonObject("spec"); + if (Objects.isNull(spec) || !spec.has("parentRefs")) { + return; + } + JsonArray parentRefs = spec.getAsJsonArray("parentRefs"); + String routeNamespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace(); + String routeName = httpRoute.getMetadata().getName(); + + for (JsonElement element : parentRefs) { + JsonObject parentRef = element.getAsJsonObject(); + String parentName = parentRef.has("name") ? parentRef.get("name").getAsString() : null; + String parentNamespace = parentRef.has("namespace") ? parentRef.get("namespace").getAsString() : routeNamespace; + if (Objects.nonNull(parentName)) { + GatewayRouteCache.getInstance().bindRouteToGateway(parentNamespace, parentName, routeNamespace, routeName); + } + } + } + + /** + * Update HTTPRoute status with Accepted=True and ResolvedRefs=True for each ShenYu-managed parent. + * Uses merge-patch on the /status subresource, same approach as GatewayReconciler. + * Skips the patch if the status is already up-to-date to avoid triggering an infinite reconcile loop. + */ + private void updateHTTPRouteStatus(final DynamicKubernetesObject httpRoute) { + if (isRouteStatusAlreadySet(httpRoute)) { + return; + } + try { + final String routeNamespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace(); + final String routeName = httpRoute.getMetadata().getName(); + + JsonObject spec = httpRoute.getRaw().getAsJsonObject("spec"); + if (Objects.isNull(spec) || !spec.has("parentRefs")) { + return; + } + JsonArray parentRefs = spec.getAsJsonArray("parentRefs"); + JsonArray parentsStatus = buildParentsStatus(parentRefs, routeNamespace); + + if (parentsStatus.size() == 0) { + return; + } + sendStatusPatch(routeNamespace, routeName, parentsStatus); + } catch (Exception e) { + LOG.warn("Failed to update HTTPRoute status, will retry on next resync", e); + } + } + + /** + * Check if the HTTPRoute already has Accepted=True condition from the ShenYu controller + * in its status.parents, to avoid unnecessary status patches that trigger infinite reconcile loops. + */ + private boolean isRouteStatusAlreadySet(final DynamicKubernetesObject httpRoute) { + JsonObject raw = httpRoute.getRaw(); + if (!raw.has("status") || raw.get("status").isJsonNull()) { + return false; + } + JsonObject status = raw.getAsJsonObject("status"); + if (!status.has("parents") || status.get("parents").isJsonNull()) { + return false; + } + JsonArray parents = status.getAsJsonArray("parents"); + for (JsonElement parentElement : parents) { + JsonObject parent = parentElement.getAsJsonObject(); + if (!parent.has("controllerName") || !GatewayApiConstants.SHENYU_CONTROLLER_NAME.equals(parent.get("controllerName").getAsString())) { + continue; + } + if (!parent.has("conditions") || parent.get("conditions").isJsonNull()) { + continue; + } + JsonArray conditions = parent.getAsJsonArray("conditions"); + for (JsonElement condElement : conditions) { + JsonObject cond = condElement.getAsJsonObject(); + if ("Accepted".equals(cond.has("type") ? cond.get("type").getAsString() : null) + && "True".equals(cond.has("status") ? cond.get("status").getAsString() : null)) { + return true; + } + } + } + return false; + } + + private JsonArray buildParentsStatus(final JsonArray parentRefs, final String routeNamespace) { + final JsonArray parentsStatus = new JsonArray(); + for (JsonElement element : parentRefs) { + JsonObject parentRef = element.getAsJsonObject(); + String parentName = parentRef.has("name") ? parentRef.get("name").getAsString() : null; + String parentNamespace = parentRef.has("namespace") ? parentRef.get("namespace").getAsString() : routeNamespace; + + if (Objects.isNull(parentName)) { + continue; + } + DynamicKubernetesObject gateway = gatewayLister.namespace(parentNamespace).get(parentName); + if (Objects.isNull(gateway) || !GatewayReconciler.isShenyuGateway(gateway)) { + continue; + } + parentsStatus.add(buildParentStatus(parentNamespace, parentName)); + } + return parentsStatus; + } + + private JsonObject buildParentStatus(final String parentNamespace, final String parentName) { + JsonObject parentRefStatus = new JsonObject(); + parentRefStatus.addProperty("group", GatewayApiConstants.GATEWAY_API_GROUP); + parentRefStatus.addProperty("kind", GatewayApiConstants.GATEWAY_KIND); + parentRefStatus.addProperty("namespace", parentNamespace); + parentRefStatus.addProperty("name", parentName); + + String now = Instant.now().toString(); + JsonArray conditions = buildStatusConditions(now); + + JsonObject parentStatus = new JsonObject(); + parentStatus.add("parentRef", parentRefStatus); + parentStatus.addProperty("controllerName", GatewayApiConstants.SHENYU_CONTROLLER_NAME); + parentStatus.add("conditions", conditions); + return parentStatus; + } + + private JsonArray buildStatusConditions(final String now) { + final JsonArray conditions = new JsonArray(); + conditions.add(buildCondition("Accepted", "Route was accepted by the ShenYu controller", now)); + conditions.add(buildCondition("ResolvedRefs", "All references resolved", now)); + return conditions; + } + + private JsonObject buildCondition(final String type, final String message, final String now) { + JsonObject condition = new JsonObject(); + condition.addProperty("type", type); + condition.addProperty("status", "True"); + condition.addProperty("reason", type); + condition.addProperty("message", message); + condition.addProperty("lastTransitionTime", now); + return condition; + } + + private void sendStatusPatch(final String routeNamespace, final String routeName, + final JsonArray parentsStatus) throws Exception { + JsonObject statusObj = new JsonObject(); + statusObj.add("parents", parentsStatus); + + JsonObject body = new JsonObject(); + body.add("status", statusObj); + body.addProperty("kind", GatewayApiConstants.HTTP_ROUTE_KIND); + body.addProperty("apiVersion", GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION); + + JsonObject metadata = new JsonObject(); + metadata.addProperty("name", routeName); + metadata.addProperty("namespace", routeNamespace); + body.add("metadata", metadata); + + String patchBody = new Gson().toJson(body); + String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION + + "/namespaces/" + routeNamespace + "/httproutes/" + routeName + "/status"; + + okhttp3.Request httpRequest = new okhttp3.Request.Builder() + .url(apiClient.getBasePath() + path) + .patch(okhttp3.RequestBody.create(patchBody, okhttp3.MediaType.parse("application/merge-patch+json"))) + .build(); + + try (okhttp3.Response response = apiClient.getHttpClient().newCall(httpRequest).execute()) { + if (response.isSuccessful()) { + LOG.info("Updated HTTPRoute {}/{} status to Accepted=True", routeNamespace, routeName); + } else { + String responseBody = Objects.nonNull(response.body()) ? response.body().string() : "empty"; + LOG.warn("Failed to update HTTPRoute {}/{} status: {} - {}", routeNamespace, routeName, response.code(), responseBody); + } + } + } +} 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 000000000000..f7fe59b13fce --- /dev/null +++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayReconcilerTest.java @@ -0,0 +1,289 @@ +/* + * 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.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.util.generic.dynamic.DynamicKubernetesObject; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.apache.shenyu.common.dto.RuleData; +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 java.util.List; + +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 { + // mock gateway indexer + SharedIndexInformer gatewayInformer = mock(SharedIndexInformer.class); + Indexer gatewayIndexer = mock(Indexer.class); + DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu"); + when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway); + when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer); + + // mock httpRoute indexer with a route referencing this gateway + SharedIndexInformer httpRouteInformer = mock(SharedIndexInformer.class); + Indexer 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.byIndex("namespace", "mockedNamespace")).thenReturn(List.of(httpRoute)); + when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer); + + ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class); + RateLimitingQueue httpRouteWorkQueue = mock(RateLimitingQueue.class); + ApiClient apiClient = mock(ApiClient.class); + OkHttpClient httpClient = mock(OkHttpClient.class); + when(apiClient.getHttpClient()).thenReturn(httpClient); + when(apiClient.getBasePath()).thenReturn("http://localhost:8080"); + okhttp3.Call call = mock(okhttp3.Call.class); + when(httpClient.newCall(any(okhttp3.Request.class))).thenReturn(call); + Response successResponse = new Response.Builder() + .request(new okhttp3.Request.Builder().url("http://localhost").build()) + .protocol(Protocol.HTTP_1_1).code(200).message("OK") + .body(ResponseBody.create("{}", MediaType.parse("application/json"))).build(); + when(call.execute()).thenReturn(successResponse); + + GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, httpRouteInformer, + shenyuCacheRepository, httpRouteWorkQueue, apiClient); + + Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway")); + Assertions.assertEquals(new Result(false), result); + verify(httpRouteWorkQueue).add(new Request("mockedNamespace", "test-route")); + verify(httpClient).newCall(any(okhttp3.Request.class)); + } + + /** + * Test non-ShenYu Gateway creation: should skip without re-queuing HTTPRoutes. + */ + @Test + public void testReconcileNonShenYuGatewayCreation() { + SharedIndexInformer gatewayInformer = mock(SharedIndexInformer.class); + Indexer gatewayIndexer = mock(Indexer.class); + DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "other-gateway", "other-class"); + when(gatewayIndexer.getByKey("mockedNamespace/other-gateway")).thenReturn(gateway); + when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer); + + SharedIndexInformer httpRouteInformer = mock(SharedIndexInformer.class); + Indexer httpRouteIndexer = mock(Indexer.class); + when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer); + + ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class); + RateLimitingQueue httpRouteWorkQueue = mock(RateLimitingQueue.class); + ApiClient apiClient = mock(ApiClient.class); + + GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, httpRouteInformer, + shenyuCacheRepository, httpRouteWorkQueue, apiClient); + + Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "other-gateway")); + Assertions.assertEquals(new Result(false), result); + verify(httpRouteWorkQueue, never()).add(any()); + } + + /** + * Test Gateway deletion: should cascade delete ShenYu config for associated routes. + */ + @Test + public void testReconcileGatewayDeletion() { + // gateway not found in indexer → treated as deletion + SharedIndexInformer gatewayInformer = mock(SharedIndexInformer.class); + Indexer gatewayIndexer = mock(Indexer.class); + when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(null); + when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer); + + SharedIndexInformer httpRouteInformer = mock(SharedIndexInformer.class); + Indexer httpRouteIndexer = mock(Indexer.class); + when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer); + + // pre-populate GatewayRouteCache with a bound route + GatewayRouteCache cache = GatewayRouteCache.getInstance(); + cache.bindRouteToGateway("mockedNamespace", "shenyu-gateway", "mockedNamespace", "test-route"); + String selectorId = cache.generateSelectorId(); + cache.addRouteSelector("mockedNamespace", "test-route", "divide", selectorId); + + ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class); + RuleData ruleData = mock(RuleData.class); + when(ruleData.getId()).thenReturn("rule-1"); + when(shenyuCacheRepository.findRuleDataList(selectorId)).thenReturn(List.of(ruleData)); + + RateLimitingQueue httpRouteWorkQueue = mock(RateLimitingQueue.class); + ApiClient apiClient = mock(ApiClient.class); + GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, httpRouteInformer, + shenyuCacheRepository, httpRouteWorkQueue, apiClient); + + Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway")); + Assertions.assertEquals(new Result(false), result); + verify(shenyuCacheRepository).deleteRuleData("divide", selectorId, "rule-1"); + verify(shenyuCacheRepository).deleteSelectorData("divide", selectorId); + } + + /** + * Test Gateway deletion with no associated routes: should not throw or delete anything. + */ + @Test + public void testReconcileGatewayDeletionWithNoAssociatedRoutes() { + SharedIndexInformer gatewayInformer = mock(SharedIndexInformer.class); + Indexer gatewayIndexer = mock(Indexer.class); + when(gatewayIndexer.getByKey("mockedNamespace/empty-gateway")).thenReturn(null); + when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer); + + SharedIndexInformer httpRouteInformer = mock(SharedIndexInformer.class); + Indexer httpRouteIndexer = mock(Indexer.class); + when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer); + + ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class); + RateLimitingQueue httpRouteWorkQueue = mock(RateLimitingQueue.class); + ApiClient apiClient = mock(ApiClient.class); + + GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, httpRouteInformer, + shenyuCacheRepository, httpRouteWorkQueue, apiClient); + + Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "empty-gateway")); + Assertions.assertEquals(new Result(false), result); + verify(shenyuCacheRepository, never()).deleteSelectorData(any(), any()); + verify(shenyuCacheRepository, never()).deleteRuleData(any(), any(), any()); + } + + /** + * Test that status update is skipped when Gateway already has Accepted=True condition. + */ + @Test + public void testReconcileGatewayAlreadyAccepted() { + // Build Accepted=True status JSON first + JsonObject acceptedCondition = new JsonObject(); + acceptedCondition.addProperty("type", "Accepted"); + acceptedCondition.addProperty("status", "True"); + acceptedCondition.addProperty("reason", "Accepted"); + acceptedCondition.addProperty("message", "Already accepted"); + JsonArray conditions = new JsonArray(); + conditions.add(acceptedCondition); + JsonObject statusObj = new JsonObject(); + statusObj.add("conditions", conditions); + + DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu"); + gateway.getRaw().add("status", statusObj); + + Indexer gatewayIndexer = mock(Indexer.class); + when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway); + SharedIndexInformer gatewayInformer = mock(SharedIndexInformer.class); + when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer); + + SharedIndexInformer httpRouteInformer = mock(SharedIndexInformer.class); + Indexer httpRouteIndexer = mock(Indexer.class); + when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer); + + ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class); + RateLimitingQueue httpRouteWorkQueue = mock(RateLimitingQueue.class); + ApiClient apiClient = mock(ApiClient.class); + OkHttpClient httpClient = mock(OkHttpClient.class); + when(apiClient.getHttpClient()).thenReturn(httpClient); + + GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, httpRouteInformer, + shenyuCacheRepository, httpRouteWorkQueue, apiClient); + + Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway")); + Assertions.assertEquals(new Result(false), result); + verify(httpClient, never()).newCall(any(okhttp3.Request.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); + + JsonObject spec = new JsonObject(); + spec.addProperty("gatewayClassName", gatewayClassName); + + 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 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 000000000000..c58bc31c86e3 --- /dev/null +++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HTTPRouteReconcilerTest.java @@ -0,0 +1,245 @@ +/* + * 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.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.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 okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; +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 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 { + // mock endpoints indexer and lister + Indexer 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 endpointsLister = new Lister<>(endpointsIndexer); + HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister); + + // mock gateway indexer + SharedIndexInformer gatewayInformer = mock(SharedIndexInformer.class); + Indexer gatewayIndexer = mock(Indexer.class); + DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu"); + when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway); + when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer); + + // mock httpRoute indexer + SharedIndexInformer httpRouteInformer = mock(SharedIndexInformer.class); + Indexer httpRouteIndexer = mock(Indexer.class); + DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route", + "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**"); + when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute); + when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer); + + // mock ApiClient for status update + ApiClient apiClient = mock(ApiClient.class); + when(apiClient.getBasePath()).thenReturn("http://localhost:8080"); + OkHttpClient httpClient = mock(OkHttpClient.class); + when(apiClient.getHttpClient()).thenReturn(httpClient); + okhttp3.Call call = mock(okhttp3.Call.class); + when(httpClient.newCall(any(okhttp3.Request.class))).thenReturn(call); + Response response = new Response.Builder() + .request(new okhttp3.Request.Builder().url("http://localhost").build()) + .protocol(Protocol.HTTP_1_1).code(200).message("OK") + .body(ResponseBody.create("", MediaType.parse("application/json"))).build(); + when(call.execute()).thenReturn(response); + + ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class); + HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer, + httpRouteParser, shenyuCacheRepository, apiClient); + + Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route")); + Assertions.assertEquals(new Result(false), result); + verify(shenyuCacheRepository).saveOrUpdateSelectorData(any()); + verify(shenyuCacheRepository).saveOrUpdateRuleData(any()); + verify(httpClient).newCall(any(okhttp3.Request.class)); + } + + /** + * Test HTTPRoute not bound to any ShenYu Gateway: should skip without creating selector/rule. + */ + @Test + public void testReconcileUnboundHTTPRoute() { + Indexer endpointsIndexer = mock(Indexer.class); + Lister endpointsLister = new Lister<>(endpointsIndexer); + HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister); + + // mock gateway indexer with non-shenyu gateway + SharedIndexInformer gatewayInformer = mock(SharedIndexInformer.class); + Indexer 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); + + // mock httpRoute indexer + SharedIndexInformer httpRouteInformer = mock(SharedIndexInformer.class); + Indexer httpRouteIndexer = mock(Indexer.class); + DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route", + "mockedNamespace", "other-gateway", "testService", 8189, "/**"); + when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute); + when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer); + + ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class); + ApiClient apiClient = mock(ApiClient.class); + OkHttpClient httpClient = mock(OkHttpClient.class); + when(apiClient.getHttpClient()).thenReturn(httpClient); + HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer, + httpRouteParser, shenyuCacheRepository, apiClient); + + Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route")); + Assertions.assertEquals(new Result(false), result); + verify(shenyuCacheRepository, never()).saveOrUpdateSelectorData(any()); + verify(shenyuCacheRepository, never()).saveOrUpdateRuleData(any()); + verify(httpClient, never()).newCall(any(okhttp3.Request.class)); + } + + /** + * Test HTTPRoute deletion: should clean up selector and rule data. + */ + @Test + public void testReconcileHTTPRouteDeletion() { + Indexer endpointsIndexer = mock(Indexer.class); + Lister endpointsLister = new Lister<>(endpointsIndexer); + HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister); + + // httpRoute not found in indexer → treated as deletion + SharedIndexInformer httpRouteInformer = mock(SharedIndexInformer.class); + Indexer httpRouteIndexer = mock(Indexer.class); + when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(null); + when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer); + + SharedIndexInformer gatewayInformer = mock(SharedIndexInformer.class); + Indexer gatewayIndexer = mock(Indexer.class); + when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer); + + ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class); + ApiClient apiClient = mock(ApiClient.class); + HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer, + httpRouteParser, shenyuCacheRepository, apiClient); + + 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 + } + + private DynamicKubernetesObject buildGateway(final String namespace, final String name, + final String gatewayClassName) { + JsonObject metadata = new JsonObject(); + metadata.addProperty("namespace", namespace); + metadata.addProperty("name", name); + + JsonObject spec = new JsonObject(); + spec.addProperty("gatewayClassName", gatewayClassName); + + 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 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/HttpRouteParserTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteParserTest.java new file mode 100644 index 000000000000..8b95c7e897e9 --- /dev/null +++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteParserTest.java @@ -0,0 +1,439 @@ +/* + * 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.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.enums.OperatorEnum; +import org.apache.shenyu.common.enums.ParamTypeEnum; +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. + */ + @Test + public void testParseWithPathPrefix() { + Lister endpointsLister = mockEndpointsLister(); + HttpRouteParser parser = new HttpRouteParser(endpointsLister); + + DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route", + NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT, + "/api/**", "PathPrefix", null, null); + ShenyuMemoryConfig config = parser.parse(httpRoute); + + List selectors = extractSelectors(config); + List rules = extractRules(config); + Assertions.assertEquals(1, selectors.size()); + Assertions.assertEquals(1, rules.size()); + + ConditionData pathCondition = config.getRouteConfigList().get(0).getSelectorData().getConditionList().get(0); + Assertions.assertEquals(ParamTypeEnum.URI.getName(), pathCondition.getParamType()); + Assertions.assertEquals(OperatorEnum.STARTS_WITH.getAlias(), pathCondition.getOperator()); + Assertions.assertEquals("/api/**", pathCondition.getParamValue()); + } + + /** + * Test parse with exact path match. + */ + @Test + public void testParseWithExactPath() { + Lister endpointsLister = mockEndpointsLister(); + HttpRouteParser parser = new HttpRouteParser(endpointsLister); + + DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route", + NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT, + "/api/v1/test", "Exact", null, null); + ShenyuMemoryConfig config = parser.parse(httpRoute); + + ConditionData pathCondition = config.getRouteConfigList().get(0).getSelectorData().getConditionList().get(0); + Assertions.assertEquals(OperatorEnum.EQ.getAlias(), pathCondition.getOperator()); + } + + /** + * Test parse with regex path match. + */ + @Test + public void testParseWithRegexPath() { + Lister endpointsLister = mockEndpointsLister(); + HttpRouteParser parser = new HttpRouteParser(endpointsLister); + + DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route", + NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT, + "/api/v[0-9]+/.*", "RegularExpression", null, null); + ShenyuMemoryConfig config = parser.parse(httpRoute); + + ConditionData pathCondition = config.getRouteConfigList().get(0).getSelectorData().getConditionList().get(0); + Assertions.assertEquals(OperatorEnum.MATCH.getAlias(), pathCondition.getOperator()); + } + + /** + * Test parse with hostname conditions. + * Each hostname should generate a separate selector+rule (one hostname per selector). + */ + @Test + public void testParseWithHostnames() { + Lister endpointsLister = mockEndpointsLister(); + HttpRouteParser parser = new HttpRouteParser(endpointsLister); + + 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); + + // Should have 2 selectors (one per hostname), each with 1 host + 1 path condition + List routeConfigs = config.getRouteConfigList(); + Assertions.assertEquals(2, routeConfigs.size()); + + for (IngressConfiguration routeConfig : routeConfigs) { + List 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())) + .count(); + Assertions.assertEquals(1, pathConditions); + } + } + + /** + * Test parse with header match. + */ + @Test + public void testParseWithHeaderMatch() { + Lister endpointsLister = mockEndpointsLister(); + HttpRouteParser parser = new HttpRouteParser(endpointsLister); + + 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 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()); + } + + /** + * Test parse with no rules: should return empty config. + */ + @Test + public void testParseWithNoRules() { + Lister endpointsLister = mockEndpointsLister(); + HttpRouteParser parser = new HttpRouteParser(endpointsLister); + + DynamicKubernetesObject httpRoute = buildHTTPRouteNoRules(NAMESPACE, "empty-route"); + ShenyuMemoryConfig config = parser.parse(httpRoute); + + Assertions.assertTrue(Objects.isNull(config.getRouteConfigList()) || config.getRouteConfigList().isEmpty()); + } + + /** + * Test parse with no matching endpoints: should return empty upstream list but still create selector/rule. + */ + @Test + public void testParseWithNoEndpoints() { + Indexer endpointsIndexer = mock(Indexer.class); + when(endpointsIndexer.getByKey(NAMESPACE + "/" + SERVICE_NAME)).thenReturn(null); + Lister endpointsLister = new Lister<>(endpointsIndexer); + + HttpRouteParser parser = new HttpRouteParser(endpointsLister); + + DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route", + NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT, + "/**", "PathPrefix", null, null); + ShenyuMemoryConfig config = parser.parse(httpRoute); + + // Selector should still exist but with empty upstream handle + List selectors = extractSelectors(config); + Assertions.assertEquals(1, selectors.size()); + } + + /** + * Test parse with query param match. + */ + @Test + public void testParseWithQueryParam() { + Lister endpointsLister = mockEndpointsLister(); + HttpRouteParser parser = new HttpRouteParser(endpointsLister); + + DynamicKubernetesObject httpRoute = buildHTTPRouteWithQueryParams(NAMESPACE, "test-route", + NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT, + "/**", "PathPrefix", "debug", "true", "Exact"); + ShenyuMemoryConfig config = parser.parse(httpRoute); + + List 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()); + } + + /** + * Test parse with multiple backend refs. + */ + @Test + public void testParseWithMultipleBackendRefs() { + Lister endpointsLister = mockEndpointsLister(); + HttpRouteParser parser = new HttpRouteParser(endpointsLister); + + DynamicKubernetesObject httpRoute = buildHTTPRouteMultiBackend(NAMESPACE, "test-route", + NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT, 8080); + ShenyuMemoryConfig config = parser.parse(httpRoute); + + // Should create selector with multiple upstreams + List selectors = extractSelectors(config); + Assertions.assertEquals(1, selectors.size()); + } + + private Lister mockEndpointsLister() { + Indexer 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); + } + + private List extractSelectors(final ShenyuMemoryConfig config) { + return config.getRouteConfigList().stream().map(r -> r.getSelectorData()).toList(); + } + + private List 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) { + // Add query params to the match + 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; + } + + private DynamicKubernetesObject buildHTTPRouteMultiBackend(final String routeNamespace, final String routeName, + final String gatewayNamespace, final String gatewayName, + final String serviceName, final int port1, final int port2) { + 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 backendRef1 = new JsonObject(); + backendRef1.addProperty("name", serviceName); + backendRef1.addProperty("port", port1); + backendRef1.addProperty("weight", 70); + + JsonObject backendRef2 = new JsonObject(); + backendRef2.addProperty("name", serviceName); + backendRef2.addProperty("port", port2); + backendRef2.addProperty("weight", 30); + + JsonArray backendRefs = new JsonArray(); + backendRefs.add(backendRef1); + backendRefs.add(backendRef2); + + JsonObject pathMatch = new JsonObject(); + pathMatch.addProperty("type", "PathPrefix"); + pathMatch.addProperty("value", "/**"); + 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); + } + + private DynamicKubernetesObject buildHTTPRouteNoRules(final String routeNamespace, final String routeName) { + JsonObject metadata = new JsonObject(); + metadata.addProperty("namespace", routeNamespace); + metadata.addProperty("name", routeName); + + JsonObject spec = new JsonObject(); + + 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-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 000000000000..fe6f7b6ae4e7 --- /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,261 @@ +/* + * 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.workqueue.RateLimitingQueue; +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.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.common.GatewayApiConstants; +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.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.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.concurrent.Executors; + +@Configuration +@ConditionalOnProperty(name = "shenyu.k8s.mode", havingValue = "gateway-api") +public class GatewayApiControllerConfiguration { + + /** + * GatewayClass SharedInformerFactory - only registers GatewayClass informer. + * Separate factory to avoid DynamicKubernetesObject class key collision. + * + * @param apiClient the Kubernetes API client + * @return the SharedInformerFactory for GatewayClass resources + */ + @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, 0); + return factory; + } + + /** + * Gateway SharedInformerFactory - only registers Gateway informer. + * Separate from other factories to avoid DynamicKubernetesObject class key collision. + * + * @param apiClient the Kubernetes API client + * @return the SharedInformerFactory for Gateway resources + */ + @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, 0); + return factory; + } + + /** + * HTTPRoute SharedInformerFactory - registers HTTPRoute and Endpoints informers. + * Separate from gatewayFactory to avoid DynamicKubernetesObject class key collision. + * + * @param apiClient the Kubernetes API client + * @return the SharedInformerFactory for HTTPRoute and Endpoints resources + */ + @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, 0); + + GenericKubernetesApi endpointsApi = new GenericKubernetesApi<>(V1Endpoints.class, + V1EndpointsList.class, "", "v1", "endpoints", apiClient); + factory.sharedIndexInformerFor(endpointsApi, V1Endpoints.class, 0); + return factory; + } + + @Bean("gatewayclass-controller-manager") + public ControllerManager gatewayClassControllerManager( + @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory, + @Qualifier("gatewayclass-controller") final Controller gatewayClassController) { + ControllerManager controllerManager = new ControllerManager(gatewayClassFactory, gatewayClassController); + Executors.newSingleThreadExecutor().submit(controllerManager); + return controllerManager; + } + + @Bean("gateway-controller-manager") + public ControllerManager gatewayControllerManager( + @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory, + @Qualifier("gateway-controller") final Controller gatewayController) { + ControllerManager controllerManager = new ControllerManager(gatewayFactory, gatewayController); + Executors.newSingleThreadExecutor().submit(controllerManager); + return controllerManager; + } + + @Bean("httproute-controller-manager") + public ControllerManager httpRouteControllerManager( + @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory, + @Qualifier("httproute-controller") final Controller httpRouteController) { + ControllerManager controllerManager = new ControllerManager(httpRouteFactory, httpRouteController); + Executors.newSingleThreadExecutor().submit(controllerManager); + return controllerManager; + } + + @Bean("gatewayclass-controller") + public Controller gatewayClassController( + @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory, + final GatewayClassReconciler gatewayClassReconciler) { + DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(gatewayClassFactory); + builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q) + .withResyncPeriod(Duration.ofMinutes(1)) + .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) + .withResyncPeriod(Duration.ofMinutes(1)) + .build()); + builder.withWorkerCount(2); + return builder.withReconciler(gatewayReconciler).withName("gatewayController").build(); + } + + @Bean("httproute-controller") + public Controller httpRouteController( + @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory, + final HTTPRouteReconciler httpRouteReconciler) { + DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(httpRouteFactory); + builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q) + .withResyncPeriod(Duration.ofMinutes(1)) + .build()); + builder.withWorkerCount(2); + return builder.withReconciler(httpRouteReconciler).withName("httpRouteController").build(); + } + + @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) { + SharedIndexInformer gatewayClassInformer = + gatewayClassFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class); + SharedIndexInformer gatewayInformer = + gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class); + RateLimitingQueue gatewayWorkQueue = ((DefaultController) gatewayController).getWorkQueue(); + return new GatewayClassReconciler(gatewayClassInformer, gatewayInformer, gatewayWorkQueue, apiClient); + } + + @Bean + public GatewayReconciler gatewayReconciler( + @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory, + @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory, + @Qualifier("httproute-controller") final Controller httpRouteController, + final ShenyuCacheRepository shenyuCacheRepository, + final ApiClient apiClient) { + SharedIndexInformer gatewayInformer = + gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class); + SharedIndexInformer httpRouteInformer = + httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class); + RateLimitingQueue httpRouteWorkQueue = ((DefaultController) httpRouteController).getWorkQueue(); + return new GatewayReconciler(gatewayInformer, httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, apiClient); + } + + @Bean + public HTTPRouteReconciler httpRouteReconciler( + @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory, + @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory, + final HttpRouteParser httpRouteParser, + final ShenyuCacheRepository shenyuCacheRepository, + final ApiClient apiClient) { + SharedIndexInformer httpRouteInformer = + httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class); + SharedIndexInformer gatewayInformer = + gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class); + return new HTTPRouteReconciler(httpRouteInformer, gatewayInformer, httpRouteParser, shenyuCacheRepository, apiClient); + } + + @Bean + public HttpRouteParser httpRouteParser( + @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory) { + SharedIndexInformer endpointsInformer = + httpRouteFactory.getExistingSharedIndexInformer(V1Endpoints.class); + Lister endpointsLister = new Lister<>(endpointsInformer.getIndexer()); + return new HttpRouteParser(endpointsLister); + } + + @Bean + public ShenyuCacheRepository shenyuCacheRepository(final CommonPluginDataSubscriber pluginDataSubscriber, + final CommonDiscoveryUpstreamDataSubscriber discoveryUpstreamDataSubscriber, + final MetaDataCacheSubscriber metaDataSubscriber, + final MetaDataCacheSubscriber metaDataCacheSubscriber) { + ShenyuCacheRepository repository = new ShenyuCacheRepository(pluginDataSubscriber, discoveryUpstreamDataSubscriber, metaDataSubscriber, metaDataCacheSubscriber); + 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}"); + enablePlugin(repository, PluginEnum.GENERAL_CONTEXT, null); + return repository; + } + + 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); + } +} 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 0a6ad99abd1e..e9ac6317ee47 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 @@ * The type shenyu ingress controller configuration. */ @Configuration +@ConditionalOnProperty(name = "shenyu.k8s.mode", havingValue = "ingress", matchIfMissing = true) public class IngressControllerConfiguration { /** @@ -264,7 +265,7 @@ public TcpSslContextSpec tcpSslContextSpec(final ObjectProvider secretData = secret.getData(); - if (MapUtils.isEmpty(secretData)) { + if (MapUtils.isNotEmpty(secretData)) { InputStream crtStream = new ByteArrayInputStream(secretData.get("tls.crt")); InputStream keyStream = new ByteArrayInputStream(secretData.get("tls.key")); return TcpSslContextSpec.forServer(crtStream, keyStream); 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 6899440f01ba..a386224c21f6 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 3eb3d33916e9..b641d56b7013 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 From 64a4a18d9b2696af8312fa32afc4aa9eb46b6e69 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Mon, 18 May 2026 17:17:18 +0800 Subject: [PATCH 2/5] upgrade k8s --- .github/workflows/integrated-test-k8s-gateway-api.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integrated-test-k8s-gateway-api.yml b/.github/workflows/integrated-test-k8s-gateway-api.yml index a37e6507fd8f..0e6ad73d0936 100644 --- a/.github/workflows/integrated-test-k8s-gateway-api.yml +++ b/.github/workflows/integrated-test-k8s-gateway-api.yml @@ -82,14 +82,14 @@ jobs: if: steps.filter.outputs.k8s-gateway-api == 'true' uses: actions/setup-go@v3 with: - go-version: 1.17.x + go-version: 1.21.x - name: Install k8s if: steps.filter.outputs.k8s-gateway-api == 'true' run: | - go install sigs.k8s.io/kind@v0.14.0 - curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.24.14/bin/linux/amd64/kubectl && sudo install kubectl /usr/local/bin/kubectl - kind create cluster --image=kindest/node:v1.21.1 --config=./shenyu-integrated-test/${{ matrix.case }}/deploy/kind-config.yaml + go install sigs.k8s.io/kind@v0.20.0 + curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.28.0/bin/linux/amd64/kubectl && sudo install kubectl /usr/local/bin/kubectl + kind create cluster --image=kindest/node:v1.28.0 --config=./shenyu-integrated-test/${{ matrix.case }}/deploy/kind-config.yaml - name: Install mvnd if: steps.filter.outputs.k8s-gateway-api == 'true' From c3213340ca7355ca02b5fe59c88d2a9bd6545077 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Mon, 18 May 2026 18:41:58 +0800 Subject: [PATCH 3/5] upgrade k8s --- .github/workflows/integrated-test-k8s-gateway-api.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integrated-test-k8s-gateway-api.yml b/.github/workflows/integrated-test-k8s-gateway-api.yml index 0e6ad73d0936..0c1dcce68b22 100644 --- a/.github/workflows/integrated-test-k8s-gateway-api.yml +++ b/.github/workflows/integrated-test-k8s-gateway-api.yml @@ -87,9 +87,9 @@ jobs: - name: Install k8s if: steps.filter.outputs.k8s-gateway-api == 'true' run: | - go install sigs.k8s.io/kind@v0.20.0 - curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.28.0/bin/linux/amd64/kubectl && sudo install kubectl /usr/local/bin/kubectl - kind create cluster --image=kindest/node:v1.28.0 --config=./shenyu-integrated-test/${{ matrix.case }}/deploy/kind-config.yaml + 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' From c890afb6380b70edf2facea6492df33aca6a1be4 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Wed, 20 May 2026 22:50:24 +0800 Subject: [PATCH 4/5] fix review --- .../shenyu/k8s/parser/HttpRouteParser.java | 4 +- .../k8s/reconciler/GatewayReconciler.java | 82 ++++++++++++++++++- .../k8s/reconciler/HTTPRouteReconciler.java | 23 ++++-- .../GatewayApiControllerConfiguration.java | 31 +++++-- 4 files changed, 123 insertions(+), 17 deletions(-) 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 index e5e9c085bf23..531e80f1e32c 100644 --- 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 @@ -133,7 +133,7 @@ private void processRule(final JsonObject rule, final JsonArray hostnames, final conditions.addAll(matchConditions); String selectorId = cache.generateSelectorId(); - String selectorName = routeName + "-rule-" + ruleIndex; + String selectorName = routeName + "-rule-" + ruleIndex + "-" + hostCondition.getParamValue(); SelectorData selectorData = buildSelectorData(selectorId, selectorName, conditions, upstreamList); RuleData ruleData = buildRuleData(cache.generateRuleId(), selectorId, selectorName, conditions); cache.addRouteSelector(namespace, routeName, PluginEnum.DIVIDE.getName(), selectorId); @@ -152,7 +152,7 @@ private void processRule(final JsonObject rule, final JsonArray hostnames, final } else { for (ConditionData hostCondition : hostnameConditions) { String selectorId = cache.generateSelectorId(); - String selectorName = routeName + "-rule-" + ruleIndex; + String selectorName = routeName + "-rule-" + ruleIndex + "-" + hostCondition.getParamValue(); List conditions = new ArrayList<>(); conditions.add(hostCondition); SelectorData selectorData = buildSelectorData(selectorId, selectorName, conditions, upstreamList); 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 index 9779ea3ac167..b12b50327b2b 100644 --- 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 @@ -104,9 +104,44 @@ public Result reconcile(final Request request) { * this Gateway and add them to the HTTPRoute controller's work queue for re-reconciliation. * This handles the case where an HTTPRoute was created before the Gateway existed. * Also handles cross-namespace references where HTTPRoute's parentRef specifies a different namespace. + * + *

Uses GatewayRouteCache as primary lookup for already-bound routes to avoid full-cluster scans + * in large deployments. Falls back to informer scanning only for routes not yet tracked in cache + * (e.g., when a Gateway is first created and no HTTPRoute has been successfully reconciled yet). */ private void requeueAffectedHTTPRoutes(final String gatewayNamespace, final String gatewayName) { - // Search in the gateway's namespace (same-namespace reference) + GatewayRouteCache cache = GatewayRouteCache.getInstance(); + List cachedRoutes = cache.getRoutesByGateway(gatewayNamespace, gatewayName); + + if (CollectionUtils.isNotEmpty(cachedRoutes)) { + // Fast path: routes already bound in cache, re-queue by parsed keys + for (String routeKey : cachedRoutes) { + String[] parts = routeKey.split("/", 2); + if (parts.length >= 2) { + Request req = new Request(parts[0], parts[1]); + httpRouteWorkQueue.add(req); + LOG.info("Re-queued cached HTTPRoute {}/{} due to Gateway {}/{} reconciliation", + parts[0], parts[1], gatewayNamespace, gatewayName); + } + } + // Also scan for cross-namespace routes that may not be in cache yet + for (DynamicKubernetesObject route : httpRouteLister.list()) { + String routeNamespace = Objects.requireNonNull(route.getMetadata()).getNamespace(); + if (routeNamespace.equals(gatewayNamespace)) { + continue; + } + if (isBoundToGateway(route, gatewayNamespace, gatewayName)) { + Request req = new Request(route.getMetadata().getNamespace(), route.getMetadata().getName()); + httpRouteWorkQueue.add(req); + LOG.info("Re-queued cross-namespace HTTPRoute {}/{} due to Gateway {}/{} reconciliation", + route.getMetadata().getNamespace(), route.getMetadata().getName(), + gatewayNamespace, gatewayName); + } + } + return; + } + + // Cache miss: Gateway just created, no routes reconciled yet. Fall back to scanning. List localRoutes = httpRouteLister.namespace(gatewayNamespace).list(); for (DynamicKubernetesObject route : localRoutes) { if (isBoundToGateway(route, gatewayNamespace, gatewayName)) { @@ -117,7 +152,6 @@ private void requeueAffectedHTTPRoutes(final String gatewayNamespace, final Stri gatewayNamespace, gatewayName); } } - // Also search all namespaces for cross-namespace references for (DynamicKubernetesObject route : httpRouteLister.list()) { String routeNamespace = Objects.requireNonNull(route.getMetadata()).getNamespace(); if (routeNamespace.equals(gatewayNamespace)) { @@ -230,8 +264,7 @@ private void updateGatewayAcceptedStatus(final DynamicKubernetesObject gateway) condition.addProperty("message", "Gateway has been accepted by the ShenYu controller"); condition.addProperty("lastTransitionTime", Instant.now().toString()); - JsonArray conditions = new JsonArray(); - conditions.add(condition); + JsonArray conditions = buildGatewayStatusConditions(gateway, condition); JsonObject statusObj = new JsonObject(); statusObj.add("conditions", conditions); @@ -268,4 +301,45 @@ private void updateGatewayAcceptedStatus(final DynamicKubernetesObject gateway) } } + /** + * Build the Gateway status conditions array for the patch body. + * Includes the accepted condition and preserves non-Accepted conditions + * already present in status. Ensures Programmed exists per Gateway API + * spec default (Unknown/Pending) if missing. + */ + private JsonArray buildGatewayStatusConditions(final DynamicKubernetesObject gateway, + final JsonObject acceptedCondition) { + JsonArray conditions = new JsonArray(); + conditions.add(acceptedCondition); + + boolean hasProgrammed = false; + JsonObject raw = gateway.getRaw(); + if (raw.has("status") && !raw.get("status").isJsonNull()) { + JsonObject status = raw.getAsJsonObject("status"); + if (status.has("conditions") && !status.get("conditions").isJsonNull()) { + JsonArray existingConditions = status.getAsJsonArray("conditions"); + for (JsonElement el : existingConditions) { + JsonObject existing = el.getAsJsonObject(); + String existingType = existing.has("type") ? existing.get("type").getAsString() : null; + if ("Programmed".equals(existingType)) { + hasProgrammed = true; + conditions.add(existing); + } else if (!"Accepted".equals(existingType)) { + conditions.add(existing); + } + } + } + } + if (!hasProgrammed) { + JsonObject programmedDefault = new JsonObject(); + programmedDefault.addProperty("type", "Programmed"); + programmedDefault.addProperty("status", "Unknown"); + programmedDefault.addProperty("reason", "Pending"); + programmedDefault.addProperty("message", "Waiting for controller"); + programmedDefault.addProperty("lastTransitionTime", "1970-01-01T00:00:00Z"); + conditions.add(programmedDefault); + } + return conditions; + } + } 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 index 1545ce3cd0a3..a2e7b37a73af 100644 --- 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 @@ -236,8 +236,11 @@ private void updateHTTPRouteStatus(final DynamicKubernetesObject httpRoute) { } /** - * Check if the HTTPRoute already has Accepted=True condition from the ShenYu controller - * in its status.parents, to avoid unnecessary status patches that trigger infinite reconcile loops. + * Check if the HTTPRoute already has both Accepted=True and ResolvedRefs=True conditions + * from the ShenYu controller in its status.parents, to avoid unnecessary status patches + * that trigger infinite reconcile loops. + * Both conditions must be present because updateHTTPRouteStatus() always sets both together; + * checking only Accepted=True would leave routes with a partial status never repaired. */ private boolean isRouteStatusAlreadySet(final DynamicKubernetesObject httpRoute) { JsonObject raw = httpRoute.getRaw(); @@ -258,13 +261,23 @@ private boolean isRouteStatusAlreadySet(final DynamicKubernetesObject httpRoute) continue; } JsonArray conditions = parent.getAsJsonArray("conditions"); + boolean hasAccepted = false; + boolean hasResolvedRefs = false; for (JsonElement condElement : conditions) { JsonObject cond = condElement.getAsJsonObject(); - if ("Accepted".equals(cond.has("type") ? cond.get("type").getAsString() : null) - && "True".equals(cond.has("status") ? cond.get("status").getAsString() : null)) { - return true; + String type = cond.has("type") ? cond.get("type").getAsString() : null; + String condStatus = cond.has("status") ? cond.get("status").getAsString() : null; + if ("True".equals(condStatus)) { + if ("Accepted".equals(type)) { + hasAccepted = true; + } else if ("ResolvedRefs".equals(type)) { + hasResolvedRefs = true; + } } } + if (hasAccepted && hasResolvedRefs) { + return true; + } } return false; } 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 index fe6f7b6ae4e7..4e238285e3a8 100644 --- 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 @@ -51,6 +51,7 @@ import org.springframework.context.annotation.Configuration; import java.time.Duration; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @Configuration @@ -118,30 +119,48 @@ public SharedInformerFactory httpRouteSharedInformerFactory(final ApiClient apiC return factory; } + /** + * Shared ExecutorService for all ControllerManager beans, with a destroy method to + * ensure graceful shutdown and prevent thread leaks on context close. + * + * @return daemon cached thread pool executor + */ + @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) { + @Qualifier("gatewayclass-controller") final Controller gatewayClassController, + final ExecutorService controllerExecutorService) { ControllerManager controllerManager = new ControllerManager(gatewayClassFactory, gatewayClassController); - Executors.newSingleThreadExecutor().submit(controllerManager); + controllerExecutorService.submit(controllerManager); return controllerManager; } @Bean("gateway-controller-manager") public ControllerManager gatewayControllerManager( @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory, - @Qualifier("gateway-controller") final Controller gatewayController) { + @Qualifier("gateway-controller") final Controller gatewayController, + final ExecutorService controllerExecutorService) { ControllerManager controllerManager = new ControllerManager(gatewayFactory, gatewayController); - Executors.newSingleThreadExecutor().submit(controllerManager); + controllerExecutorService.submit(controllerManager); return controllerManager; } @Bean("httproute-controller-manager") public ControllerManager httpRouteControllerManager( @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory, - @Qualifier("httproute-controller") final Controller httpRouteController) { + @Qualifier("httproute-controller") final Controller httpRouteController, + final ExecutorService controllerExecutorService) { ControllerManager controllerManager = new ControllerManager(httpRouteFactory, httpRouteController); - Executors.newSingleThreadExecutor().submit(controllerManager); + controllerExecutorService.submit(controllerManager); return controllerManager; } From 6624c29265c3234eb220f1832d771b3a7bd85b76 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Mon, 1 Jun 2026 21:53:23 +0800 Subject: [PATCH 5/5] ci: trigger CI re-run