From deed0a81416ee06f1b4c774a56efbc7777af11ee Mon Sep 17 00:00:00 2001 From: rongxin Date: Fri, 4 Jul 2025 11:30:54 +0800 Subject: [PATCH 1/3] feat: backport release v2 --- .github/workflows/apisix-conformance-test.yml | 16 +- .github/workflows/apisix-e2e-test.yml | 10 +- .github/workflows/codeql-analysis.yml | 5 +- .github/workflows/conformance-test.yml | 147 ++ .github/workflows/e2e-test.yml | 121 ++ .github/workflows/golangci-lint.yml | 4 +- .github/workflows/license-checker.yml | 6 +- .github/workflows/lint-checker.yml | 5 +- .github/workflows/push-docker-v2-dev.yaml | 61 + .github/workflows/push-docker.yaml | 12 +- .github/workflows/release-drafter.yml | 4 +- .github/workflows/spell-checker.yml | 4 +- .github/workflows/unit-test.yml | 6 +- .gitignore | 7 + Makefile | 31 +- README.md | 54 + api/dashboard/v1/doc.go | 18 + api/dashboard/v1/plugin_types.go | 210 +++ api/dashboard/v1/types.go | 952 ++++++++++ api/dashboard/v1/zz_generated.deepcopy.go | 928 ++++++++++ .../api7-ingress-controller-architecture.png | Bin 0 -> 379340 bytes docs/concepts.md | 27 + docs/configure.md | 30 + docs/crd/api.md | 1587 +++++++++++++++++ docs/crd/config.yaml | 30 + docs/gateway-api.md | 82 + docs/quickstart.md | 60 + docs/template/gv_details.tpl | 57 + docs/template/gv_list.tpl | 40 + docs/template/type.tpl | 61 + docs/template/type_members.tpl | 28 + docs/upgrade-guide.md | 171 ++ go.mod | 176 +- go.sum | 423 +++-- internal/controller/config/config.go | 4 +- internal/controller/config/types.go | 1 + internal/provider/adc/adc.go | 10 +- .../provider/controlplane/controlplane.go | 192 ++ internal/provider/controlplane/manifest.go | 18 + .../controlplane/translator/gateway.go | 166 ++ .../controlplane/translator/httproute.go | 474 +++++ .../controlplane/translator/translator.go | 55 + pkg/dashboard/cache/cache.go | 97 + pkg/dashboard/cache/memdb.go | 368 ++++ pkg/dashboard/cache/memdb_test.go | 390 ++++ pkg/dashboard/cache/noop_db.go | 166 ++ pkg/dashboard/cache/schema.go | 229 +++ pkg/dashboard/cluster.go | 1000 +++++++++++ pkg/dashboard/consumer.go | 156 ++ pkg/dashboard/consumer_test.go | 242 +++ pkg/dashboard/dashboard.go | 255 +++ pkg/dashboard/global_rule.go | 170 ++ pkg/dashboard/nonexistentclient.go | 378 ++++ pkg/dashboard/noop.go | 51 + pkg/dashboard/plugin.go | 53 + pkg/dashboard/plugin_metadata.go | 154 ++ pkg/dashboard/plugin_test.go | 121 ++ pkg/dashboard/pluginconfig.go | 164 ++ pkg/dashboard/resource.go | 386 ++++ pkg/dashboard/route.go | 159 ++ pkg/dashboard/schema.go | 119 ++ pkg/dashboard/service.go | 192 ++ pkg/dashboard/ssl.go | 170 ++ pkg/dashboard/stream_route.go | 164 ++ pkg/dashboard/utils.go | 85 + pkg/dashboard/validator.go | 136 ++ test/conformance/conformance_test.go | 95 + test/conformance/suite_test.go | 260 +++ test/e2e/api7/gatewayproxy.go | 272 +++ test/e2e/apisix/e2e_test.go | 7 +- .../e2e/crds/v1alpha1/backendtrafficpolicy.go | 298 ++++ test/e2e/crds/v1alpha1/consumer.go | 515 ++++++ test/e2e/crds/v2/consumer.go | 344 ++++ test/e2e/crds/v2/globalrule.go | 345 ++++ test/e2e/crds/v2/pluginconfig.go | 514 ++++++ test/e2e/crds/v2/route.go | 523 ++++++ test/e2e/crds/v2/tls.go | 267 +++ test/e2e/e2e_test.go | 49 + test/e2e/framework/api7_consts.go | 37 + test/e2e/framework/api7_dashboard.go | 455 +++++ test/e2e/framework/api7_framework.go | 216 +++ test/e2e/framework/api7_gateway.go | 114 ++ test/e2e/framework/manifests/dp.yaml | 284 +++ test/e2e/framework/manifests/ingress.yaml | 2 +- test/e2e/scaffold/api7_deployer.go | 310 ++++ test/e2e/scaffold/apisix_deployer.go | 6 +- test/e2e/scaffold/deployer.go | 1 + test/e2e/scaffold/scaffold.go | 1 + 88 files changed, 16368 insertions(+), 245 deletions(-) create mode 100644 .github/workflows/conformance-test.yml create mode 100644 .github/workflows/e2e-test.yml create mode 100644 .github/workflows/push-docker-v2-dev.yaml create mode 100644 api/dashboard/v1/doc.go create mode 100644 api/dashboard/v1/plugin_types.go create mode 100644 api/dashboard/v1/types.go create mode 100644 api/dashboard/v1/zz_generated.deepcopy.go create mode 100644 docs/assets/images/api7-ingress-controller-architecture.png create mode 100644 docs/concepts.md create mode 100644 docs/configure.md create mode 100644 docs/crd/api.md create mode 100644 docs/crd/config.yaml create mode 100644 docs/gateway-api.md create mode 100644 docs/quickstart.md create mode 100644 docs/template/gv_details.tpl create mode 100644 docs/template/gv_list.tpl create mode 100644 docs/template/type.tpl create mode 100644 docs/template/type_members.tpl create mode 100644 docs/upgrade-guide.md create mode 100644 internal/provider/controlplane/controlplane.go create mode 100644 internal/provider/controlplane/manifest.go create mode 100644 internal/provider/controlplane/translator/gateway.go create mode 100644 internal/provider/controlplane/translator/httproute.go create mode 100644 internal/provider/controlplane/translator/translator.go create mode 100644 pkg/dashboard/cache/cache.go create mode 100644 pkg/dashboard/cache/memdb.go create mode 100644 pkg/dashboard/cache/memdb_test.go create mode 100644 pkg/dashboard/cache/noop_db.go create mode 100644 pkg/dashboard/cache/schema.go create mode 100644 pkg/dashboard/cluster.go create mode 100644 pkg/dashboard/consumer.go create mode 100644 pkg/dashboard/consumer_test.go create mode 100644 pkg/dashboard/dashboard.go create mode 100644 pkg/dashboard/global_rule.go create mode 100644 pkg/dashboard/nonexistentclient.go create mode 100644 pkg/dashboard/noop.go create mode 100644 pkg/dashboard/plugin.go create mode 100644 pkg/dashboard/plugin_metadata.go create mode 100644 pkg/dashboard/plugin_test.go create mode 100644 pkg/dashboard/pluginconfig.go create mode 100644 pkg/dashboard/resource.go create mode 100644 pkg/dashboard/route.go create mode 100644 pkg/dashboard/schema.go create mode 100644 pkg/dashboard/service.go create mode 100644 pkg/dashboard/ssl.go create mode 100644 pkg/dashboard/stream_route.go create mode 100644 pkg/dashboard/utils.go create mode 100644 pkg/dashboard/validator.go create mode 100644 test/conformance/conformance_test.go create mode 100644 test/conformance/suite_test.go create mode 100644 test/e2e/api7/gatewayproxy.go create mode 100644 test/e2e/crds/v1alpha1/backendtrafficpolicy.go create mode 100644 test/e2e/crds/v1alpha1/consumer.go create mode 100644 test/e2e/crds/v2/consumer.go create mode 100644 test/e2e/crds/v2/globalrule.go create mode 100644 test/e2e/crds/v2/pluginconfig.go create mode 100644 test/e2e/crds/v2/route.go create mode 100644 test/e2e/crds/v2/tls.go create mode 100644 test/e2e/e2e_test.go create mode 100644 test/e2e/framework/api7_consts.go create mode 100644 test/e2e/framework/api7_dashboard.go create mode 100644 test/e2e/framework/api7_framework.go create mode 100644 test/e2e/framework/api7_gateway.go create mode 100644 test/e2e/framework/manifests/dp.yaml create mode 100644 test/e2e/scaffold/api7_deployer.go diff --git a/.github/workflows/apisix-conformance-test.yml b/.github/workflows/apisix-conformance-test.yml index 014d13027..b1f6ca95d 100644 --- a/.github/workflows/apisix-conformance-test.yml +++ b/.github/workflows/apisix-conformance-test.yml @@ -21,11 +21,11 @@ on: push: branches: - master - - next + - release-v2-dev pull_request: branches: - master - - next + - release-v2-dev concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -34,7 +34,7 @@ concurrency: jobs: prepare: name: Prepare - runs-on: ubuntu-latest + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - name: Checkout uses: actions/checkout@v4 @@ -58,7 +58,7 @@ jobs: provider_type: - apisix-standalone - apisix - runs-on: ubuntu-latest + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - name: Checkout uses: actions/checkout@v4 @@ -124,3 +124,11 @@ jobs: echo '```yaml' >> report.md cat apisix-ingress-controller-conformance-report.yaml >> report.md echo '```' >> report.md + + - name: Report Conformance Test Result to PR Comment + if: ${{ github.event_name == 'pull_request' }} + uses: mshick/add-pr-comment@v2 + with: + message-id: 'apisix-conformance-test-report' + message-path: | + report.md diff --git a/.github/workflows/apisix-e2e-test.yml b/.github/workflows/apisix-e2e-test.yml index 0fee50a0f..6aa98c80b 100644 --- a/.github/workflows/apisix-e2e-test.yml +++ b/.github/workflows/apisix-e2e-test.yml @@ -21,11 +21,11 @@ on: push: branches: - master - - next + - release-v2-dev pull_request: branches: - master - - next + - release-v2-dev concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -34,7 +34,7 @@ concurrency: jobs: prepare: name: Prepare - runs-on: ubuntu-latest + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - name: Checkout uses: actions/checkout@v4 @@ -61,7 +61,7 @@ jobs: - apisix.apache.org - networking.k8s.io fail-fast: false - runs-on: ubuntu-latest + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - name: Checkout uses: actions/checkout@v4 @@ -86,7 +86,7 @@ jobs: - name: Extract adc binary run: | echo "Extracting adc binary..." - docker create --name adc-temp apache/apisix-ingress-controller:dev + docker create --name adc-temp api7/api7-ingress-controller:dev docker cp adc-temp:/bin/adc /usr/local/bin/adc docker rm adc-temp chmod +x /usr/local/bin/adc diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f2e591fc7..bdc68f511 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -24,14 +24,13 @@ on: pull_request: branches: - master - - next - - 1.8.0 + - release-v2-dev schedule: - cron: '25 5 * * 5' jobs: changes: - runs-on: ubuntu-latest + runs-on: buildjet-2vcpu-ubuntu-2204 outputs: go: ${{ steps.filter.outputs.go }} steps: diff --git a/.github/workflows/conformance-test.yml b/.github/workflows/conformance-test.yml new file mode 100644 index 000000000..355cd1486 --- /dev/null +++ b/.github/workflows/conformance-test.yml @@ -0,0 +1,147 @@ +# 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: Conformance Test + +on: + push: + branches: + - master + - release-v2-dev + pull_request: + branches: + - master + - release-v2-dev + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + prepare: + name: Prepare + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go Env + id: go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + + - name: Install kind + run: | + go install sigs.k8s.io/kind@v0.23.0 + + - name: Install Helm + run: | + curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 + chmod 700 get_helm.sh + ./get_helm.sh + + conformance-test: + timeout-minutes: 60 + needs: + - prepare + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go Env + uses: actions/setup-go@v4 + with: + go-version: "1.22" + + - name: Login to Private Registry + uses: docker/login-action@v1 + with: + registry: hkccr.ccs.tencentyun.com + username: ${{ secrets.PRIVATE_DOCKER_USERNAME }} + password: ${{ secrets.PRIVATE_DOCKER_PASSWORD }} + + - name: Build images + env: + TAG: dev + ARCH: amd64 + ENABLE_PROXY: "false" + BASE_IMAGE_TAG: "debug" + run: | + echo "building images..." + make build-image + + - name: Launch Kind Cluster + run: | + make kind-up + + - name: Install And Run Cloud Provider KIND + run: | + go install sigs.k8s.io/cloud-provider-kind@latest + nohup cloud-provider-kind > /tmp/kind-loadbalancer.log 2>&1 & + + - name: Install Gateway API And CRDs + run: | + make install + + - name: Loading Docker Image to Kind Cluster + run: | + make kind-load-images + + - name: Install API7EE3 + run: | + make download-api7ee3-chart + + - name: Run Conformance Test + shell: bash + env: + API7_EE_LICENSE: ${{ secrets.API7_EE_LICENSE }} + continue-on-error: true + run: | + make conformance-test + + - name: Get Logs from api7-ingress-controller + shell: bash + run: | + export KUBECONFIG=/tmp/apisix-ingress-cluster.kubeconfig + kubectl logs -n apisix-conformance-test -l app=apisix-ingress-controller + + - name: Upload Gateway API Conformance Report + if: ${{ github.event_name == 'push' }} + uses: actions/upload-artifact@v4 + with: + name: apisix-ingress-controller-conformance-report.yaml + path: apisix-ingress-controller-conformance-report.yaml + + - name: Format Conformance Test Report + if: ${{ github.event_name == 'pull_request' }} + run: | + echo '# conformance test report' > report.md + echo '```yaml' >> report.md + cat apisix-ingress-controller-conformance-report.yaml >> report.md + echo '```' >> report.md + + - name: Report Conformance Test Result to PR Comment + if: ${{ github.event_name == 'pull_request' }} + uses: mshick/add-pr-comment@v2 + with: + message-id: 'conformance-test-report' + message-path: | + report.md diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 000000000..63301ee06 --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,121 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: E2E Test + +on: + push: + branches: + - master + - release-v2-dev + pull_request: + branches: + - master + - release-v2-dev + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + prepare: + name: Prepare + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go Env + id: go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + + - name: Install kind + run: | + go install sigs.k8s.io/kind@v0.23.0 + + - name: Install Helm + run: | + curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 + chmod 700 get_helm.sh + ./get_helm.sh + + e2e-test: + needs: + - prepare + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go Env + uses: actions/setup-go@v4 + with: + go-version: "1.22" + + - name: Login to Private Registry + uses: docker/login-action@v1 + with: + registry: hkccr.ccs.tencentyun.com + username: ${{ secrets.PRIVATE_DOCKER_USERNAME }} + password: ${{ secrets.PRIVATE_DOCKER_PASSWORD }} + + - name: Build images + env: + TAG: dev + ARCH: amd64 + ENABLE_PROXY: "false" + BASE_IMAGE_TAG: "debug" + run: | + echo "building images..." + make build-image + + - name: Extract adc binary + run: | + echo "Extracting adc binary..." + docker create --name adc-temp api7/api7-ingress-controller:dev + docker cp adc-temp:/bin/adc /usr/local/bin/adc + docker rm adc-temp + chmod +x /usr/local/bin/adc + echo "ADC binary extracted to /usr/local/bin/adc" + + - name: Launch Kind Cluster + run: | + make kind-up + + - name: Install Gateway API And CRDs + run: | + make install + + - name: Download API7EE3 Chart + run: | + make download-api7ee3-chart + + - name: Loading Docker Image to Kind Cluster + run: | + make kind-load-images + + - name: Run E2E test suite + shell: bash + env: + API7_EE_LICENSE: ${{ secrets.API7_EE_LICENSE }} + PROVIDER_TYPE: api7ee + run: | + make e2e-test diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index cd150346a..e4ff28872 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -21,11 +21,11 @@ on: push: branches: - master - - next + - release-v2-dev pull_request: branches: - master - - next + - release-v2-dev jobs: golangci: diff --git a/.github/workflows/license-checker.yml b/.github/workflows/license-checker.yml index ba3f89275..a7902165f 100644 --- a/.github/workflows/license-checker.yml +++ b/.github/workflows/license-checker.yml @@ -22,15 +22,15 @@ on: push: branches: - master + - release-v2-dev pull_request: branches: - master - - next - - 1.8.0 + - release-v2-dev jobs: check-license: - runs-on: ubuntu-latest + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/lint-checker.yml b/.github/workflows/lint-checker.yml index 2cfff073c..e75a3eecd 100644 --- a/.github/workflows/lint-checker.yml +++ b/.github/workflows/lint-checker.yml @@ -22,11 +22,12 @@ on: push: branches: - master + - release-v2-dev pull_request: branches: - master - - next - - 1.8.0 + - release-v2-dev + jobs: changes: runs-on: ubuntu-latest diff --git a/.github/workflows/push-docker-v2-dev.yaml b/.github/workflows/push-docker-v2-dev.yaml new file mode 100644 index 000000000..0b37186f6 --- /dev/null +++ b/.github/workflows/push-docker-v2-dev.yaml @@ -0,0 +1,61 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: push v2-dev on dockerhub +on: + release: + types: [ published ] + push: + branches: + - release-v2-dev + workflow_dispatch: +jobs: + docker: + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go Env + uses: actions/setup-go@v4 + with: + go-version: "1.22" + +# - name: Set up QEMU +# uses: docker/setup-qemu-action@v3 +# +# - name: Set up Docker Buildx +# uses: docker/setup-buildx-action@v3 + + - name: Login to Registry + uses: docker/login-action@v1 + with: + registry: ${{ secrets.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build push image + env: + TAG: dev + ARCH: amd64 + ENABLE_PROXY: "false" + BASE_IMAGE_TAG: "debug" + run: | + echo "building images..." + make build-push-image diff --git a/.github/workflows/push-docker.yaml b/.github/workflows/push-docker.yaml index c99e0116c..a822a5aff 100644 --- a/.github/workflows/push-docker.yaml +++ b/.github/workflows/push-docker.yaml @@ -22,10 +22,11 @@ on: - '*' branches: - master - + - release-v2-dev + jobs: docker: - runs-on: ubuntu-latest + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - name: Checkout uses: actions/checkout@v4 @@ -46,10 +47,11 @@ jobs: - name: Login to Registry uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKERHUB_USER }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + registry: ${{ secrets.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and push multi-arch image (Tag) + name: Build and push multi-arch image if: github.ref_type == 'tag' env: TAG: ${{ github.ref_name }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 38043224b..74044fdf7 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -27,8 +27,8 @@ on: - '.github/**' jobs: update_release_draft: - if: github.repository == 'apache/apisix-ingress-controller' - runs-on: ubuntu-latest + if: github.repository == 'api7/api7-ingress-controller' + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - name: Drafting release id: release_drafter diff --git a/.github/workflows/spell-checker.yml b/.github/workflows/spell-checker.yml index 0506979b7..58484a660 100644 --- a/.github/workflows/spell-checker.yml +++ b/.github/workflows/spell-checker.yml @@ -21,11 +21,11 @@ on: push: branches: - master - - next + - release-v2-dev pull_request: branches: - master - - next + - release-v2-dev jobs: misspell: name: runner / misspell diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index bc6e9ab3b..af697cc7c 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -22,15 +22,15 @@ on: push: branches: - master - - next + - release-v2-dev pull_request: branches: - master - - next + - release-v2-dev - 1.8.0 jobs: run-test: - runs-on: ubuntu-latest + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - uses: actions/checkout@v4 - name: Setup Go Env diff --git a/.gitignore b/.gitignore index 81f2edfe1..e6752a932 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,10 @@ dist .tmp apisix-ingress-controller apisix-ingress-controller-conformance-report.yaml + +*.mdx +.cursor/ +.env + +charts/api7ee3 +docs/api diff --git a/Makefile b/Makefile index 27701ec6e..d0b67d3dd 100644 --- a/Makefile +++ b/Makefile @@ -17,18 +17,20 @@ # Image URL to use all building/pushing image targets -VERSION ?= 2.0.0-rc1 +VERSION ?= 2.0.0 RELEASE_SRC = apache-apisix-ingress-controller-${VERSION}-src IMAGE_TAG ?= dev -IMG ?= apache/apisix-ingress-controller:$(IMAGE_TAG) + +IMG ?= api7/api7-ingress-controller:$(IMAGE_TAG) # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.30.0 KIND_NAME ?= apisix-ingress-cluster GATEAY_API_VERSION ?= v1.2.0 ADC_VERSION ?= 0.20.0 +DASHBOARD_VERSION ?= dev TEST_TIMEOUT ?= 80m TEST_DIR ?= ./test/e2e/apisix/ @@ -127,12 +129,20 @@ kind-e2e-test: kind-up build-image kind-load-images e2e-test e2e-test: go test $(TEST_DIR) -test.timeout=$(TEST_TIMEOUT) -v -ginkgo.v -ginkgo.focus="$(TEST_FOCUS)" -ginkgo.label-filter="$(TEST_LABEL)" +.PHONY: download-api7ee3-chart +download-api7ee3-chart: + @helm repo add api7 https://charts.api7.ai || true + @helm repo update + @helm pull api7/api7ee3 --destination "$(shell helm env HELM_REPOSITORY_CACHE)" + @echo "Downloaded API7EE3 chart" + .PHONY: conformance-test conformance-test: - go test -v ./test/conformance -tags=conformance -timeout 60m + DASHBOARD_VERSION=$(DASHBOARD_VERSION) go test -v ./test/conformance -tags=conformance -timeout 60m .PHONY: conformance-test-standalone conformance-test-standalone: + @kind get kubeconfig --name $(KIND_NAME) > $$KUBECONFIG go test -v ./test/conformance/apisix -tags=conformance -timeout 60m .PHONY: lint @@ -158,15 +168,30 @@ kind-down: .PHONY: kind-load-images kind-load-images: pull-infra-images kind-load-ingress-image + @kind load docker-image hkccr.ccs.tencentyun.com/api7-dev/api7-ee-3-gateway:dev --name $(KIND_NAME) + @kind load docker-image hkccr.ccs.tencentyun.com/api7-dev/api7-ee-dp-manager:$(DASHBOARD_VERSION) --name $(KIND_NAME) + @kind load docker-image hkccr.ccs.tencentyun.com/api7-dev/api7-ee-3-integrated:$(DASHBOARD_VERSION) --name $(KIND_NAME) @kind load docker-image kennethreitz/httpbin:latest --name $(KIND_NAME) @kind load docker-image jmalloc/echo-server:latest --name $(KIND_NAME) +.PHONY: kind-load-gateway-image +kind-load-gateway-image: + @kind load docker-image hkccr.ccs.tencentyun.com/api7-dev/api7-ee-3-gateway:dev --name $(KIND_NAME) + +.PHONY: kind-load-dashboard-images +kind-load-dashboard-images: + @kind load docker-image hkccr.ccs.tencentyun.com/api7-dev/api7-ee-dp-manager:$(DASHBOARD_VERSION) --name $(KIND_NAME) + @kind load docker-image hkccr.ccs.tencentyun.com/api7-dev/api7-ee-3-integrated:$(DASHBOARD_VERSION) --name $(KIND_NAME) + .PHONY: kind-load-ingress-image kind-load-ingress-image: @kind load docker-image $(IMG) --name $(KIND_NAME) .PHONY: pull-infra-images pull-infra-images: + @docker pull hkccr.ccs.tencentyun.com/api7-dev/api7-ee-3-gateway:dev + @docker pull hkccr.ccs.tencentyun.com/api7-dev/api7-ee-dp-manager:$(DASHBOARD_VERSION) + @docker pull hkccr.ccs.tencentyun.com/api7-dev/api7-ee-3-integrated:$(DASHBOARD_VERSION) @docker pull kennethreitz/httpbin:latest @docker pull jmalloc/echo-server:latest diff --git a/README.md b/README.md index 9da41b54c..d902fd1c1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD +======= +>>>>>>> release-v2-dev # apisix-ingress-controller ## Description +<<<<<<< HEAD The APISIX Ingress Controller allows you to run the APISIX Gateway as a Kubernetes Ingress to handle inbound traffic for a Kubernetes cluster. It dynamically configures and manages the APISIX Gateway using Gateway API resources. +======= +The APISIX Ingress Controller allows you to run the APISIX Gateway as a Kubernetes Ingress to handle inbound traffic for a Kubernetes cluster. It dynamically configures and manages the API7 Gateway using Gateway API resources. +>>>>>>> release-v2-dev ## Document @@ -50,10 +57,16 @@ resources. make build-image ``` +<<<<<<< HEAD **NOTE:** This image ought to be published in the personal registry you specified. And it is required to have access to pull the image from the working environment. Make sure you have the proper permission to the registry if the above commands don't work. +======= +**NOTE:** This image ought to be published in the personal registry you specified. +And it is required to have access to pull the image from the working environment. +Make sure you have the proper permission to the registry if the above commands don’t work. +>>>>>>> release-v2-dev **Install the CRDs & Gateway API into the cluster:** @@ -64,11 +77,19 @@ make install **Deploy the Manager to the cluster with the image specified by `IMG`:** ```sh +<<<<<<< HEAD make deploy #IMG=apache/apisix-ingress-controller:dev ``` > **NOTE**: If you encounter RBAC errors, you may need to grant yourself > cluster-admin privileges or be logged in as admin. +======= +make deploy #IMG=api7/api7-ingress-controller:dev +``` + +> **NOTE**: If you encounter RBAC errors, you may need to grant yourself cluster-admin +privileges or be logged in as admin. +>>>>>>> release-v2-dev **Delete the APIs(CRDs) from the cluster:** @@ -84,13 +105,21 @@ make undeploy ## Project Distribution +<<<<<<< HEAD Following are the steps to build the installer and distribute this project to users. +======= +Following are the steps to build the installer and distribute this project to users. +>>>>>>> release-v2-dev 1. Build the installer for the image built and published in the registry: ```sh +<<<<<<< HEAD make build-installer # IMG=apache/apisix-ingress-controller:dev +======= +make build-installer # IMG=api7/api7-ingress-controller:dev +>>>>>>> release-v2-dev ``` NOTE: The makefile target mentioned above generates an 'install.yaml' @@ -98,11 +127,36 @@ file in the dist directory. This file contains all the resources built with Kustomize, which are necessary to install this project without its dependencies. +<<<<<<< HEAD 1. Using the installer Users can just run kubectl apply -f with the YAML bundle to install the project, i.e.: +======= +2. Using the installer + +Users can just run kubectl apply -f to install the project, i.e.: +>>>>>>> release-v2-dev ```sh kubectl apply -f dist/install.yaml ``` +<<<<<<< HEAD +======= + +## License + +Copyright 2024. + +Licensed 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. +>>>>>>> release-v2-dev diff --git a/api/dashboard/v1/doc.go b/api/dashboard/v1/doc.go new file mode 100644 index 000000000..fb08e3745 --- /dev/null +++ b/api/dashboard/v1/doc.go @@ -0,0 +1,18 @@ +// 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 v1 diff --git a/api/dashboard/v1/plugin_types.go b/api/dashboard/v1/plugin_types.go new file mode 100644 index 000000000..cf8994401 --- /dev/null +++ b/api/dashboard/v1/plugin_types.go @@ -0,0 +1,210 @@ +// 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 v1 + +import ( + "github.com/incubator4/go-resty-expr/expr" +) + +const ( + PluginProxyRewrite string = "proxy-rewrite" + PluginRedirect string = "redirect" + PluginResponseRewrite string = "response-rewrite" + PluginProxyMirror string = "proxy-mirror" +) + +// TrafficSplitConfig is the config of traffic-split plugin. +// +k8s:deepcopy-gen=true +type TrafficSplitConfig struct { + Rules []TrafficSplitConfigRule `json:"rules"` +} + +// TrafficSplitConfigRule is the rule config in traffic-split plugin config. +// +k8s:deepcopy-gen=true +type TrafficSplitConfigRule struct { + WeightedUpstreams []TrafficSplitConfigRuleWeightedUpstream `json:"weighted_upstreams"` +} + +// TrafficSplitConfigRuleWeightedUpstream is the weighted upstream config in +// the traffic split plugin rule. +// +k8s:deepcopy-gen=true +type TrafficSplitConfigRuleWeightedUpstream struct { + UpstreamID string `json:"upstream_id,omitempty"` + Upstream *Upstream `json:"upstream,omitempty"` + Weight int `json:"weight"` +} + +// IPRestrictConfig is the rule config for ip-restriction plugin. +// +k8s:deepcopy-gen=true +type IPRestrictConfig struct { + Allowlist []string `json:"whitelist,omitempty"` + Blocklist []string `json:"blacklist,omitempty"` +} + +// CorsConfig is the rule config for cors plugin. +// +k8s:deepcopy-gen=true +type CorsConfig struct { + AllowOrigins string `json:"allow_origins,omitempty"` + AllowMethods string `json:"allow_methods,omitempty"` + AllowHeaders string `json:"allow_headers,omitempty"` +} + +// CSRfConfig is the rule config for csrf plugin. +// +k8s:deepcopy-gen=true +type CSRFConfig struct { + Key string `json:"key"` +} + +// KeyAuthConsumerConfig is the rule config for key-auth plugin +// used in Consumer object. +// +k8s:deepcopy-gen=true +type KeyAuthConsumerConfig struct { + Key string `json:"key"` +} + +// KeyAuthRouteConfig is the rule config for key-auth plugin +// used in Route object. +type KeyAuthRouteConfig struct { + Header string `json:"header,omitempty"` +} + +// BasicAuthConsumerConfig is the rule config for basic-auth plugin +// used in Consumer object. +// +k8s:deepcopy-gen=true +type BasicAuthConsumerConfig struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// JwtAuthConsumerConfig is the rule config for jwt-auth plugin +// used in Consumer object. +// +k8s:deepcopy-gen=true +type JwtAuthConsumerConfig struct { + Key string `json:"key" yaml:"key"` + Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` + PublicKey string `json:"public_key,omitempty" yaml:"public_key,omitempty"` + PrivateKey string `json:"private_key" yaml:"private_key,omitempty"` + Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"` + Exp int64 `json:"exp,omitempty" yaml:"exp,omitempty"` + Base64Secret bool `json:"base64_secret,omitempty" yaml:"base64_secret,omitempty"` + LifetimeGracePeriod int64 `json:"lifetime_grace_period,omitempty" yaml:"lifetime_grace_period,omitempty"` +} + +// HMACAuthConsumerConfig is the rule config for hmac-auth plugin +// used in Consumer object. +// +k8s:deepcopy-gen=true +type HMACAuthConsumerConfig struct { + AccessKey string `json:"access_key" yaml:"access_key"` + SecretKey string `json:"secret_key" yaml:"secret_key"` + Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"` + ClockSkew int64 `json:"clock_skew,omitempty" yaml:"clock_skew,omitempty"` + SignedHeaders []string `json:"signed_headers,omitempty" yaml:"signed_headers,omitempty"` + KeepHeaders bool `json:"keep_headers,omitempty" yaml:"keep_headers,omitempty"` + EncodeURIParams bool `json:"encode_uri_params,omitempty" yaml:"encode_uri_params,omitempty"` + ValidateRequestBody bool `json:"validate_request_body,omitempty" yaml:"validate_request_body,omitempty"` + MaxReqBody int64 `json:"max_req_body,omitempty" yaml:"max_req_body,omitempty"` +} + +// LDAPAuthConsumerConfig is the rule config for ldap-auth plugin +// used in Consumer object. +// +k8s:deepcopy-gen=true +type LDAPAuthConsumerConfig struct { + UserDN string `json:"user_dn"` +} + +// BasicAuthRouteConfig is the rule config for basic-auth plugin +// used in Route object. +// +k8s:deepcopy-gen=true +type BasicAuthRouteConfig struct{} + +// WolfRBACConsumerConfig is the rule config for wolf-rbac plugin +// used in Consumer object. +// +k8s:deepcopy-gen=true +type WolfRBACConsumerConfig struct { + Server string `json:"server,omitempty"` + Appid string `json:"appid,omitempty"` + HeaderPrefix string `json:"header_prefix,omitempty"` +} + +// RewriteConfig is the rule config for proxy-rewrite plugin. +// +k8s:deepcopy-gen=true +type RewriteConfig struct { + RewriteTarget string `json:"uri,omitempty"` + RewriteTargetRegex []string `json:"regex_uri,omitempty"` + Headers *Headers `json:"headers,omitempty"` + Host string `json:"host,omitempty"` +} + +// ResponseRewriteConfig is the rule config for response-rewrite plugin. +// +k8s:deepcopy-gen=true +type ResponseRewriteConfig struct { + StatusCode int `json:"status_code,omitempty"` + Body string `json:"body,omitempty"` + BodyBase64 bool `json:"body_base64,omitempty"` + Headers *ResponseHeaders `json:"headers,omitempty"` + LuaRestyExpr []expr.Expr `json:"vars,omitempty"` + Filters []map[string]string `json:"filters,omitempty"` +} + +// RedirectConfig is the rule config for redirect plugin. +// +k8s:deepcopy-gen=true +type RedirectConfig struct { + HttpToHttps bool `json:"http_to_https,omitempty"` + URI string `json:"uri,omitempty"` + RetCode int `json:"ret_code,omitempty"` +} + +// ForwardAuthConfig is the rule config for forward-auth plugin. +// +k8s:deepcopy-gen=true +type ForwardAuthConfig struct { + URI string `json:"uri"` + SSLVerify bool `json:"ssl_verify"` + RequestHeaders []string `json:"request_headers,omitempty"` + UpstreamHeaders []string `json:"upstream_headers,omitempty"` + ClientHeaders []string `json:"client_headers,omitempty"` +} + +// BasicAuthConfig is the rule config for basic-auth plugin. +// +k8s:deepcopy-gen=true +type BasicAuthConfig struct { +} + +// KeyAuthConfig is the rule config for key-auth plugin. +// +k8s:deepcopy-gen=true +type KeyAuthConfig struct { +} + +// RequestMirror is the rule config for proxy-mirror plugin. +// +k8s:deepcopy-gen=true +type RequestMirror struct { + Host string `json:"host"` +} + +// +k8s:deepcopy-gen=true +type Headers struct { + Set map[string]string `json:"set" yaml:"set"` + Add map[string]string `json:"add" yaml:"add"` + Remove []string `json:"remove" yaml:"remove"` +} + +// +k8s:deepcopy-gen=true +type ResponseHeaders struct { + Set map[string]string `json:"set" yaml:"set"` + Add []string `json:"add" yaml:"add"` + Remove []string `json:"remove" yaml:"remove"` +} diff --git a/api/dashboard/v1/types.go b/api/dashboard/v1/types.go new file mode 100644 index 000000000..831ef5500 --- /dev/null +++ b/api/dashboard/v1/types.go @@ -0,0 +1,952 @@ +// 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 v1 + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "database/sql/driver" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + clientv3 "go.etcd.io/etcd/client/v3" +) + +const ( + // HashOnVars means the hash scope is variable. + HashOnVars = "vars" + // HashOnVarsCombination means the hash scope is the + // variable combination. + HashOnVarsCombination = "vars_combinations" + // HashOnHeader means the hash scope is HTTP request + // headers. + HashOnHeader = "header" + // HashOnCookie means the hash scope is HTTP Cookie. + HashOnCookie = "cookie" + // HashOnConsumer means the hash scope is APISIX consumer. + HashOnConsumer = "consumer" + + // LbRoundRobin is the round robin load balancer. + LbRoundRobin = "roundrobin" + // LbConsistentHash is the consistent hash load balancer. + LbConsistentHash = "chash" + // LbEwma is the ewma load balancer. + LbEwma = "ewma" + // LbLeaseConn is the least connection load balancer. + LbLeastConn = "least_conn" + + // SchemeHTTP represents the HTTP protocol. + SchemeHTTP = "http" + // SchemeGRPC represents the GRPC protocol. + SchemeGRPC = "grpc" + // SchemeHTTPS represents the HTTPS protocol. + SchemeHTTPS = "https" + // SchemeGRPCS represents the GRPCS protocol. + SchemeGRPCS = "grpcs" + // SchemeTCP represents the TCP protocol. + SchemeTCP = "tcp" + // SchemeUDP represents the UDP protocol. + SchemeUDP = "udp" + + // HealthCheckHTTP represents the HTTP kind health check. + HealthCheckHTTP = "http" + // HealthCheckHTTPS represents the HTTPS kind health check. + HealthCheckHTTPS = "https" + // HealthCheckTCP represents the TCP kind health check. + HealthCheckTCP = "tcp" + + // HealthCheckMaxConsecutiveNumber is the max number for + // the consecutive success/failure in upstream health check. + HealthCheckMaxConsecutiveNumber = 254 + // ActiveHealthCheckMinInterval is the minimum interval for + // the active health check. + ActiveHealthCheckMinInterval = time.Second + + // DefaultUpstreamTimeout represents the default connect, + // read and send timeout (in seconds) with upstreams. + DefaultUpstreamTimeout = 60 + + // PassHostPass represents pass option for pass_host Upstream settings. + PassHostPass = "pass" + // PassHostPass represents node option for pass_host Upstream settings. + PassHostNode = "node" + // PassHostPass represents rewrite option for pass_host Upstream settings. + PassHostRewrite = "rewrite" +) + +var ValidSchemes map[string]struct{} = map[string]struct{}{ + SchemeHTTP: {}, + SchemeHTTPS: {}, + SchemeGRPC: {}, + SchemeGRPCS: {}, +} + +// Metadata contains all meta information about resources. +// +k8s:deepcopy-gen=true +type Metadata struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Desc string `json:"desc,omitempty" yaml:"desc,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` +} + +func (m *Metadata) GetID() string { + return m.ID +} + +func (m *Metadata) GetName() string { + return m.Name +} + +func (m *Metadata) GetLabels() map[string]string { + return m.Labels +} + +// Upstream is the apisix upstream definition. +// +k8s:deepcopy-gen=true +type Upstream struct { + Metadata `json:",inline" yaml:",inline"` + + Type string `json:"type,omitempty" yaml:"type,omitempty"` + HashOn string `json:"hash_on,omitempty" yaml:"hash_on,omitempty"` + Key string `json:"key,omitempty" yaml:"key,omitempty"` + Checks *UpstreamHealthCheck `json:"checks,omitempty" yaml:"checks,omitempty"` + Nodes UpstreamNodes `json:"nodes" yaml:"nodes"` + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` + Retries *int `json:"retries,omitempty" yaml:"retries,omitempty"` + Timeout *UpstreamTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` + TLS *ClientTLS `json:"tls,omitempty" yaml:"tls,omitempty"` + PassHost string `json:"pass_host,omitempty" yaml:"pass_host,omitempty"` + UpstreamHost string `json:"upstream_host,omitempty" yaml:"upstream_host,omitempty"` + + // for Service Discovery + ServiceName string `json:"service_name,omitempty" yaml:"service_name,omitempty"` + DiscoveryType string `json:"discovery_type,omitempty" yaml:"discovery_type,omitempty"` + DiscoveryArgs map[string]string `json:"discovery_args,omitempty" yaml:"discovery_args,omitempty"` +} + +type ServiceType string + +const ( + ServiceTypeHTTP ServiceType = "http" + ServiceTypeStream ServiceType = "stream" +) + +// Upstream is the apisix upstream definition. +// +k8s:deepcopy-gen=true +type Service struct { + Metadata `json:",inline" yaml:",inline"` + Type ServiceType `json:"type,omitempty" yaml:"type,omitempty"` + Upstream *Upstream `json:"upstream,omitempty" yaml:"upstream,omitempty"` + Hosts []string `json:"hosts,omitempty" yaml:"hosts,omitempty"` + Plugins Plugins `json:"plugins,omitempty" yaml:"plugins,omitempty"` +} + +// Route apisix route object +// +k8s:deepcopy-gen=true +type Route struct { + Metadata `json:",inline" yaml:",inline"` + Host string `json:"host,omitempty" yaml:"host,omitempty"` + Hosts []string `json:"hosts,omitempty" yaml:"hosts,omitempty"` + Uri string `json:"uri,omitempty" yaml:"uri,omitempty"` + Priority int `json:"priority,omitempty" yaml:"priority,omitempty"` + Timeout *UpstreamTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` + Vars Vars `json:"vars,omitempty" yaml:"vars,omitempty"` + Paths []string `json:"paths,omitempty" yaml:"paths,omitempty"` + Methods []string `json:"methods,omitempty" yaml:"methods,omitempty"` + EnableWebsocket bool `json:"enable_websocket,omitempty" yaml:"enable_websocket,omitempty"` + RemoteAddrs []string `json:"remote_addrs,omitempty" yaml:"remote_addrs,omitempty"` + ServiceID string `json:"service_id,omitempty" yaml:"service_id,omitempty"` + Plugins Plugins `json:"plugins,omitempty" yaml:"plugins,omitempty"` + PluginConfigId string `json:"plugin_config_id,omitempty" yaml:"plugin_config_id,omitempty"` + FilterFunc string `json:"filter_func,omitempty" yaml:"filter_func,omitempty"` +} + +// Vars represents the route match expressions of APISIX. +type Vars [][]StringOrSlice + +// UnmarshalJSON implements json.Unmarshaler interface. +// lua-cjson doesn't distinguish empty array and table, +// and by default empty array will be encoded as '{}'. +// We have to maintain the compatibility. +func (vars *Vars) UnmarshalJSON(p []byte) error { + if p[0] == '{' { + if len(p) != 2 { + return errors.New("unexpected non-empty object") + } + return nil + } + var data [][]StringOrSlice + if err := json.Unmarshal(p, &data); err != nil { + return err + } + *vars = data + return nil +} + +// StringOrSlice represents a string or a string slice. +// TODO Do not use interface{} to avoid the reflection overheads. +// +k8s:deepcopy-gen=true +type StringOrSlice struct { + StrVal string `json:"-"` + SliceVal []string `json:"-"` +} + +func (s *StringOrSlice) MarshalJSON() ([]byte, error) { + var ( + p []byte + err error + ) + if s.SliceVal != nil { + p, err = json.Marshal(s.SliceVal) + } else { + p, err = json.Marshal(s.StrVal) + } + return p, err +} + +func (s *StringOrSlice) UnmarshalJSON(p []byte) error { + var err error + + if len(p) == 0 { + return errors.New("empty object") + } + if p[0] == '[' { + err = json.Unmarshal(p, &s.SliceVal) + } else { + err = json.Unmarshal(p, &s.StrVal) + } + return err +} + +type Plugins map[string]any + +func (p *Plugins) DeepCopyInto(out *Plugins) { + b, _ := json.Marshal(&p) + _ = json.Unmarshal(b, out) +} + +func (p Plugins) DeepCopy() Plugins { + if p == nil { + return nil + } + out := make(Plugins) + p.DeepCopyInto(&out) + return out +} + +// ClientTLS is tls cert and key use in mTLS +type ClientTLS struct { + Cert string `json:"client_cert,omitempty" yaml:"client_cert,omitempty"` + Key string `json:"client_key,omitempty" yaml:"client_key,omitempty"` +} + +// UpstreamTimeout represents the timeout settings on Upstream. +type UpstreamTimeout struct { + // Connect is the connect timeout + Connect int `json:"connect" yaml:"connect"` + // Send is the send timeout + Send int `json:"send" yaml:"send"` + // Read is the read timeout + Read int `json:"read" yaml:"read"` +} + +// UpstreamNodes is the upstream node list. +type UpstreamNodes []UpstreamNode + +// UnmarshalJSON implements json.Unmarshaler interface. +// lua-cjson doesn't distinguish empty array and table, +// and by default empty array will be encoded as '{}'. +// We have to maintain the compatibility. +func (n *UpstreamNodes) UnmarshalJSON(p []byte) error { + var data []UpstreamNode + if p[0] == '{' { + value := map[string]float64{} + if err := json.Unmarshal(p, &value); err != nil { + return err + } + for k, v := range value { + node, err := mapKV2Node(k, v) + if err != nil { + return err + } + data = append(data, *node) + } + *n = data + return nil + } + if err := json.Unmarshal(p, &data); err != nil { + return err + } + *n = data + return nil +} + +// MarshalJSON is used to implement custom json.MarshalJSON +func (up Upstream) MarshalJSON() ([]byte, error) { + + if up.DiscoveryType != "" { + return json.Marshal(&struct { + Metadata `json:",inline" yaml:",inline"` + + Type string `json:"type,omitempty" yaml:"type,omitempty"` + HashOn string `json:"hash_on,omitempty" yaml:"hash_on,omitempty"` + Key string `json:"key,omitempty" yaml:"key,omitempty"` + Checks *UpstreamHealthCheck `json:"checks,omitempty" yaml:"checks,omitempty"` + // Nodes UpstreamNodes `json:"nodes" yaml:"nodes"` + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` + Retries *int `json:"retries,omitempty" yaml:"retries,omitempty"` + Timeout *UpstreamTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` + HostPass string `json:"pass_host,omitempty" yaml:"pass_host,omitempty"` + UpstreamHost string `json:"upstream_host,omitempty" yaml:"upstream_host,omitempty"` + TLS *ClientTLS `json:"tls,omitempty" yaml:"tls,omitempty"` + + // for Service Discovery + ServiceName string `json:"service_name,omitempty" yaml:"service_name,omitempty"` + DiscoveryType string `json:"discovery_type,omitempty" yaml:"discovery_type,omitempty"` + DiscoveryArgs map[string]string `json:"discovery_args,omitempty" yaml:"discovery_args,omitempty"` + }{ + Metadata: up.Metadata, + + Type: up.Type, + HashOn: up.HashOn, + Key: up.Key, + Checks: up.Checks, + // Nodes: up.Nodes, + Scheme: up.Scheme, + Retries: up.Retries, + Timeout: up.Timeout, + HostPass: up.PassHost, + UpstreamHost: up.UpstreamHost, + TLS: up.TLS, + + ServiceName: up.ServiceName, + DiscoveryType: up.DiscoveryType, + DiscoveryArgs: up.DiscoveryArgs, + }) + } else { + return json.Marshal(&struct { + Metadata `json:",inline" yaml:",inline"` + + Type string `json:"type,omitempty" yaml:"type,omitempty"` + HashOn string `json:"hash_on,omitempty" yaml:"hash_on,omitempty"` + Key string `json:"key,omitempty" yaml:"key,omitempty"` + Checks *UpstreamHealthCheck `json:"checks,omitempty" yaml:"checks,omitempty"` + Nodes UpstreamNodes `json:"nodes" yaml:"nodes"` + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` + Retries *int `json:"retries,omitempty" yaml:"retries,omitempty"` + Timeout *UpstreamTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` + HostPass string `json:"pass_host,omitempty" yaml:"pass_host,omitempty"` + UpstreamHost string `json:"upstream_host,omitempty" yaml:"upstream_host,omitempty"` + TLS *ClientTLS `json:"tls,omitempty" yaml:"tls,omitempty"` + + // for Service Discovery + // ServiceName string `json:"service_name,omitempty" yaml:"service_name,omitempty"` + // DiscoveryType string `json:"discovery_type,omitempty" yaml:"discovery_type,omitempty"` + // DiscoveryArgs map[string]string `json:"discovery_args,omitempty" yaml:"discovery_args,omitempty"` + }{ + Metadata: up.Metadata, + + Type: up.Type, + HashOn: up.HashOn, + Key: up.Key, + Checks: up.Checks, + Nodes: up.Nodes, + Scheme: up.Scheme, + Retries: up.Retries, + Timeout: up.Timeout, + HostPass: up.PassHost, + UpstreamHost: up.UpstreamHost, + TLS: up.TLS, + + // ServiceName: up.ServiceName, + // DiscoveryType: up.DiscoveryType, + // DiscoveryArgs: up.DiscoveryArgs, + }) + } + +} + +func mapKV2Node(key string, val float64) (*UpstreamNode, error) { + hp := strings.Split(key, ":") + host := hp[0] + // according to APISIX upstream nodes policy, port is required + port := "80" + + if len(hp) > 2 { + return nil, errors.New("invalid upstream node") + } else if len(hp) == 2 { + port = hp[1] + } + + portInt, err := strconv.Atoi(port) + if err != nil { + return nil, fmt.Errorf("parse port to int fail: %s", err.Error()) + } + + node := &UpstreamNode{ + Host: host, + Port: portInt, + Weight: int(val), + } + + return node, nil +} + +// UpstreamNode is the node in upstream +// +k8s:deepcopy-gen=true +type UpstreamNode struct { + Host string `json:"host,omitempty" yaml:"host,omitempty"` + Port int `json:"port,omitempty" yaml:"port,omitempty"` + Weight int `json:"weight,omitempty" yaml:"weight,omitempty"` +} + +// UpstreamHealthCheck defines the active and/or passive health check for an Upstream, +// with the upstream health check feature, pods can be kicked out or joined in quickly, +// if the feedback of Kubernetes liveness/readiness probe is long. +// +k8s:deepcopy-gen=true +type UpstreamHealthCheck struct { + Active *UpstreamActiveHealthCheck `json:"active" yaml:"active"` + Passive *UpstreamPassiveHealthCheck `json:"passive,omitempty" yaml:"passive,omitempty"` +} + +// UpstreamActiveHealthCheck defines the active kind of upstream health check. +// +k8s:deepcopy-gen=true +type UpstreamActiveHealthCheck struct { + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty"` + Concurrency int `json:"concurrency,omitempty" yaml:"concurrency,omitempty"` + Host string `json:"host,omitempty" yaml:"host,omitempty"` + Port int32 `json:"port,omitempty" yaml:"port,omitempty"` + HTTPPath string `json:"http_path,omitempty" yaml:"http_path,omitempty"` + HTTPSVerifyCert bool `json:"https_verify_certificate,omitempty" yaml:"https_verify_certificate,omitempty"` + HTTPRequestHeaders []string `json:"req_headers,omitempty" yaml:"req_headers,omitempty"` + Healthy UpstreamActiveHealthCheckHealthy `json:"healthy,omitempty" yaml:"healthy,omitempty"` + Unhealthy UpstreamActiveHealthCheckUnhealthy `json:"unhealthy,omitempty" yaml:"unhealthy,omitempty"` +} + +// UpstreamPassiveHealthCheck defines the passive kind of upstream health check. +// +k8s:deepcopy-gen=true +type UpstreamPassiveHealthCheck struct { + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Healthy UpstreamPassiveHealthCheckHealthy `json:"healthy,omitempty" yaml:"healthy,omitempty"` + Unhealthy UpstreamPassiveHealthCheckUnhealthy `json:"unhealthy,omitempty" yaml:"unhealthy,omitempty"` +} + +// UpstreamActiveHealthCheckHealthy defines the conditions to judge whether +// an upstream node is healthy with the active manner. +// +k8s:deepcopy-gen=true +type UpstreamActiveHealthCheckHealthy struct { + UpstreamPassiveHealthCheckHealthy `json:",inline" yaml:",inline"` + + Interval int `json:"interval,omitempty" yaml:"interval,omitempty"` +} + +// UpstreamPassiveHealthCheckHealthy defines the conditions to judge whether +// an upstream node is healthy with the passive manner. +// +k8s:deepcopy-gen=true +type UpstreamPassiveHealthCheckHealthy struct { + HTTPStatuses []int `json:"http_statuses,omitempty" yaml:"http_statuses,omitempty"` + Successes int `json:"successes,omitempty" yaml:"successes,omitempty"` +} + +// UpstreamActiveHealthCheckUnhealthy defines the conditions to judge whether +// an upstream node is unhealthy with the active manager. +// +k8s:deepcopy-gen=true +type UpstreamActiveHealthCheckUnhealthy struct { + UpstreamPassiveHealthCheckUnhealthy `json:",inline" yaml:",inline"` + + Interval int `json:"interval,omitempty" yaml:"interval,omitempty"` +} + +// UpstreamPassiveHealthCheckUnhealthy defines the conditions to judge whether +// an upstream node is unhealthy with the passive manager. +// +k8s:deepcopy-gen=true +type UpstreamPassiveHealthCheckUnhealthy struct { + HTTPStatuses []int `json:"http_statuses,omitempty" yaml:"http_statuses,omitempty"` + HTTPFailures int `json:"http_failures,omitempty" yaml:"http_failures,omitempty"` + TCPFailures int `json:"tcp_failures,omitempty" yaml:"tcp_failures,omitempty"` + Timeouts int `json:"timeouts,omitempty" yaml:"timeouts,omitempty"` +} + +// Ssl apisix ssl object +// +k8s:deepcopy-gen=true +type Ssl struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Snis []string `json:"snis,omitempty" yaml:"snis,omitempty"` + Cert string `json:"cert,omitempty" yaml:"cert,omitempty"` + Key string `json:"key,omitempty" yaml:"key,omitempty"` + Status int `json:"status,omitempty" yaml:"status,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Client *MutualTLSClientConfig `json:"client,omitempty" yaml:"client,omitempty"` +} + +// MutualTLSClientConfig apisix SSL client field +// +k8s:deepcopy-gen=true +type MutualTLSClientConfig struct { + CA string `json:"ca,omitempty" yaml:"ca,omitempty"` + Depth int `json:"depth,omitempty" yaml:"depth,omitempty"` + SkipMTLSUriRegex []string `json:"skip_mtls_uri_regex,omitempty" yaml:"skip_mtls_uri_regex, omitempty"` +} + +// StreamRoute represents the stream_route object in APISIX. +// +k8s:deepcopy-gen=true +type StreamRoute struct { + // TODO metadata should use Metadata type + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Desc string `json:"desc,omitempty" yaml:"desc,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + ServerPort int32 `json:"server_port,omitempty" yaml:"server_port,omitempty"` + SNI string `json:"sni,omitempty" yaml:"sni,omitempty"` + ServiceID string `json:"service_id,omitempty" yaml:"service_id,omitempty"` + Plugins Plugins `json:"plugins,omitempty" yaml:"plugins,omitempty"` +} + +// GlobalRule represents the global_rule object in APISIX. +// +k8s:deepcopy-gen=true +type GlobalRule struct { + ID string `json:"id" yaml:"id"` + Plugins Plugins `json:"plugins" yaml:"plugins"` +} + +// Consumer represents the consumer object in APISIX. +// +k8s:deepcopy-gen=true +type Consumer struct { + Username string `json:"username" yaml:"username"` + Desc string `json:"desc,omitempty" yaml:"desc,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Plugins Plugins `json:"plugins,omitempty" yaml:"plugins,omitempty"` +} + +// PluginConfig apisix plugin object +// +k8s:deepcopy-gen=true +type PluginConfig struct { + Metadata `json:",inline" yaml:",inline"` + Plugins Plugins `json:"plugins" yaml:"plugins"` +} + +type PluginMetadata struct { + Name string + Metadata map[string]any +} + +// NewDefaultUpstream returns an empty Upstream with default values. +func NewDefaultService() *Service { + return &Service{ + Metadata: Metadata{ + Labels: map[string]string{ + "managed-by": "api7-ingress-controller", + }, + }, + Plugins: make(Plugins), + } +} + +// NewDefaultRoute returns an empty Route with default values. +func NewDefaultRoute() *Route { + return &Route{ + Metadata: Metadata{ + Desc: "Created by api7-ingress-controller, DO NOT modify it manually", + Labels: map[string]string{ + "managed-by": "api7-ingress-controller", + }, + }, + } +} + +func NewDefaultUpstream() *Upstream { + return &Upstream{ + Type: LbRoundRobin, + Key: "", + Nodes: make(UpstreamNodes, 0), + Scheme: SchemeHTTP, + Metadata: Metadata{ + Desc: "Created by apisix-ingress-controller, DO NOT modify it manually", + Labels: map[string]string{ + "managed-by": "apisix-ingress-controller", + }, + }, + } +} + +// NewDefaultStreamRoute returns an empty StreamRoute with default values. +func NewDefaultStreamRoute() *StreamRoute { + return &StreamRoute{ + Desc: "Created by api7-ingress-controller, DO NOT modify it manually", + Labels: map[string]string{ + "managed-by": "api7-ingress-controller", + }, + } +} + +// NewDefaultConsumer returns an empty Consumer with default values. +func NewDefaultConsumer() *Consumer { + return &Consumer{ + Desc: "Created by api7-ingress-controller, DO NOT modify it manually", + Labels: map[string]string{ + "managed-by": "api7-ingress-controller", + }, + } +} + +// NewDefaultPluginConfig returns an empty PluginConfig with default values. +func NewDefaultPluginConfig() *PluginConfig { + return &PluginConfig{ + Metadata: Metadata{ + Desc: "Created by api7-ingress-controller, DO NOT modify it manually", + Labels: map[string]string{ + "managed-by": "api7-ingress-controller", + }, + }, + Plugins: make(Plugins), + } +} + +// NewDefaultGlobalRule returns an empty PluginConfig with default values. +func NewDefaultGlobalRule() *GlobalRule { + return &GlobalRule{ + Plugins: make(Plugins), + } +} + +// ComposeUpstreamName uses namespace, name, subset (optional), port, resolveGranularity info to compose +// the upstream name. +// the resolveGranularity is not composited in the upstream name when it is endpoint. +func ComposeUpstreamName(namespace, name string, port int32) string { + pstr := strconv.Itoa(int(port)) + // FIXME Use sync.Pool to reuse this buffer if the upstream + // name composing code path is hot. + var p []byte + plen := len(namespace) + len(name) + len(pstr) + 2 + + p = make([]byte, 0, plen) + buf := bytes.NewBuffer(p) + buf.WriteString(namespace) + buf.WriteByte('_') + buf.WriteString(name) + buf.WriteByte('_') + buf.WriteString(pstr) + + return buf.String() +} + +func ComposeServiceNameWithRule(namespace, name string, rule string) string { + // FIXME Use sync.Pool to reuse this buffer if the upstream + // name composing code path is hot. + var p []byte + plen := len(namespace) + len(name) + 2 + + p = make([]byte, 0, plen) + buf := bytes.NewBuffer(p) + buf.WriteString(namespace) + buf.WriteByte('_') + buf.WriteString(name) + buf.WriteByte('_') + buf.WriteString(rule) + + return buf.String() +} + +func ComposeUpstreamNameWithRule(namespace, name string, rule string) string { + // FIXME Use sync.Pool to reuse this buffer if the upstream + // name composing code path is hot. + var p []byte + plen := len(namespace) + len(name) + 2 + + p = make([]byte, 0, plen) + buf := bytes.NewBuffer(p) + buf.WriteString(namespace) + buf.WriteByte('_') + buf.WriteString(name) + buf.WriteByte('_') + buf.WriteString(rule) + + return buf.String() +} + +// ComposeExternalUpstreamName uses ApisixUpstream namespace, name to compose the upstream name. +func ComposeExternalUpstreamName(namespace, name string) string { + return namespace + "_" + name +} + +// ComposeRouteName uses namespace, name and rule name to compose +// the route name. +func ComposeRouteName(namespace, name string, rule string) string { + // FIXME Use sync.Pool to reuse this buffer if the upstream + // name composing code path is hot. + p := make([]byte, 0, len(namespace)+len(name)+len(rule)+2) + buf := bytes.NewBuffer(p) + + buf.WriteString(namespace) + buf.WriteByte('_') + buf.WriteString(name) + buf.WriteByte('_') + buf.WriteString(rule) + + return buf.String() +} + +// ComposeStreamRouteName uses namespace, name and rule name to compose +// the stream_route name. +func ComposeStreamRouteName(namespace, name string, rule string) string { + // FIXME Use sync.Pool to reuse this buffer if the upstream + // name composing code path is hot. + p := make([]byte, 0, len(namespace)+len(name)+len(rule)+6) + buf := bytes.NewBuffer(p) + + buf.WriteString(namespace) + buf.WriteByte('_') + buf.WriteString(name) + buf.WriteByte('_') + buf.WriteString(rule) + buf.WriteString("_tcp") + + return buf.String() +} + +// ComposeConsumerName uses namespace and name of ApisixConsumer to compose +// the Consumer name. +func ComposeConsumerName(namespace, name string) string { + p := make([]byte, 0, len(namespace)+len(name)+1) + buf := bytes.NewBuffer(p) + + // TODO If APISIX modifies the consumer name schema, we can drop this. + buf.WriteString(strings.ReplaceAll(namespace, "-", "_")) + buf.WriteString("_") + buf.WriteString(strings.ReplaceAll(name, "-", "_")) + + return buf.String() +} + +// ComposePluginConfigName uses namespace, name to compose +// the plugin_config name. +func ComposePluginConfigName(namespace, name string) string { + // FIXME Use sync.Pool to reuse this buffer if the upstream + // name composing code path is hot. + p := make([]byte, 0, len(namespace)+len(name)+1) + buf := bytes.NewBuffer(p) + + buf.WriteString(namespace) + buf.WriteByte('_') + buf.WriteString(name) + + return buf.String() +} + +// ComposeGlobalRuleName uses namespace, name to compose +// the global_rule name. +func ComposeGlobalRuleName(namespace, name string) string { + // FIXME Use sync.Pool to reuse this buffer if the upstream + // name composing code path is hot. + p := make([]byte, 0, len(namespace)+len(name)+1) + buf := bytes.NewBuffer(p) + + buf.WriteString(namespace) + buf.WriteByte('_') + buf.WriteString(name) + + return buf.String() +} + +// Schema represents the schema of APISIX objects. +type Schema struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Content string `json:"content,omitempty" yaml:"content,omitempty"` +} + +func (s *Schema) DeepCopyInto(out *Schema) { + b, _ := json.Marshal(&s) + _ = json.Unmarshal(b, out) +} + +func (s *Schema) DeepCopy() *Schema { + if s == nil { + return nil + } + out := new(Schema) + s.DeepCopyInto(out) + return out +} + +type GatewayGroup struct { + ID string `json:"id" gorm:"column:id; primaryKey; size:255;"` + ShortID string `json:"short_id" gorm:"column:short_id; size:255; uniqueIndex:UQE_gateway_group_short_id;"` + Name string `json:"name" gorm:"name; size:255;"` + OrgID string `json:"-" gorm:"org_id; size:255; index:gateway_group_org_id;"` + Type string `json:"type" gorm:"column:type; size:255; default:api7_gateway;"` + Description string `json:"description" gorm:"description; type:text;"` + Labels map[string]string `json:"labels,omitempty" gorm:"serializer:json; column:labels; type:text;"` + Config GatewayGroupBasicConfig `json:"config" gorm:"serializer:json; column:config; type:text;"` + RunningConfigID string `json:"-" gorm:"column:running_config_id; size:255;"` + ConfigVersion int64 `json:"-" gorm:"column:config_version;"` + AdminKeySalt string `json:"-" gorm:"column:admin_key_salt; size:255;"` + EncryptedAdminKey string `json:"-" gorm:"column:encrypted_admin_key; size:255;"` + EnforceServicePublishing bool `json:"enforce_service_publishing" gorm:"column:enforce_service_publishing; default:false;"` +} + +func (g *GatewayGroup) GetKeyPrefix() string { + return fmt.Sprintf("/gateway_groups/%s", g.ShortID) +} + +func (g *GatewayGroup) GetKeyPrefixEnd() string { + return clientv3.GetPrefixRangeEnd(g.GetKeyPrefix()) +} + +type GatewayGroupBasicConfig struct { + ImageTag string `json:"image_tag,omitempty"` +} + +func (GatewayGroup) TableName() string { + return "gateway_group" +} + +type GatewayGroupAdminKey struct { + Key string `json:"key" mask:"fixed"` +} + +type CertificateType string + +const ( + CertificateTypeEndpoint CertificateType = "Endpoint" + CertificateTypeIntermediate CertificateType = "Intermediate" + CertificateTypeRoot CertificateType = "Root" +) + +type AesEncrypt string + +var AESKeyring = "b2zanhtrq35f6j3m" + +func PKCS7Unpadding(plantText []byte) []byte { + length := len(plantText) + if length == 0 { + return plantText + } + padding := int(plantText[length-1]) + return plantText[:(length - padding)] +} + +func FAesDecrypt(encrypted string, keyring string) (string, error) { + if len(encrypted) == 0 { + return "", nil + } + ciphertext, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + return "", err + } + + block, err := aes.NewCipher([]byte(keyring)) + if err != nil { + return "", err + } + if len(ciphertext)%aes.BlockSize != 0 { + return "", errors.New("block size cant be zero") + } + iv := []byte(keyring)[:aes.BlockSize] + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(ciphertext, ciphertext) + + return string(PKCS7Unpadding(ciphertext)), nil +} + +func (aesEncrypt AesEncrypt) Value() (driver.Value, error) { + return FAesDecrypt(string(aesEncrypt), AESKeyring) +} + +func (aesEncrypt *AesEncrypt) Scan(value any) error { + var str string + switch v := value.(type) { + case string: // for postgres + str = v + case []uint8: // for mysql + str = string(v) + default: + return fmt.Errorf("invalid type scan from database driver: %T", value) + } + res, err := FAesDecrypt(str, AESKeyring) + if err == nil { + *aesEncrypt = AesEncrypt(res) + } + return err +} + +type BaseCertificate struct { + ID string `json:"id" gorm:"primaryKey; column:id; size:255;"` + Certificate string `json:"certificate" gorm:"column:certificate; type:text;"` + PrivateKey AesEncrypt `json:"private_key" gorm:"column:private_key; type:text;" mask:"fixed"` + Expiry Time `json:"expiry" gorm:"column:expiry"` + CreatedAt Time `json:"-" gorm:"column:created_at;autoCreateTime; <-:create;"` + UpdatedAt Time `json:"-" gorm:"column:updated_at;autoUpdateTime"` + Type CertificateType `json:"-" gorm:"column:type;"` +} + +type DataplaneCertificate struct { + *BaseCertificate + + GatewayGroupID string `json:"gateway_group_id" gorm:"column:gateway_group_id;size:255;"` + CACertificate string `json:"ca_certificate" gorm:"column:ca_certificate;type:text;"` +} + +func (DataplaneCertificate) TableName() string { + return "dataplane_certificate" +} + +type Time time.Time + +func (t *Time) UnmarshalJSON(data []byte) error { + ts, err := strconv.ParseInt(string(data), 10, 64) + if err != nil { + return err + } + + *t = Time(time.Unix(ts, 0)) + return nil +} + +func (t Time) MarshalJSON() ([]byte, error) { + ts := (time.Time)(t).Unix() + return []byte(strconv.FormatInt(ts, 10)), nil +} + +func (t Time) String() string { + return strconv.FormatInt(time.Time(t).Unix(), 10) +} + +func (t *Time) Unix() int64 { + return time.Time(*t).Unix() +} + +func (t *Time) Scan(src any) error { + switch s := src.(type) { + case time.Time: + *t = Time(s) + default: + return fmt.Errorf("invalid time type from database driver: %T", src) + } + return nil +} + +func (t Time) Value() (driver.Value, error) { + return time.Time(t), nil +} diff --git a/api/dashboard/v1/zz_generated.deepcopy.go b/api/dashboard/v1/zz_generated.deepcopy.go new file mode 100644 index 000000000..c10ae099e --- /dev/null +++ b/api/dashboard/v1/zz_generated.deepcopy.go @@ -0,0 +1,928 @@ +//go:build !ignore_autogenerated + +// 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. + +// Code generated by controller-gen. DO NOT EDIT. + +package v1 + +import ( + "github.com/incubator4/go-resty-expr/expr" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BasicAuthConfig) DeepCopyInto(out *BasicAuthConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BasicAuthConfig. +func (in *BasicAuthConfig) DeepCopy() *BasicAuthConfig { + if in == nil { + return nil + } + out := new(BasicAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BasicAuthConsumerConfig) DeepCopyInto(out *BasicAuthConsumerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BasicAuthConsumerConfig. +func (in *BasicAuthConsumerConfig) DeepCopy() *BasicAuthConsumerConfig { + if in == nil { + return nil + } + out := new(BasicAuthConsumerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BasicAuthRouteConfig) DeepCopyInto(out *BasicAuthRouteConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BasicAuthRouteConfig. +func (in *BasicAuthRouteConfig) DeepCopy() *BasicAuthRouteConfig { + if in == nil { + return nil + } + out := new(BasicAuthRouteConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CSRFConfig) DeepCopyInto(out *CSRFConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CSRFConfig. +func (in *CSRFConfig) DeepCopy() *CSRFConfig { + if in == nil { + return nil + } + out := new(CSRFConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Consumer) DeepCopyInto(out *Consumer) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + out.Plugins = in.Plugins.DeepCopy() +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Consumer. +func (in *Consumer) DeepCopy() *Consumer { + if in == nil { + return nil + } + out := new(Consumer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CorsConfig) DeepCopyInto(out *CorsConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CorsConfig. +func (in *CorsConfig) DeepCopy() *CorsConfig { + if in == nil { + return nil + } + out := new(CorsConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ForwardAuthConfig) DeepCopyInto(out *ForwardAuthConfig) { + *out = *in + if in.RequestHeaders != nil { + in, out := &in.RequestHeaders, &out.RequestHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.UpstreamHeaders != nil { + in, out := &in.UpstreamHeaders, &out.UpstreamHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ClientHeaders != nil { + in, out := &in.ClientHeaders, &out.ClientHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ForwardAuthConfig. +func (in *ForwardAuthConfig) DeepCopy() *ForwardAuthConfig { + if in == nil { + return nil + } + out := new(ForwardAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GlobalRule) DeepCopyInto(out *GlobalRule) { + *out = *in + out.Plugins = in.Plugins.DeepCopy() +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GlobalRule. +func (in *GlobalRule) DeepCopy() *GlobalRule { + if in == nil { + return nil + } + out := new(GlobalRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HMACAuthConsumerConfig) DeepCopyInto(out *HMACAuthConsumerConfig) { + *out = *in + if in.SignedHeaders != nil { + in, out := &in.SignedHeaders, &out.SignedHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HMACAuthConsumerConfig. +func (in *HMACAuthConsumerConfig) DeepCopy() *HMACAuthConsumerConfig { + if in == nil { + return nil + } + out := new(HMACAuthConsumerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Headers) DeepCopyInto(out *Headers) { + *out = *in + if in.Set != nil { + in, out := &in.Set, &out.Set + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Add != nil { + in, out := &in.Add, &out.Add + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Remove != nil { + in, out := &in.Remove, &out.Remove + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Headers. +func (in *Headers) DeepCopy() *Headers { + if in == nil { + return nil + } + out := new(Headers) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPRestrictConfig) DeepCopyInto(out *IPRestrictConfig) { + *out = *in + if in.Allowlist != nil { + in, out := &in.Allowlist, &out.Allowlist + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Blocklist != nil { + in, out := &in.Blocklist, &out.Blocklist + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPRestrictConfig. +func (in *IPRestrictConfig) DeepCopy() *IPRestrictConfig { + if in == nil { + return nil + } + out := new(IPRestrictConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JwtAuthConsumerConfig) DeepCopyInto(out *JwtAuthConsumerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JwtAuthConsumerConfig. +func (in *JwtAuthConsumerConfig) DeepCopy() *JwtAuthConsumerConfig { + if in == nil { + return nil + } + out := new(JwtAuthConsumerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeyAuthConfig) DeepCopyInto(out *KeyAuthConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeyAuthConfig. +func (in *KeyAuthConfig) DeepCopy() *KeyAuthConfig { + if in == nil { + return nil + } + out := new(KeyAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeyAuthConsumerConfig) DeepCopyInto(out *KeyAuthConsumerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeyAuthConsumerConfig. +func (in *KeyAuthConsumerConfig) DeepCopy() *KeyAuthConsumerConfig { + if in == nil { + return nil + } + out := new(KeyAuthConsumerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPAuthConsumerConfig) DeepCopyInto(out *LDAPAuthConsumerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPAuthConsumerConfig. +func (in *LDAPAuthConsumerConfig) DeepCopy() *LDAPAuthConsumerConfig { + if in == nil { + return nil + } + out := new(LDAPAuthConsumerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Metadata) DeepCopyInto(out *Metadata) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Metadata. +func (in *Metadata) DeepCopy() *Metadata { + if in == nil { + return nil + } + out := new(Metadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MutualTLSClientConfig) DeepCopyInto(out *MutualTLSClientConfig) { + *out = *in + if in.SkipMTLSUriRegex != nil { + in, out := &in.SkipMTLSUriRegex, &out.SkipMTLSUriRegex + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MutualTLSClientConfig. +func (in *MutualTLSClientConfig) DeepCopy() *MutualTLSClientConfig { + if in == nil { + return nil + } + out := new(MutualTLSClientConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PluginConfig) DeepCopyInto(out *PluginConfig) { + *out = *in + in.Metadata.DeepCopyInto(&out.Metadata) + out.Plugins = in.Plugins.DeepCopy() +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PluginConfig. +func (in *PluginConfig) DeepCopy() *PluginConfig { + if in == nil { + return nil + } + out := new(PluginConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedirectConfig) DeepCopyInto(out *RedirectConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedirectConfig. +func (in *RedirectConfig) DeepCopy() *RedirectConfig { + if in == nil { + return nil + } + out := new(RedirectConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RequestMirror) DeepCopyInto(out *RequestMirror) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RequestMirror. +func (in *RequestMirror) DeepCopy() *RequestMirror { + if in == nil { + return nil + } + out := new(RequestMirror) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseHeaders) DeepCopyInto(out *ResponseHeaders) { + *out = *in + if in.Set != nil { + in, out := &in.Set, &out.Set + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Add != nil { + in, out := &in.Add, &out.Add + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Remove != nil { + in, out := &in.Remove, &out.Remove + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseHeaders. +func (in *ResponseHeaders) DeepCopy() *ResponseHeaders { + if in == nil { + return nil + } + out := new(ResponseHeaders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseRewriteConfig) DeepCopyInto(out *ResponseRewriteConfig) { + *out = *in + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = new(ResponseHeaders) + (*in).DeepCopyInto(*out) + } + if in.LuaRestyExpr != nil { + in, out := &in.LuaRestyExpr, &out.LuaRestyExpr + *out = make([]expr.Expr, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Filters != nil { + in, out := &in.Filters, &out.Filters + *out = make([]map[string]string, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseRewriteConfig. +func (in *ResponseRewriteConfig) DeepCopy() *ResponseRewriteConfig { + if in == nil { + return nil + } + out := new(ResponseRewriteConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RewriteConfig) DeepCopyInto(out *RewriteConfig) { + *out = *in + if in.RewriteTargetRegex != nil { + in, out := &in.RewriteTargetRegex, &out.RewriteTargetRegex + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = new(Headers) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RewriteConfig. +func (in *RewriteConfig) DeepCopy() *RewriteConfig { + if in == nil { + return nil + } + out := new(RewriteConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Route) DeepCopyInto(out *Route) { + *out = *in + in.Metadata.DeepCopyInto(&out.Metadata) + if in.Hosts != nil { + in, out := &in.Hosts, &out.Hosts + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(UpstreamTimeout) + **out = **in + } + if in.Vars != nil { + in, out := &in.Vars, &out.Vars + *out = make(Vars, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = make([]StringOrSlice, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + } + } + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Methods != nil { + in, out := &in.Methods, &out.Methods + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.RemoteAddrs != nil { + in, out := &in.RemoteAddrs, &out.RemoteAddrs + *out = make([]string, len(*in)) + copy(*out, *in) + } + out.Plugins = in.Plugins.DeepCopy() +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Route. +func (in *Route) DeepCopy() *Route { + if in == nil { + return nil + } + out := new(Route) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Service) DeepCopyInto(out *Service) { + *out = *in + in.Metadata.DeepCopyInto(&out.Metadata) + if in.Upstream != nil { + in, out := &in.Upstream, &out.Upstream + *out = new(Upstream) + (*in).DeepCopyInto(*out) + } + if in.Hosts != nil { + in, out := &in.Hosts, &out.Hosts + *out = make([]string, len(*in)) + copy(*out, *in) + } + out.Plugins = in.Plugins.DeepCopy() +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Service. +func (in *Service) DeepCopy() *Service { + if in == nil { + return nil + } + out := new(Service) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Ssl) DeepCopyInto(out *Ssl) { + *out = *in + if in.Snis != nil { + in, out := &in.Snis, &out.Snis + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Client != nil { + in, out := &in.Client, &out.Client + *out = new(MutualTLSClientConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Ssl. +func (in *Ssl) DeepCopy() *Ssl { + if in == nil { + return nil + } + out := new(Ssl) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StreamRoute) DeepCopyInto(out *StreamRoute) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + out.Plugins = in.Plugins.DeepCopy() +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StreamRoute. +func (in *StreamRoute) DeepCopy() *StreamRoute { + if in == nil { + return nil + } + out := new(StreamRoute) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StringOrSlice) DeepCopyInto(out *StringOrSlice) { + *out = *in + if in.SliceVal != nil { + in, out := &in.SliceVal, &out.SliceVal + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StringOrSlice. +func (in *StringOrSlice) DeepCopy() *StringOrSlice { + if in == nil { + return nil + } + out := new(StringOrSlice) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TrafficSplitConfig) DeepCopyInto(out *TrafficSplitConfig) { + *out = *in + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]TrafficSplitConfigRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrafficSplitConfig. +func (in *TrafficSplitConfig) DeepCopy() *TrafficSplitConfig { + if in == nil { + return nil + } + out := new(TrafficSplitConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TrafficSplitConfigRule) DeepCopyInto(out *TrafficSplitConfigRule) { + *out = *in + if in.WeightedUpstreams != nil { + in, out := &in.WeightedUpstreams, &out.WeightedUpstreams + *out = make([]TrafficSplitConfigRuleWeightedUpstream, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrafficSplitConfigRule. +func (in *TrafficSplitConfigRule) DeepCopy() *TrafficSplitConfigRule { + if in == nil { + return nil + } + out := new(TrafficSplitConfigRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TrafficSplitConfigRuleWeightedUpstream) DeepCopyInto(out *TrafficSplitConfigRuleWeightedUpstream) { + *out = *in + if in.Upstream != nil { + in, out := &in.Upstream, &out.Upstream + *out = new(Upstream) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrafficSplitConfigRuleWeightedUpstream. +func (in *TrafficSplitConfigRuleWeightedUpstream) DeepCopy() *TrafficSplitConfigRuleWeightedUpstream { + if in == nil { + return nil + } + out := new(TrafficSplitConfigRuleWeightedUpstream) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Upstream) DeepCopyInto(out *Upstream) { + *out = *in + in.Metadata.DeepCopyInto(&out.Metadata) + if in.Checks != nil { + in, out := &in.Checks, &out.Checks + *out = new(UpstreamHealthCheck) + (*in).DeepCopyInto(*out) + } + if in.Nodes != nil { + in, out := &in.Nodes, &out.Nodes + *out = make(UpstreamNodes, len(*in)) + copy(*out, *in) + } + if in.Retries != nil { + in, out := &in.Retries, &out.Retries + *out = new(int) + **out = **in + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(UpstreamTimeout) + **out = **in + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(ClientTLS) + **out = **in + } + if in.DiscoveryArgs != nil { + in, out := &in.DiscoveryArgs, &out.DiscoveryArgs + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Upstream. +func (in *Upstream) DeepCopy() *Upstream { + if in == nil { + return nil + } + out := new(Upstream) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamActiveHealthCheck) DeepCopyInto(out *UpstreamActiveHealthCheck) { + *out = *in + if in.HTTPRequestHeaders != nil { + in, out := &in.HTTPRequestHeaders, &out.HTTPRequestHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Healthy.DeepCopyInto(&out.Healthy) + in.Unhealthy.DeepCopyInto(&out.Unhealthy) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamActiveHealthCheck. +func (in *UpstreamActiveHealthCheck) DeepCopy() *UpstreamActiveHealthCheck { + if in == nil { + return nil + } + out := new(UpstreamActiveHealthCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamActiveHealthCheckHealthy) DeepCopyInto(out *UpstreamActiveHealthCheckHealthy) { + *out = *in + in.UpstreamPassiveHealthCheckHealthy.DeepCopyInto(&out.UpstreamPassiveHealthCheckHealthy) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamActiveHealthCheckHealthy. +func (in *UpstreamActiveHealthCheckHealthy) DeepCopy() *UpstreamActiveHealthCheckHealthy { + if in == nil { + return nil + } + out := new(UpstreamActiveHealthCheckHealthy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamActiveHealthCheckUnhealthy) DeepCopyInto(out *UpstreamActiveHealthCheckUnhealthy) { + *out = *in + in.UpstreamPassiveHealthCheckUnhealthy.DeepCopyInto(&out.UpstreamPassiveHealthCheckUnhealthy) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamActiveHealthCheckUnhealthy. +func (in *UpstreamActiveHealthCheckUnhealthy) DeepCopy() *UpstreamActiveHealthCheckUnhealthy { + if in == nil { + return nil + } + out := new(UpstreamActiveHealthCheckUnhealthy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamHealthCheck) DeepCopyInto(out *UpstreamHealthCheck) { + *out = *in + if in.Active != nil { + in, out := &in.Active, &out.Active + *out = new(UpstreamActiveHealthCheck) + (*in).DeepCopyInto(*out) + } + if in.Passive != nil { + in, out := &in.Passive, &out.Passive + *out = new(UpstreamPassiveHealthCheck) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamHealthCheck. +func (in *UpstreamHealthCheck) DeepCopy() *UpstreamHealthCheck { + if in == nil { + return nil + } + out := new(UpstreamHealthCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamNode) DeepCopyInto(out *UpstreamNode) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamNode. +func (in *UpstreamNode) DeepCopy() *UpstreamNode { + if in == nil { + return nil + } + out := new(UpstreamNode) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamPassiveHealthCheck) DeepCopyInto(out *UpstreamPassiveHealthCheck) { + *out = *in + in.Healthy.DeepCopyInto(&out.Healthy) + in.Unhealthy.DeepCopyInto(&out.Unhealthy) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamPassiveHealthCheck. +func (in *UpstreamPassiveHealthCheck) DeepCopy() *UpstreamPassiveHealthCheck { + if in == nil { + return nil + } + out := new(UpstreamPassiveHealthCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamPassiveHealthCheckHealthy) DeepCopyInto(out *UpstreamPassiveHealthCheckHealthy) { + *out = *in + if in.HTTPStatuses != nil { + in, out := &in.HTTPStatuses, &out.HTTPStatuses + *out = make([]int, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamPassiveHealthCheckHealthy. +func (in *UpstreamPassiveHealthCheckHealthy) DeepCopy() *UpstreamPassiveHealthCheckHealthy { + if in == nil { + return nil + } + out := new(UpstreamPassiveHealthCheckHealthy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamPassiveHealthCheckUnhealthy) DeepCopyInto(out *UpstreamPassiveHealthCheckUnhealthy) { + *out = *in + if in.HTTPStatuses != nil { + in, out := &in.HTTPStatuses, &out.HTTPStatuses + *out = make([]int, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamPassiveHealthCheckUnhealthy. +func (in *UpstreamPassiveHealthCheckUnhealthy) DeepCopy() *UpstreamPassiveHealthCheckUnhealthy { + if in == nil { + return nil + } + out := new(UpstreamPassiveHealthCheckUnhealthy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WolfRBACConsumerConfig) DeepCopyInto(out *WolfRBACConsumerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WolfRBACConsumerConfig. +func (in *WolfRBACConsumerConfig) DeepCopy() *WolfRBACConsumerConfig { + if in == nil { + return nil + } + out := new(WolfRBACConsumerConfig) + in.DeepCopyInto(out) + return out +} diff --git a/docs/assets/images/api7-ingress-controller-architecture.png b/docs/assets/images/api7-ingress-controller-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..089419160a5ef12237e054bbab06a5abca7c9347 GIT binary patch literal 379340 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 z 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

kt3KJ>{~p4q0_G|>&(xrIAqao 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 W_Uxm3&jrBWUZdZ4CG9+N@qYjuC0Sem literal 0 HcmV?d00001 diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 000000000..c320753a6 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,27 @@ +# Concepts + +The APISIX Ingress Controller is used to manage the APISIX Gateway as either a standalone application or a Kubernetes-based application. It dynamically configures and manages the API7 Gateway using Gateway API resources. + +## Architecture + +![APISIX Ingress Controller Architecture](./assets/images/api7-ingress-controller-architecture.png) + +## Kubernetes Resources + +### Service + +In Kubernetes, a Service is a method to expose network applications running on a set of Pods as network services. + +When proxying ingress traffic, APISIX Gateway by default directs traffic directly to the Pods instead of through kube-proxy. + +### EndpointSlicea + +EndpointSlice objects represent subsets (slices) of backend network endpoints for a Service. + +The APISIX Ingress Controller continuously tracks matching EndpointSlice objects, and whenever the set of Pods in a Service changes, the set of Pods proxied by the APISIX Gateway will also update accordingly. + +## Gateway API + +Gateway API is an official Kubernetes project focused on L4 and L7 routing in Kubernetes. This project represents the next generation of Kubernetes Ingress, Load Balancing, and Service Mesh APIs. + +For more information on supporting Gateway API, please refer to [Gateway API](./gateway-api.md). diff --git a/docs/configure.md b/docs/configure.md new file mode 100644 index 000000000..06c69d5da --- /dev/null +++ b/docs/configure.md @@ -0,0 +1,30 @@ +# Configure + +The APISIX Ingress Controller is a Kubernetes Ingress Controller that implements the Gateway API. This document describes how to configure the APISIX Ingress Controller. + +## Example + +```yaml +log_level: "info" # The log level of the APISIX Ingress Controller. + # the default value is "info". + +controller_name: apisix.apache.org/apisix-ingress-controller # The controller name of the APISIX Ingress Controller, + # which is used to identify the controller in the GatewayClass. + # The default value is "apisix.apache.org/apisix-ingress-controller". + +leader_election_id: "apisix-ingress-controller-leader" # The leader election ID for the APISIX Ingress Controller. + # The default value is "apisix-ingress-controller-leader". +``` + +### Controller Name + +The `controller_name` field is used to identify the `controllerName` in the GatewayClass. + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: apisix +spec: + controllerName: "apisix.apache.org/apisix-ingress-controller" +``` diff --git a/docs/crd/api.md b/docs/crd/api.md new file mode 100644 index 000000000..eb1a1bea9 --- /dev/null +++ b/docs/crd/api.md @@ -0,0 +1,1587 @@ +--- +title: Custom Resource Definitions API Reference +slug: /reference/apisix-ingress-controller/crd-reference +description: Explore detailed reference documentation for the custom resource definitions (CRDs) supported by the APISIX Ingress Controller. +--- + +This document provides the API resource description the API7 Ingress Controller custom resource definitions (CRDs). + +## Packages +- [apisix.apache.org/v1alpha1](#apisixapacheorgv1alpha1) +- [apisix.apache.org/v2](#apisixapacheorgv2) + + +## apisix.apache.org/v1alpha1 + +Package v1alpha1 contains API Schema definitions for the apisix.apache.org v1alpha1 API group + +- [BackendTrafficPolicy](#backendtrafficpolicy) +- [Consumer](#consumer) +- [GatewayProxy](#gatewayproxy) +- [HTTPRoutePolicy](#httproutepolicy) +- [PluginConfig](#pluginconfig) +### BackendTrafficPolicy + + + + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` +| `kind` _string_ | `BackendTrafficPolicy` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[BackendTrafficPolicySpec](#backendtrafficpolicyspec)_ | BackendTrafficPolicySpec defines traffic handling policies applied to backend services, such as load balancing strategy, connection settings, and failover behavior. | + + + +### Consumer + + + + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` +| `kind` _string_ | `Consumer` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[ConsumerSpec](#consumerspec)_ | ConsumerSpec defines the configuration for a consumer, including consumer name, authentication credentials, and plugin settings. | + + + +### GatewayProxy + + +GatewayProxy is the Schema for the gatewayproxies API. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` +| `kind` _string_ | `GatewayProxy` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[GatewayProxySpec](#gatewayproxyspec)_ | GatewayProxySpec defines the desired state and configuration of a GatewayProxy, including networking settings, global plugins, and plugin metadata. | + + + +### HTTPRoutePolicy + + +HTTPRoutePolicy is the Schema for the httproutepolicies API. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` +| `kind` _string_ | `HTTPRoutePolicy` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[HTTPRoutePolicySpec](#httproutepolicyspec)_ | HTTPRoutePolicySpec defines the desired state and configuration of a HTTPRoutePolicy, including route priority and request matching conditions. | + + + +### PluginConfig + + +PluginConfig is the Schema for the PluginConfigs API. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` +| `kind` _string_ | `PluginConfig` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[PluginConfigSpec](#pluginconfigspec)_ | PluginConfigSpec defines the desired state of a PluginConfig, in which plugins and their configurations are specified. | + + + +### Types + +In this section you will find types that the CRDs rely on. +#### AdminKeyAuth + + +AdminKeyAuth defines the admin key authentication configuration. + + + +| Field | Description | +| --- | --- | +| `value` _string_ | Value sets the admin key value explicitly (not recommended for production). | +| `valueFrom` _[AdminKeyValueFrom](#adminkeyvaluefrom)_ | ValueFrom specifies the source of the admin key. | + + +_Appears in:_ +- [ControlPlaneAuth](#controlplaneauth) + +#### AdminKeyValueFrom + + +AdminKeyValueFrom defines the source of the admin key. + + + +| Field | Description | +| --- | --- | +| `secretKeyRef` _[SecretKeySelector](#secretkeyselector)_ | SecretKeyRef references a key in a Secret. | + + +_Appears in:_ +- [AdminKeyAuth](#adminkeyauth) + +#### AuthType +_Base type:_ `string` + +AuthType defines the type of authentication. + + + + + +_Appears in:_ +- [ControlPlaneAuth](#controlplaneauth) + +#### BackendPolicyTargetReferenceWithSectionName +_Base type:_ `LocalPolicyTargetReferenceWithSectionName` + + + + + +| Field | Description | +| --- | --- | +| `group` _[Group](#group)_ | Group is the group of the target resource. | +| `kind` _[Kind](#kind)_ | Kind is kind of the target resource. | +| `name` _[ObjectName](#objectname)_ | Name is the name of the target resource. | +| `sectionName` _[SectionName](#sectionname)_ | SectionName is the name of a section within the target resource. When unspecified, this targetRef targets the entire resource. In the following resources, SectionName is interpreted as the following:

• Gateway: Listener name
• HTTPRoute: HTTPRouteRule name
• Service: Port name

If a SectionName is specified, but does not exist on the targeted object, the Policy must fail to attach, and the policy implementation should record a `ResolvedRefs` or similar Condition in the Policy's status. | + + +_Appears in:_ +- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) + +#### BackendTrafficPolicySpec + + + + + + +| Field | Description | +| --- | --- | +| `targetRefs` _[BackendPolicyTargetReferenceWithSectionName](#backendpolicytargetreferencewithsectionname) array_ | TargetRef identifies an API object to apply policy to. Currently, Backends (i.e. Service, ServiceImport, or any implementation-specific backendRef) are the only valid API target references. | +| `loadbalancer` _[LoadBalancer](#loadbalancer)_ | LoadBalancer represents the load balancer configuration for Kubernetes Service. The default strategy is round robin. | +| `scheme` _string_ | Scheme is the protocol used to communicate with the upstream. Default is `http`. Can be one of `http`, `https`, `grpc`, or `grpcs`. | +| `retries` _integer_ | Retries specify the number of times the gateway should retry sending requests when errors such as timeouts or 502 errors occur. | +| `timeout` _[Timeout](#timeout)_ | Timeout sets the read, send, and connect timeouts to the upstream. | +| `passHost` _string_ | PassHost configures how the host header should be determined when a request is forwarded to the upstream. Default is `pass`. Can be one of `pass`, `node` or `rewrite`. | +| `upstreamHost` _[Hostname](#hostname)_ | UpstreamHost specifies the host of the Upstream request. Used only if passHost is set to `rewrite`. | + + +_Appears in:_ +- [BackendTrafficPolicy](#backendtrafficpolicy) + +#### ConsumerSpec + + + + + + +| Field | Description | +| --- | --- | +| `gatewayRef` _[GatewayRef](#gatewayref)_ | GatewayRef specifies the gateway details. | +| `credentials` _[Credential](#credential) array_ | Credentials specifies the credential details of a consumer. | +| `plugins` _[Plugin](#plugin) array_ | Plugins define the plugins associated with a consumer. | + + +_Appears in:_ +- [Consumer](#consumer) + + + +#### ControlPlaneAuth + + +ControlPlaneAuth defines the authentication configuration for control plane. + + + +| Field | Description | +| --- | --- | +| `type` _[AuthType](#authtype)_ | Type specifies the type of authentication. Can only be `AdminKey`. | +| `adminKey` _[AdminKeyAuth](#adminkeyauth)_ | AdminKey specifies the admin key authentication configuration. | + + +_Appears in:_ +- [ControlPlaneProvider](#controlplaneprovider) + +#### ControlPlaneProvider + + +ControlPlaneProvider defines the configuration for control plane provider. + + + +| Field | Description | +| --- | --- | +| `endpoints` _string array_ | Endpoints specifies the list of control plane endpoints. | +| `service` _[ProviderService](#providerservice)_ | | +| `tlsVerify` _boolean_ | TlsVerify specifies whether to verify the TLS certificate of the control plane. | +| `auth` _[ControlPlaneAuth](#controlplaneauth)_ | Auth specifies the authentication configurations. | + + +_Appears in:_ +- [GatewayProxyProvider](#gatewayproxyprovider) + +#### Credential + + + + + + +| Field | Description | +| --- | --- | +| `type` _string_ | Type specifies the type of authentication to configure credentials for. Can be one of `jwt-auth`, `basic-auth`, `key-auth`, or `hmac-auth`. | +| `config` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io)_ | Config specifies the credential details for authentication. | +| `secretRef` _[SecretReference](#secretreference)_ | SecretRef references to the Secret that contains the credentials. | +| `name` _string_ | Name is the name of the credential. | + + +_Appears in:_ +- [ConsumerSpec](#consumerspec) + +#### GatewayProxyPlugin + + +GatewayProxyPlugin contains plugin configurations. + + + +| Field | Description | +| --- | --- | +| `name` _string_ | Name is the name of the plugin. | +| `enabled` _boolean_ | Enabled defines whether the plugin is enabled. | +| `config` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io)_ | Config defines the plugin's configuration details. | + + +_Appears in:_ +- [GatewayProxySpec](#gatewayproxyspec) + +#### GatewayProxyProvider + + +GatewayProxyProvider defines the provider configuration for GatewayProxy. + + + +| Field | Description | +| --- | --- | +| `type` _[ProviderType](#providertype)_ | Type specifies the type of provider. Can only be `ControlPlane`. | +| `controlPlane` _[ControlPlaneProvider](#controlplaneprovider)_ | ControlPlane specifies the configuration for control plane provider. | + + +_Appears in:_ +- [GatewayProxySpec](#gatewayproxyspec) + +#### GatewayProxySpec + + +GatewayProxySpec defines the desired state of GatewayProxy. + + + +| Field | Description | +| --- | --- | +| `publishService` _string_ | PublishService specifies the LoadBalancer-type Service whose external address the controller uses to update the status of Ingress resources. | +| `statusAddress` _string array_ | StatusAddress specifies the external IP addresses that the controller uses to populate the status field of GatewayProxy or Ingress resources for developers to access. | +| `provider` _[GatewayProxyProvider](#gatewayproxyprovider)_ | Provider configures the provider details. | +| `plugins` _[GatewayProxyPlugin](#gatewayproxyplugin) array_ | Plugins configure global plugins. | +| `pluginMetadata` _object (keys:string, values:[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io))_ | PluginMetadata configures common configurations shared by all plugin instances of the same name. | + + +_Appears in:_ +- [GatewayProxy](#gatewayproxy) + +#### GatewayRef + + + + + + +| Field | Description | +| --- | --- | +| `name` _string_ | Name is the name of the gateway. | +| `kind` _string_ | Kind is the type of Kubernetes object. Default is `Gateway`. | +| `group` _string_ | Group is the API group the resource belongs to. Default is `gateway.networking.k8s.io`. | +| `namespace` _string_ | Namespace is namespace of the resource. | + + +_Appears in:_ +- [ConsumerSpec](#consumerspec) + +#### HTTPRoutePolicySpec + + +HTTPRoutePolicySpec defines the desired state of HTTPRoutePolicy. + + + +| Field | Description | +| --- | --- | +| `targetRefs` _LocalPolicyTargetReferenceWithSectionName array_ | TargetRef identifies an API object (i.e. HTTPRoute, Ingress) to apply HTTPRoutePolicy to. | +| `priority` _integer_ | Priority sets the priority for route. A higher value sets a higher priority in route matching. | +| `vars` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io) array_ | Vars sets the request matching conditions. | + + +_Appears in:_ +- [HTTPRoutePolicy](#httproutepolicy) + +#### Hostname +_Base type:_ `string` + + + + + + + +_Appears in:_ +- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) + +#### LoadBalancer + + +LoadBalancer describes the load balancing parameters. + + + +| Field | Description | +| --- | --- | +| `type` _string_ | Type specifies the load balancing algorithms. Default is `roundrobin`. Can be one of `roundrobin`, `chash`, `ewma`, or `least_conn`. | +| `hashOn` _string_ | HashOn specified the type of field used for hashing, required when Type is `chash`. Default is `vars`. Can be one of `vars`, `header`, `cookie`, `consumer`, or `vars_combinations`. | +| `key` _string_ | Key is used with HashOn, generally required when Type is `chash`. When HashOn is `header` or `cookie`, specifies the name of the header or cookie. When HashOn is `consumer`, key is not required, as the consumer name is used automatically. When HashOn is `vars` or `vars_combinations`, key refers to one or a combination of [built-in variables](/enterprise/reference/built-in-variables). | + + +_Appears in:_ +- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) + +#### Plugin + + + + + + +| Field | Description | +| --- | --- | +| `name` _string_ | Name is the name of the plugin. | +| `config` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io)_ | Config is plugin configuration details. | + + +_Appears in:_ +- [ConsumerSpec](#consumerspec) +- [PluginConfigSpec](#pluginconfigspec) + +#### PluginConfigSpec + + +PluginConfigSpec defines the desired state of PluginConfig. + + + +| Field | Description | +| --- | --- | +| `plugins` _[Plugin](#plugin) array_ | Plugins are an array of plugins and their configurations to be applied. | + + +_Appears in:_ +- [PluginConfig](#pluginconfig) + + + +#### ProviderService + + + + + + +| Field | Description | +| --- | --- | +| `name` _string_ | | +| `port` _integer_ | | + + +_Appears in:_ +- [ControlPlaneProvider](#controlplaneprovider) + +#### ProviderType +_Base type:_ `string` + +ProviderType defines the type of provider. + + + + + +_Appears in:_ +- [GatewayProxyProvider](#gatewayproxyprovider) + +#### SecretKeySelector + + +SecretKeySelector defines a reference to a specific key within a Secret. + + + +| Field | Description | +| --- | --- | +| `name` _string_ | Name is the name of the secret. | +| `key` _string_ | Key is the key in the secret to retrieve the secret from. | + + +_Appears in:_ +- [AdminKeyValueFrom](#adminkeyvaluefrom) + +#### SecretReference + + + + + + +| Field | Description | +| --- | --- | +| `name` _string_ | Name is the name of the secret. | +| `namespace` _string_ | Namespace is the namespace of the secret. | + + +_Appears in:_ +- [Credential](#credential) + +#### Status + + + + + + +| Field | Description | +| --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#condition-v1-meta) array_ | | + + +_Appears in:_ +- [ConsumerStatus](#consumerstatus) + +#### Timeout + + + + + + +| Field | Description | +| --- | --- | +| `connect` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Connection timeout. Default is `60s`. | +| `send` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Send timeout. Default is `60s`. | +| `read` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Read timeout. Default is `60s`. | + + +_Appears in:_ +- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) + + +## apisix.apache.org/v2 + +Package v2 contains API Schema definitions for the apisix.apache.org v2 API group. + +- [ApisixConsumer](#apisixconsumer) +- [ApisixGlobalRule](#apisixglobalrule) +- [ApisixPluginConfig](#apisixpluginconfig) +- [ApisixRoute](#apisixroute) +- [ApisixTls](#apisixtls) +- [ApisixUpstream](#apisixupstream) +### ApisixConsumer + + +ApisixConsumer is the Schema for the apisixconsumers API. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v2` +| `kind` _string_ | `ApisixConsumer` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[ApisixConsumerSpec](#apisixconsumerspec)_ | | + + + +### ApisixGlobalRule + + +ApisixGlobalRule is the Schema for the apisixglobalrules API. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v2` +| `kind` _string_ | `ApisixGlobalRule` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[ApisixGlobalRuleSpec](#apisixglobalrulespec)_ | | + + + +### ApisixPluginConfig + + +ApisixPluginConfig is the Schema for the apisixpluginconfigs API. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v2` +| `kind` _string_ | `ApisixPluginConfig` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[ApisixPluginConfigSpec](#apisixpluginconfigspec)_ | | + + + +### ApisixRoute + + +ApisixRoute is the Schema for the apisixroutes API. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v2` +| `kind` _string_ | `ApisixRoute` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[ApisixRouteSpec](#apisixroutespec)_ | | + + + +### ApisixTls + + +ApisixTls is the Schema for the apisixtls API. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v2` +| `kind` _string_ | `ApisixTls` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[ApisixTlsSpec](#apisixtlsspec)_ | | + + + +### ApisixUpstream + + +ApisixUpstream is the Schema for the apisixupstreams API. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `apisix.apache.org/v2` +| `kind` _string_ | `ApisixUpstream` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | +| `spec` _[ApisixUpstreamSpec](#apisixupstreamspec)_ | | + + + +### Types + +In this section you will find types that the CRDs rely on. +#### ActiveHealthCheck + + +ActiveHealthCheck defines the active kind of upstream health check. + + + +| Field | Description | +| --- | --- | +| `type` _string_ | | +| `timeout` _[Duration](#duration)_ | | +| `concurrency` _integer_ | | +| `host` _string_ | | +| `port` _integer_ | | +| `httpPath` _string_ | | +| `strictTLS` _boolean_ | | +| `requestHeaders` _string array_ | | +| `healthy` _[ActiveHealthCheckHealthy](#activehealthcheckhealthy)_ | | +| `unhealthy` _[ActiveHealthCheckUnhealthy](#activehealthcheckunhealthy)_ | | + + +_Appears in:_ +- [HealthCheck](#healthcheck) + +#### ActiveHealthCheckHealthy + + +ActiveHealthCheckHealthy defines the conditions to judge whether +an upstream node is healthy with the active manner. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | | +| `successes` _integer_ | | +| `interval` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | + + +_Appears in:_ +- [ActiveHealthCheck](#activehealthcheck) + +#### ActiveHealthCheckUnhealthy + + +ActiveHealthCheckUnhealthy defines the conditions to judge whether +an upstream node is unhealthy with the active manager. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | | +| `httpFailures` _integer_ | | +| `tcpFailures` _integer_ | | +| `timeout` _integer_ | | +| `interval` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | + + +_Appears in:_ +- [ActiveHealthCheck](#activehealthcheck) + +#### ApisixConsumerAuthParameter + + + + + + +| Field | Description | +| --- | --- | +| `basicAuth` _[ApisixConsumerBasicAuth](#apisixconsumerbasicauth)_ | | +| `keyAuth` _[ApisixConsumerKeyAuth](#apisixconsumerkeyauth)_ | | +| `wolfRBAC` _[ApisixConsumerWolfRBAC](#apisixconsumerwolfrbac)_ | | +| `jwtAuth` _[ApisixConsumerJwtAuth](#apisixconsumerjwtauth)_ | | +| `hmacAuth` _[ApisixConsumerHMACAuth](#apisixconsumerhmacauth)_ | | +| `ldapAuth` _[ApisixConsumerLDAPAuth](#apisixconsumerldapauth)_ | | + + +_Appears in:_ +- [ApisixConsumerSpec](#apisixconsumerspec) + +#### ApisixConsumerBasicAuth + + +ApisixConsumerBasicAuth defines the configuration for basic auth. + + + +| Field | Description | +| --- | --- | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | +| `value` _[ApisixConsumerBasicAuthValue](#apisixconsumerbasicauthvalue)_ | | + + +_Appears in:_ +- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) + +#### ApisixConsumerBasicAuthValue + + +ApisixConsumerBasicAuthValue defines the in-place username and password configuration for basic auth. + + + +| Field | Description | +| --- | --- | +| `username` _string_ | | +| `password` _string_ | | + + +_Appears in:_ +- [ApisixConsumerBasicAuth](#apisixconsumerbasicauth) + +#### ApisixConsumerHMACAuth + + +ApisixConsumerHMACAuth defines the configuration for the hmac auth. + + + +| Field | Description | +| --- | --- | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | +| `value` _[ApisixConsumerHMACAuthValue](#apisixconsumerhmacauthvalue)_ | | + + +_Appears in:_ +- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) + +#### ApisixConsumerHMACAuthValue + + +ApisixConsumerHMACAuthValue defines the in-place configuration for hmac auth. + + + +| Field | Description | +| --- | --- | +| `access_key` _string_ | | +| `secret_key` _string_ | | +| `algorithm` _string_ | | +| `clock_skew` _integer_ | | +| `signed_headers` _string array_ | | +| `keep_headers` _boolean_ | | +| `encode_uri_params` _boolean_ | | +| `validate_request_body` _boolean_ | | +| `max_req_body` _integer_ | | + + +_Appears in:_ +- [ApisixConsumerHMACAuth](#apisixconsumerhmacauth) + +#### ApisixConsumerJwtAuth + + +ApisixConsumerJwtAuth defines the configuration for the jwt auth. + + + +| Field | Description | +| --- | --- | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | +| `value` _[ApisixConsumerJwtAuthValue](#apisixconsumerjwtauthvalue)_ | | + + +_Appears in:_ +- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) + +#### ApisixConsumerJwtAuthValue + + +ApisixConsumerJwtAuthValue defines the in-place configuration for jwt auth. + + + +| Field | Description | +| --- | --- | +| `key` _string_ | | +| `secret` _string_ | | +| `public_key` _string_ | | +| `private_key` _string_ | | +| `algorithm` _string_ | | +| `exp` _integer_ | | +| `base64_secret` _boolean_ | | +| `lifetime_grace_period` _integer_ | | + + +_Appears in:_ +- [ApisixConsumerJwtAuth](#apisixconsumerjwtauth) + +#### ApisixConsumerKeyAuth + + +ApisixConsumerKeyAuth defines the configuration for the key auth. + + + +| Field | Description | +| --- | --- | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | +| `value` _[ApisixConsumerKeyAuthValue](#apisixconsumerkeyauthvalue)_ | | + + +_Appears in:_ +- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) + +#### ApisixConsumerKeyAuthValue + + +ApisixConsumerKeyAuthValue defines the in-place configuration for basic auth. + + + +| Field | Description | +| --- | --- | +| `key` _string_ | | + + +_Appears in:_ +- [ApisixConsumerKeyAuth](#apisixconsumerkeyauth) + +#### ApisixConsumerLDAPAuth + + +ApisixConsumerLDAPAuth defines the configuration for the ldap auth. + + + +| Field | Description | +| --- | --- | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | +| `value` _[ApisixConsumerLDAPAuthValue](#apisixconsumerldapauthvalue)_ | | + + +_Appears in:_ +- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) + +#### ApisixConsumerLDAPAuthValue + + +ApisixConsumerLDAPAuthValue defines the in-place configuration for ldap auth. + + + +| Field | Description | +| --- | --- | +| `user_dn` _string_ | | + + +_Appears in:_ +- [ApisixConsumerLDAPAuth](#apisixconsumerldapauth) + +#### ApisixConsumerSpec + + +ApisixConsumerSpec defines the desired state of ApisixConsumer. + + + +| Field | Description | +| --- | --- | +| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. controller implementations use this field to know whether they should be serving this ApisixConsumer resource, by a transitive connection (controller -> IngressClass -> ApisixConsumer resource). | +| `authParameter` _[ApisixConsumerAuthParameter](#apisixconsumerauthparameter)_ | | + + +_Appears in:_ +- [ApisixConsumer](#apisixconsumer) + +#### ApisixConsumerWolfRBAC + + +ApisixConsumerWolfRBAC defines the configuration for the wolf-rbac auth. + + + +| Field | Description | +| --- | --- | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | +| `value` _[ApisixConsumerWolfRBACValue](#apisixconsumerwolfrbacvalue)_ | | + + +_Appears in:_ +- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) + +#### ApisixConsumerWolfRBACValue + + +ApisixConsumerWolfRBAC defines the in-place server and appid and header_prefix configuration for wolf-rbac auth. + + + +| Field | Description | +| --- | --- | +| `server` _string_ | | +| `appid` _string_ | | +| `header_prefix` _string_ | | + + +_Appears in:_ +- [ApisixConsumerWolfRBAC](#apisixconsumerwolfrbac) + +#### ApisixGlobalRuleSpec + + +ApisixGlobalRuleSpec defines the desired state of ApisixGlobalRule. + + + +| Field | Description | +| --- | --- | +| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. The controller uses this field to decide whether the resource should be managed or not. | +| `plugins` _[ApisixRoutePlugin](#apisixrouteplugin) array_ | Plugins contains a list of ApisixRoutePlugin | + + +_Appears in:_ +- [ApisixGlobalRule](#apisixglobalrule) + +#### ApisixMutualTlsClientConfig + + +ApisixMutualTlsClientConfig describes the mutual TLS CA and verify depth + + + +| Field | Description | +| --- | --- | +| `caSecret` _[ApisixSecret](#apisixsecret)_ | | +| `depth` _integer_ | | +| `skip_mtls_uri_regex` _string array_ | | + + +_Appears in:_ +- [ApisixTlsSpec](#apisixtlsspec) + +#### ApisixPluginConfigSpec + + +ApisixPluginConfigSpec defines the desired state of ApisixPluginConfigSpec. + + + +| Field | Description | +| --- | --- | +| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. The controller uses this field to decide whether the resource should be managed or not. | +| `plugins` _[ApisixRoutePlugin](#apisixrouteplugin) array_ | Plugins contain a list of ApisixRoutePlugin | + + +_Appears in:_ +- [ApisixPluginConfig](#apisixpluginconfig) + +#### ApisixRouteAuthentication + + +ApisixRouteAuthentication is the authentication-related +configuration in ApisixRoute. + + + +| Field | Description | +| --- | --- | +| `enable` _boolean_ | | +| `type` _string_ | | +| `keyAuth` _[ApisixRouteAuthenticationKeyAuth](#apisixrouteauthenticationkeyauth)_ | | +| `jwtAuth` _[ApisixRouteAuthenticationJwtAuth](#apisixrouteauthenticationjwtauth)_ | | +| `ldapAuth` _[ApisixRouteAuthenticationLDAPAuth](#apisixrouteauthenticationldapauth)_ | | + + +_Appears in:_ +- [ApisixRouteHTTP](#apisixroutehttp) + +#### ApisixRouteAuthenticationJwtAuth + + +ApisixRouteAuthenticationJwtAuth is the jwt auth related +configuration in ApisixRouteAuthentication. + + + +| Field | Description | +| --- | --- | +| `header` _string_ | | +| `query` _string_ | | +| `cookie` _string_ | | + + +_Appears in:_ +- [ApisixRouteAuthentication](#apisixrouteauthentication) + +#### ApisixRouteAuthenticationKeyAuth + + +ApisixRouteAuthenticationKeyAuth is the keyAuth-related +configuration in ApisixRouteAuthentication. + + + +| Field | Description | +| --- | --- | +| `header` _string_ | | + + +_Appears in:_ +- [ApisixRouteAuthentication](#apisixrouteauthentication) + +#### ApisixRouteAuthenticationLDAPAuth + + +ApisixRouteAuthenticationLDAPAuth is the LDAP auth related +configuration in ApisixRouteAuthentication. + + + +| Field | Description | +| --- | --- | +| `base_dn` _string_ | | +| `ldap_uri` _string_ | | +| `use_tls` _boolean_ | | +| `uid` _string_ | | + + +_Appears in:_ +- [ApisixRouteAuthentication](#apisixrouteauthentication) + +#### ApisixRouteHTTP + + +ApisixRouteHTTP represents a single route in for HTTP traffic. + + + +| Field | Description | +| --- | --- | +| `name` _string_ | The rule name, cannot be empty. | +| `priority` _integer_ | Route priority, when multiple routes contains same URI path (for path matching), route with higher priority will take effect. | +| `timeout` _[UpstreamTimeout](#upstreamtimeout)_ | | +| `match` _[ApisixRouteHTTPMatch](#apisixroutehttpmatch)_ | | +| `backends` _[ApisixRouteHTTPBackend](#apisixroutehttpbackend) array_ | Backends represents potential backends to proxy after the route rule matched. When number of backends are more than one, traffic-split plugin in APISIX will be used to split traffic based on the backend weight. | +| `upstreams` _[ApisixRouteUpstreamReference](#apisixrouteupstreamreference) array_ | Upstreams refer to ApisixUpstream CRD | +| `websocket` _boolean_ | | +| `plugin_config_name` _string_ | | +| `plugin_config_namespace` _string_ | By default, PluginConfigNamespace will be the same as the namespace of ApisixRoute | +| `plugins` _[ApisixRoutePlugin](#apisixrouteplugin) array_ | | +| `authentication` _[ApisixRouteAuthentication](#apisixrouteauthentication)_ | | + + +_Appears in:_ +- [ApisixRouteSpec](#apisixroutespec) + +#### ApisixRouteHTTPBackend + + +ApisixRouteHTTPBackend represents an HTTP backend (a Kubernetes Service). + + + +| Field | Description | +| --- | --- | +| `serviceName` _string_ | The name (short) of the service, note cross namespace is forbidden, so be sure the ApisixRoute and Service are in the same namespace. | +| `servicePort` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#intorstring-intstr-util)_ | The service port, could be the name or the port number. | +| `resolveGranularity` _string_ | The resolve granularity, can be "endpoints" or "service", when set to "endpoints", the pod ips will be used; other wise, the service ClusterIP or ExternalIP will be used, default is endpoints. | +| `weight` _integer_ | Weight of this backend. | +| `subset` _string_ | Subset specifies a subset for the target Service. The subset should be pre-defined in ApisixUpstream about this service. | + + +_Appears in:_ +- [ApisixRouteHTTP](#apisixroutehttp) + +#### ApisixRouteHTTPMatch + + +ApisixRouteHTTPMatch represents the match condition for hitting this route. + + + +| Field | Description | +| --- | --- | +| `paths` _string array_ | URI path predicates, at least one path should be configured, path could be exact or prefix, for prefix path, append "*" after it, for instance, "/foo*". | +| `methods` _string array_ | HTTP request method predicates. | +| `hosts` _string array_ | HTTP Host predicates, host can be a wildcard domain or an exact domain. For wildcard domain, only one generic level is allowed, for instance, "*.foo.com" is valid but "*.*.foo.com" is not. | +| `remoteAddrs` _string array_ | Remote address predicates, items can be valid IPv4 address or IPv6 address or CIDR. | +| `exprs` _[ApisixRouteHTTPMatchExprs](#apisixroutehttpmatchexprs)_ | NginxVars represents generic match predicates, it uses Nginx variable systems, so any predicate like headers, querystring and etc can be leveraged here to match the route. For instance, it can be: nginxVars: - subject: "$remote_addr" op: in value: - "127.0.0.1" - "10.0.5.11" | +| `filter_func` _string_ | Matches based on a user-defined filtering function. These functions can accept an input parameter `vars` which can be used to access the Nginx variables. | + + +_Appears in:_ +- [ApisixRouteHTTP](#apisixroutehttp) + +#### ApisixRouteHTTPMatchExpr + + +ApisixRouteHTTPMatchExpr represents a binary route match expression . + + + +| Field | Description | +| --- | --- | +| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject is the expression subject, it can be any string composed by literals and nginx vars. | +| `op` _string_ | Op is the operator. | +| `set` _string array_ | Set is an array type object of the expression. It should be used when the Op is "in" or "not_in"; | +| `value` _string_ | Value is the normal type object for the expression, it should be used when the Op is not "in" and "not_in". Set and Value are exclusive so only of them can be set in the same time. | + + +_Appears in:_ +- [ApisixRouteHTTPMatchExprs](#apisixroutehttpmatchexprs) + +#### ApisixRouteHTTPMatchExprSubject + + +ApisixRouteHTTPMatchExprSubject describes the route match expression subject. + + + +| Field | Description | +| --- | --- | +| `scope` _string_ | The subject scope, can be: ScopeQuery, ScopeHeader, ScopePath when subject is ScopePath, Name field will be ignored. | +| `name` _string_ | The name of subject. | + + +_Appears in:_ +- [ApisixRouteHTTPMatchExpr](#apisixroutehttpmatchexpr) + +#### ApisixRouteHTTPMatchExprs +_Base type:_ `[ApisixRouteHTTPMatchExpr](#apisixroutehttpmatchexpr)` + + + + + +| Field | Description | +| --- | --- | +| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject is the expression subject, it can be any string composed by literals and nginx vars. | +| `op` _string_ | Op is the operator. | +| `set` _string array_ | Set is an array type object of the expression. It should be used when the Op is "in" or "not_in"; | +| `value` _string_ | Value is the normal type object for the expression, it should be used when the Op is not "in" and "not_in". Set and Value are exclusive so only of them can be set in the same time. | + + +_Appears in:_ +- [ApisixRouteHTTPMatch](#apisixroutehttpmatch) + +#### ApisixRoutePlugin + + +ApisixRoutePlugin represents an APISIX plugin. + + + +| Field | Description | +| --- | --- | +| `name` _string_ | The plugin name. | +| `enable` _boolean_ | Whether this plugin is in use, default is true. | +| `config` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io)_ | Plugin configuration. | +| `secretRef` _string_ | Plugin configuration secretRef. | + + +_Appears in:_ +- [ApisixGlobalRuleSpec](#apisixglobalrulespec) +- [ApisixPluginConfigSpec](#apisixpluginconfigspec) +- [ApisixRouteHTTP](#apisixroutehttp) +- [ApisixRouteStream](#apisixroutestream) + + + +#### ApisixRouteSpec + + +ApisixRouteSpec is the spec definition for ApisixRouteSpec. + + + +| Field | Description | +| --- | --- | +| `ingressClassName` _string_ | | +| `http` _[ApisixRouteHTTP](#apisixroutehttp) array_ | | +| `stream` _[ApisixRouteStream](#apisixroutestream) array_ | | + + +_Appears in:_ +- [ApisixRoute](#apisixroute) + +#### ApisixRouteStream + + +ApisixRouteStream is the configuration for level 4 route + + + +| Field | Description | +| --- | --- | +| `name` _string_ | The rule name cannot be empty. | +| `protocol` _string_ | | +| `match` _[ApisixRouteStreamMatch](#apisixroutestreammatch)_ | | +| `backend` _[ApisixRouteStreamBackend](#apisixroutestreambackend)_ | | +| `plugins` _[ApisixRoutePlugin](#apisixrouteplugin) array_ | | + + +_Appears in:_ +- [ApisixRouteSpec](#apisixroutespec) + +#### ApisixRouteStreamBackend + + +ApisixRouteStreamBackend represents a TCP backend (a Kubernetes Service). + + + +| Field | Description | +| --- | --- | +| `serviceName` _string_ | The name (short) of the service, note cross namespace is forbidden, so be sure the ApisixRoute and Service are in the same namespace. | +| `servicePort` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#intorstring-intstr-util)_ | The service port, could be the name or the port number. | +| `resolveGranularity` _string_ | The resolve granularity, can be "endpoints" or "service", when set to "endpoints", the pod ips will be used; other wise, the service ClusterIP or ExternalIP will be used, default is endpoints. | +| `subset` _string_ | Subset specifies a subset for the target Service. The subset should be pre-defined in ApisixUpstream about this service. | + + +_Appears in:_ +- [ApisixRouteStream](#apisixroutestream) + +#### ApisixRouteStreamMatch + + +ApisixRouteStreamMatch represents the match conditions of stream route. + + + +| Field | Description | +| --- | --- | +| `ingressPort` _integer_ | IngressPort represents the port listening on the Ingress proxy server. It should be pre-defined as APISIX doesn't support dynamic listening. | +| `host` _string_ | | + + +_Appears in:_ +- [ApisixRouteStream](#apisixroutestream) + +#### ApisixRouteUpstreamReference + + +ApisixRouteUpstreamReference contains a ApisixUpstream CRD reference + + + +| Field | Description | +| --- | --- | +| `name` _string_ | | +| `weight` _integer_ | | + + +_Appears in:_ +- [ApisixRouteHTTP](#apisixroutehttp) + +#### ApisixSecret + + +ApisixSecret describes the Kubernetes Secret name and namespace. + + + +| Field | Description | +| --- | --- | +| `name` _string_ | | +| `namespace` _string_ | | + + +_Appears in:_ +- [ApisixMutualTlsClientConfig](#apisixmutualtlsclientconfig) +- [ApisixTlsSpec](#apisixtlsspec) +- [ApisixUpstreamConfig](#apisixupstreamconfig) +- [ApisixUpstreamSpec](#apisixupstreamspec) +- [PortLevelSettings](#portlevelsettings) + + + + + + + + + + + + + + + +#### ApisixTlsSpec + + +ApisixTlsSpec defines the desired state of ApisixTls. + + + +| Field | Description | +| --- | --- | +| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. controller implementations use this field to know whether they should be serving this ApisixTls resource, by a transitive connection (controller -> IngressClass -> ApisixTls resource). | +| `hosts` _[HostType](#hosttype) array_ | | +| `secret` _[ApisixSecret](#apisixsecret)_ | | +| `client` _[ApisixMutualTlsClientConfig](#apisixmutualtlsclientconfig)_ | | + + +_Appears in:_ +- [ApisixTls](#apisixtls) + +#### ApisixUpstreamConfig + + +ApisixUpstreamConfig contains rich features on APISIX Upstream, for instance +load balancer, health check, etc. + + + +| Field | Description | +| --- | --- | +| `loadbalancer` _[LoadBalancer](#loadbalancer)_ | LoadBalancer represents the load balancer configuration for Kubernetes Service. The default strategy is round robin. | +| `scheme` _string_ | The scheme used to talk with the upstream. Now value can be http, grpc. | +| `retries` _integer_ | How many times that the proxy (Apache APISIX) should do when errors occur (error, timeout or bad http status codes like 500, 502). | +| `timeout` _[UpstreamTimeout](#upstreamtimeout)_ | Timeout settings for the read, send and connect to the upstream. | +| `healthCheck` _[HealthCheck](#healthcheck)_ | Deprecated: this is no longer support on standalone mode. The health check configurations for the upstream. | +| `tlsSecret` _[ApisixSecret](#apisixsecret)_ | Set the client certificate when connecting to TLS upstream. | +| `subsets` _[ApisixUpstreamSubset](#apisixupstreamsubset) array_ | Subsets groups the service endpoints by their labels. Usually used to differentiate service versions. | +| `passHost` _string_ | Configures the host when the request is forwarded to the upstream. Can be one of pass, node or rewrite. | +| `upstreamHost` _string_ | Specifies the host of the Upstream request. This is only valid if the pass_host is set to rewrite | +| `discovery` _[Discovery](#discovery)_ | Deprecated: this is no longer support on standalone mode. Discovery is used to configure service discovery for upstream. | + + +_Appears in:_ +- [ApisixUpstreamSpec](#apisixupstreamspec) +- [PortLevelSettings](#portlevelsettings) + +#### ApisixUpstreamExternalNode + + +ApisixUpstreamExternalNode is the external node conf + + + +| Field | Description | +| --- | --- | +| `name` _string_ | | +| `type` _[ApisixUpstreamExternalType](#apisixupstreamexternaltype)_ | | +| `weight` _integer_ | | +| `port` _integer_ | Port defines the port of the external node | + + +_Appears in:_ +- [ApisixUpstreamSpec](#apisixupstreamspec) + +#### ApisixUpstreamExternalType +_Base type:_ `string` + +ApisixUpstreamExternalType is the external service type + + + + + +_Appears in:_ +- [ApisixUpstreamExternalNode](#apisixupstreamexternalnode) + +#### ApisixUpstreamSpec + + +ApisixUpstreamSpec describes the specification of ApisixUpstream. + + + +| Field | Description | +| --- | --- | +| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. controller implementations use this field to know whether they should be serving this ApisixUpstream resource, by a transitive connection (controller -> IngressClass -> ApisixUpstream resource). | +| `externalNodes` _[ApisixUpstreamExternalNode](#apisixupstreamexternalnode) array_ | ExternalNodes contains external nodes the Upstream should use If this field is set, the upstream will use these nodes directly without any further resolves | +| `loadbalancer` _[LoadBalancer](#loadbalancer)_ | LoadBalancer represents the load balancer configuration for Kubernetes Service. The default strategy is round robin. | +| `scheme` _string_ | The scheme used to talk with the upstream. Now value can be http, grpc. | +| `retries` _integer_ | How many times that the proxy (Apache APISIX) should do when errors occur (error, timeout or bad http status codes like 500, 502). | +| `timeout` _[UpstreamTimeout](#upstreamtimeout)_ | Timeout settings for the read, send and connect to the upstream. | +| `healthCheck` _[HealthCheck](#healthcheck)_ | Deprecated: this is no longer support on standalone mode. The health check configurations for the upstream. | +| `tlsSecret` _[ApisixSecret](#apisixsecret)_ | Set the client certificate when connecting to TLS upstream. | +| `subsets` _[ApisixUpstreamSubset](#apisixupstreamsubset) array_ | Subsets groups the service endpoints by their labels. Usually used to differentiate service versions. | +| `passHost` _string_ | Configures the host when the request is forwarded to the upstream. Can be one of pass, node or rewrite. | +| `upstreamHost` _string_ | Specifies the host of the Upstream request. This is only valid if the pass_host is set to rewrite | +| `discovery` _[Discovery](#discovery)_ | Deprecated: this is no longer support on standalone mode. Discovery is used to configure service discovery for upstream. | +| `portLevelSettings` _[PortLevelSettings](#portlevelsettings) array_ | | + + +_Appears in:_ +- [ApisixUpstream](#apisixupstream) + +#### ApisixUpstreamSubset + + +ApisixUpstreamSubset defines a single endpoints group of one Service. + + + +| Field | Description | +| --- | --- | +| `name` _string_ | Name is the name of subset. | +| `labels` _object (keys:string, values:string)_ | Labels is the label set of this subset. | + + +_Appears in:_ +- [ApisixUpstreamConfig](#apisixupstreamconfig) +- [ApisixUpstreamSpec](#apisixupstreamspec) +- [PortLevelSettings](#portlevelsettings) + +#### Discovery + + +Discovery defines Service discovery related configuration. + + + +| Field | Description | +| --- | --- | +| `serviceName` _string_ | | +| `type` _string_ | | +| `args` _object (keys:string, values:string)_ | | + + +_Appears in:_ +- [ApisixUpstreamConfig](#apisixupstreamconfig) +- [ApisixUpstreamSpec](#apisixupstreamspec) +- [PortLevelSettings](#portlevelsettings) + +#### HealthCheck + + +HealthCheck describes the upstream health check parameters. + + + +| Field | Description | +| --- | --- | +| `active` _[ActiveHealthCheck](#activehealthcheck)_ | | +| `passive` _[PassiveHealthCheck](#passivehealthcheck)_ | | + + +_Appears in:_ +- [ApisixUpstreamConfig](#apisixupstreamconfig) +- [ApisixUpstreamSpec](#apisixupstreamspec) +- [PortLevelSettings](#portlevelsettings) + +#### HostType +_Base type:_ `string` + + + + + + + +_Appears in:_ +- [ApisixTlsSpec](#apisixtlsspec) + +#### LoadBalancer + + +LoadBalancer describes the load balancing parameters. + + + +| Field | Description | +| --- | --- | +| `type` _string_ | | +| `hashOn` _string_ | The HashOn and Key fields are required when Type is "chash". HashOn represents the key fetching scope. | +| `key` _string_ | Key represents the hash key. | + + +_Appears in:_ +- [ApisixUpstreamConfig](#apisixupstreamconfig) +- [ApisixUpstreamSpec](#apisixupstreamspec) +- [PortLevelSettings](#portlevelsettings) + +#### PassiveHealthCheck + + +PassiveHealthCheck defines the conditions to judge whether +an upstream node is healthy with the passive manager. + + + +| Field | Description | +| --- | --- | +| `type` _string_ | | +| `healthy` _[PassiveHealthCheckHealthy](#passivehealthcheckhealthy)_ | | +| `unhealthy` _[PassiveHealthCheckUnhealthy](#passivehealthcheckunhealthy)_ | | + + +_Appears in:_ +- [HealthCheck](#healthcheck) + +#### PassiveHealthCheckHealthy + + +PassiveHealthCheckHealthy defines the conditions to judge whether +an upstream node is healthy with the passive manner. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | | +| `successes` _integer_ | | + + +_Appears in:_ +- [ActiveHealthCheckHealthy](#activehealthcheckhealthy) +- [PassiveHealthCheck](#passivehealthcheck) + +#### PassiveHealthCheckUnhealthy + + +PassiveHealthCheckUnhealthy defines the conditions to judge whether +an upstream node is unhealthy with the passive manager. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | | +| `httpFailures` _integer_ | | +| `tcpFailures` _integer_ | | +| `timeout` _integer_ | | + + +_Appears in:_ +- [ActiveHealthCheckUnhealthy](#activehealthcheckunhealthy) +- [PassiveHealthCheck](#passivehealthcheck) + +#### PortLevelSettings + + +PortLevelSettings configures the ApisixUpstreamConfig for each individual port. It inherits +configurations from the outer level (the whole Kubernetes Service) and overrides some of +them if they are set on the port level. + + + +| Field | Description | +| --- | --- | +| `loadbalancer` _[LoadBalancer](#loadbalancer)_ | LoadBalancer represents the load balancer configuration for Kubernetes Service. The default strategy is round robin. | +| `scheme` _string_ | The scheme used to talk with the upstream. Now value can be http, grpc. | +| `retries` _integer_ | How many times that the proxy (Apache APISIX) should do when errors occur (error, timeout or bad http status codes like 500, 502). | +| `timeout` _[UpstreamTimeout](#upstreamtimeout)_ | Timeout settings for the read, send and connect to the upstream. | +| `healthCheck` _[HealthCheck](#healthcheck)_ | Deprecated: this is no longer support on standalone mode. The health check configurations for the upstream. | +| `tlsSecret` _[ApisixSecret](#apisixsecret)_ | Set the client certificate when connecting to TLS upstream. | +| `subsets` _[ApisixUpstreamSubset](#apisixupstreamsubset) array_ | Subsets groups the service endpoints by their labels. Usually used to differentiate service versions. | +| `passHost` _string_ | Configures the host when the request is forwarded to the upstream. Can be one of pass, node or rewrite. | +| `upstreamHost` _string_ | Specifies the host of the Upstream request. This is only valid if the pass_host is set to rewrite | +| `discovery` _[Discovery](#discovery)_ | Deprecated: this is no longer support on standalone mode. Discovery is used to configure service discovery for upstream. | +| `port` _integer_ | Port is a Kubernetes Service port, it should be already defined. | + + +_Appears in:_ +- [ApisixUpstreamSpec](#apisixupstreamspec) + + + + + +#### UpstreamTimeout + + +UpstreamTimeout is settings for the read, send and connect to the upstream. + + + +| Field | Description | +| --- | --- | +| `connect` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | +| `send` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | +| `read` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | + + +_Appears in:_ +- [ApisixRouteHTTP](#apisixroutehttp) +- [ApisixUpstreamConfig](#apisixupstreamconfig) +- [ApisixUpstreamSpec](#apisixupstreamspec) +- [PortLevelSettings](#portlevelsettings) + diff --git a/docs/crd/config.yaml b/docs/crd/config.yaml new file mode 100644 index 000000000..c224cc429 --- /dev/null +++ b/docs/crd/config.yaml @@ -0,0 +1,30 @@ +# 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. + +processor: + # RE2 regular expressions describing types that should be excluded from the generated documentation. + ignoreTypes: + - "List$" + # RE2 regular expressions describing type fields that should be excluded from the generated documentation. + ignoreFields: + - "status$" + - "TypeMeta$" + +render: + # Version of Kubernetes to use when generating links to Kubernetes API documentation. + # NOTE: Quotes are required, otherwise the value will be intepreted as a number so versions ending with `0` like 1.30 would be covreted to "1.3" in results. + kubernetesVersion: "1.30" diff --git a/docs/gateway-api.md b/docs/gateway-api.md new file mode 100644 index 000000000..459ff5a83 --- /dev/null +++ b/docs/gateway-api.md @@ -0,0 +1,82 @@ + +# Gateway API + +Gateway API is dedicated to achieving expressive and scalable Kubernetes service networking through various custom resources. + +By supporting Gateway API, the APISIX Ingress controller can realize richer functions, including Gateway management, multi-cluster support, and other features. It is also possible to manage running instances of the APISIX gateway through Gateway API resource management. + +## Concepts + +- **GatewayClass**: Defines a set of Gateways that share a common configuration and behavior. Each GatewayClass is handled by a single controller, although controllers may handle more than one GatewayClass. +- **Gateway**: A resource in Kubernetes that describes how traffic can be translated to services within the cluster. +- **HTTPRoute**: Can be attached to a Gateway to configure HTTP + +For more information about Gateway API, please refer to [Gateway API](https://gateway-api.sigs.k8s.io/). + +## Gateway API Support Level + +| Resource | Core Support Level | Extended Support Level | Implementation-Specific Support Level | API Version | +| ---------------- | ------------------- | ---------------------- | ------------------------------------- | ----------- | +| GatewayClass | Supported | N/A | Not supported | v1 | +| Gateway | Partially supported | Partially supported | Not supported | v1 | +| HTTPRoute | Supported | Partially supported | Not supported | v1 | +| GRPCRoute | Not supported | Not supported | Not supported | v1 | +| ReferenceGrant | Not supported | Not supported | Not supported | v1beta1 | +| TLSRoute | Not supported | Not supported | Not supported | v1alpha2 | +| TCPRoute | Not supported | Not supported | Not supported | v1alpha2 | +| UDPRoute | Not supported | Not supported | Not supported | v1alpha2 | +| BackendTLSPolicy | Not supported | Not supported | Not supported | v1alpha3 | + +## HTTPRoute + +The HTTPRoute resource allows users to configure HTTP routing by matching HTTP traffic and forwarding it to Kubernetes backends. Currently, the only backend supported by API7 Gateway is the Service resource. + +### Example + +The following example demonstrates how to configure an HTTPRoute resource to route traffic to the `httpbin` service: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: apisix +spec: + controllerName: "apisix.apache.org/apisix-ingress-controller" + +--- + +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: apisix + namespace: default +spec: + gatewayClassName: apisix + listeners: + - name: http + protocol: HTTP + port: 80 + +--- + +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httpbin +spec: + parentRefs: + - name: apisix + hostnames: + - backends.example + rules: + - matches: + - path: + type: Exact + value: /get + - path: + type: Exact + value: /headers + backendRefs: + - name: httpbin + port: 80 +``` diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 000000000..da0a3a686 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,60 @@ +# Quickstart + +This quickstart guide will help you get started with APISIX Ingress Controller in a few simple steps. + +## Prerequisites + +* Kubernetes +* API7 Dashboard +* API7 Gateway + +Please ensure you have deployed the API7 Dashboard control plane. + +Note: Refer to the [Gateway API Release Changelog](https://github.com/kubernetes-sigs/gateway-api/releases/tag/v1.0.0), it is recommended to use Kubernetes version 1.25+. + +## Installation + +Install the Gateway API CRDs: + +```shell +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.1.0/standard-install.yaml + +``` + +Install The APISIX Ingress Controller: + +```shell +kubectl apply -f https://github.com/apache/apisix-ingress-controller/releases/download/install.yaml + +``` + +## Test HTTP Routing + +Install the GatewayClass, Gateway, HTTPRoute and httpbin example app: + +```shell +kubectl apply -f https://github.com/apache/apisix-ingress-controller/blob/release-v2-dev/examples/quickstart.yaml +``` + +Requests will be forwarded by the gateway to the httpbin application: + +```shell +curl http://{apisix_gateway_loadbalancer_ip}/headers +``` + +:::Note If the APISIX Gateway service without loadbalancer + +You can forward the local port to the APISIX Gateway service with the following command: + +```shell +# Listen on port 9080 locally, forwarding to 80 in the pod +kubectl port-forward svc/${apisix-gateway-svc} 9080:80 -n ${apisix_gateway_namespace} +``` + +Now you can send HTTP requests to access it: + +```shell +curl http://localhost:9080/headers +``` + +::: diff --git a/docs/template/gv_details.tpl b/docs/template/gv_details.tpl new file mode 100644 index 000000000..611ca1c9b --- /dev/null +++ b/docs/template/gv_details.tpl @@ -0,0 +1,57 @@ +{* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*} + +{{- define "gvDetails" -}} +{{- $gv := . -}} + +## {{ $gv.GroupVersionString }} + +{{ $gv.Doc }} + +{{- if $gv.Kinds }} +{{- range $gv.SortedKinds }} +- {{ $gv.TypeForKind . | markdownRenderTypeLink }} +{{- end }} +{{ end }} + +{{- /* Display exported Kinds first */ -}} +{{- range $gv.SortedKinds -}} +{{- $typ := $gv.TypeForKind . }} +{{- $isKind := true -}} +{{ template "type" (dict "type" $typ "isKind" $isKind) }} +{{ end -}} + +### Types + +In this section you will find types that the CRDs rely on. + +{{- /* Display Types that are not exported Kinds */ -}} +{{- range $typ := $gv.SortedTypes -}} +{{- $isKind := false -}} +{{- range $kind := $gv.SortedKinds -}} +{{- if eq $typ.Name $kind -}} +{{- $isKind = true -}} +{{- end -}} +{{- end -}} +{{- if not $isKind }} +{{ template "type" (dict "type" $typ "isKind" $isKind) }} +{{ end -}} +{{- end }} + +{{- end -}} diff --git a/docs/template/gv_list.tpl b/docs/template/gv_list.tpl new file mode 100644 index 000000000..31ae052d3 --- /dev/null +++ b/docs/template/gv_list.tpl @@ -0,0 +1,40 @@ +{* + 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. +*} + +{{- define "gvList" -}} +{{- $groupVersions := . -}} + +--- +title: Custom Resource Definitions API Reference +slug: /reference/apisix-ingress-controller/crd-reference +description: Explore detailed reference documentation for the custom resource definitions (CRDs) supported by the APISIX Ingress Controller. +--- + +This document provides the API resource description the API7 Ingress Controller custom resource definitions (CRDs). + +## Packages +{{- range $groupVersions }} +- {{ markdownRenderGVLink . }} +{{- end }} + +{{ range $groupVersions }} +{{ template "gvDetails" . }} +{{ end }} + +{{- end -}} diff --git a/docs/template/type.tpl b/docs/template/type.tpl new file mode 100644 index 000000000..57f6a4339 --- /dev/null +++ b/docs/template/type.tpl @@ -0,0 +1,61 @@ +{* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*} + +{{- define "type" -}} +{{- $type := $.type -}} +{{- $isKind := $.isKind -}} +{{- if markdownShouldRenderType $type -}} + +{{- if $isKind -}} +### {{ $type.Name }} +{{ else -}} +#### {{ $type.Name }} +{{ end -}} + +{{ if $type.IsAlias }}_Base type:_ `{{ markdownRenderTypeLink $type.UnderlyingType }}`{{ end }} + +{{ $type.Doc | replace "\n\n" "

" }} + +{{ if $type.GVK -}} + +{{- end }} + +{{ if $type.Members -}} +| Field | Description | +| --- | --- | +{{ if $type.GVK -}} +| `apiVersion` _string_ | `{{ $type.GVK.Group }}/{{ $type.GVK.Version }}` +| `kind` _string_ | `{{ $type.GVK.Kind }}` +{{ end -}} + +{{ range $type.Members -}} +| `{{ .Name }}` _{{ markdownRenderType .Type }}_ | {{ template "type_members" . }} | +{{ end -}} + +{{ end }} + +{{ if $type.References -}} +_Appears in:_ +{{- range $type.SortedReferences }} +- {{ markdownRenderTypeLink . }} +{{- end }} +{{- end }} + +{{- end -}} +{{- end -}} diff --git a/docs/template/type_members.tpl b/docs/template/type_members.tpl new file mode 100644 index 000000000..6d6f88b61 --- /dev/null +++ b/docs/template/type_members.tpl @@ -0,0 +1,28 @@ +{* + 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. +*} + +{{- define "type_members" -}} +{{- $field := . -}} +{{- if eq $field.Name "metadata" -}} +Please refer to the Kubernetes API documentation for details on the `metadata` field. +{{- else -}} +{{- /* First replace makes paragraphs separated, second merges lines in paragraphs. */ -}} +{{ $field.Doc | replace "\n\n" "

" | replace "\n" " " | replace " *" "
•" | replace "


" "

" }} +{{- end -}} +{{- end -}} diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md new file mode 100644 index 000000000..297fff7b3 --- /dev/null +++ b/docs/upgrade-guide.md @@ -0,0 +1,171 @@ +# APISIX Ingress Controller Upgrade Guide + +## Upgrading from 1.x.x to 2.0.0: Key Changes and Considerations + +This document outlines the major updates, configuration compatibility changes, API behavior differences, and critical considerations when upgrading the APISIX Ingress Controller from version 1.x.x to 2.0.0. Please read carefully and assess the impact on your existing system before proceeding with the upgrade. + +### APISIX Version Dependency (Data Plane) + +The `apisix-standalone` mode is supported only with **APISIX 3.13.0**. When using this mode, it is mandatory to upgrade the data plane APISIX instance along with the Ingress Controller. + +### Architecture Changes + +#### Architecture in 1.x.x + +There were two main deployment architectures in 1.x.x: + +| Mode | Description | Issue | +| -------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Admin API Mode | Runs a separate etcd instance, with APISIX Admin API managing data plane configuration | Complex to deploy; high maintenance overhead for etcd | +| Mock-ETCD Mode | APISIX and the Ingress Controller are deployed in the same Pod, mocking etcd endpoints | Stateless Ingress cannot persist revision info; may lead to data inconsistency | + +#### Architecture in 2.0.0 + +![upgrade to 2.0.0 architecture](./assets/images/upgrade-to-architecture.png) + +##### Mock-ETCD Mode Deprecated + +The mock-etcd architecture is no longer supported. This mode introduced significant reliability issues: stateless ingress controllers could not persist revision metadata, leading to memory pollution in the data plane and data inconsistencies. + +The following configuration block has been removed: + +```yaml +etcdserver: + enabled: false + listen_address: ":12379" + prefix: /apisix + ssl_key_encrypt_salt: edd1c9f0985e76a2 +``` + +##### Controller-Only Configuration Source + +In 2.0.0, all data plane configurations must originate from the Ingress Controller. Configurations via Admin API or any external methods are no longer supported and will be ignored or may cause errors. + +### Ingress Configuration Changes + +#### Configuration Path Changes + +| Old Path | New Path | +| ------------------------ | -------------------- | +| `kubernetes.election_id` | `leader_election_id` | + +#### Removed Configuration Fields + +| Configuration Path | Description | +| -------------------- | ---------------------------------------- | +| `kubernetes.*` | Multi-namespace control / sync interval | +| `plugin_metadata_cm` | Plugin metadata ConfigMap | +| `log_rotation_*` | Log rotation settings | +| `apisix.*` | Static Admin API configuration | +| `etcdserver.*` | Configuration for mock-etcd (deprecated) | + +#### Example: Legacy Configuration Removed in 2.0.0 + +```yaml +apisix: + admin_api_version: v3 + default_cluster_base_url: "http://127.0.0.1:9180/apisix/admin" + default_cluster_admin_key: "" + default_cluster_name: "default" +``` + +#### New Configuration via `GatewayProxy` CRD + +From version 2.0.0, the data plane must be connected via the `GatewayProxy` CRD: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: apisix +spec: + controller: "apisix.apache.org/apisix-ingress-controller" + parameters: + apiGroup: "apisix.apache.org" + kind: "GatewayProxy" + name: "apisix-proxy-config" + namespace: "default" + scope: "Namespace" +--- +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config + namespace: default +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - https://127.0.0.1:9180 + auth: + type: AdminKey + adminKey: + value: "" +``` + +### API Changes + +#### `ApisixUpstream` + +Due to current limitations in the ADC (API Definition Controller) component, the following fields are not yet supported: + +* `spec.discovery`: Service Discovery +* `spec.healthCheck`: Health Checking + +More details: [ADC Backend Differences](https://github.com/api7/adc/blob/2449ca81e3c61169f8c1e59efb4c1173a766bce2/libs/backend-apisix-standalone/README.md#differences-in-upstream) + +#### Limited Support for Ingress Annotations + +Ingress annotations used in version 1.x.x are not fully supported in 2.0.0. If your existing setup relies on any of the following annotations, validate compatibility or consider delaying the upgrade. + +| Ingress Annotations | +| ------------------------------------------------------ | +| `k8s.apisix.apache.org/use-regex` | +| `k8s.apisix.apache.org/enable-websocket` | +| `k8s.apisix.apache.org/plugin-config-name` | +| `k8s.apisix.apache.org/upstream-scheme` | +| `k8s.apisix.apache.org/upstream-retries` | +| `k8s.apisix.apache.org/upstream-connect-timeout` | +| `k8s.apisix.apache.org/upstream-read-timeout` | +| `k8s.apisix.apache.org/upstream-send-timeout` | +| `k8s.apisix.apache.org/enable-cors` | +| `k8s.apisix.apache.org/cors-allow-origin` | +| `k8s.apisix.apache.org/cors-allow-headers` | +| `k8s.apisix.apache.org/cors-allow-methods` | +| `k8s.apisix.apache.org/enable-csrf` | +| `k8s.apisix.apache.org/csrf-key` | +| `k8s.apisix.apache.org/http-to-https` | +| `k8s.apisix.apache.org/http-redirect` | +| `k8s.apisix.apache.org/http-redirect-code` | +| `k8s.apisix.apache.org/rewrite-target` | +| `k8s.apisix.apache.org/rewrite-target-regex` | +| `k8s.apisix.apache.org/rewrite-target-regex-template` | +| `k8s.apisix.apache.org/enable-response-rewrite` | +| `k8s.apisix.apache.org/response-rewrite-status-code` | +| `k8s.apisix.apache.org/response-rewrite-body` | +| `k8s.apisix.apache.org/response-rewrite-body-base64` | +| `k8s.apisix.apache.org/response-rewrite-add-header` | +| `k8s.apisix.apache.org/response-rewrite-set-header` | +| `k8s.apisix.apache.org/response-rewrite-remove-header` | +| `k8s.apisix.apache.org/auth-uri` | +| `k8s.apisix.apache.org/auth-ssl-verify` | +| `k8s.apisix.apache.org/auth-request-headers` | +| `k8s.apisix.apache.org/auth-upstream-headers` | +| `k8s.apisix.apache.org/auth-client-headers` | +| `k8s.apisix.apache.org/allowlist-source-range` | +| `k8s.apisix.apache.org/blocklist-source-range` | +| `k8s.apisix.apache.org/http-allow-methods` | +| `k8s.apisix.apache.org/http-block-methods` | +| `k8s.apisix.apache.org/auth-type` | +| `k8s.apisix.apache.org/svc-namespace` | + +### Summary + +| Category | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------- | +| Architecture | The `mock-etcd` component has been removed. Configuration is now centralized through the Controller. | +| Configuration | Static configuration fields have been removed. Use `GatewayProxy` CRD to configure the data plane. | +| Data Plane | Requires APISIX version 3.13.0 running in `standalone` mode. | +| API | Some fields in `Ingress Annotations` and `ApisixUpstream` are not yet supported. | +| Upgrade Strategy | Blue-green deployment or canary release is recommended before full switchover. | diff --git a/go.mod b/go.mod index 068e83a91..3a5fd7431 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/apache/apisix-ingress-controller -go 1.23.0 +go 1.24.0 -toolchain go1.23.7 +toolchain go1.24.4 require ( - github.com/Masterminds/sprig/v3 v3.2.3 + github.com/Masterminds/sprig/v3 v3.3.0 github.com/api7/gopkg v0.2.1-0.20230601092738-0f3730f9b57a github.com/gavv/httpexpect/v2 v2.16.0 github.com/go-logr/logr v1.4.2 @@ -13,30 +13,43 @@ require ( github.com/google/uuid v1.6.0 github.com/gruntwork-io/terratest v0.47.0 github.com/hashicorp/go-memdb v1.3.4 + github.com/hashicorp/go-multierror v1.1.1 github.com/incubator4/go-resty-expr v0.1.1 - github.com/onsi/ginkgo/v2 v2.20.0 - github.com/onsi/gomega v1.34.1 + github.com/onsi/ginkgo/v2 v2.21.0 + github.com/onsi/gomega v1.35.1 github.com/pkg/errors v0.9.1 github.com/samber/lo v1.47.0 - github.com/spf13/cobra v1.8.1 - github.com/stretchr/testify v1.9.0 + github.com/spf13/cobra v1.9.1 + github.com/stretchr/testify v1.10.0 + github.com/xeipuuv/gojsonschema v1.2.0 + go.etcd.io/etcd/client/v3 v3.5.21 + go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 + golang.org/x/net v0.40.0 gopkg.in/yaml.v3 v3.0.1 - k8s.io/api v0.31.1 - k8s.io/apiextensions-apiserver v0.31.1 - k8s.io/apimachinery v0.31.1 - k8s.io/client-go v0.31.1 - k8s.io/kubectl v0.30.3 - k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 + helm.sh/helm/v3 v3.18.3 + k8s.io/api v0.33.1 + k8s.io/apiextensions-apiserver v0.33.1 + k8s.io/apimachinery v0.33.1 + k8s.io/client-go v0.33.1 + k8s.io/kubectl v0.33.1 + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 sigs.k8s.io/controller-runtime v0.19.0 sigs.k8s.io/gateway-api v1.2.0 sigs.k8s.io/yaml v1.4.0 ) require ( + cel.dev/expr v0.19.1 // indirect + dario.cat/mergo v1.0.1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect github.com/ajg/form v1.5.1 // indirect github.com/andybalholm/brotli v1.0.4 // indirect @@ -48,122 +61,151 @@ require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/containerd/containerd v1.7.27 // indirect + github.com/containerd/errdefs v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.0 // indirect - github.com/evanphx/json-patch v5.9.0+incompatible // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.17.0 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-errors/errors v1.4.2 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/cel-go v0.20.1 // indirect - github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.23.2 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect - github.com/gorilla/websocket v1.5.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/gosuri/uitable v0.0.4 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/gruntwork-io/go-commons v0.8.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.1 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hpcloud/tail v1.0.0 // indirect - github.com/huandu/xstrings v1.4.0 // indirect - github.com/imdario/mergo v0.3.16 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/imkira/go-interpol v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 // indirect github.com/miekg/dns v1.1.62 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/moby/spdystream v0.4.0 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pquerna/otp v1.2.0 // indirect - github.com/prometheus/client_golang v1.19.1 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sanity-io/litter v1.5.5 // indirect github.com/sergi/go-diff v1.3.1 // indirect - github.com/shopspring/decimal v1.3.1 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect github.com/urfave/cli v1.22.14 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.34.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xlab/treeprint v1.2.0 // indirect github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect github.com/yudai/gojsondiff v1.0.0 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect - go.opentelemetry.io/otel v1.28.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect - go.opentelemetry.io/otel/metric v1.28.0 // indirect - go.opentelemetry.io/otel/sdk v1.28.0 // indirect - go.opentelemetry.io/otel/trace v1.28.0 // indirect - go.opentelemetry.io/proto/otlp v1.3.1 // indirect - go.uber.org/multierr v1.11.0 // indirect + go.etcd.io/etcd/api/v3 v3.5.21 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect + go.opentelemetry.io/otel v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/otel/sdk v1.33.0 // indirect + go.opentelemetry.io/otel/trace v1.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect golang.org/x/arch v0.6.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/mod v0.20.0 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.24.0 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.33.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect - google.golang.org/grpc v1.66.2 // indirect - google.golang.org/protobuf v1.34.2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/grpc v1.68.1 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/apiserver v0.31.1 // indirect - k8s.io/component-base v0.31.1 // indirect + k8s.io/apiserver v0.33.1 // indirect + k8s.io/cli-runtime v0.33.1 // indirect + k8s.io/component-base v0.33.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect moul.io/http2curl/v2 v2.3.0 // indirect - sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/kustomize/api v0.19.0 // indirect + sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect ) diff --git a/go.sum b/go.sum index 2be83ffae..bdff91fa3 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,29 @@ +cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= +cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= -github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= github.com/agiledragon/gomonkey/v2 v2.10.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= @@ -31,28 +48,62 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/cch123/supermonkey v1.0.1 h1:sPNQhaqMpfpERGb1oNoPcYV5tGln72SLlG2q2ozpzqg= github.com/cch123/supermonkey v1.0.1/go.mod h1:d5jXTCyG6nu/pu0vYmoC0P/l0eBGesv3oQQ315uNBOA= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII= +github.com/containerd/containerd v1.7.27/go.mod h1:xZmPnl75Vc+BLGt4MIfu6bp+fy03gdHAn9bz+FreFR0= +github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= +github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= +github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= -github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= @@ -60,6 +111,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -73,6 +126,8 @@ github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -90,44 +145,54 @@ github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= -github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= -github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84= -github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= +github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= github.com/gruntwork-io/go-commons v0.8.0 h1:k/yypwrPqSeYHevLlEDmvmgQzcyTwrlZGRaxEM6G0ro= github.com/gruntwork-io/go-commons v0.8.0/go.mod h1:gtp0yTtIBExIZp7vyIV9I0XQkVwiQZze678hvDXof78= github.com/gruntwork-io/terratest v0.47.0 h1:xIy1pT7NbGVlMLDZEHl3+3iSnvffh8tN2pL6idn448c= @@ -149,16 +214,16 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= +github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= +github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= -github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -169,6 +234,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -177,8 +244,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -189,7 +256,17 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -201,39 +278,53 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 h1:ofNAzWCcyTALn2Zv40+8XitdzCgXY6e9qvXwN9W0YXg= github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/spdystream v0.4.0 h1:Vy79D6mHeJJjiPdFEL2yku1kl0chZpJfZcPpb16BRl8= -github.com/moby/spdystream v0.4.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo/v2 v2.20.0 h1:PE84V2mHqoT1sglvHc8ZdQtPcwmvvt29WLEEO3xmdZw= -github.com/onsi/ginkgo/v2 v2.20.0/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -243,20 +334,30 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/pquerna/otp v1.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok= github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o= +github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -267,39 +368,41 @@ github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sony/sonyflake v1.1.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= -github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= @@ -320,6 +423,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= @@ -331,22 +436,58 @@ github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= -go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= -go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ= -go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= -go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= -go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= -go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= -go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= -go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8= +go.etcd.io/etcd/api/v3 v3.5.21/go.mod h1:c3aH5wcvXv/9dqIw2Y810LDXJfhSYdHQ0vxmP3CCHVY= +go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoBqUTc= +go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs= +go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY= +go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= +go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= +go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= +go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= @@ -365,17 +506,16 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= -golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -386,17 +526,16 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -406,33 +545,33 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -440,24 +579,24 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU= -google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go.mod h1:OimBR/bc1wPO9iV4NC2bpyjy3VnAwZh5EBPQdtaE5oo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= -google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= -google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -474,45 +613,57 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= -k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= -k8s.io/apiextensions-apiserver v0.31.1 h1:L+hwULvXx+nvTYX/MKM3kKMZyei+UiSXQWciX/N6E40= -k8s.io/apiextensions-apiserver v0.31.1/go.mod h1:tWMPR3sgW+jsl2xm9v7lAyRF1rYEK71i9G5dRtkknoQ= -k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= -k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c= -k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM= -k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= -k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= -k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8= -k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w= +helm.sh/helm/v3 v3.18.3 h1:+cvyGKgs7Jt7BN3Klmb4SsG4IkVpA7GAZVGvMz6VO4I= +helm.sh/helm/v3 v3.18.3/go.mod h1:wUc4n3txYBocM7S9RjTeZBN9T/b5MjffpcSsWEjSIpw= +k8s.io/api v0.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw= +k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= +k8s.io/apiextensions-apiserver v0.33.1 h1:N7ccbSlRN6I2QBcXevB73PixX2dQNIW0ZRuguEE91zI= +k8s.io/apiextensions-apiserver v0.33.1/go.mod h1:uNQ52z1A1Gu75QSa+pFK5bcXc4hq7lpOXbweZgi4dqA= +k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= +k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apiserver v0.33.1 h1:yLgLUPDVC6tHbNcw5uE9mo1T6ELhJj7B0geifra3Qdo= +k8s.io/apiserver v0.33.1/go.mod h1:VMbE4ArWYLO01omz+k8hFjAdYfc3GVAYPrhP2tTKccs= +k8s.io/cli-runtime v0.33.1 h1:TvpjEtF71ViFmPeYMj1baZMJR4iWUEplklsUQ7D3quA= +k8s.io/cli-runtime v0.33.1/go.mod h1:9dz5Q4Uh8io4OWCLiEf/217DXwqNgiTS/IOuza99VZE= +k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4= +k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= +k8s.io/component-base v0.33.1 h1:EoJ0xA+wr77T+G8p6T3l4efT2oNwbqBVKR71E0tBIaI= +k8s.io/component-base v0.33.1/go.mod h1:guT/w/6piyPfTgq7gfvgetyXMIh10zuXA6cRRm3rDuY= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f h1:0LQagt0gDpKqvIkAMPaRGcXawNMouPECM1+F9BVxEaM= -k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f/go.mod h1:S9tOR0FxgyusSNR+MboCuiDpVWkAifZvaYI1Q2ubgro= -k8s.io/kubectl v0.30.3 h1:YIBBvMdTW0xcDpmrOBzcpUVsn+zOgjMYIu7kAq+yqiI= -k8s.io/kubectl v0.30.3/go.mod h1:IcR0I9RN2+zzTRUa1BzZCm4oM0NLOawE6RzlDvd1Fpo= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/kubectl v0.33.1 h1:OJUXa6FV5bap6iRy345ezEjU9dTLxqv1zFTVqmeHb6A= +k8s.io/kubectl v0.33.1/go.mod h1:Z07pGqXoP4NgITlPRrnmiM3qnoo1QrK1zjw85Aiz8J0= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs= moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 h1:2770sDpzrjjsAtVhSeUFseziht227YAWYHLGNM8QPwY= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q= sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4= sigs.k8s.io/gateway-api v1.2.0 h1:LrToiFwtqKTKZcZtoQPTuo3FxhrrhTgzQG0Te+YGSo8= sigs.k8s.io/gateway-api v1.2.0/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ= +sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o= +sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA= +sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/controller/config/config.go b/internal/controller/config/config.go index f1bfa118b..e2b0eaf0f 100644 --- a/internal/controller/config/config.go +++ b/internal/controller/config/config.go @@ -51,7 +51,7 @@ func NewDefaultConfig() *Config { LeaderElection: NewLeaderElection(), ExecADCTimeout: types.TimeDuration{Duration: 15 * time.Second}, ProviderConfig: ProviderConfig{ - Type: ProviderTypeAPISIX, + Type: ProviderTypeAPI7EE, SyncPeriod: types.TimeDuration{Duration: 1 * time.Second}, InitSyncDelay: types.TimeDuration{Duration: 20 * time.Minute}, }, @@ -123,6 +123,8 @@ func validateProvider(config ProviderConfig) error { return fmt.Errorf("sync_period must be greater than 0 for standalone provider") } return nil + case ProviderTypeAPI7EE: + return nil default: return fmt.Errorf("unsupported provider type: %s", config.Type) } diff --git a/internal/controller/config/types.go b/internal/controller/config/types.go index dfe1dd326..8669268f4 100644 --- a/internal/controller/config/types.go +++ b/internal/controller/config/types.go @@ -25,6 +25,7 @@ type ProviderType string const ( ProviderTypeStandalone ProviderType = "apisix-standalone" + ProviderTypeAPI7EE ProviderType = "api7ee" ProviderTypeAPISIX ProviderType = "apisix" ) diff --git a/internal/provider/adc/adc.go b/internal/provider/adc/adc.go index ae0a4513c..4a173b9c7 100644 --- a/internal/provider/adc/adc.go +++ b/internal/provider/adc/adc.go @@ -52,6 +52,7 @@ type BackendMode string const ( BackendModeAPISIXStandalone string = "apisix-standalone" + BackendModeAPI7EE string = "api7ee" BackendModeAPISIX string = "apisix" ) @@ -195,7 +196,7 @@ func (d *adcClient) Update(ctx context.Context, tctx *provider.TranslateContext, // This mode is full synchronization, // which only needs to be saved in cache // and triggered by a timer for synchronization - if d.BackendMode == BackendModeAPISIXStandalone || d.BackendMode == BackendModeAPISIX || apiv2.Is(obj) { + if d.BackendMode == BackendModeAPISIXStandalone || d.BackendMode == BackendModeAPISIX { return nil } @@ -267,6 +268,13 @@ func (d *adcClient) Delete(ctx context.Context, obj client.Object) error { }) } return nil + case BackendModeAPI7EE: + return d.sync(ctx, Task{ + Name: obj.GetName(), + Labels: labels, + ResourceTypes: resourceTypes, + configs: configs, + }) default: log.Errorw("unknown backend mode", zap.String("mode", d.BackendMode)) return errors.New("unknown backend mode: " + d.BackendMode) diff --git a/internal/provider/controlplane/controlplane.go b/internal/provider/controlplane/controlplane.go new file mode 100644 index 000000000..4a6e38f92 --- /dev/null +++ b/internal/provider/controlplane/controlplane.go @@ -0,0 +1,192 @@ +// 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 controlplane + +import ( + "context" + "fmt" + + "github.com/api7/gopkg/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/apache/apisix-ingress-controller/internal/controller/config" + "github.com/apache/apisix-ingress-controller/internal/provider" + "github.com/apache/apisix-ingress-controller/internal/provider/controlplane/translator" + "github.com/apache/apisix-ingress-controller/pkg/dashboard" +) + +type dashboardProvider struct { + translator *translator.Translator + c dashboard.Dashboard +} + +//nolint:unused +func NewDashboard() (provider.Provider, error) { + control, err := dashboard.NewClient() + if err != nil { + return nil, err + } + + if err := control.AddCluster(context.TODO(), &dashboard.ClusterOptions{ + Name: "default", + Labels: map[string]string{ + "k8s/controller-name": config.ControllerConfig.ControllerName, + }, + ControllerName: config.ControllerConfig.ControllerName, + SyncCache: true, + }); err != nil { + return nil, err + } + + return &dashboardProvider{ + translator: &translator.Translator{}, + c: control, + }, nil +} + +func (d *dashboardProvider) Update(ctx context.Context, tctx *provider.TranslateContext, obj client.Object) error { + var result *translator.TranslateResult + var err error + switch obj := obj.(type) { + case *gatewayv1.HTTPRoute: + result, err = d.translator.TranslateHTTPRoute(tctx, obj.DeepCopy()) + case *gatewayv1.Gateway: + result, err = d.translator.TranslateGateway(tctx, obj.DeepCopy()) + } + if err != nil { + return err + } + // TODO: support diff resources + name := "default" + for _, service := range result.Services { + if _, err := d.c.Cluster(name).Service().Update(ctx, service); err != nil { + return err + } + } + for _, route := range result.Routes { + if _, err := d.c.Cluster(name).Route().Update(ctx, route); err != nil { + return err + } + } + for _, ssl := range result.SSL { + // to avoid duplication + ssl.Snis = arrayUniqueElements(ssl.Snis, []string{}) + if len(ssl.Snis) == 1 && ssl.Snis[0] == "*" { + log.Warnf("wildcard hostname is not allowed in ssl object. Skipping SSL creation for %s: %s", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName()) + return nil + } + ssl.Snis = removeWildcard(ssl.Snis) + oldssl, err := d.c.Cluster(name).SSL().Get(ctx, ssl.Cert) + if err != nil || oldssl == nil { + if _, err := d.c.Cluster(name).SSL().Create(ctx, ssl); err != nil { + return fmt.Errorf("failed to create ssl for sni %+v: %w", ssl.Snis, err) + } + } else { + // array union is done to avoid host duplication + ssl.Snis = arrayUniqueElements(ssl.Snis, oldssl.Snis) + if _, err := d.c.Cluster(name).SSL().Update(ctx, ssl); err != nil { + return fmt.Errorf("failed to update ssl for sni %+v: %w", ssl.Snis, err) + } + } + } + return nil +} + +func removeWildcard(snis []string) []string { + newSni := make([]string, 0) + for _, sni := range snis { + if sni != "*" { + newSni = append(newSni, sni) + } + } + return newSni +} + +func arrayUniqueElements(arr1 []string, arr2 []string) []string { + // return a union of elements from both array + presentEle := make(map[string]bool) + newArr := make([]string, 0) + for _, ele := range arr1 { + if !presentEle[ele] { + presentEle[ele] = true + newArr = append(newArr, ele) + } + } + for _, ele := range arr2 { + if !presentEle[ele] { + presentEle[ele] = true + newArr = append(newArr, ele) + } + } + return newArr +} + +func (d *dashboardProvider) Delete(ctx context.Context, obj client.Object) error { + clusters := d.c.ListClusters() + kindLabel := dashboard.ListByKindLabelOptions{ + Kind: obj.GetObjectKind().GroupVersionKind().Kind, + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + } + for _, cluster := range clusters { + switch obj.(type) { + case *gatewayv1.Gateway: + ssls, _ := cluster.SSL().List(ctx, dashboard.ListOptions{ + From: dashboard.ListFromCache, + KindLabel: kindLabel, + }) + for _, ssl := range ssls { + if err := cluster.SSL().Delete(ctx, ssl); err != nil { + return err + } + } + case *gatewayv1.HTTPRoute: + routes, _ := cluster.Route().List(ctx, dashboard.ListOptions{ + From: dashboard.ListFromCache, + KindLabel: kindLabel, + }) + + for _, route := range routes { + if err := cluster.Route().Delete(ctx, route); err != nil { + return err + } + } + + services, _ := cluster.Service().List(ctx, dashboard.ListOptions{ + From: dashboard.ListFromCache, + KindLabel: kindLabel, + }) + + for _, service := range services { + if err := cluster.Service().Delete(ctx, service); err != nil { + return err + } + } + } + } + return nil +} + +func (d *dashboardProvider) Sync(ctx context.Context) error { + return nil +} + +func (d *dashboardProvider) Start(ctx context.Context) error { + return nil +} diff --git a/internal/provider/controlplane/manifest.go b/internal/provider/controlplane/manifest.go new file mode 100644 index 000000000..32a16b4f0 --- /dev/null +++ b/internal/provider/controlplane/manifest.go @@ -0,0 +1,18 @@ +// 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 controlplane diff --git a/internal/provider/controlplane/translator/gateway.go b/internal/provider/controlplane/translator/gateway.go new file mode 100644 index 000000000..bc1b3560e --- /dev/null +++ b/internal/provider/controlplane/translator/gateway.go @@ -0,0 +1,166 @@ +// 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 translator + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + + "github.com/api7/gopkg/pkg/log" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/internal/controller/label" + "github.com/apache/apisix-ingress-controller/internal/id" + "github.com/apache/apisix-ingress-controller/internal/provider" +) + +func (t *Translator) TranslateGateway(tctx *provider.TranslateContext, obj *gatewayv1.Gateway) (*TranslateResult, error) { + result := &TranslateResult{} + for _, listener := range obj.Spec.Listeners { + if listener.TLS != nil { + tctx.GatewayTLSConfig = append(tctx.GatewayTLSConfig, *listener.TLS) + ssl, err := t.translateSecret(tctx, listener, obj) + if err != nil { + return nil, fmt.Errorf("failed to translate secret: %w", err) + } + result.SSL = append(result.SSL, ssl...) + } + } + return result, nil +} + +func (t *Translator) translateSecret(tctx *provider.TranslateContext, listener gatewayv1.Listener, obj *gatewayv1.Gateway) ([]*v1.Ssl, error) { + if tctx.Secrets == nil { + return nil, nil + } + if listener.TLS.CertificateRefs == nil { + return nil, fmt.Errorf("no certificateRefs found in listener %s", listener.Name) + } + sslObjs := make([]*v1.Ssl, 0) + switch *listener.TLS.Mode { + case gatewayv1.TLSModeTerminate: + for _, ref := range listener.TLS.CertificateRefs { + ns := obj.GetNamespace() + if ref.Namespace != nil { + ns = string(*ref.Namespace) + } + if listener.TLS.CertificateRefs[0].Kind != nil && *listener.TLS.CertificateRefs[0].Kind == "Secret" { + sslObj := &v1.Ssl{ + Snis: []string{}, + } + name := listener.TLS.CertificateRefs[0].Name + secret := tctx.Secrets[types.NamespacedName{Namespace: ns, Name: string(ref.Name)}] + if secret == nil { + continue + } + if secret.Data == nil { + log.Error("secret data is nil", "secret", secret) + return nil, fmt.Errorf("no secret data found for %s/%s", ns, name) + } + cert, key, err := extractKeyPair(secret, true) + if err != nil { + return nil, err + } + sslObj.Cert = string(cert) + sslObj.Key = string(key) + // Dashboard doesn't allow wildcard hostname + if listener.Hostname != nil && *listener.Hostname != "" { + sslObj.Snis = append(sslObj.Snis, string(*listener.Hostname)) + } + hosts, err := extractHost(cert) + if err != nil { + return nil, err + } + sslObj.Snis = append(sslObj.Snis, hosts...) + // Note: Dashboard doesn't allow duplicate certificate across ssl objects + sslObj.ID = id.GenID(sslObj.Cert) + sslObj.Labels = label.GenLabel(obj) + sslObjs = append(sslObjs, sslObj) + } + + } + // Only supported on TLSRoute. The certificateRefs field is ignored in this mode. + case gatewayv1.TLSModePassthrough: + return sslObjs, nil + default: + return nil, fmt.Errorf("unknown TLS mode %s", *listener.TLS.Mode) + } + + return sslObjs, nil +} + +func extractHost(cert []byte) ([]string, error) { + block, _ := pem.Decode(cert) + if block == nil { + return nil, errors.New("parse certificate: not in PEM format") + } + der, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, errors.Wrap(err, "parse certificate") + } + return der.DNSNames, nil +} + +func extractKeyPair(s *corev1.Secret, hasPrivateKey bool) ([]byte, []byte, error) { + if _, ok := s.Data["cert"]; ok { + return extractApisixSecretKeyPair(s, hasPrivateKey) + } else if _, ok := s.Data[corev1.TLSCertKey]; ok { + return extractKubeSecretKeyPair(s, hasPrivateKey) + } else if ca, ok := s.Data[corev1.ServiceAccountRootCAKey]; ok && !hasPrivateKey { + return ca, nil, nil + } else { + return nil, nil, errors.New("unknown secret format") + } +} + +func extractApisixSecretKeyPair(s *corev1.Secret, hasPrivateKey bool) (cert []byte, key []byte, err error) { + var ok bool + cert, ok = s.Data["cert"] + if !ok { + return nil, nil, errors.New("missing cert field") + } + + if hasPrivateKey { + key, ok = s.Data["key"] + if !ok { + return nil, nil, errors.New("missing key field") + } + } + return +} + +func extractKubeSecretKeyPair(s *corev1.Secret, hasPrivateKey bool) (cert []byte, key []byte, err error) { + var ok bool + cert, ok = s.Data[corev1.TLSCertKey] + if !ok { + return nil, nil, errors.New("missing cert field") + } + + if hasPrivateKey { + key, ok = s.Data[corev1.TLSPrivateKeyKey] + if !ok { + return nil, nil, errors.New("missing key field") + } + } + return +} diff --git a/internal/provider/controlplane/translator/httproute.go b/internal/provider/controlplane/translator/httproute.go new file mode 100644 index 000000000..9127b09ca --- /dev/null +++ b/internal/provider/controlplane/translator/httproute.go @@ -0,0 +1,474 @@ +// 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 translator + +import ( + "fmt" + "strings" + + "github.com/api7/gopkg/pkg/log" + "github.com/pkg/errors" + "go.uber.org/zap" + discoveryv1 "k8s.io/api/discovery/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/internal/controller/label" + "github.com/apache/apisix-ingress-controller/internal/id" + "github.com/apache/apisix-ingress-controller/internal/provider" +) + +func (t *Translator) fillPluginsFromHTTPRouteFilters( + plugins v1.Plugins, + namespace string, + filters []gatewayv1.HTTPRouteFilter, + matches []gatewayv1.HTTPRouteMatch, + tctx *provider.TranslateContext, +) { + for _, filter := range filters { + switch filter.Type { + case gatewayv1.HTTPRouteFilterRequestHeaderModifier: + t.fillPluginFromHTTPRequestHeaderFilter(plugins, filter.RequestHeaderModifier) + case gatewayv1.HTTPRouteFilterRequestRedirect: + t.fillPluginFromHTTPRequestRedirectFilter(plugins, filter.RequestRedirect) + case gatewayv1.HTTPRouteFilterRequestMirror: + t.fillPluginFromHTTPRequestMirrorFilter(plugins, namespace, filter.RequestMirror) + case gatewayv1.HTTPRouteFilterURLRewrite: + t.fillPluginFromURLRewriteFilter(plugins, filter.URLRewrite, matches) + case gatewayv1.HTTPRouteFilterResponseHeaderModifier: + t.fillPluginFromHTTPResponseHeaderFilter(plugins, filter.ResponseHeaderModifier) + case gatewayv1.HTTPRouteFilterExtensionRef: + t.fillPluginFromExtensionRef(plugins, namespace, filter.ExtensionRef, tctx) + } + } +} + +func (t *Translator) fillPluginFromExtensionRef(plugins v1.Plugins, namespace string, extensionRef *gatewayv1.LocalObjectReference, tctx *provider.TranslateContext) { + if extensionRef == nil { + return + } + if extensionRef.Kind == "PluginConfig" { + pluginconfig := tctx.PluginConfigs[types.NamespacedName{ + Namespace: namespace, + Name: string(extensionRef.Name), + }] + for _, plugin := range pluginconfig.Spec.Plugins { + pluginName := plugin.Name + plugins[pluginName] = plugin.Config + log.Errorw("plugin config", zap.String("namespace", namespace), zap.Any("plugin_config", plugin)) + } + log.Errorw("plugin config", zap.String("namespace", namespace), zap.Any("plugins", plugins)) + } +} + +func (t *Translator) fillPluginFromURLRewriteFilter(plugins v1.Plugins, urlRewrite *gatewayv1.HTTPURLRewriteFilter, matches []gatewayv1.HTTPRouteMatch) { + pluginName := v1.PluginProxyRewrite + obj := plugins[pluginName] + var plugin *v1.RewriteConfig + if obj == nil { + plugin = &v1.RewriteConfig{} + plugins[pluginName] = plugin + } else { + plugin = obj.(*v1.RewriteConfig) + } + if urlRewrite.Hostname != nil { + plugin.Host = string(*urlRewrite.Hostname) + } + + if urlRewrite.Path != nil { + switch urlRewrite.Path.Type { + case gatewayv1.FullPathHTTPPathModifier: + plugin.RewriteTarget = *urlRewrite.Path.ReplaceFullPath + case gatewayv1.PrefixMatchHTTPPathModifier: + prefixPaths := make([]string, 0, len(matches)) + for _, match := range matches { + if match.Path == nil || match.Path.Type == nil || *match.Path.Type != gatewayv1.PathMatchPathPrefix { + continue + } + prefixPaths = append(prefixPaths, *match.Path.Value) + } + regexPattern := "^(" + strings.Join(prefixPaths, "|") + ")" + "/(.*)" + replaceTarget := *urlRewrite.Path.ReplacePrefixMatch + regexTarget := replaceTarget + "/$2" + + plugin.RewriteTargetRegex = []string{ + regexPattern, + regexTarget, + } + } + } +} + +func (t *Translator) fillPluginFromHTTPRequestHeaderFilter(plugins v1.Plugins, reqHeaderModifier *gatewayv1.HTTPHeaderFilter) { + pluginName := v1.PluginProxyRewrite + obj := plugins[pluginName] + var plugin *v1.RewriteConfig + if obj == nil { + plugin = &v1.RewriteConfig{ + Headers: &v1.Headers{ + Add: make(map[string]string, len(reqHeaderModifier.Add)), + Set: make(map[string]string, len(reqHeaderModifier.Set)), + Remove: make([]string, 0, len(reqHeaderModifier.Remove)), + }, + } + plugins[pluginName] = plugin + } else { + plugin = obj.(*v1.RewriteConfig) + } + for _, header := range reqHeaderModifier.Add { + val := plugin.Headers.Add[string(header.Name)] + if val != "" { + val += ", " + header.Value + } else { + val = header.Value + } + plugin.Headers.Add[string(header.Name)] = val + } + for _, header := range reqHeaderModifier.Set { + plugin.Headers.Set[string(header.Name)] = header.Value + } + plugin.Headers.Remove = append(plugin.Headers.Remove, reqHeaderModifier.Remove...) +} + +func (t *Translator) fillPluginFromHTTPResponseHeaderFilter(plugins v1.Plugins, respHeaderModifier *gatewayv1.HTTPHeaderFilter) { + pluginName := v1.PluginResponseRewrite + obj := plugins[pluginName] + var plugin *v1.ResponseRewriteConfig + if obj == nil { + plugin = &v1.ResponseRewriteConfig{ + Headers: &v1.ResponseHeaders{ + Add: make([]string, 0, len(respHeaderModifier.Add)), + Set: make(map[string]string, len(respHeaderModifier.Set)), + Remove: make([]string, 0, len(respHeaderModifier.Remove)), + }, + } + plugins[pluginName] = plugin + } else { + plugin = obj.(*v1.ResponseRewriteConfig) + } + for _, header := range respHeaderModifier.Add { + plugin.Headers.Add = append(plugin.Headers.Add, fmt.Sprintf("%s: %s", header.Name, header.Value)) + } + for _, header := range respHeaderModifier.Set { + plugin.Headers.Set[string(header.Name)] = header.Value + } + plugin.Headers.Remove = append(plugin.Headers.Remove, respHeaderModifier.Remove...) +} + +func (t *Translator) fillPluginFromHTTPRequestMirrorFilter(plugins v1.Plugins, namespace string, reqMirror *gatewayv1.HTTPRequestMirrorFilter) { + pluginName := v1.PluginProxyMirror + obj := plugins[pluginName] + + var plugin *v1.RequestMirror + if obj == nil { + plugin = &v1.RequestMirror{} + plugins[pluginName] = plugin + } else { + plugin = obj.(*v1.RequestMirror) + } + + var ( + port = 80 + ns = namespace + ) + if reqMirror.BackendRef.Port != nil { + port = int(*reqMirror.BackendRef.Port) + } + if reqMirror.BackendRef.Namespace != nil { + ns = string(*reqMirror.BackendRef.Namespace) + } + + host := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", reqMirror.BackendRef.Name, ns, port) + + plugin.Host = host +} + +func (t *Translator) fillPluginFromHTTPRequestRedirectFilter(plugins v1.Plugins, reqRedirect *gatewayv1.HTTPRequestRedirectFilter) { + pluginName := v1.PluginRedirect + obj := plugins[pluginName] + + var plugin *v1.RedirectConfig + if obj == nil { + plugin = &v1.RedirectConfig{} + plugins[pluginName] = plugin + } else { + plugin = obj.(*v1.RedirectConfig) + } + var uri string + + code := 302 + if reqRedirect.StatusCode != nil { + code = *reqRedirect.StatusCode + } + + hostname := "$host" + if reqRedirect.Hostname != nil { + hostname = string(*reqRedirect.Hostname) + } + + scheme := "$scheme" + if reqRedirect.Scheme != nil { + scheme = *reqRedirect.Scheme + } + + if reqRedirect.Port != nil { + uri = fmt.Sprintf("%s://%s:%d$request_uri", scheme, hostname, int(*reqRedirect.Port)) + } else { + uri = fmt.Sprintf("%s://%s$request_uri", scheme, hostname) + } + plugin.RetCode = code + plugin.URI = uri +} + +func (t *Translator) translateEndpointSlice(endpointSlices []discoveryv1.EndpointSlice) v1.UpstreamNodes { + var nodes v1.UpstreamNodes + if len(endpointSlices) == 0 { + return nodes + } + for _, endpointSlice := range endpointSlices { + for _, port := range endpointSlice.Ports { + for _, endpoint := range endpointSlice.Endpoints { + for _, addr := range endpoint.Addresses { + node := v1.UpstreamNode{ + Host: addr, + Port: int(*port.Port), + Weight: 1, + } + nodes = append(nodes, node) + } + } + } + } + + return nodes +} + +func (t *Translator) translateBackendRef(tctx *provider.TranslateContext, ref gatewayv1.BackendRef) *v1.Upstream { + upstream := v1.NewDefaultUpstream() + endpointSlices := tctx.EndpointSlices[types.NamespacedName{ + Namespace: string(*ref.Namespace), + Name: string(ref.Name), + }] + + upstream.Nodes = t.translateEndpointSlice(endpointSlices) + return upstream +} + +func (t *Translator) TranslateHTTPRoute(tctx *provider.TranslateContext, httpRoute *gatewayv1.HTTPRoute) (*TranslateResult, error) { + result := &TranslateResult{} + + hosts := make([]string, 0, len(httpRoute.Spec.Hostnames)) + for _, hostname := range httpRoute.Spec.Hostnames { + hosts = append(hosts, string(hostname)) + } + + rules := httpRoute.Spec.Rules + + for i, rule := range rules { + + var weightedUpstreams []v1.TrafficSplitConfigRuleWeightedUpstream + upstreams := []*v1.Upstream{} + for _, backend := range rule.BackendRefs { + if backend.Namespace == nil { + namespace := gatewayv1.Namespace(httpRoute.Namespace) + backend.Namespace = &namespace + } + upstream := t.translateBackendRef(tctx, backend.BackendRef) + upstream.Labels["name"] = string(backend.Name) + upstream.Labels["namespace"] = string(*backend.Namespace) + upstreams = append(upstreams, upstream) + if len(upstream.Nodes) == 0 { + upstream.Nodes = v1.UpstreamNodes{ + { + Host: "0.0.0.0", + Port: 80, + Weight: 100, + }, + } + } + + weight := 100 + if backend.Weight != nil { + weight = int(*backend.Weight) + } + weightedUpstreams = append(weightedUpstreams, v1.TrafficSplitConfigRuleWeightedUpstream{ + Upstream: upstream, + Weight: weight, + }) + } + + if len(upstreams) == 0 { + upstream := v1.NewDefaultUpstream() + upstream.Nodes = v1.UpstreamNodes{ + { + Host: "0.0.0.0", + Port: 80, + Weight: 100, + }, + } + upstreams = append(upstreams, upstream) + } + + service := v1.NewDefaultService() + service.Upstream = upstreams[0] + if len(weightedUpstreams) > 1 { + weightedUpstreams[0].Upstream = nil + service.Plugins["traffic-split"] = &v1.TrafficSplitConfig{ + Rules: []v1.TrafficSplitConfigRule{ + { + WeightedUpstreams: weightedUpstreams, + }, + }, + } + } + + service.Name = v1.ComposeServiceNameWithRule(httpRoute.Namespace, httpRoute.Name, fmt.Sprintf("%d", i)) + service.ID = id.GenID(service.Name) + service.Labels = label.GenLabel(httpRoute) + service.Hosts = hosts + t.fillPluginsFromHTTPRouteFilters(service.Plugins, httpRoute.GetNamespace(), rule.Filters, rule.Matches, tctx) + + result.Services = append(result.Services, service) + + matches := rule.Matches + if len(matches) == 0 { + defaultType := gatewayv1.PathMatchPathPrefix + defaultValue := "/" + matches = []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: &defaultType, + Value: &defaultValue, + }, + }, + } + } + + for j, match := range matches { + route, err := t.translateGatewayHTTPRouteMatch(&match) + if err != nil { + return nil, err + } + + name := v1.ComposeRouteName(httpRoute.Namespace, httpRoute.Name, fmt.Sprintf("%d-%d", i, j)) + route.Name = name + route.ID = id.GenID(name) + route.Labels = label.GenLabel(httpRoute) + route.ServiceID = service.ID + result.Routes = append(result.Routes, route) + } + } + + return result, nil +} + +func (t *Translator) translateGatewayHTTPRouteMatch(match *gatewayv1.HTTPRouteMatch) (*v1.Route, error) { + route := v1.NewDefaultRoute() + + if match.Path != nil { + switch *match.Path.Type { + case gatewayv1.PathMatchExact: + route.Paths = []string{*match.Path.Value} + case gatewayv1.PathMatchPathPrefix: + route.Paths = []string{*match.Path.Value + "*"} + case gatewayv1.PathMatchRegularExpression: + var this []v1.StringOrSlice + this = append(this, v1.StringOrSlice{ + StrVal: "uri", + }) + this = append(this, v1.StringOrSlice{ + StrVal: "~~", + }) + this = append(this, v1.StringOrSlice{ + StrVal: *match.Path.Value, + }) + + route.Vars = append(route.Vars, this) + default: + return nil, errors.New("unknown path match type " + string(*match.Path.Type)) + } + } + + if len(match.Headers) > 0 { + for _, header := range match.Headers { + name := strings.ToLower(string(header.Name)) + name = strings.ReplaceAll(name, "-", "_") + + var this []v1.StringOrSlice + this = append(this, v1.StringOrSlice{ + StrVal: "http_" + name, + }) + + switch *header.Type { + case gatewayv1.HeaderMatchExact: + this = append(this, v1.StringOrSlice{ + StrVal: "==", + }) + case gatewayv1.HeaderMatchRegularExpression: + this = append(this, v1.StringOrSlice{ + StrVal: "~~", + }) + default: + return nil, errors.New("unknown header match type " + string(*header.Type)) + } + + this = append(this, v1.StringOrSlice{ + StrVal: header.Value, + }) + + route.Vars = append(route.Vars, this) + } + } + + if len(match.QueryParams) > 0 { + for _, query := range match.QueryParams { + var this []v1.StringOrSlice + this = append(this, v1.StringOrSlice{ + StrVal: "arg_" + strings.ToLower(fmt.Sprintf("%v", query.Name)), + }) + + switch *query.Type { + case gatewayv1.QueryParamMatchExact: + this = append(this, v1.StringOrSlice{ + StrVal: "==", + }) + case gatewayv1.QueryParamMatchRegularExpression: + this = append(this, v1.StringOrSlice{ + StrVal: "~~", + }) + default: + return nil, errors.New("unknown query match type " + string(*query.Type)) + } + + this = append(this, v1.StringOrSlice{ + StrVal: query.Value, + }) + + route.Vars = append(route.Vars, this) + } + } + + if match.Method != nil { + route.Methods = []string{ + string(*match.Method), + } + } + + return route, nil +} diff --git a/internal/provider/controlplane/translator/translator.go b/internal/provider/controlplane/translator/translator.go new file mode 100644 index 000000000..8a817ccda --- /dev/null +++ b/internal/provider/controlplane/translator/translator.go @@ -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. + +package translator + +import ( + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + discoveryv1 "k8s.io/api/discovery/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/api/v1alpha1" +) + +type Translator struct { + Log logr.Logger +} + +type TranslateContext struct { + BackendRefs []gatewayv1.BackendRef + GatewayTLSConfig []gatewayv1.GatewayTLSConfig + EndpointSlices map[types.NamespacedName][]discoveryv1.EndpointSlice + Secrets map[types.NamespacedName]*corev1.Secret + PluginConfigs map[types.NamespacedName]*v1alpha1.PluginConfig +} + +type TranslateResult struct { + Routes []*v1.Route + Services []*v1.Service + SSL []*v1.Ssl +} + +func NewDefaultTranslateContext() *TranslateContext { + return &TranslateContext{ + EndpointSlices: make(map[types.NamespacedName][]discoveryv1.EndpointSlice), + Secrets: make(map[types.NamespacedName]*corev1.Secret), + PluginConfigs: make(map[types.NamespacedName]*v1alpha1.PluginConfig), + } +} diff --git a/pkg/dashboard/cache/cache.go b/pkg/dashboard/cache/cache.go new file mode 100644 index 000000000..7319a2e18 --- /dev/null +++ b/pkg/dashboard/cache/cache.go @@ -0,0 +1,97 @@ +// 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 cache + +import v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + +// Cache defines the necessary behaviors that the cache object should have. +// Note this interface is for APISIX, not for generic purpose, it supports +// standard APISIX resources, i.e. Route, Upstream, and SSL. +// Cache implementations should copy the target objects before/after read/write +// operations for the sake of avoiding data corrupted by other writers. +type Cache interface { + // InsertRoute adds or updates route to cache. + InsertRoute(*v1.Route) error + // InsertStreamRoute adds or updates stream_route to cache. + InsertStreamRoute(*v1.StreamRoute) error + // InsertSSL adds or updates ssl to cache. + InsertSSL(*v1.Ssl) error + // InsertUpstream adds or updates upstream to cache. + InsertService(*v1.Service) error + // InsertGlobalRule adds or updates global_rule to cache. + InsertGlobalRule(*v1.GlobalRule) error + // InsertConsumer adds or updates consumer to cache. + InsertConsumer(*v1.Consumer) error + // InsertSchema adds or updates schema to cache. + InsertSchema(*v1.Schema) error + // InsertPluginConfig adds or updates plugin_config to cache. + InsertPluginConfig(*v1.PluginConfig) error + + // GetRoute finds the route from cache according to the primary index (id). + GetRoute(string) (*v1.Route, error) + GetStreamRoute(string) (*v1.StreamRoute, error) + // GetSSL finds the ssl from cache according to the primary index (id). + GetSSL(string) (*v1.Ssl, error) + // GetUpstream finds the upstream from cache according to the primary index (id). + GetService(string) (*v1.Service, error) + // GetGlobalRule finds the global_rule from cache according to the primary index (id). + GetGlobalRule(string) (*v1.GlobalRule, error) + // GetConsumer finds the consumer from cache according to the primary index (username). + GetConsumer(string) (*v1.Consumer, error) + // GetSchema finds the scheme from cache according to the primary index (name). + GetSchema(string) (*v1.Schema, error) + // GetPluginConfig finds the plugin_config from cache according to the primary index (id). + GetPluginConfig(string) (*v1.PluginConfig, error) + + // ListRoutes lists all routes in cache. + ListRoutes(...any) ([]*v1.Route, error) + // ListStreamRoutes lists all stream_route objects in cache. + ListStreamRoutes() ([]*v1.StreamRoute, error) + // ListSSL lists all ssl objects in cache. + ListSSL(...any) ([]*v1.Ssl, error) + // ListUpstreams lists all upstreams in cache. + ListServices(...any) ([]*v1.Service, error) + // ListGlobalRules lists all global_rule objects in cache. + ListGlobalRules() ([]*v1.GlobalRule, error) + // ListConsumers lists all consumer objects in cache. + ListConsumers() ([]*v1.Consumer, error) + // ListSchema lists all schema in cache. + ListSchema() ([]*v1.Schema, error) + // ListPluginConfigs lists all plugin_config in cache. + ListPluginConfigs() ([]*v1.PluginConfig, error) + + // DeleteRoute deletes the specified route in cache. + DeleteRoute(*v1.Route) error + // DeleteStreamRoute deletes the specified stream_route in cache. + DeleteStreamRoute(*v1.StreamRoute) error + // DeleteSSL deletes the specified ssl in cache. + DeleteSSL(*v1.Ssl) error + // DeleteUpstream deletes the specified upstream in cache. + DeleteService(*v1.Service) error + // DeleteGlobalRule deletes the specified stream_route in cache. + DeleteGlobalRule(*v1.GlobalRule) error + // DeleteConsumer deletes the specified consumer in cache. + DeleteConsumer(*v1.Consumer) error + // DeleteSchema deletes the specified schema in cache. + DeleteSchema(*v1.Schema) error + // DeletePluginConfig deletes the specified plugin_config in cache. + DeletePluginConfig(*v1.PluginConfig) error + + CheckServiceReference(*v1.Service) error + CheckPluginConfigReference(*v1.PluginConfig) error +} diff --git a/pkg/dashboard/cache/memdb.go b/pkg/dashboard/cache/memdb.go new file mode 100644 index 000000000..403b7aa28 --- /dev/null +++ b/pkg/dashboard/cache/memdb.go @@ -0,0 +1,368 @@ +// 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 cache + +import ( + "errors" + + "github.com/hashicorp/go-memdb" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +var ( + // ErrStillInUse means an object is still in use. + ErrStillInUse = errors.New("still in use") + // ErrNotFound is returned when the requested item is not found. + ErrNotFound = memdb.ErrNotFound +) + +type dbCache struct { + db *memdb.MemDB +} + +// NewMemDBCache creates a Cache object backs with a memory DB. +func NewMemDBCache() (Cache, error) { + db, err := memdb.NewMemDB(_schema) + if err != nil { + return nil, err + } + return &dbCache{ + db: db, + }, nil +} + +func (c *dbCache) InsertRoute(r *v1.Route) error { + route := r.DeepCopy() + return c.insert("route", route) +} + +func (c *dbCache) InsertSSL(ssl *v1.Ssl) error { + return c.insert("ssl", ssl.DeepCopy()) +} + +func (c *dbCache) InsertService(u *v1.Service) error { + return c.insert("service", u.DeepCopy()) +} + +func (c *dbCache) InsertGlobalRule(gr *v1.GlobalRule) error { + return c.insert("global_rule", gr.DeepCopy()) +} + +func (c *dbCache) InsertConsumer(consumer *v1.Consumer) error { + return c.insert("consumer", consumer.DeepCopy()) +} +func (c *dbCache) InsertStreamRoute(sr *v1.StreamRoute) error { + return c.insert("stream_route", sr.DeepCopy()) +} + +func (c *dbCache) InsertSchema(schema *v1.Schema) error { + return c.insert("schema", schema.DeepCopy()) +} + +func (c *dbCache) InsertPluginConfig(pc *v1.PluginConfig) error { + return c.insert("plugin_config", pc.DeepCopy()) +} + +func (c *dbCache) insert(table string, obj any) error { + txn := c.db.Txn(true) + defer txn.Abort() + if err := txn.Insert(table, obj); err != nil { + return err + } + txn.Commit() + return nil +} + +func (c *dbCache) GetRoute(id string) (*v1.Route, error) { + obj, err := c.get("route", id) + if err != nil { + return nil, err + } + return obj.(*v1.Route).DeepCopy(), nil +} + +func (c *dbCache) GetSSL(id string) (*v1.Ssl, error) { + obj, err := c.get("ssl", id) + if err != nil { + return nil, err + } + return obj.(*v1.Ssl).DeepCopy(), nil +} + +func (c *dbCache) GetService(id string) (*v1.Service, error) { + obj, err := c.get("service", id) + if err != nil { + return nil, err + } + return obj.(*v1.Service).DeepCopy(), nil +} + +func (c *dbCache) GetGlobalRule(id string) (*v1.GlobalRule, error) { + obj, err := c.get("global_rule", id) + if err != nil { + return nil, err + } + return obj.(*v1.GlobalRule).DeepCopy(), nil +} + +func (c *dbCache) GetConsumer(username string) (*v1.Consumer, error) { + obj, err := c.get("consumer", username) + if err != nil { + return nil, err + } + return obj.(*v1.Consumer).DeepCopy(), nil +} + +func (c *dbCache) GetStreamRoute(id string) (*v1.StreamRoute, error) { + obj, err := c.get("stream_route", id) + if err != nil { + return nil, err + } + return obj.(*v1.StreamRoute).DeepCopy(), nil +} + +func (c *dbCache) GetSchema(name string) (*v1.Schema, error) { + obj, err := c.get("schema", name) + if err != nil { + return nil, err + } + return obj.(*v1.Schema).DeepCopy(), nil +} + +func (c *dbCache) GetPluginConfig(name string) (*v1.PluginConfig, error) { + obj, err := c.get("plugin_config", name) + if err != nil { + return nil, err + } + return obj.(*v1.PluginConfig).DeepCopy(), nil +} + +func (c *dbCache) get(table, id string) (any, error) { + txn := c.db.Txn(false) + defer txn.Abort() + obj, err := txn.First(table, "id", id) + if err != nil { + if err == memdb.ErrNotFound { + return nil, ErrNotFound + } + return nil, err + } + if obj == nil { + return nil, ErrNotFound + } + return obj, nil +} + +func (c *dbCache) ListRoutes(args ...any) ([]*v1.Route, error) { + raws, err := c.list("route", args...) + if err != nil { + return nil, err + } + routes := make([]*v1.Route, 0, len(raws)) + for _, raw := range raws { + routes = append(routes, raw.(*v1.Route).DeepCopy()) + } + return routes, nil +} + +func (c *dbCache) ListSSL(args ...any) ([]*v1.Ssl, error) { + raws, err := c.list("ssl", args...) + if err != nil { + return nil, err + } + ssl := make([]*v1.Ssl, 0, len(raws)) + for _, raw := range raws { + ssl = append(ssl, raw.(*v1.Ssl).DeepCopy()) + } + return ssl, nil +} + +func (c *dbCache) ListServices(args ...any) ([]*v1.Service, error) { + raws, err := c.list("service", args...) + if err != nil { + return nil, err + } + services := make([]*v1.Service, 0, len(raws)) + for _, raw := range raws { + services = append(services, raw.(*v1.Service).DeepCopy()) + } + return services, nil +} + +func (c *dbCache) ListGlobalRules() ([]*v1.GlobalRule, error) { + raws, err := c.list("global_rule") + if err != nil { + return nil, err + } + globalRules := make([]*v1.GlobalRule, 0, len(raws)) + for _, raw := range raws { + globalRules = append(globalRules, raw.(*v1.GlobalRule).DeepCopy()) + } + return globalRules, nil +} + +func (c *dbCache) ListStreamRoutes() ([]*v1.StreamRoute, error) { + raws, err := c.list("stream_route") + if err != nil { + return nil, err + } + streamRoutes := make([]*v1.StreamRoute, 0, len(raws)) + for _, raw := range raws { + streamRoutes = append(streamRoutes, raw.(*v1.StreamRoute).DeepCopy()) + } + return streamRoutes, nil +} + +func (c *dbCache) ListConsumers() ([]*v1.Consumer, error) { + raws, err := c.list("consumer") + if err != nil { + return nil, err + } + consumers := make([]*v1.Consumer, 0, len(raws)) + for _, raw := range raws { + consumers = append(consumers, raw.(*v1.Consumer).DeepCopy()) + } + return consumers, nil +} + +func (c *dbCache) ListSchema() ([]*v1.Schema, error) { + raws, err := c.list("schema") + if err != nil { + return nil, err + } + schemaList := make([]*v1.Schema, 0, len(raws)) + for _, raw := range raws { + schemaList = append(schemaList, raw.(*v1.Schema).DeepCopy()) + } + return schemaList, nil +} + +func (c *dbCache) ListPluginConfigs() ([]*v1.PluginConfig, error) { + raws, err := c.list("plugin_config") + if err != nil { + return nil, err + } + pluginConfigs := make([]*v1.PluginConfig, 0, len(raws)) + for _, raw := range raws { + pluginConfigs = append(pluginConfigs, raw.(*v1.PluginConfig).DeepCopy()) + } + return pluginConfigs, nil +} + +func (c *dbCache) list(table string, args ...any) ([]any, error) { + txn := c.db.Txn(false) + defer txn.Abort() + index := "id" + if len(args) > 0 { + idx, ok := args[0].(string) + if !ok { + return nil, errors.New("unexpected index type") + } + index = idx + args = args[1:] + } + iter, err := txn.Get(table, index, args...) + if err != nil { + return nil, err + } + var objs []any + for obj := iter.Next(); obj != nil; obj = iter.Next() { + objs = append(objs, obj) + } + return objs, nil +} + +func (c *dbCache) DeleteRoute(r *v1.Route) error { + return c.delete("route", r) +} + +func (c *dbCache) DeleteSSL(ssl *v1.Ssl) error { + return c.delete("ssl", ssl) +} + +func (c *dbCache) DeleteService(u *v1.Service) error { + if err := c.CheckServiceReference(u); err != nil { + return err + } + return c.delete("service", u) +} + +func (c *dbCache) DeleteStreamRoute(sr *v1.StreamRoute) error { + return c.delete("stream_route", sr) +} + +func (c *dbCache) DeleteGlobalRule(gr *v1.GlobalRule) error { + return c.delete("global_rule", gr) +} + +func (c *dbCache) DeleteConsumer(consumer *v1.Consumer) error { + return c.delete("consumer", consumer) +} + +func (c *dbCache) DeleteSchema(schema *v1.Schema) error { + return c.delete("schema", schema) +} + +func (c *dbCache) DeletePluginConfig(pc *v1.PluginConfig) error { + if err := c.CheckPluginConfigReference(pc); err != nil { + return err + } + return c.delete("plugin_config", pc) +} + +func (c *dbCache) delete(table string, obj any) error { + txn := c.db.Txn(true) + defer txn.Abort() + if err := txn.Delete(table, obj); err != nil { + if err == memdb.ErrNotFound { + return ErrNotFound + } + return err + } + txn.Commit() + return nil +} + +func (c *dbCache) CheckServiceReference(u *v1.Service) error { + // Upstream is referenced by Route. + txn := c.db.Txn(false) + defer txn.Abort() + obj, err := txn.First("route", "service_id", u.ID) + if err != nil && err != memdb.ErrNotFound { + return err + } + if obj != nil { + return ErrStillInUse + } + return nil +} + +func (c *dbCache) CheckPluginConfigReference(u *v1.PluginConfig) error { + // PluginConfig is referenced by Route. + txn := c.db.Txn(false) + defer txn.Abort() + obj, err := txn.First("route", "plugin_config_id", u.ID) + if err != nil && err != memdb.ErrNotFound { + return err + } + if obj != nil { + return ErrStillInUse + } + return nil +} diff --git a/pkg/dashboard/cache/memdb_test.go b/pkg/dashboard/cache/memdb_test.go new file mode 100644 index 000000000..ae1bc3c07 --- /dev/null +++ b/pkg/dashboard/cache/memdb_test.go @@ -0,0 +1,390 @@ +// 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 cache + +import ( + "testing" + + "github.com/hashicorp/go-memdb" + "github.com/stretchr/testify/assert" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +func TestMemDBCacheRoute(t *testing.T) { + c, err := NewMemDBCache() + assert.Nil(t, err, "NewMemDBCache") + + r1 := &v1.Route{ + Metadata: v1.Metadata{ + ID: "1", + Name: "abc", + }, + } + assert.Nil(t, c.InsertRoute(r1), "inserting route 1") + + r, err := c.GetRoute("1") + assert.Nil(t, err) + assert.Equal(t, r1, r) + + r2 := &v1.Route{ + Metadata: v1.Metadata{ + ID: "2", + Name: "def", + }, + } + r3 := &v1.Route{ + Metadata: v1.Metadata{ + ID: "3", + Name: "ghi", + }, + } + assert.Nil(t, c.InsertRoute(r2), "inserting route r2") + assert.Nil(t, c.InsertRoute(r3), "inserting route r3") + + r, err = c.GetRoute("3") + assert.Nil(t, err) + assert.Equal(t, r3, r) + + assert.Nil(t, c.DeleteRoute(r3), "delete route r3") + + routes, err := c.ListRoutes() + assert.Nil(t, err, "listing routes") + + if routes[0].Name > routes[1].Name { + routes[0], routes[1] = routes[1], routes[0] + } + assert.Equal(t, r1, routes[0]) + assert.Equal(t, r2, routes[1]) + + r4 := &v1.Route{ + Metadata: v1.Metadata{ + ID: "4", + Name: "name4", + }, + } + assert.Error(t, ErrNotFound, c.DeleteRoute(r4)) +} + +func TestMemDBCacheSSL(t *testing.T) { + c, err := NewMemDBCache() + assert.Nil(t, err, "NewMemDBCache") + + s1 := &v1.Ssl{ + ID: "abc", + } + assert.Nil(t, c.InsertSSL(s1), "inserting ssl 1") + + s, err := c.GetSSL("abc") + assert.Nil(t, err) + assert.Equal(t, s1, s) + + s2 := &v1.Ssl{ + ID: "def", + } + s3 := &v1.Ssl{ + ID: "ghi", + } + assert.Nil(t, c.InsertSSL(s2), "inserting ssl 2") + assert.Nil(t, c.InsertSSL(s3), "inserting ssl 3") + + s, err = c.GetSSL("ghi") + assert.Nil(t, err) + assert.Equal(t, s3, s) + + assert.Nil(t, c.DeleteSSL(s3), "delete ssl 3") + + ssl, err := c.ListSSL() + assert.Nil(t, err, "listing ssl") + + if ssl[0].ID > ssl[1].ID { + ssl[0], ssl[1] = ssl[1], ssl[0] + } + assert.Equal(t, s1, ssl[0]) + assert.Equal(t, s2, ssl[1]) + + s4 := &v1.Ssl{ + ID: "id4", + } + assert.Error(t, ErrNotFound, c.DeleteSSL(s4)) +} + +func TestMemDBCacheUpstream(t *testing.T) { + c, err := NewMemDBCache() + assert.Nil(t, err, "NewMemDBCache") + + u1 := &v1.Service{ + Metadata: v1.Metadata{ + ID: "1", + Name: "abc", + }, + } + err = c.InsertService(u1) + assert.Nil(t, err, "inserting upstream 1") + + u, err := c.GetService("1") + assert.Nil(t, err) + assert.Equal(t, u1, u) + + u2 := &v1.Service{ + Metadata: v1.Metadata{ + Name: "def", + ID: "2", + }, + } + u3 := &v1.Service{ + Metadata: v1.Metadata{ + Name: "ghi", + ID: "3", + }, + } + assert.Nil(t, c.InsertService(u2), "inserting upstream 2") + assert.Nil(t, c.InsertService(u3), "inserting upstream 3") + + u, err = c.GetService("3") + assert.Nil(t, err) + assert.Equal(t, u3, u) + + assert.Nil(t, c.DeleteService(u3), "delete upstream 3") + + upstreams, err := c.ListServices() + assert.Nil(t, err, "listing upstreams") + + if upstreams[0].Name > upstreams[1].Name { + upstreams[0], upstreams[1] = upstreams[1], upstreams[0] + } + assert.Equal(t, u1, upstreams[0]) + assert.Equal(t, u2, upstreams[1]) + + u4 := &v1.Service{ + Metadata: v1.Metadata{ + Name: "name4", + ID: "4", + }, + } + assert.Error(t, ErrNotFound, c.DeleteService(u4)) +} + +func TestMemDBCacheReference(t *testing.T) { + r := &v1.Route{ + Metadata: v1.Metadata{ + Name: "route", + ID: "1", + }, + ServiceID: "1", + PluginConfigId: "1", + } + u := &v1.Service{ + Metadata: v1.Metadata{ + ID: "1", + Name: "upstream", + }, + } + pc := &v1.PluginConfig{ + Metadata: v1.Metadata{ + ID: "1", + Name: "pluginConfig", + }, + } + pc2 := &v1.PluginConfig{ + Metadata: v1.Metadata{ + ID: "2", + Name: "pluginConfig", + }, + } + + db, err := NewMemDBCache() + assert.Nil(t, err, "NewMemDBCache") + assert.Nil(t, db.InsertRoute(r)) + assert.Nil(t, db.InsertService(u)) + assert.Nil(t, db.InsertPluginConfig(pc)) + + assert.Error(t, ErrStillInUse, db.DeleteService(u)) + assert.Error(t, ErrStillInUse, db.DeletePluginConfig(pc)) + assert.Equal(t, memdb.ErrNotFound, db.DeletePluginConfig(pc2)) + assert.Nil(t, db.DeleteRoute(r)) + assert.Nil(t, db.DeleteService(u)) + assert.Nil(t, db.DeletePluginConfig(pc)) +} + +func testInsertAndGetGlobalRule(t *testing.T, c Cache, id string) { + gr1 := &v1.GlobalRule{ + ID: id, + } + assert.Nil(t, c.InsertGlobalRule(gr1), "inserting global rule "+id) + + gr, err := c.GetGlobalRule(id) + assert.Nil(t, err) + assert.Equal(t, gr1, gr) +} + +func TestMemDBCacheGlobalRule(t *testing.T) { + c, err := NewMemDBCache() + assert.Nil(t, err, "NewMemDBCache") + + testInsertAndGetGlobalRule(t, c, "1") + testInsertAndGetGlobalRule(t, c, "2") + testInsertAndGetGlobalRule(t, c, "3") + + grs, err := c.ListGlobalRules() + assert.Nil(t, err, "listing global rules") + assert.Len(t, grs, 3) + assert.ElementsMatch(t, []string{"1", "2", "3"}, []string{grs[0].ID, grs[1].ID, grs[2].ID}) + + assert.Error(t, ErrNotFound, c.DeleteGlobalRule(&v1.GlobalRule{ + ID: "4", + })) +} + +func testInsertAndGetConsumer(t *testing.T, c Cache, username string) { + c1 := &v1.Consumer{ + Username: username, + } + assert.Nil(t, c.InsertConsumer(c1), "inserting consumer "+username) + + c11, err := c.GetConsumer(username) + assert.Nil(t, err) + assert.Equal(t, c1, c11) +} + +func TestMemDBCacheConsumer(t *testing.T) { + c, err := NewMemDBCache() + assert.Nil(t, err, "NewMemDBCache") + + testInsertAndGetConsumer(t, c, "jack") + testInsertAndGetConsumer(t, c, "tom") + testInsertAndGetConsumer(t, c, "jerry") + consumers, err := c.ListConsumers() + assert.Nil(t, err, "listing consumers") + assert.Len(t, consumers, 3) + + assert.Nil(t, c.DeleteConsumer( + &v1.Consumer{ + Username: "jerry", + }), "delete consumer jerry") + + consumers, err = c.ListConsumers() + assert.Nil(t, err, "listing consumers") + assert.Len(t, consumers, 2) + assert.ElementsMatch(t, []string{"jack", "tom"}, []string{consumers[0].Username, consumers[1].Username}) + + assert.Error(t, ErrNotFound, c.DeleteConsumer( + &v1.Consumer{ + Username: "chandler", + }, + )) +} + +func TestMemDBCacheSchema(t *testing.T) { + c, err := NewMemDBCache() + assert.Nil(t, err, "NewMemDBCache") + + s1 := &v1.Schema{ + Name: "plugins/p1", + Content: "plugin schema", + } + assert.Nil(t, c.InsertSchema(s1), "inserting schema s1") + + s11, err := c.GetSchema("plugins/p1") + assert.Nil(t, err) + assert.Equal(t, s1, s11) + + s2 := &v1.Schema{ + Name: "plugins/p2", + } + s3 := &v1.Schema{ + Name: "plugins/p3", + } + assert.Nil(t, c.InsertSchema(s2), "inserting schema s2") + assert.Nil(t, c.InsertSchema(s3), "inserting schema s3") + + s22, err := c.GetSchema("plugins/p2") + assert.Nil(t, err) + assert.Equal(t, s2, s22) + + assert.Nil(t, c.DeleteSchema(s3), "delete schema s3") + + schemaList, err := c.ListSchema() + assert.Nil(t, err, "listing schema") + + if schemaList[0].Name > schemaList[1].Name { + schemaList[0], schemaList[1] = schemaList[1], schemaList[0] + } + assert.Equal(t, s1, schemaList[0]) + assert.Equal(t, s2, schemaList[1]) + + s4 := &v1.Schema{ + Name: "plugins/p4", + } + assert.Error(t, ErrNotFound, c.DeleteSchema(s4)) +} + +func TestMemDBCachePluginConfig(t *testing.T) { + c, err := NewMemDBCache() + assert.Nil(t, err, "NewMemDBCache") + + pc1 := &v1.PluginConfig{ + Metadata: v1.Metadata{ + ID: "1", + Name: "name1", + }, + } + assert.Nil(t, c.InsertPluginConfig(pc1), "inserting plugin_config pc1") + + pc11, err := c.GetPluginConfig("1") + assert.Nil(t, err) + assert.Equal(t, pc1, pc11) + + pc2 := &v1.PluginConfig{ + Metadata: v1.Metadata{ + ID: "2", + Name: "name2", + }, + } + pc3 := &v1.PluginConfig{ + Metadata: v1.Metadata{ + ID: "3", + Name: "name3", + }, + } + assert.Nil(t, c.InsertPluginConfig(pc2), "inserting plugin_config pc2") + assert.Nil(t, c.InsertPluginConfig(pc3), "inserting plugin_config pc3") + + pc22, err := c.GetPluginConfig("2") + assert.Nil(t, err) + assert.Equal(t, pc2, pc22) + + assert.Nil(t, c.DeletePluginConfig(pc3), "delete plugin_config pc3") + + pcList, err := c.ListPluginConfigs() + assert.Nil(t, err, "listing plugin_config") + + if pcList[0].Name > pcList[1].Name { + pcList[0], pcList[1] = pcList[1], pcList[0] + } + assert.Equal(t, pcList[0], pc1) + assert.Equal(t, pcList[1], pc2) + + pc4 := &v1.PluginConfig{ + Metadata: v1.Metadata{ + ID: "4", + Name: "name4", + }, + } + assert.Error(t, ErrNotFound, c.DeletePluginConfig(pc4)) +} diff --git a/pkg/dashboard/cache/noop_db.go b/pkg/dashboard/cache/noop_db.go new file mode 100644 index 000000000..1f4c0e8e3 --- /dev/null +++ b/pkg/dashboard/cache/noop_db.go @@ -0,0 +1,166 @@ +// 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 cache + +import ( + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +type noopCache struct { +} + +// NewMemDBCache creates a Cache object backs with a memory DB. +func NewNoopDBCache() (Cache, error) { + return &noopCache{}, nil +} + +func (c *noopCache) InsertRoute(r *v1.Route) error { + return nil +} + +func (c *noopCache) InsertSSL(ssl *v1.Ssl) error { + return nil +} + +func (c *noopCache) InsertService(u *v1.Service) error { + return nil +} + +func (c *noopCache) InsertStreamRoute(sr *v1.StreamRoute) error { + return nil +} + +func (c *noopCache) InsertGlobalRule(gr *v1.GlobalRule) error { + return nil +} + +func (c *noopCache) InsertConsumer(consumer *v1.Consumer) error { + return nil +} + +func (c *noopCache) InsertSchema(schema *v1.Schema) error { + return nil +} + +func (c *noopCache) InsertPluginConfig(pc *v1.PluginConfig) error { + return nil +} + +func (c *noopCache) GetRoute(id string) (*v1.Route, error) { + return nil, nil +} + +func (c *noopCache) GetSSL(id string) (*v1.Ssl, error) { + return nil, nil +} + +func (c *noopCache) GetService(id string) (*v1.Service, error) { + return nil, nil +} + +func (c *noopCache) GetStreamRoute(id string) (*v1.StreamRoute, error) { + return nil, nil +} + +func (c *noopCache) GetGlobalRule(id string) (*v1.GlobalRule, error) { + return nil, nil +} + +func (c *noopCache) GetConsumer(username string) (*v1.Consumer, error) { + return nil, nil +} + +func (c *noopCache) GetSchema(name string) (*v1.Schema, error) { + return nil, nil +} + +func (c *noopCache) GetPluginConfig(name string) (*v1.PluginConfig, error) { + return nil, nil +} + +func (c *noopCache) ListRoutes(...any) ([]*v1.Route, error) { + return nil, nil +} + +func (c *noopCache) ListSSL(...any) ([]*v1.Ssl, error) { + return nil, nil +} + +func (c *noopCache) ListServices(...any) ([]*v1.Service, error) { + return nil, nil +} + +func (c *noopCache) ListStreamRoutes() ([]*v1.StreamRoute, error) { + return nil, nil +} + +func (c *noopCache) ListGlobalRules() ([]*v1.GlobalRule, error) { + return nil, nil +} + +func (c *noopCache) ListConsumers() ([]*v1.Consumer, error) { + return nil, nil +} + +func (c *noopCache) ListSchema() ([]*v1.Schema, error) { + return nil, nil +} + +func (c *noopCache) ListPluginConfigs() ([]*v1.PluginConfig, error) { + return nil, nil +} + +func (c *noopCache) DeleteRoute(r *v1.Route) error { + return nil +} + +func (c *noopCache) DeleteSSL(ssl *v1.Ssl) error { + return nil +} + +func (c *noopCache) DeleteService(u *v1.Service) error { + return nil +} + +func (c *noopCache) DeleteStreamRoute(sr *v1.StreamRoute) error { + return nil +} + +func (c *noopCache) DeleteGlobalRule(gr *v1.GlobalRule) error { + return nil +} + +func (c *noopCache) DeleteConsumer(consumer *v1.Consumer) error { + return nil +} + +func (c *noopCache) DeleteSchema(schema *v1.Schema) error { + return nil +} + +func (c *noopCache) DeletePluginConfig(pc *v1.PluginConfig) error { + return nil +} + +func (c *noopCache) CheckServiceReference(u *v1.Service) error { + return nil +} + +func (c *noopCache) CheckPluginConfigReference(pc *v1.PluginConfig) error { + return nil +} diff --git a/pkg/dashboard/cache/schema.go b/pkg/dashboard/cache/schema.go new file mode 100644 index 000000000..10ec84cee --- /dev/null +++ b/pkg/dashboard/cache/schema.go @@ -0,0 +1,229 @@ +// 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 cache + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-memdb" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +var ( + _schema = &memdb.DBSchema{ + Tables: map[string]*memdb.TableSchema{ + "route": { + Name: "route", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "ID"}, + }, + "name": { + Name: "name", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "Name"}, + AllowMissing: true, + }, + "service_id": { + Name: "service_id", + Unique: false, + Indexer: &memdb.StringFieldIndex{Field: "ServiceID"}, + AllowMissing: true, + }, + "plugin_config_id": { + Name: "plugin_config_id", + Unique: false, + Indexer: &memdb.StringFieldIndex{Field: "PluginConfigId"}, + AllowMissing: true, + }, + "label": { + Name: "label", + Unique: false, + AllowMissing: true, + Indexer: &LabelIndexer{ + LabelKeys: []string{"kind", "namespace", "name"}, + GetLabels: func(obj any) map[string]string { + service, ok := obj.(*v1.Route) + if !ok { + return nil + } + return service.Labels + }, + }, + }, + }, + }, + "service": { + Name: "service", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "ID"}, + }, + "name": { + Name: "name", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "Name"}, + AllowMissing: true, + }, + "label": { + Name: "label", + Unique: false, + AllowMissing: true, + Indexer: &LabelIndexer{ + LabelKeys: []string{"kind", "namespace", "name"}, + GetLabels: func(obj any) map[string]string { + service, ok := obj.(*v1.Service) + if !ok { + return nil + } + return service.Labels + }, + }, + }, + }, + }, + "ssl": { + Name: "ssl", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "ID"}, + }, + }, + }, + "stream_route": { + Name: "stream_route", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "ID"}, + }, + "service_id": { + Name: "service_id", + Unique: false, + Indexer: &memdb.StringFieldIndex{Field: "ServiceID"}, + AllowMissing: true, + }, + }, + }, + "global_rule": { + Name: "global_rule", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "ID"}, + }, + }, + }, + "consumer": { + Name: "consumer", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "Username"}, + }, + }, + }, + "schema": { + Name: "schema", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "Name"}, + }, + }, + }, + "plugin_config": { + Name: "plugin_config", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "ID"}, + }, + "name": { + Name: "name", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "Name"}, + AllowMissing: true, + }, + }, + }, + "upstream_service": { + Name: "upstream_service", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "ServiceName"}, + }, + }, + }, + }, + } +) + +// LabelIndexer is a custom indexer for exact match indexing +type LabelIndexer struct { + LabelKeys []string + GetLabels func(any) map[string]string +} + +func (emi *LabelIndexer) FromObject(obj any) (bool, []byte, error) { + labels := emi.GetLabels(obj) + var labelValues []string + for _, key := range emi.LabelKeys { + if value, exists := labels[key]; exists { + labelValues = append(labelValues, value) + } + } + + if len(labelValues) == 0 { + return false, nil, nil + } + + return true, []byte(strings.Join(labelValues, "/")), nil +} + +func (emi *LabelIndexer) FromArgs(args ...any) ([]byte, error) { + if len(args) != len(emi.LabelKeys) { + return nil, fmt.Errorf("expected %d arguments, got %d", len(emi.LabelKeys), len(args)) + } + + labelValues := make([]string, 0, len(args)) + for _, arg := range args { + value, ok := arg.(string) + if !ok { + return nil, fmt.Errorf("argument is not a string") + } + labelValues = append(labelValues, value) + } + + return []byte(strings.Join(labelValues, "/")), nil +} diff --git a/pkg/dashboard/cluster.go b/pkg/dashboard/cluster.go new file mode 100644 index 000000000..a3f8613db --- /dev/null +++ b/pkg/dashboard/cluster.go @@ -0,0 +1,1000 @@ +// 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 dashboard + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync/atomic" + "time" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/multierr" + "go.uber.org/zap" + "k8s.io/apimachinery/pkg/util/wait" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" +) + +const ( + _defaultTimeout = 5 * time.Second + _defaultSyncInterval = 6 * time.Hour + + _cacheSyncing = iota + _cacheSynced +) + +var ( + // ErrClusterNotExist means a cluster doesn't exist. + ErrClusterNotExist = errors.New("cluster not exist") + // ErrDuplicatedCluster means the cluster adding request was + // rejected since the cluster was already created. + ErrDuplicatedCluster = errors.New("duplicated cluster") + // ErrFunctionDisabled means the APISIX function is disabled + ErrFunctionDisabled = errors.New("function disabled") + + DefaultLabelsManagedBy map[string]string = map[string]string{ + "managed-by": "apisix-ingress-controller", + } + + // ErrRouteNotFound means the [route, ssl, upstream] was not found. + ErrNotFound = cache.ErrNotFound + + errReadOnClosedResBody = errors.New("http: read on closed response body") +) + +// ClusterOptions contains parameters to customize APISIX client. +type ClusterOptions struct { + ControllerName string + AdminAPIVersion string + Name string + AdminKey string + BaseURL string + Timeout time.Duration + // SyncInterval is the interval to sync schema. + SyncComparison bool + EnableEtcdServer bool + Prefix string + ListenAddress string + SchemaSynced bool + SyncCache bool + SSLKeyEncryptSalt string + SkipTLSVerify bool + Labels map[string]string +} + +type cluster struct { + labels map[string]string + controllerName string + adminVersion string + name string + baseURL string + baseURLHost string + adminKey string + prefix string + cli *http.Client + cacheState int32 + cache cache.Cache + cacheSynced chan struct{} + cacheSyncErr error + route Route + service Service + ssl SSL + streamRoute StreamRoute + globalRules GlobalRule + consumer Consumer + plugin Plugin + schema Schema + pluginConfig PluginConfig + pluginMetadata PluginMetadata + waitforCacheSync bool + validator APISIXSchemaValidator + sslKeyEncryptSalt string +} + +func newCluster(ctx context.Context, o *ClusterOptions) (Cluster, error) { + if o.BaseURL == "" { + return nil, errors.New("empty base url") + } + if o.Timeout == time.Duration(0) { + o.Timeout = _defaultTimeout + } + o.BaseURL = strings.TrimSuffix(o.BaseURL, "/") + + u, err := url.Parse(o.BaseURL) + if err != nil { + return nil, err + } + + switch u.Scheme { + case "http": + if u.Port() == "" { + u.Host = u.Host + ":80" + } + case "https": + if u.Port() == "" { + u.Host = u.Host + ":443" + } + } + + // if the version is not v3, then fallback to v2 + adminVersion := o.AdminAPIVersion + c := &cluster{ + labels: o.Labels, + controllerName: o.ControllerName, + adminVersion: adminVersion, + name: o.Name, + baseURL: o.BaseURL, + baseURLHost: u.Host, + adminKey: o.AdminKey, + prefix: o.Prefix, + cli: &http.Client{ + Timeout: o.Timeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 3 * time.Second, + }).Dial, + DialContext: (&net.Dialer{ + Timeout: 3 * time.Second, + }).DialContext, + ResponseHeaderTimeout: 30 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{InsecureSkipVerify: o.SkipTLSVerify}, + }, + }, + cacheState: _cacheSyncing, // default state + cacheSynced: make(chan struct{}), + sslKeyEncryptSalt: o.SSLKeyEncryptSalt, + } + + c.route = newRouteClient(c) + c.service = newServiceClient(c) + c.ssl = newSSLClient(c) + c.streamRoute = newStreamRouteClient(c) + c.globalRules = newGlobalRuleClient(c) + c.consumer = newConsumerClient(c) + c.plugin = newPluginClient(c) + c.schema = newSchemaClient(c) + c.pluginConfig = newPluginConfigClient(c) + c.pluginMetadata = newPluginMetadataClient(c) + c.validator = newDummyValidator() + + c.cache, err = cache.NewMemDBCache() + if err != nil { + return nil, err + } + if o.SyncCache { + c.waitforCacheSync = true + go c.syncCache(ctx) + } + + return c, nil +} + +func (c *cluster) syncCache(ctx context.Context) { + log.Infow("syncing cache", zap.String("cluster", c.name)) + now := time.Now() + defer func() { + if c.cacheSyncErr == nil { + log.Infow("cache synced", + zap.String("cost_time", time.Since(now).String()), + zap.String("cluster", c.name), + ) + } else { + log.Errorw("failed to sync cache", + zap.String("cost_time", time.Since(now).String()), + zap.String("cluster", c.name), + ) + } + }() + + backoff := wait.Backoff{ + Duration: 2 * time.Second, + Factor: 1, + Steps: 5, + } + var lastSyncErr error + err := wait.ExponentialBackoff(backoff, func() (done bool, err error) { + // impossibly return: false, nil + // so can safe used + done, lastSyncErr = c.syncCacheOnce(ctx) + select { + case <-ctx.Done(): + err = context.Canceled + default: + break + } + return + }) + if err != nil { + // if ErrWaitTimeout then set lastSyncErr + c.cacheSyncErr = lastSyncErr + } + close(c.cacheSynced) + + if !atomic.CompareAndSwapInt32(&c.cacheState, _cacheSyncing, _cacheSynced) { + panic("dubious state when sync cache") + } +} + +func (c *cluster) syncCacheOnce(ctx context.Context) (bool, error) { + routes, err := c.route.List(ctx) + if err != nil { + log.Errorf("failed to list routes in APISIX: %s", err) + return false, err + } + ssl, err := c.ssl.List(ctx) + if err != nil { + log.Errorf("failed to list ssl in APISIX: %s", err) + return false, err + } + globalRules, err := c.globalRules.List(ctx) + if err != nil { + log.Errorf("failed to list global_rules in APISIX: %s", err) + return false, err + } + consumers, err := c.consumer.List(ctx) + if err != nil { + log.Errorf("failed to list consumers in APISIX: %s", err) + return false, err + } + + for _, r := range routes { + log.Debug("syncing route with labels", r.Labels) + if err := c.cache.InsertRoute(r); err != nil { + log.Errorw("failed to insert route to cache", + zap.String("route", r.ID), + zap.String("cluster", c.name), + zap.String("error", err.Error()), + ) + return false, err + } + } + for _, s := range ssl { + log.Debug("syncing ssl with labels", s.Labels) + if err := c.cache.InsertSSL(s); err != nil { + log.Errorw("failed to insert ssl to cache", + zap.String("ssl", s.ID), + zap.String("cluster", c.name), + zap.String("error", err.Error()), + ) + return false, err + } + } + for _, gr := range globalRules { + if err := c.cache.InsertGlobalRule(gr); err != nil { + log.Errorw("failed to insert global_rule to cache", + zap.Any("global_rule", gr), + zap.String("cluster", c.name), + zap.String("error", err.Error()), + ) + return false, err + } + } + for _, consumer := range consumers { + log.Debug("syncing consumer with labels", consumer.Labels) + if err := c.cache.InsertConsumer(consumer); err != nil { + log.Errorw("failed to insert consumer to cache", + zap.Any("consumer", consumer), + zap.String("cluster", c.name), + zap.String("error", err.Error()), + ) + } + } + log.Info("All cache synced successfully") + // for _, u := range pluginConfigs { + // if err := c.cache.InsertPluginConfig(u); err != nil { + // log.Errorw("failed to insert pluginConfig to cache", + // zap.String("pluginConfig", u.ID), + // zap.String("cluster", c.name), + // zap.String("error", err.Error()), + // ) + // return false, err + // } + // } + return true, nil +} + +// String implements Cluster.String method. +func (c *cluster) String() string { + return fmt.Sprintf("name=%s; base_url=%s", c.name, c.baseURL) +} + +// HasSynced implements Cluster.HasSynced method. +func (c *cluster) HasSynced(ctx context.Context) error { + if !c.waitforCacheSync { + return nil + } + if c.cacheSyncErr != nil { + return c.cacheSyncErr + } + if atomic.LoadInt32(&c.cacheState) == _cacheSynced { + return nil + } + + // still in sync + now := time.Now() + log.Warnf("waiting cluster %s to ready, it may takes a while", c.name) + select { + case <-ctx.Done(): + log.Errorf("failed to wait cluster to ready: %s", ctx.Err()) + return ctx.Err() + case <-c.cacheSynced: + if c.cacheSyncErr != nil { + // See https://github.com/apache/apisix-ingress-controller/issues/448 + // for more details. + return c.cacheSyncErr + } + log.Warnf("cluster %s now is ready, cost time %s", c.name, time.Since(now).String()) + return nil + } +} + +// Route implements Cluster.Route method. +func (c *cluster) Route() Route { + return c.route +} + +// Upstream implements Cluster.Upstream method. +func (c *cluster) Service() Service { + return c.service +} + +// SSL implements Cluster.SSL method. +func (c *cluster) SSL() SSL { + return c.ssl +} + +// StreamRoute implements Cluster.StreamRoute method. +func (c *cluster) StreamRoute() StreamRoute { + return c.streamRoute +} + +// GlobalRule implements Cluster.GlobalRule method. +func (c *cluster) GlobalRule() GlobalRule { + return c.globalRules +} + +// Consumer implements Cluster.Consumer method. +func (c *cluster) Consumer() Consumer { + return c.consumer +} + +// Plugin implements Cluster.Plugin method. +func (c *cluster) Plugin() Plugin { + return c.plugin +} + +// PluginConfig implements Cluster.PluginConfig method. +func (c *cluster) PluginConfig() PluginConfig { + return c.pluginConfig +} + +// Schema implements Cluster.Schema method. +func (c *cluster) Schema() Schema { + return c.schema +} + +func (c *cluster) PluginMetadata() PluginMetadata { + return c.pluginMetadata +} + +func (c *cluster) Validator() APISIXSchemaValidator { + return c.validator +} + +// HealthCheck implements Cluster.HealthCheck method. +// +// It checks the health of an APISIX cluster by performing a TCP socket probe +// against the baseURLHost. It will retry up to 3 times with exponential backoff +// before returning an error. +// +// Parameters: +// +// ctx: The context for the health check. +// +// Returns: +// +// err: Any error encountered while performing the health check. +func (c *cluster) HealthCheck(ctx context.Context) (err error) { + // Retry three times in a row, and exit if all of them fail. + backoff := wait.Backoff{ + Duration: 5 * time.Second, + Factor: 1, + Steps: 3, + } + + err = wait.ExponentialBackoffWithContext(ctx, backoff, func(ctx context.Context) (done bool, _ error) { + if lastCheckErr := c.healthCheck(ctx); lastCheckErr != nil { + log.Warnf("failed to check health for cluster %s: %s, will retry", c.name, lastCheckErr) + return + } + done = true + return + }) + + return err +} + +func (c *cluster) healthCheck(ctx context.Context) (err error) { + // tcp socket probe + d := net.Dialer{Timeout: 3 * time.Second} + conn, err := d.DialContext(ctx, "tcp", c.baseURLHost) + if err != nil { + return err + } + defer func(conn net.Conn) { + err := conn.Close() + if err != nil { + log.Warnw("failed to close tcp probe connection", + zap.Error(err), + zap.String("cluster", c.name), + ) + } + }(conn) + + return +} + +func (c *cluster) applyAuth(req *http.Request) { + if c.adminKey != "" { + req.Header.Set("X-API-Key", c.adminKey) + } +} + +func (c *cluster) do(req *http.Request) (*http.Response, error) { + c.applyAuth(req) + return c.cli.Do(req) +} + +func (c *cluster) isFunctionDisabled(body string) bool { + return strings.Contains(body, "is disabled") +} + +func (c *cluster) getResource(ctx context.Context, url, resource string) (*getResponse, error) { + log.Debugw("get resource in cluster", + zap.String("cluster_name", c.name), + zap.String("name", resource), + zap.String("url", url), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := c.do(req) + if err != nil { + return nil, err + } + + defer drainBody(resp.Body, url) + if resp.StatusCode != http.StatusOK { + body := readBody(resp.Body, url) + if c.isFunctionDisabled(body) { + return nil, ErrFunctionDisabled + } + if resp.StatusCode == http.StatusNotFound { + return nil, cache.ErrNotFound + } else { + err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) + err = multierr.Append(err, fmt.Errorf("error message: %s", body)) + } + return nil, err + } + + if c.adminVersion == "v3" { + var res getResponse + + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&res); err != nil { + return nil, err + } + return &res, nil + } + var res getResponse + + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&res); err != nil { + return nil, err + } + return &res, nil +} + +func addQueryParam(urlStr string, labels map[string]string) string { + parsedUrl, err := url.Parse(urlStr) + if err != nil { + return urlStr + } + query := parsedUrl.Query() + for key, value := range labels { + query.Add(fmt.Sprintf("labels[%s]", key), value) + } + parsedUrl.RawQuery = query.Encode() + return parsedUrl.String() +} + +func (c *cluster) listResource(ctx context.Context, url, resource string) (listResponse, error) { + var list listResponse + err := c.listResourceToResponse(ctx, url, resource, &list) + return list, err +} + +func (c *cluster) listResourceToResponse(ctx context.Context, url, resource string, listResponse any) error { + log.Debugw("list resource in cluster", + zap.String("cluster_name", c.name), + zap.String("name", resource), + zap.String("url", url), + ) + if c.labels != nil { + url = addQueryParam(url, c.labels) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := c.do(req) + if err != nil { + return err + } + + defer drainBody(resp.Body, url) + if resp.StatusCode != http.StatusOK { + body := readBody(resp.Body, url) + if c.isFunctionDisabled(body) { + return ErrFunctionDisabled + } + err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) + err = multierr.Append(err, fmt.Errorf("error message: %s", body)) + return err + } + + return json.NewDecoder(resp.Body).Decode(listResponse) +} + +func (c *cluster) createResource(ctx context.Context, url, resource string, body []byte) (*getResponse, error) { + log.Debugw("creating resource in cluster", + zap.String("cluster_name", c.name), + zap.String("name", resource), + zap.String("url", url), + zap.ByteString("body", body), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer drainBody(resp.Body, url) + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body := readBody(resp.Body, url) + if c.isFunctionDisabled(body) { + return nil, ErrFunctionDisabled + } + err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) + err = multierr.Append(err, fmt.Errorf("error message: %s", body)) + return nil, err + } + + var cr getResponse + byt, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if err := json.Unmarshal(byt, &cr); err != nil { + return nil, err + } + return &cr, nil +} + +func (c *cluster) updateResource(ctx context.Context, url, resource string, body []byte) (*getResponse, error) { + log.Debugw("updating resource in cluster", + zap.String("cluster_name", c.name), + zap.String("name", resource), + zap.String("url", url), + zap.ByteString("body", body), + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.do(req) + if err != nil { + return nil, err + } + + defer drainBody(resp.Body, url) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body := readBody(resp.Body, url) + log.Debugw("update response", + zap.Int("status code %d", resp.StatusCode), + zap.String("body %s", body), + ) + if c.isFunctionDisabled(body) { + return nil, ErrFunctionDisabled + } + err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) + err = multierr.Append(err, fmt.Errorf("error message: %s", body)) + return nil, err + } + if c.adminVersion == "v3" { + var ur updateResponseV3 + + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&ur); err != nil { + return nil, err + } + + return &ur, nil + } + var ur updateResponse + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&ur); err != nil { + return nil, err + } + return &ur, nil +} + +func (c *cluster) deleteResource(ctx context.Context, url, resource string) error { + log.Debugw("deleting resource in cluster", + zap.String("cluster_name", c.name), + zap.String("name", resource), + zap.String("url", url), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return err + } + resp, err := c.do(req) + if err != nil { + return err + } + + defer drainBody(resp.Body, url) + + if resp.StatusCode != http.StatusOK && + resp.StatusCode != http.StatusNoContent && + resp.StatusCode != http.StatusNotFound { + message := readBody(resp.Body, url) + if c.isFunctionDisabled(message) { + return ErrFunctionDisabled + } + err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) + err = multierr.Append(err, fmt.Errorf("error message: %s", message)) + if strings.Contains(message, "still using") { + return cache.ErrStillInUse + } + return err + } + return nil +} + +// drainBody reads whole data until EOF from r, then close it. +func drainBody(r io.ReadCloser, url string) { + _, err := io.Copy(io.Discard, r) + if err != nil { + if err.Error() != errReadOnClosedResBody.Error() { + log.Warnw("failed to drain body (read)", + zap.String("url", url), + zap.Error(err), + ) + } + } + + if err := r.Close(); err != nil { + log.Warnw("failed to drain body (close)", + zap.String("url", url), + zap.Error(err), + ) + } +} + +func readBody(r io.ReadCloser, url string) string { + defer func() { + if err := r.Close(); err != nil { + log.Warnw("failed to close body", zap.String("url", url), zap.Error(err)) + } + }() + data, err := io.ReadAll(r) + if err != nil { + log.Warnw("failed to read body", zap.String("url", url), zap.Error(err)) + return "" + } + return string(data) +} + +// getSchema returns the schema of APISIX object. +func (c *cluster) getSchema(_ context.Context, url, resource string) (string, error) { + log.Debugw("get schema in cluster", + zap.String("url", url), + zap.String("cluster", c.name), + zap.String("resource", resource), + ) + // TODO: fixme The above passed context gets cancelled for some reason. Investigate + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil) + if err != nil { + return "", err + } + resp, err := c.do(req) + if err != nil { + return "", err + } + + defer drainBody(resp.Body, url) + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + return "", cache.ErrNotFound + } else { + err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) + err = multierr.Append(err, fmt.Errorf("error message: %s", readBody(resp.Body, url))) + } + return "", err + } + + return readBody(resp.Body, url), nil +} + +// getList returns a list of string. +func (c *cluster) getList(ctx context.Context, url, resource string) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := c.do(req) + if err != nil { + return nil, err + } + + defer drainBody(resp.Body, url) + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + return nil, cache.ErrNotFound + } else { + err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) + err = multierr.Append(err, fmt.Errorf("error message: %s", readBody(resp.Body, url))) + } + return nil, err + } + + // In EE, for plugins the response is an array of string and not an object. + // sent to /list + if resource == "plugin" { + byt, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var listResponse []string + err = json.Unmarshal(byt, &listResponse) + if err != nil { + return nil, err + } + return listResponse, nil + } + var listResponse map[string]any + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&listResponse); err != nil { + return nil, err + } + res := make([]string, 0, len(listResponse)) + + for name := range listResponse { + res = append(res, name) + } + return res, nil +} + +func (c *cluster) GetGlobalRule(ctx context.Context, baseUrl, id string) (*v1.GlobalRule, error) { + url := baseUrl + "/" + id + resp, err := c.getResource(ctx, url, "globalRule") + if err != nil { + return nil, err + } + + globalRule, err := resp.globalRule() + if err != nil { + log.Errorw("failed to convert global_rule item", + zap.String("url", url), + zap.Error(err), + ) + return nil, err + } + + return globalRule, nil +} + +func (c *cluster) GetConsumer(ctx context.Context, baseUrl, name string) (*v1.Consumer, error) { + url := baseUrl + "/" + name + resp, err := c.getResource(ctx, url, "consumer") + if err != nil { + return nil, err + } + + consumer, err := resp.consumer() + if err != nil { + log.Errorw("failed to convert consumer item", + zap.String("url", url), + zap.Error(err), + ) + return nil, err + } + return consumer, nil +} + +func (c *cluster) GetPluginConfig(ctx context.Context, baseUrl, id string) (*v1.PluginConfig, error) { + url := baseUrl + "/" + id + resp, err := c.getResource(ctx, url, "pluginConfig") + if err != nil { + return nil, err + } + + pluginConfig, err := resp.pluginConfig() + if err != nil { + log.Errorw("failed to convert pluginConfig item", + zap.String("url", url), + zap.Error(err), + ) + return nil, err + } + return pluginConfig, nil +} + +func (c *cluster) GetRoute(ctx context.Context, baseUrl, id string) (*v1.Route, error) { + url := baseUrl + "/" + id + resp, err := c.getResource(ctx, url, "route") + if err != nil { + return nil, err + } + + route, err := resp.route() + if err != nil { + log.Errorw("failed to convert route item", + zap.String("url", url), + zap.Error(err), + ) + return nil, err + } + return route, nil +} + +func (c *cluster) GetStreamRoute(ctx context.Context, baseUrl, id string) (*v1.StreamRoute, error) { + url := baseUrl + "/" + id + resp, err := c.getResource(ctx, url, "streamRoute") + if err != nil { + return nil, err + } + + streamRoute, err := resp.streamRoute() + if err != nil { + log.Errorw("failed to convert stream_route item", + zap.String("url", url), + zap.Error(err), + ) + return nil, err + } + return streamRoute, nil +} + +func (c *cluster) GetService(ctx context.Context, baseUrl, id string) (*v1.Service, error) { + url := baseUrl + "/" + id + resp, err := c.getResource(ctx, url, "service") + if err != nil { + return nil, err + } + svc, err := resp.service() + if err != nil { + log.Errorw("failed to convert service item", + zap.String("url", url), + zap.Error(err), + ) + return nil, err + } + return svc, nil +} + +func (c *cluster) GetSSL(ctx context.Context, baseUrl, id string) (*v1.Ssl, error) { + url := baseUrl + "/" + id + resp, err := c.getResource(ctx, url, "ssl") + if err != nil { + return nil, err + } + ssl, err := resp.ssl() + if err != nil { + return nil, err + } + return ssl, nil +} + +func getFromCacheOrAPI[T any]( + ctx context.Context, + id string, + url string, + cacheGet func(string) (T, error), + cacheInsert func(T) error, + apiGet func(context.Context, string, string) (T, error), +) (T, error) { + item, err := cacheGet(id) + if err == nil { + return item, nil + } + if err != cache.ErrNotFound { + log.Errorw("failed to find in cache, will try to lookup from APISIX", + zap.Error(err), + ) + } else { + log.Debugw("not found in cache, will try to lookup from APISIX", + zap.Error(err), + ) + } + + // TODO Add mutex here to avoid dog-pile effect. + item, err = apiGet(ctx, url, id) + if err != nil { + return item, err + } + + if err := cacheInsert(item); err != nil { + log.Errorf("failed to reflect create to cache: %s", err) + return item, err + } + return item, nil +} + +func updateResource[T any]( + ctx context.Context, + obj T, + url string, + resourceType string, + apiUpdate func(context.Context, string, string, []byte) (*getResponse, error), + cacheInsert func(T) error, + parseResponse func(*getResponse) (T, error), +) (T, error) { + var val T + body, err := json.Marshal(obj) + if err != nil { + return val, err + } + resp, err := apiUpdate(ctx, url, resourceType, body) + if err != nil { + return val, err + } + val, err = parseResponse(resp) + if err != nil { + return val, err + } + if err := cacheInsert(val); err != nil { + log.Errorf("failed to reflect update to cache: %s", err) + return val, err + } + return val, nil +} diff --git a/pkg/dashboard/consumer.go b/pkg/dashboard/consumer.go new file mode 100644 index 000000000..12d3629a0 --- /dev/null +++ b/pkg/dashboard/consumer.go @@ -0,0 +1,156 @@ +// 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 dashboard + +import ( + "context" + "encoding/json" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" + "github.com/apache/apisix-ingress-controller/pkg/id" +) + +type consumerClient struct { + url string + cluster *cluster +} + +func newConsumerClient(c *cluster) Consumer { + return &consumerClient{ + url: c.baseURL + "/consumers", + cluster: c, + } +} + +// Get returns the Consumer. +// FIXME, currently if caller pass a non-existent resource, the Get always passes +// through cache. +func (r *consumerClient) Get(ctx context.Context, name string) (*v1.Consumer, error) { + return getFromCacheOrAPI( + ctx, + id.GenID(name), + r.url, + r.cluster.cache.GetConsumer, + r.cluster.cache.InsertConsumer, + r.cluster.GetConsumer, + ) +} + +// List is only used in cache warming up. So here just pass through +// to APISIX. +func (r *consumerClient) List(ctx context.Context) ([]*v1.Consumer, error) { + log.Debugw("try to list consumers in APISIX", + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + url := r.url + consumerItems, err := r.cluster.listResource(ctx, url, "consumer") + if err != nil { + log.Errorf("failed to list consumers: %s", err) + return nil, err + } + items := make([]*v1.Consumer, 0, len(consumerItems.List)) + for _, item := range consumerItems.List { + consumer, err := item.consumer() + if err != nil { + log.Errorw("failed to convert consumer item", + zap.String("url", url), + zap.Error(err), + ) + return nil, err + } + + items = append(items, consumer) + } + + return items, nil +} + +func (r *consumerClient) Create(ctx context.Context, obj *v1.Consumer) (*v1.Consumer, error) { + log.Debugw("try to create consumer", + zap.String("name", obj.Username), + zap.Any("plugins", obj.Plugins), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + + if err := r.cluster.HasSynced(ctx); err != nil { + return nil, err + } + data, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + url := r.url + "/" + obj.Username + resp, err := r.cluster.createResource(ctx, url, "consumer", data) + if err != nil { + log.Errorf("failed to create consumer: %s", err) + return nil, err + } + consumer, err := resp.consumer() + if err != nil { + return nil, err + } + if err := r.cluster.cache.InsertConsumer(consumer); err != nil { + log.Errorf("failed to reflect consumer create to cache: %s", err) + return nil, err + } + return consumer, nil +} + +func (r *consumerClient) Delete(ctx context.Context, obj *v1.Consumer) error { + log.Debugw("try to delete consumer", + zap.String("name", obj.Username), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + if err := r.cluster.HasSynced(ctx); err != nil { + return err + } + url := r.url + "/" + obj.Username + if err := r.cluster.deleteResource(ctx, url, "consumer"); err != nil { + return err + } + if err := r.cluster.cache.DeleteConsumer(obj); err != nil { + log.Errorf("failed to reflect consumer delete to cache: %s", err) + if err != cache.ErrNotFound { + return err + } + } + return nil +} + +func (r *consumerClient) Update(ctx context.Context, obj *v1.Consumer) (*v1.Consumer, error) { + url := r.url + "/" + obj.Username + return updateResource( + ctx, + obj, + url, + "consumer", + r.cluster.updateResource, + r.cluster.cache.InsertConsumer, + func(resp *getResponse) (*v1.Consumer, error) { + return resp.consumer() + }, + ) +} diff --git a/pkg/dashboard/consumer_test.go b/pkg/dashboard/consumer_test.go new file mode 100644 index 000000000..22599920c --- /dev/null +++ b/pkg/dashboard/consumer_test.go @@ -0,0 +1,242 @@ +// 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 dashboard + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/net/nettest" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +type fakeAPISIXConsumerSrv struct { + consumer map[string]map[string]any +} + +type Value map[string]any + +type fakeListResp struct { + Total string `json:"total"` + List []fakeListItem `json:"list"` +} + +type fakeGetCreateResp struct { + fakeGetCreateItem +} + +type fakeGetCreateItem struct { + Value Value `json:"value"` + Key string `json:"key"` +} + +type fakeListItem Value + +func (srv *fakeAPISIXConsumerSrv) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer func() { + _ = r.Body.Close() + }() + + if !strings.HasPrefix(r.URL.Path, "/apisix/admin/consumers") { + w.WriteHeader(http.StatusNotFound) + return + } + + if r.Method == http.MethodGet { + // For individual resource, the getcreate response is sent + var key string + if strings.HasPrefix(r.URL.Path, "/apisix/admin/consumers/") && + strings.TrimPrefix(r.URL.Path, "/apisix/admin/consumers/") != "" { + key = strings.TrimPrefix(r.URL.Path, "/apisix/admin/consumers/") + } + if key != "" { + resp := fakeGetCreateResp{ + fakeGetCreateItem{ + Key: key, + Value: srv.consumer[key], + }, + } + resp.Value = srv.consumer[key] + w.WriteHeader(http.StatusOK) + data, _ := json.Marshal(resp) + _, _ = w.Write(data) + } else { + resp := fakeListResp{} + resp.Total = fmt.Sprintf("%d", len(srv.consumer)) + resp.List = make([]fakeListItem, 0, len(srv.consumer)) + for _, v := range srv.consumer { + resp.List = append(resp.List, v) + } + data, _ := json.Marshal(resp) + _, _ = w.Write(data) + } + + return + } + + if r.Method == http.MethodDelete { + id := strings.TrimPrefix(r.URL.Path, "/apisix/admin/consumers/") + id = "/apisix/admin/consumers/" + id + code := http.StatusNotFound + if _, ok := srv.consumer[id]; ok { + delete(srv.consumer, id) + code = http.StatusOK + } + w.WriteHeader(code) + } + + if r.Method == http.MethodPut { + paths := strings.Split(r.URL.Path, "/") + key := fmt.Sprintf("/apisix/admin/consumers/%s", paths[len(paths)-1]) + data, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + consumer := make(map[string]any, 0) + _ = json.Unmarshal(data, &consumer) + srv.consumer[key] = consumer + var val Value + _ = json.Unmarshal(data, &val) + resp := fakeGetCreateResp{ + fakeGetCreateItem{ + Value: val, + Key: key, + }, + } + data, _ = json.Marshal(resp) + _, _ = w.Write(data) + return + } + + if r.Method == http.MethodPatch { + id := strings.TrimPrefix(r.URL.Path, "/apisix/admin/consumers/") + id = "/apisix/admin/consumers/" + id + if _, ok := srv.consumer[id]; !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + data, _ := io.ReadAll(r.Body) + var val Value + _ = json.Unmarshal(data, &val) + consumer := make(map[string]any, 0) + _ = json.Unmarshal(data, &consumer) + srv.consumer[id] = consumer + w.WriteHeader(http.StatusOK) + resp := fakeGetCreateResp{ + fakeGetCreateItem{ + Value: val, + Key: id, + }, + } + byt, _ := json.Marshal(resp) + _, _ = w.Write(byt) + return + } +} + +func runFakeConsumerSrv(t *testing.T) *http.Server { + srv := &fakeAPISIXConsumerSrv{ + consumer: make(map[string]map[string]any), + } + + ln, _ := nettest.NewLocalListener("tcp") + + httpSrv := &http.Server{ + Addr: ln.Addr().String(), + Handler: srv, + } + + go func() { + if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { + t.Errorf("failed to run http server: %s", err) + } + }() + + return httpSrv +} + +func TestConsumerClient(t *testing.T) { + srv := runFakeConsumerSrv(t) + defer func() { + assert.Nil(t, srv.Shutdown(context.Background())) + }() + + u := url.URL{ + Scheme: "http", + Host: srv.Addr, + Path: "/apisix/admin", + } + + closedCh := make(chan struct{}) + close(closedCh) + cli := newConsumerClient(&cluster{ + baseURL: u.String(), + cli: http.DefaultClient, + cache: &dummyCache{}, + cacheSynced: closedCh, + }) + + // Create + obj, err := cli.Create(context.Background(), &v1.Consumer{ + Username: "1", + }) + assert.Nil(t, err) + assert.Equal(t, "1", obj.Username) + + obj, err = cli.Create(context.Background(), &v1.Consumer{ + Username: "2", + }) + assert.Nil(t, err) + assert.Equal(t, "2", obj.Username) + + // List + objs, err := cli.List(context.Background()) + assert.Nil(t, err) + assert.Len(t, objs, 2) + assert.ElementsMatch(t, []string{"1", "2"}, []string{objs[0].Username, objs[1].Username}) + + // Delete then List + if objs[0].Username != "1" { + objs[0], objs[1] = objs[1], objs[0] + } + assert.Nil(t, cli.Delete(context.Background(), objs[0])) + objs, err = cli.List(context.Background()) + assert.Nil(t, err) + assert.Len(t, objs, 1) + assert.Equal(t, "2", objs[0].Username) + + // Patch then List + _, err = cli.Update(context.Background(), &v1.Consumer{ + Username: "2", + Plugins: map[string]any{ + "prometheus": struct{}{}, + }, + }) + assert.Nil(t, err) + objs, err = cli.List(context.Background()) + assert.Nil(t, err) + assert.Len(t, objs, 1) + assert.Equal(t, "2", objs[0].Username) +} diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go new file mode 100644 index 000000000..a22dd638f --- /dev/null +++ b/pkg/dashboard/dashboard.go @@ -0,0 +1,255 @@ +// 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 dashboard + +import ( + "context" + "sync" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +type Dashboard interface { + // Cluster specifies the target cluster to talk. + Cluster(name string) Cluster + // AddCluster adds a new cluster. + AddCluster(context.Context, *ClusterOptions) error + // UpdateCluster updates an existing cluster. + UpdateCluster(context.Context, *ClusterOptions) error + // ListClusters lists all APISIX clusters. + ListClusters() []Cluster + // DeleteCluster deletes the target APISIX cluster by its name. + DeleteCluster(name string) +} + +// Cluster defines specific operations that can be applied in an APISIX +// cluster. +type Cluster interface { + // Route returns a Route interface that can operate Route resources. + Route() Route + + Service() Service + // SSL returns a SSL interface that can operate SSL resources. + SSL() SSL + // StreamRoute returns a StreamRoute interface that can operate StreamRoute resources. + StreamRoute() StreamRoute + // GlobalRule returns a GlobalRule interface that can operate GlobalRule resources. + GlobalRule() GlobalRule + // String exposes the client information in human-readable format. + String() string + // HasSynced checks whether all resources in APISIX cluster is synced to cache. + HasSynced(context.Context) error + // Consumer returns a Consumer interface that can operate Consumer resources. + Consumer() Consumer + // HealthCheck checks apisix cluster health in realtime. + HealthCheck(context.Context) error + // Plugin returns a Plugin interface that can operate Plugin resources. + Plugin() Plugin + // PluginConfig returns a PluginConfig interface that can operate PluginConfig resources. + PluginConfig() PluginConfig + // Schema returns a Schema interface that can fetch schema of APISIX objects. + Schema() Schema + + PluginMetadata() PluginMetadata + + Validator() APISIXSchemaValidator +} + +// Route is the specific client interface to take over the create, update, +// list and delete for APISIX Route resource. +type Route interface { + Get(ctx context.Context, name string) (*v1.Route, error) + List(ctx context.Context, args ...any) ([]*v1.Route, error) + Create(ctx context.Context, route *v1.Route) (*v1.Route, error) + Delete(ctx context.Context, route *v1.Route) error + Update(ctx context.Context, route *v1.Route) (*v1.Route, error) +} + +// SSL is the specific client interface to take over the create, update, +// list and delete for APISIX SSL resource. +type SSL interface { + // name is namespace_sslname + Get(ctx context.Context, name string) (*v1.Ssl, error) + List(ctx context.Context, args ...any) ([]*v1.Ssl, error) + Create(ctx context.Context, ssl *v1.Ssl) (*v1.Ssl, error) + Delete(ctx context.Context, ssl *v1.Ssl) error + Update(ctx context.Context, ssl *v1.Ssl) (*v1.Ssl, error) +} + +// Upstream is the specific client interface to take over the create, update, +// list and delete for APISIX Upstream resource. +type Service interface { + Get(ctx context.Context, name string) (*v1.Service, error) + List(ctx context.Context, args ...any) ([]*v1.Service, error) + Create(ctx context.Context, svc *v1.Service) (*v1.Service, error) + Delete(ctx context.Context, svc *v1.Service) error + Update(ctx context.Context, svc *v1.Service) (*v1.Service, error) +} + +// StreamRoute is the specific client interface to take over the create, update, +// list and delete for APISIX Stream Route resource. +type StreamRoute interface { + Get(ctx context.Context, name string) (*v1.StreamRoute, error) + List(ctx context.Context) ([]*v1.StreamRoute, error) + Create(ctx context.Context, route *v1.StreamRoute) (*v1.StreamRoute, error) + Delete(ctx context.Context, route *v1.StreamRoute) error + Update(ctx context.Context, route *v1.StreamRoute) (*v1.StreamRoute, error) +} + +// GlobalRule is the specific client interface to take over the create, update, +// list and delete for APISIX Global Rule resource. +type GlobalRule interface { + Get(ctx context.Context, id string) (*v1.GlobalRule, error) + List(ctx context.Context) ([]*v1.GlobalRule, error) + Create(ctx context.Context, rule *v1.GlobalRule) (*v1.GlobalRule, error) + Delete(ctx context.Context, rule *v1.GlobalRule) error + Update(ctx context.Context, rule *v1.GlobalRule) (*v1.GlobalRule, error) +} + +// Consumer is the specific client interface to take over the create, update, +// list and delete for APISIX Consumer resource. +type Consumer interface { + Get(ctx context.Context, name string) (*v1.Consumer, error) + List(ctx context.Context) ([]*v1.Consumer, error) + Create(ctx context.Context, consumer *v1.Consumer) (*v1.Consumer, error) + Delete(ctx context.Context, consumer *v1.Consumer) error + Update(ctx context.Context, consumer *v1.Consumer) (*v1.Consumer, error) +} + +// Plugin is the specific client interface to fetch APISIX Plugin resource. +type Plugin interface { + List(ctx context.Context) ([]string, error) +} + +// Schema is the specific client interface to fetch the schema of APISIX objects. +type Schema interface { + GetPluginSchema(ctx context.Context, pluginName string) (*v1.Schema, error) + GetRouteSchema(ctx context.Context) (*v1.Schema, error) + GetUpstreamSchema(ctx context.Context) (*v1.Schema, error) + GetConsumerSchema(ctx context.Context) (*v1.Schema, error) + GetSslSchema(ctx context.Context) (*v1.Schema, error) + GetPluginConfigSchema(ctx context.Context) (*v1.Schema, error) +} + +// PluginConfig is the specific client interface to take over the create, update, +// list and delete for APISIX PluginConfig resource. +type PluginConfig interface { + Get(ctx context.Context, name string) (*v1.PluginConfig, error) + List(ctx context.Context) ([]*v1.PluginConfig, error) + Create(ctx context.Context, plugin *v1.PluginConfig) (*v1.PluginConfig, error) + Delete(ctx context.Context, plugin *v1.PluginConfig) error + Update(ctx context.Context, plugin *v1.PluginConfig) (*v1.PluginConfig, error) +} + +type PluginMetadata interface { + Get(ctx context.Context, name string) (*v1.PluginMetadata, error) + List(ctx context.Context) ([]*v1.PluginMetadata, error) + Delete(ctx context.Context, metadata *v1.PluginMetadata) error + Update(ctx context.Context, metadata *v1.PluginMetadata) (*v1.PluginMetadata, error) + Create(ctx context.Context, metadata *v1.PluginMetadata) (*v1.PluginMetadata, error) +} + +type APISIXSchemaValidator interface { + ValidateStreamPluginSchema(plugins v1.Plugins) (bool, error) + ValidateHTTPPluginSchema(plugins v1.Plugins) (bool, error) +} + +type apisix struct { + adminVersion string + mu sync.RWMutex + nonExistentCluster Cluster + clusters map[string]Cluster +} + +// NewClient creates an api7ee Dashboard client to perform resources change pushing. +func NewClient() (Dashboard, error) { + cli := &apisix{ + nonExistentCluster: newNonExistentCluster(), + clusters: make(map[string]Cluster), + } + return cli, nil +} + +// Cluster implements APISIX.Cluster method. +func (c *apisix) Cluster(name string) Cluster { + c.mu.RLock() + defer c.mu.RUnlock() + cluster, ok := c.clusters[name] + if !ok { + return c.nonExistentCluster + } + return cluster +} + +// ListClusters implements APISIX.ListClusters method. +func (c *apisix) ListClusters() []Cluster { + c.mu.RLock() + defer c.mu.RUnlock() + clusters := make([]Cluster, 0, len(c.clusters)) + for _, cluster := range c.clusters { + clusters = append(clusters, cluster) + } + return clusters +} + +// AddCluster implements APISIX.AddCluster method. +func (c *apisix) AddCluster(ctx context.Context, co *ClusterOptions) error { + c.mu.Lock() + defer c.mu.Unlock() + _, ok := c.clusters[co.Name] + if ok { + return ErrDuplicatedCluster + } + if co.AdminAPIVersion == "" { + co.AdminAPIVersion = c.adminVersion + } + cluster, err := newCluster(ctx, co) + if err != nil { + return err + } + c.clusters[co.Name] = cluster + return nil +} + +func (c *apisix) UpdateCluster(ctx context.Context, co *ClusterOptions) error { + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.clusters[co.Name]; !ok { + return ErrClusterNotExist + } + + if co.AdminAPIVersion == "" { + co.AdminAPIVersion = c.adminVersion + } + cluster, err := newCluster(ctx, co) + if err != nil { + return err + } + + c.clusters[co.Name] = cluster + return nil +} + +func (c *apisix) DeleteCluster(name string) { + c.mu.Lock() + defer c.mu.Unlock() + + // Don't have to close or free some resources in that cluster, so + // just delete its index. + delete(c.clusters, name) +} diff --git a/pkg/dashboard/global_rule.go b/pkg/dashboard/global_rule.go new file mode 100644 index 000000000..f339e26af --- /dev/null +++ b/pkg/dashboard/global_rule.go @@ -0,0 +1,170 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package dashboard + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" +) + +type globalRuleClient struct { + url string + cluster *cluster +} + +func newGlobalRuleClient(c *cluster) GlobalRule { + return &globalRuleClient{ + url: c.baseURL + "/global_rules", + cluster: c, + } +} + +// Get returns the GlobalRule. +// FIXME, currently if caller pass a non-existent resource, the Get always passes +// through cache. +func (r *globalRuleClient) Get(ctx context.Context, id string) (*v1.GlobalRule, error) { + return getFromCacheOrAPI( + ctx, + id, + r.url, + r.cluster.cache.GetGlobalRule, + r.cluster.cache.InsertGlobalRule, + r.cluster.GetGlobalRule, + ) +} + +// List is only used in cache warming up. So here just pass through +// to APISIX. +func (r *globalRuleClient) List(ctx context.Context) ([]*v1.GlobalRule, error) { + log.Debugw("try to list global_rules in APISIX", + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + url := r.url + globalRuleItems, err := r.cluster.listResource(ctx, url, "globalRule") + if err != nil { + log.Errorf("failed to list global_rules: %s", err) + return nil, err + } + + items := make([]*v1.GlobalRule, 0, len(globalRuleItems.List)) + for _, item := range globalRuleItems.List { + globalRule, err := item.globalRule() + if err != nil { + log.Errorw("failed to convert global_rule item", + zap.String("url", r.url), + zap.Error(err), + ) + return nil, err + } + + items = append(items, globalRule) + } + + return items, nil +} + +func (r *globalRuleClient) Create(ctx context.Context, obj *v1.GlobalRule) (*v1.GlobalRule, error) { + // Overwrite global rule ID with the plugin name + if len(obj.Plugins) == 0 { // This case will not happen as its handled at schema validation level + return nil, fmt.Errorf("global rule must have at least one plugin") + } + + // This is checked on dashboard that global rule id should be the plugin name + for pluginName := range obj.Plugins { + obj.ID = pluginName + break + } + + log.Debugw("try to create global_rule", + zap.String("id", obj.ID), + zap.Any("plugins", obj.Plugins), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + + if err := r.cluster.HasSynced(ctx); err != nil { + return nil, err + } + data, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + url := r.url + "/" + obj.ID + log.Debugw("creating global_rule", zap.ByteString("body", data), zap.String("url", url)) + resp, err := r.cluster.createResource(ctx, url, "globalRule", data) + if err != nil { + log.Errorf("failed to create global_rule: %s", err) + return nil, err + } + + globalRules, err := resp.globalRule() + if err != nil { + return nil, err + } + if err := r.cluster.cache.InsertGlobalRule(globalRules); err != nil { + log.Errorf("failed to reflect global_rules create to cache: %s", err) + return nil, err + } + return globalRules, nil +} + +func (r *globalRuleClient) Delete(ctx context.Context, obj *v1.GlobalRule) error { + log.Debugw("try to delete global_rule", + zap.String("id", obj.ID), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + if err := r.cluster.HasSynced(ctx); err != nil { + return err + } + url := r.url + "/" + obj.ID + if err := r.cluster.deleteResource(ctx, url, "globalRule"); err != nil { + return err + } + if err := r.cluster.cache.DeleteGlobalRule(obj); err != nil { + log.Errorf("failed to reflect global_rule delete to cache: %s", err) + if err != cache.ErrNotFound { + return err + } + } + return nil +} + +func (r *globalRuleClient) Update(ctx context.Context, obj *v1.GlobalRule) (*v1.GlobalRule, error) { + url := r.url + "/" + obj.ID + return updateResource( + ctx, + obj, + url, + "globalRule", + r.cluster.updateResource, + r.cluster.cache.InsertGlobalRule, + func(gr *getResponse) (*v1.GlobalRule, error) { + return gr.globalRule() + }, + ) +} diff --git a/pkg/dashboard/nonexistentclient.go b/pkg/dashboard/nonexistentclient.go new file mode 100644 index 000000000..c1405a0b9 --- /dev/null +++ b/pkg/dashboard/nonexistentclient.go @@ -0,0 +1,378 @@ +// 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 dashboard + +import ( + "context" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" +) + +type nonExistentCluster struct { + embedDummyResourceImplementer +} + +func newNonExistentCluster() *nonExistentCluster { + return &nonExistentCluster{ + embedDummyResourceImplementer{ + route: &dummyRoute{}, + ssl: &dummySSL{}, + service: &dummyService{}, + streamRoute: &dummyStreamRoute{}, + globalRule: &dummyGlobalRule{}, + consumer: &dummyConsumer{}, + plugin: &dummyPlugin{}, + schema: &dummySchema{}, + pluginConfig: &dummyPluginConfig{}, + pluginMetadata: &dummyPluginMetadata{}, + }, + } +} + +type embedDummyResourceImplementer struct { + route Route + ssl SSL + service Service + streamRoute StreamRoute + globalRule GlobalRule + consumer Consumer + plugin Plugin + schema Schema + pluginConfig PluginConfig + pluginMetadata PluginMetadata + validator APISIXSchemaValidator +} + +type dummyRoute struct{} + +func (f *dummyRoute) Get(_ context.Context, _ string) (*v1.Route, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyRoute) List(_ context.Context, _ ...any) ([]*v1.Route, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyRoute) Create(_ context.Context, _ *v1.Route) (*v1.Route, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyRoute) Delete(_ context.Context, _ *v1.Route) error { + return ErrClusterNotExist +} + +func (f *dummyRoute) Update(_ context.Context, _ *v1.Route) (*v1.Route, error) { + return nil, ErrClusterNotExist +} + +type dummySSL struct{} + +func (f *dummySSL) Get(_ context.Context, _ string) (*v1.Ssl, error) { + return nil, ErrClusterNotExist +} + +func (f *dummySSL) List(_ context.Context, _ ...any) ([]*v1.Ssl, error) { + return nil, ErrClusterNotExist +} + +func (f *dummySSL) Create(_ context.Context, _ *v1.Ssl) (*v1.Ssl, error) { + return nil, ErrClusterNotExist +} + +func (f *dummySSL) Delete(_ context.Context, _ *v1.Ssl) error { + return ErrClusterNotExist +} + +func (f *dummySSL) Update(_ context.Context, _ *v1.Ssl) (*v1.Ssl, error) { + return nil, ErrClusterNotExist +} + +type dummyService struct{} + +func (f *dummyService) Get(_ context.Context, _ string) (*v1.Service, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyService) List(_ context.Context, _ ...any) ([]*v1.Service, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyService) Create(_ context.Context, _ *v1.Service) (*v1.Service, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyService) Delete(_ context.Context, _ *v1.Service) error { + return ErrClusterNotExist +} + +func (f *dummyService) Update(_ context.Context, _ *v1.Service) (*v1.Service, error) { + return nil, ErrClusterNotExist +} + +type dummyStreamRoute struct{} + +func (f *dummyStreamRoute) Get(_ context.Context, _ string) (*v1.StreamRoute, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyStreamRoute) List(_ context.Context) ([]*v1.StreamRoute, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyStreamRoute) Create(_ context.Context, _ *v1.StreamRoute) (*v1.StreamRoute, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyStreamRoute) Delete(_ context.Context, _ *v1.StreamRoute) error { + return ErrClusterNotExist +} + +func (f *dummyStreamRoute) Update(_ context.Context, _ *v1.StreamRoute) (*v1.StreamRoute, error) { + return nil, ErrClusterNotExist +} + +type dummyGlobalRule struct{} + +func (f *dummyGlobalRule) Get(_ context.Context, _ string) (*v1.GlobalRule, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyGlobalRule) List(_ context.Context) ([]*v1.GlobalRule, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyGlobalRule) Create(_ context.Context, _ *v1.GlobalRule) (*v1.GlobalRule, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyGlobalRule) Delete(_ context.Context, _ *v1.GlobalRule) error { + return ErrClusterNotExist +} + +func (f *dummyGlobalRule) Update(_ context.Context, _ *v1.GlobalRule) (*v1.GlobalRule, error) { + return nil, ErrClusterNotExist +} + +type dummyConsumer struct{} + +func (f *dummyConsumer) Get(_ context.Context, _ string) (*v1.Consumer, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyConsumer) List(_ context.Context) ([]*v1.Consumer, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyConsumer) Create(_ context.Context, _ *v1.Consumer) (*v1.Consumer, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyConsumer) Delete(_ context.Context, _ *v1.Consumer) error { + return ErrClusterNotExist +} + +func (f *dummyConsumer) Update(_ context.Context, _ *v1.Consumer) (*v1.Consumer, error) { + return nil, ErrClusterNotExist +} + +type dummyPlugin struct{} + +func (f *dummyPlugin) List(_ context.Context) ([]string, error) { + return nil, ErrClusterNotExist +} + +type dummySchema struct{} + +func (f *dummySchema) GetPluginSchema(_ context.Context, _ string) (*v1.Schema, error) { + return nil, ErrClusterNotExist +} + +func (f *dummySchema) GetRouteSchema(_ context.Context) (*v1.Schema, error) { + return nil, ErrClusterNotExist +} + +func (f *dummySchema) GetUpstreamSchema(_ context.Context) (*v1.Schema, error) { + return nil, ErrClusterNotExist +} + +func (f *dummySchema) GetConsumerSchema(_ context.Context) (*v1.Schema, error) { + return nil, ErrClusterNotExist +} + +func (f *dummySchema) GetSslSchema(_ context.Context) (*v1.Schema, error) { + return nil, ErrClusterNotExist +} + +func (f *dummySchema) GetPluginConfigSchema(_ context.Context) (*v1.Schema, error) { + return nil, ErrClusterNotExist +} + +type dummyPluginConfig struct{} + +func (f *dummyPluginConfig) Get(_ context.Context, _ string) (*v1.PluginConfig, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyPluginConfig) List(_ context.Context) ([]*v1.PluginConfig, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyPluginConfig) Create(_ context.Context, _ *v1.PluginConfig) (*v1.PluginConfig, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyPluginConfig) Delete(_ context.Context, _ *v1.PluginConfig) error { + return ErrClusterNotExist +} + +func (f *dummyPluginConfig) Update(_ context.Context, _ *v1.PluginConfig) (*v1.PluginConfig, error) { + return nil, ErrClusterNotExist +} + +type dummyPluginMetadata struct { +} + +func (f *dummyPluginMetadata) Get(_ context.Context, _ string) (*v1.PluginMetadata, error) { + return nil, ErrClusterNotExist +} + +func (f *dummyPluginMetadata) List(_ context.Context) ([]*v1.PluginMetadata, error) { + return nil, ErrClusterNotExist +} +func (f *dummyPluginMetadata) Delete(_ context.Context, _ *v1.PluginMetadata) error { + return ErrClusterNotExist +} +func (f *dummyPluginMetadata) Update(_ context.Context, _ *v1.PluginMetadata) (*v1.PluginMetadata, error) { + return nil, ErrClusterNotExist +} +func (f *dummyPluginMetadata) Create(_ context.Context, _ *v1.PluginMetadata) (*v1.PluginMetadata, error) { + return nil, ErrClusterNotExist +} + +type dummyValidator struct{} + +func newDummyValidator() APISIXSchemaValidator { + return &dummyValidator{} +} + +func (d *dummyValidator) ValidateHTTPPluginSchema(plugins v1.Plugins) (bool, error) { + return true, nil +} + +func (d *dummyValidator) ValidateStreamPluginSchema(plugins v1.Plugins) (bool, error) { + return true, nil +} + +func (nc *nonExistentCluster) Route() Route { + return nc.route +} + +func (nc *nonExistentCluster) SSL() SSL { + return nc.ssl +} + +func (nc *nonExistentCluster) Service() Service { + return nc.service +} + +func (nc *nonExistentCluster) StreamRoute() StreamRoute { + return nc.streamRoute +} + +func (nc *nonExistentCluster) GlobalRule() GlobalRule { + return nc.globalRule +} + +func (nc *nonExistentCluster) Consumer() Consumer { + return nc.consumer +} + +func (nc *nonExistentCluster) Plugin() Plugin { + return nc.plugin +} + +func (nc *nonExistentCluster) Validator() APISIXSchemaValidator { + return nc.validator +} + +func (nc *nonExistentCluster) PluginConfig() PluginConfig { + return nc.pluginConfig +} + +func (nc *nonExistentCluster) Schema() Schema { + return nc.schema +} +func (nc *nonExistentCluster) PluginMetadata() PluginMetadata { + return nc.pluginMetadata +} + +func (nc *nonExistentCluster) HasSynced(_ context.Context) error { + return nil +} + +func (nc *nonExistentCluster) HealthCheck(_ context.Context) error { + return nil +} + +func (nc *nonExistentCluster) String() string { + return "non-existent cluster" +} + +type dummyCache struct{} + +var _ cache.Cache = &dummyCache{} + +func (c *dummyCache) InsertRoute(_ *v1.Route) error { return nil } +func (c *dummyCache) InsertSSL(_ *v1.Ssl) error { return nil } +func (c *dummyCache) InsertService(_ *v1.Service) error { return nil } +func (c *dummyCache) InsertStreamRoute(_ *v1.StreamRoute) error { return nil } +func (c *dummyCache) InsertGlobalRule(_ *v1.GlobalRule) error { return nil } +func (c *dummyCache) InsertConsumer(_ *v1.Consumer) error { return nil } +func (c *dummyCache) InsertSchema(_ *v1.Schema) error { return nil } +func (c *dummyCache) InsertPluginConfig(_ *v1.PluginConfig) error { return nil } +func (c *dummyCache) GetRoute(_ string) (*v1.Route, error) { return nil, cache.ErrNotFound } +func (c *dummyCache) GetSSL(_ string) (*v1.Ssl, error) { return nil, cache.ErrNotFound } +func (c *dummyCache) GetService(_ string) (*v1.Service, error) { return nil, cache.ErrNotFound } +func (c *dummyCache) GetStreamRoute(_ string) (*v1.StreamRoute, error) { return nil, cache.ErrNotFound } +func (c *dummyCache) GetGlobalRule(_ string) (*v1.GlobalRule, error) { return nil, cache.ErrNotFound } +func (c *dummyCache) GetConsumer(_ string) (*v1.Consumer, error) { return nil, cache.ErrNotFound } +func (c *dummyCache) GetSchema(_ string) (*v1.Schema, error) { return nil, cache.ErrNotFound } +func (c *dummyCache) GetPluginConfig(_ string) (*v1.PluginConfig, error) { + return nil, cache.ErrNotFound +} + +func (c *dummyCache) ListRoutes(...any) ([]*v1.Route, error) { return nil, nil } +func (c *dummyCache) ListSSL(_ ...any) ([]*v1.Ssl, error) { return nil, nil } +func (c *dummyCache) ListServices(...any) ([]*v1.Service, error) { return nil, nil } +func (c *dummyCache) ListStreamRoutes() ([]*v1.StreamRoute, error) { return nil, nil } +func (c *dummyCache) ListGlobalRules() ([]*v1.GlobalRule, error) { return nil, nil } +func (c *dummyCache) ListConsumers() ([]*v1.Consumer, error) { return nil, nil } +func (c *dummyCache) ListSchema() ([]*v1.Schema, error) { return nil, nil } +func (c *dummyCache) ListPluginConfigs() ([]*v1.PluginConfig, error) { return nil, nil } + +func (c *dummyCache) DeleteRoute(_ *v1.Route) error { return nil } +func (c *dummyCache) DeleteSSL(_ *v1.Ssl) error { return nil } +func (c *dummyCache) DeleteService(_ *v1.Service) error { return nil } +func (c *dummyCache) DeleteStreamRoute(_ *v1.StreamRoute) error { return nil } +func (c *dummyCache) DeleteGlobalRule(_ *v1.GlobalRule) error { return nil } +func (c *dummyCache) DeleteConsumer(_ *v1.Consumer) error { return nil } +func (c *dummyCache) DeleteSchema(_ *v1.Schema) error { return nil } +func (c *dummyCache) DeletePluginConfig(_ *v1.PluginConfig) error { return nil } +func (c *dummyCache) CheckServiceReference(_ *v1.Service) error { return nil } +func (c *dummyCache) CheckPluginConfigReference(_ *v1.PluginConfig) error { return nil } diff --git a/pkg/dashboard/noop.go b/pkg/dashboard/noop.go new file mode 100644 index 000000000..d5906bb47 --- /dev/null +++ b/pkg/dashboard/noop.go @@ -0,0 +1,51 @@ +// 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 dashboard + +import ( + "context" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +var ( + _ StreamRoute = (*noopClient)(nil) +) + +type noopClient struct { +} + +func (r *noopClient) Get(ctx context.Context, name string) (*v1.StreamRoute, error) { + return nil, nil +} + +func (r *noopClient) List(ctx context.Context) ([]*v1.StreamRoute, error) { + return nil, nil +} + +func (r *noopClient) Create(ctx context.Context, obj *v1.StreamRoute) (*v1.StreamRoute, error) { + return nil, nil +} + +func (r *noopClient) Delete(ctx context.Context, obj *v1.StreamRoute) error { + return nil +} + +func (r *noopClient) Update(ctx context.Context, obj *v1.StreamRoute) (*v1.StreamRoute, error) { + return nil, nil +} diff --git a/pkg/dashboard/plugin.go b/pkg/dashboard/plugin.go new file mode 100644 index 000000000..cb8b8128b --- /dev/null +++ b/pkg/dashboard/plugin.go @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package dashboard + +import ( + "context" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" +) + +type pluginClient struct { + url string + cluster *cluster +} + +func newPluginClient(c *cluster) Plugin { + return &pluginClient{ + url: c.baseURL + "/plugins", + cluster: c, + } +} + +// List returns the names of all plugins. +func (p *pluginClient) List(ctx context.Context) ([]string, error) { + log.Debugw("try to list plugin names in APISIX", + zap.String("cluster", p.cluster.name), + zap.String("url", p.url), + ) + url := p.url + "/list" + pluginList, err := p.cluster.getList(ctx, url, "plugin") + if err != nil { + log.Errorf("failed to list plugin names: %s", err) + return nil, err + } + log.Debugf("plugin list: %v", pluginList) + return pluginList, nil +} diff --git a/pkg/dashboard/plugin_metadata.go b/pkg/dashboard/plugin_metadata.go new file mode 100644 index 000000000..c389bed75 --- /dev/null +++ b/pkg/dashboard/plugin_metadata.go @@ -0,0 +1,154 @@ +// 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 dashboard + +import ( + "context" + "encoding/json" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +type pluginMetadataClient struct { + url string + cluster *cluster +} + +func newPluginMetadataClient(c *cluster) *pluginMetadataClient { + return &pluginMetadataClient{ + url: c.baseURL + "/plugin_metadata", + cluster: c, + } +} + +func (r *pluginMetadataClient) Get(ctx context.Context, name string) (*v1.PluginMetadata, error) { + log.Debugw("try to look up pluginMetadata", + zap.String("name", name), + zap.String("url", r.url), + zap.String("cluster", r.cluster.name), + ) + + // TODO Add mutex here to avoid dog-pile effect. + url := r.url + "/" + name + resp, err := r.cluster.getResource(ctx, url, "pluginMetadata") + if err != nil { + log.Errorw("failed to get pluginMetadata from APISIX", + zap.String("name", name), + zap.String("url", url), + zap.String("cluster", r.cluster.name), + zap.Error(err), + ) + return nil, err + } + + pluginMetadata, err := resp.pluginMetadata() + if err != nil { + log.Errorw("failed to convert pluginMetadata item", + zap.String("url", r.url), + zap.Error(err), + ) + return nil, err + } + return pluginMetadata, nil +} + +func (r *pluginMetadataClient) List(ctx context.Context) (list []*v1.PluginMetadata, err error) { + log.Debugw("try to list pluginMetadatas in APISIX", + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + var resp = struct { + Value map[string]map[string]any + }{} + err = r.cluster.listResourceToResponse(ctx, r.url, "plugin_metadata", &resp) + if err != nil { + log.Errorf("failed to list pluginMetadatas: %s", err) + return nil, err + } + for name, metadata := range resp.Value { + list = append(list, &v1.PluginMetadata{ + Name: name, + Metadata: metadata, + }) + } + + return +} + +func (r *pluginMetadataClient) Delete(ctx context.Context, obj *v1.PluginMetadata) error { + log.Debugw("try to delete pluginMetadata", + zap.String("name", obj.Name), + zap.Any("metadata", obj.Metadata), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + if err := r.cluster.HasSynced(ctx); err != nil { + return err + } + url := r.url + "/" + obj.Name + if err := r.cluster.deleteResource(ctx, url, "pluginMetadata"); err != nil { + return err + } + return nil +} + +func (r *pluginMetadataClient) Update(ctx context.Context, obj *v1.PluginMetadata) (*v1.PluginMetadata, error) { + url := r.url + "/" + obj.Name + return updateResource( + ctx, + obj, + url, + "pluginMetadata", + r.cluster.updateResource, + func(obj *v1.PluginMetadata) error { + return nil + }, + func(resp *getResponse) (*v1.PluginMetadata, error) { + return resp.pluginMetadata() + }, + ) +} + +func (r *pluginMetadataClient) Create(ctx context.Context, obj *v1.PluginMetadata) (*v1.PluginMetadata, error) { + log.Debugw("try to create pluginMetadata", + zap.String("name", obj.Name), + zap.Any("metadata", obj.Metadata), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + if err := r.cluster.HasSynced(ctx); err != nil { + return nil, err + } + body, err := json.Marshal(obj.Metadata) + if err != nil { + return nil, err + } + url := r.url + "/" + obj.Name + resp, err := r.cluster.updateResource(ctx, url, "pluginMetadata", body) + if err != nil { + return nil, err + } + pluginMetadata, err := resp.pluginMetadata() + if err != nil { + return nil, err + } + return pluginMetadata, nil +} diff --git a/pkg/dashboard/plugin_test.go b/pkg/dashboard/plugin_test.go new file mode 100644 index 000000000..36b6b890f --- /dev/null +++ b/pkg/dashboard/plugin_test.go @@ -0,0 +1,121 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package dashboard + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/net/nettest" +) + +type fakeAPISIXPluginSrv struct { + plugins []string +} + +var fakePluginNames = []string{ + "plugin-1", + "plugin-2", + "plugin-3", +} + +func (srv *fakeAPISIXPluginSrv) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer func() { + _ = r.Body.Close() + }() + + if !strings.HasPrefix(r.URL.Path, "/apisix/admin/plugins") { + w.WriteHeader(http.StatusNotFound) + return + } + if strings.HasPrefix(r.URL.Path, "/apisix/admin/plugins/list") { + byt, _ := json.Marshal(fakePluginNames) + _, _ = w.Write(byt) + return + } + fakePluginsResp := make(map[string]any, len(srv.plugins)) + for _, fp := range srv.plugins { + fakePluginsResp[fp] = struct{}{} + } + + if r.Method == http.MethodGet { + data, _ := json.Marshal(fakePluginsResp) + _, _ = w.Write(data) + w.WriteHeader(http.StatusOK) + return + } +} + +func runFakePluginSrv(t *testing.T) *http.Server { + srv := &fakeAPISIXPluginSrv{ + plugins: fakePluginNames, + } + + ln, _ := nettest.NewLocalListener("tcp") + + httpSrv := &http.Server{ + Addr: ln.Addr().String(), + Handler: srv, + } + + go func() { + if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { + t.Errorf("failed to run http server: %s", err) + } + }() + + return httpSrv +} + +func TestPluginClient(t *testing.T) { + srv := runFakePluginSrv(t) + defer func() { + assert.Nil(t, srv.Shutdown(context.Background())) + }() + + u := url.URL{ + Scheme: "http", + Host: srv.Addr, + Path: "/apisix/admin", + } + + closedCh := make(chan struct{}) + close(closedCh) + cli := newPluginClient(&cluster{ + baseURL: u.String(), + cli: http.DefaultClient, + cache: &dummyCache{}, + cacheSynced: closedCh, + }) + + // List + objs, err := cli.List(context.Background()) + assert.Nil(t, err) + assert.Len(t, objs, len(fakePluginNames)) + sort.Strings(fakePluginNames) + sort.Strings(objs) + for i := range fakePluginNames { + assert.Equal(t, fakePluginNames[i], objs[i]) + } +} diff --git a/pkg/dashboard/pluginconfig.go b/pkg/dashboard/pluginconfig.go new file mode 100644 index 000000000..f846c595f --- /dev/null +++ b/pkg/dashboard/pluginconfig.go @@ -0,0 +1,164 @@ +// 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 dashboard + +import ( + "context" + "encoding/json" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" + "github.com/apache/apisix-ingress-controller/pkg/id" +) + +type pluginConfigClient struct { + url string + cluster *cluster +} + +func newPluginConfigClient(c *cluster) PluginConfig { + return &pluginConfigClient{ + url: c.baseURL + "/plugin_configs", + cluster: c, + } +} + +// Get returns the v1.PluginConfig. +// FIXME, currently if caller pass a non-existent resource, the Get always passes +// through cache. +func (pc *pluginConfigClient) Get(ctx context.Context, name string) (*v1.PluginConfig, error) { + return getFromCacheOrAPI( + ctx, + id.GenID(name), + pc.url, + pc.cluster.cache.GetPluginConfig, + pc.cluster.cache.InsertPluginConfig, + pc.cluster.GetPluginConfig, + ) +} + +// List is only used in cache warming up. So here just pass through +// to APISIX. +func (pc *pluginConfigClient) List(ctx context.Context) ([]*v1.PluginConfig, error) { + log.Debugw("try to list pluginConfig in APISIX", + zap.String("cluster", pc.cluster.name), + zap.String("url", pc.url), + ) + pluginConfigItems, err := pc.cluster.listResource(ctx, pc.url, "pluginConfig") + if err != nil { + log.Errorf("failed to list pluginConfig: %s", err) + return nil, err + } + + items := make([]*v1.PluginConfig, 0, len(pluginConfigItems.List)) + for _, item := range pluginConfigItems.List { + pluginConfig, err := item.pluginConfig() + if err != nil { + log.Errorw("failed to convert pluginConfig item", + zap.String("url", pc.url), + zap.Error(err), + ) + return nil, err + } + + items = append(items, pluginConfig) + } + + return items, nil +} + +func (pc *pluginConfigClient) Create(ctx context.Context, obj *v1.PluginConfig) (*v1.PluginConfig, error) { + log.Debugw("try to create pluginConfig", + zap.String("name", obj.Name), + zap.Any("plugins", obj.Plugins), + zap.String("cluster", pc.cluster.name), + zap.String("url", pc.url), + ) + + if err := pc.cluster.HasSynced(ctx); err != nil { + return nil, err + } + data, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + url := pc.url + "/" + obj.ID + log.Debugw("creating pluginConfig", zap.ByteString("body", data), zap.String("url", url)) + resp, err := pc.cluster.createResource(ctx, url, "pluginConfig", data) + if err != nil { + log.Errorf("failed to create pluginConfig: %s", err) + return nil, err + } + + pluginConfig, err := resp.pluginConfig() + if err != nil { + return nil, err + } + if err := pc.cluster.cache.InsertPluginConfig(pluginConfig); err != nil { + log.Errorf("failed to reflect pluginConfig create to cache: %s", err) + return nil, err + } + return pluginConfig, nil +} + +func (pc *pluginConfigClient) Delete(ctx context.Context, obj *v1.PluginConfig) error { + log.Debugw("try to delete pluginConfig", + zap.String("id", obj.ID), + zap.String("name", obj.Name), + zap.String("cluster", pc.cluster.name), + zap.String("url", pc.url), + ) + err := pc.cluster.cache.CheckPluginConfigReference(obj) + if err != nil { + log.Warnw("deletion for plugin config: " + obj.Name + " aborted as it is still in use.") + return err + } + if err := pc.cluster.HasSynced(ctx); err != nil { + return err + } + url := pc.url + "/" + obj.ID + if err := pc.cluster.deleteResource(ctx, url, "pluginConfig"); err != nil { + return err + } + if err := pc.cluster.cache.DeletePluginConfig(obj); err != nil { + log.Errorf("failed to reflect pluginConfig delete to cache: %s", err) + if err != cache.ErrNotFound { + return err + } + } + return nil +} + +func (pc *pluginConfigClient) Update(ctx context.Context, obj *v1.PluginConfig) (*v1.PluginConfig, error) { + url := pc.url + "/" + obj.ID + return updateResource( + ctx, + obj, + url, + "pluginConfig", + pc.cluster.updateResource, + pc.cluster.cache.InsertPluginConfig, + func(resp *getResponse) (*v1.PluginConfig, error) { + return resp.pluginConfig() + }, + ) +} diff --git a/pkg/dashboard/resource.go b/pkg/dashboard/resource.go new file mode 100644 index 000000000..972d9c694 --- /dev/null +++ b/pkg/dashboard/resource.go @@ -0,0 +1,386 @@ +// 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 dashboard + +import ( + "encoding/json" + "strconv" + "strings" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +type getResponse struct { + Key string `json:"key"` + Value map[string]any `json:"value"` +} + +type listResponse struct { + Total IntOrString `json:"total"` + List listItems `json:"list"` +} + +type listItems []listItem + +type listItem map[string]any + +// IntOrString processing number and string types, after json deserialization will output int +type IntOrString struct { + IntValue int `json:"int_value"` +} + +func (ios *IntOrString) UnmarshalJSON(p []byte) error { + result := strings.Trim(string(p), "\"") + count, err := strconv.Atoi(result) + if err != nil { + return err + } + ios.IntValue = count + return nil +} + +type updateResponse = getResponse + +type updateResponseV3 = getResponse + +// type node struct { +// Key string `json:"key"` +// Items items `json:"nodes"` +// } + +// type items []item + +// // UnmarshalJSON implements json.Unmarshaler interface. +// // lua-cjson doesn't distinguish empty array and table, +// // and by default empty array will be encoded as '{}'. +// // We have to maintain the compatibility. +// func (items *items) UnmarshalJSON(p []byte) error { +// if p[0] == '{' { +// if len(p) != 2 { +// return errors.New("unexpected non-empty object") +// } +// return nil +// } +// var data []item +// if err := json.Unmarshal(p, &data); err != nil { +// return err +// } +// *items = data +// return nil +// } + +// type item struct { +// Key string `json:"key"` +// Value json.RawMessage `json:"value"` +// } + +// // route decodes item.Value and converts it to v1.Route. +// func (i *item) route() (*v1.Route, error) { +// log.Debugf("got route: %s", string(i.Value)) +// list := strings.Split(i.Key, "/") +// if len(list) < 1 { +// return nil, fmt.Errorf("bad route config key: %s", i.Key) +// } + +// var route v1.Route +// if err := json.Unmarshal(i.Value, &route); err != nil { +// return nil, err +// } +// return &route, nil +// } + +func (i *getResponse) route() (*v1.Route, error) { + byt, err := json.Marshal(i.Value) + if err != nil { + return nil, err + } + var route v1.Route + if err := json.Unmarshal(byt, &route); err != nil { + return nil, err + } + return &route, nil +} + +func (i *listItem) route() (*v1.Route, error) { + byt, err := json.Marshal(i) + if err != nil { + return nil, err + } + var route v1.Route + if err := json.Unmarshal(byt, &route); err != nil { + return nil, err + } + return &route, nil +} + +func (i *listItem) streamRoute() (*v1.StreamRoute, error) { + byt, err := json.Marshal(i) + if err != nil { + return nil, err + } + var streamRoute v1.StreamRoute + if err := json.Unmarshal(byt, &streamRoute); err != nil { + return nil, err + } + return &streamRoute, nil +} + +func (i *getResponse) streamRoute() (*v1.StreamRoute, error) { + byt, err := json.Marshal(i.Value) + if err != nil { + return nil, err + } + var streamRoute v1.StreamRoute + if err := json.Unmarshal(byt, &streamRoute); err != nil { + return nil, err + } + return &streamRoute, nil +} + +// upstream decodes item.Value and converts it to v1.Upstream. +// func (i *item) upstream() (*v1.Upstream, error) { +// log.Debugf("got upstream: %s", string(i.Value)) +// list := strings.Split(i.Key, "/") +// if len(list) < 1 { +// return nil, fmt.Errorf("bad upstream config key: %s", i.Key) +// } + +// var ups v1.Upstream +// if err := json.Unmarshal(i.Value, &ups); err != nil { +// return nil, err +// } + +// // This is a workaround scheme to avoid APISIX's +// // health check schema about the health checker intervals. +// if ups.Checks != nil && ups.Checks.Active != nil { +// if ups.Checks.Active.Healthy.Interval == 0 { +// ups.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) +// } +// if ups.Checks.Active.Unhealthy.Interval == 0 { +// ups.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) +// } +// } +// return &ups, nil +// } + +// upstream decodes response and converts it to v1.Upstream. +func (i *getResponse) service() (*v1.Service, error) { + byt, err := json.Marshal(i.Value) + if err != nil { + return nil, err + } + var svc v1.Service + if err := json.Unmarshal(byt, &svc); err != nil { + return nil, err + } + ups := svc.Upstream + ups.ID = svc.ID + // This is a workaround scheme to avoid APISIX's + // health check schema about the health checker intervals. + if ups.Checks != nil && ups.Checks.Active != nil { + if ups.Checks.Active.Healthy.Interval == 0 { + ups.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) + } + if ups.Checks.Active.Unhealthy.Interval == 0 { + ups.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) + } + } + svc.Upstream = ups + return &svc, nil +} + +func (i *listItem) service() (*v1.Service, error) { + byt, err := json.Marshal(i) + if err != nil { + return nil, err + } + var svc v1.Service + if err := json.Unmarshal(byt, &svc); err != nil { + return nil, err + } + // This is a workaround scheme to avoid APISIX's + // health check schema about the health checker intervals. + if svc.Upstream.Checks != nil && svc.Upstream.Checks.Active != nil { + if svc.Upstream.Checks.Active.Healthy.Interval == 0 { + svc.Upstream.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) + } + if svc.Upstream.Checks.Active.Unhealthy.Interval == 0 { + svc.Upstream.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) + } + } + return &svc, nil +} + +// ssl decodes item.Value and converts it to v1.Ssl. +// func (i *item) ssl() (*v1.Ssl, error) { +// log.Debugf("got ssl: %s", string(i.Value)) +// var ssl v1.Ssl +// if err := json.Unmarshal(i.Value, &ssl); err != nil { +// return nil, err +// } +// return &ssl, nil +// } + +func (i *getResponse) ssl() (*v1.Ssl, error) { + byt, err := json.Marshal(i.Value) + if err != nil { + return nil, err + } + var ssl v1.Ssl + if err := json.Unmarshal(byt, &ssl); err != nil { + return nil, err + } + return &ssl, nil +} + +func (i *listItem) ssl() (*v1.Ssl, error) { + byt, err := json.Marshal(i) + if err != nil { + return nil, err + } + var ssl v1.Ssl + if err := json.Unmarshal(byt, &ssl); err != nil { + return nil, err + } + return &ssl, nil +} + +// globalRule decodes item.Value and converts it to v1.GlobalRule. +// func (i *item) globalRule() (*v1.GlobalRule, error) { +// log.Debugf("got global_rule: %s", string(i.Value)) +// var globalRule v1.GlobalRule +// if err := json.Unmarshal(i.Value, &globalRule); err != nil { +// return nil, err +// } +// return &globalRule, nil +// } + +func (i *getResponse) globalRule() (*v1.GlobalRule, error) { + byt, err := json.Marshal(i.Value) + if err != nil { + return nil, err + } + var globalRule v1.GlobalRule + if err := json.Unmarshal(byt, &globalRule); err != nil { + return nil, err + } + return &globalRule, nil +} + +func (i *listItem) globalRule() (*v1.GlobalRule, error) { + byt, err := json.Marshal(i) + if err != nil { + return nil, err + } + var globalRule v1.GlobalRule + if err := json.Unmarshal(byt, &globalRule); err != nil { + return nil, err + } + return &globalRule, nil +} + +// consumer decodes item.Value and converts it to v1.Consumer. +// func (i *item) consumer() (*v1.Consumer, error) { +// log.Debugf("got consumer: %s", string(i.Value)) +// var consumer v1.Consumer +// if err := json.Unmarshal(i.Value, &consumer); err != nil { +// return nil, err +// } +// return &consumer, nil +// } + +func (i *getResponse) consumer() (*v1.Consumer, error) { + byt, err := json.Marshal(i.Value) + if err != nil { + return nil, err + } + var consumer v1.Consumer + if err := json.Unmarshal(byt, &consumer); err != nil { + return nil, err + } + return &consumer, nil +} + +func (i *listItem) consumer() (*v1.Consumer, error) { + byt, err := json.Marshal(i) + if err != nil { + return nil, err + } + var consumer v1.Consumer + if err := json.Unmarshal(byt, &consumer); err != nil { + return nil, err + } + return &consumer, nil +} + +// func (i *item) pluginMetadata() (*v1.PluginMetadata, error) { +// log.Debugf("got pluginMetadata: %s", string(i.Value)) +// var pluginMetadata v1.PluginMetadata +// if err := json.Unmarshal(i.Value, &pluginMetadata.Metadata); err != nil { +// return nil, err +// } +// keys := strings.Split(i.Key, "/") +// pluginMetadata.Name = keys[len(keys)-1] +// return &pluginMetadata, nil +// } + +func (i *getResponse) pluginMetadata() (*v1.PluginMetadata, error) { + byt, err := json.Marshal(i.Value) + if err != nil { + return nil, err + } + var pluginMetadata v1.PluginMetadata + if err := json.Unmarshal(byt, &pluginMetadata.Metadata); err != nil { + return nil, err + } + return &pluginMetadata, nil +} + +// // pluginConfig decodes item.Value and converts it to v1.PluginConfig. +// func (i *item) pluginConfig() (*v1.PluginConfig, error) { +// log.Debugf("got pluginConfig: %s", string(i.Value)) +// var pluginConfig v1.PluginConfig +// if err := json.Unmarshal(i.Value, &pluginConfig); err != nil { +// return nil, err +// } +// return &pluginConfig, nil +// } + +func (i *getResponse) pluginConfig() (*v1.PluginConfig, error) { + byt, err := json.Marshal(i.Value) + if err != nil { + return nil, err + } + var pluginConfig v1.PluginConfig + if err := json.Unmarshal(byt, &pluginConfig); err != nil { + return nil, err + } + return &pluginConfig, nil +} + +func (i *listItem) pluginConfig() (*v1.PluginConfig, error) { + byt, err := json.Marshal(i) + if err != nil { + return nil, err + } + var pluginConfig v1.PluginConfig + if err := json.Unmarshal(byt, &pluginConfig); err != nil { + return nil, err + } + return &pluginConfig, nil +} diff --git a/pkg/dashboard/route.go b/pkg/dashboard/route.go new file mode 100644 index 000000000..bea628310 --- /dev/null +++ b/pkg/dashboard/route.go @@ -0,0 +1,159 @@ +// 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 dashboard + +import ( + "context" + "encoding/json" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" + "github.com/apache/apisix-ingress-controller/pkg/id" +) + +type routeClient struct { + url string + cluster *cluster +} + +func newRouteClient(c *cluster) Route { + return &routeClient{ + url: c.baseURL + "/routes", + cluster: c, + } +} + +// Get returns the Route. +// FIXME, currently if caller pass a non-existent resource, the Get always passes +// through cache. +func (r *routeClient) Get(ctx context.Context, name string) (*v1.Route, error) { + return getFromCacheOrAPI( + ctx, + id.GenID(name), + r.url, + r.cluster.cache.GetRoute, + r.cluster.cache.InsertRoute, + r.cluster.GetRoute, + ) +} + +// List is only used in cache warming up. So here just pass through +// to APISIX. +func (r *routeClient) List(ctx context.Context, args ...any) ([]*v1.Route, error) { + log.Debugw("try to list routes in APISIX", + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + routeItems, err := r.cluster.listResource(ctx, r.url, "route") + if err != nil { + log.Errorf("failed to list routes: %s", err) + return nil, err + } + + items := make([]*v1.Route, 0, len(routeItems.List)) + for _, item := range routeItems.List { + route, err := item.route() + if err != nil { + log.Errorw("failed to convert route item", + zap.String("url", r.url), + zap.Error(err), + ) + return nil, err + } + + items = append(items, route) + } + + return items, nil +} + +func (r *routeClient) Create(ctx context.Context, obj *v1.Route) (*v1.Route, error) { + obj.Name = obj.ID + log.Debugw("try to create route", + zap.Strings("hosts", obj.Hosts), + zap.String("name", obj.Name), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + + if err := r.cluster.HasSynced(ctx); err != nil { + return nil, err + } + data, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + url := r.url + "/" + obj.ID + resp, err := r.cluster.createResource(ctx, url, "route", data) + if err != nil { + log.Errorf("failed to create route: %s", err) + return nil, err + } + + route, err := resp.route() + if err != nil { + return nil, err + } + if err := r.cluster.cache.InsertRoute(route); err != nil { + log.Errorf("failed to reflect route create to cache: %s", err) + return nil, err + } + return route, nil +} + +func (r *routeClient) Delete(ctx context.Context, obj *v1.Route) error { + log.Debugw("try to delete route", + zap.String("id", obj.ID), + zap.String("name", obj.Name), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + if err := r.cluster.HasSynced(ctx); err != nil { + return err + } + url := r.url + "/" + obj.ID + if err := r.cluster.deleteResource(ctx, url, "route"); err != nil { + return err + } + if err := r.cluster.cache.DeleteRoute(obj); err != nil { + log.Errorf("failed to reflect route delete to cache: %s", err) + if err != cache.ErrNotFound { + return err + } + } + return nil +} + +func (r *routeClient) Update(ctx context.Context, obj *v1.Route) (*v1.Route, error) { + url := r.url + "/" + obj.ID + return updateResource( + ctx, + obj, + url, + "route", + r.cluster.updateResource, + r.cluster.cache.InsertRoute, + func(resp *getResponse) (*v1.Route, error) { + return resp.route() + }, + ) +} diff --git a/pkg/dashboard/schema.go b/pkg/dashboard/schema.go new file mode 100644 index 000000000..5b82ef466 --- /dev/null +++ b/pkg/dashboard/schema.go @@ -0,0 +1,119 @@ +// 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 dashboard + +import ( + "context" + "strings" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" + "github.com/apache/apisix-ingress-controller/pkg/id" +) + +type schemaClient struct { + url string + cluster *cluster +} + +func newSchemaClient(c *cluster) Schema { + return &schemaClient{ + url: c.baseURL + "/schema", + cluster: c, + } +} + +// GetSchema returns APISIX object's schema. +func (sc schemaClient) getSchema(ctx context.Context, name string) (*v1.Schema, error) { + log.Debugw("try to look up schema", + zap.String("name", name), + zap.String("url", sc.url), + zap.String("cluster", sc.cluster.name), + ) + + sid := id.GenID(name) + schema, err := sc.cluster.cache.GetSchema(sid) + if err == nil { + return schema, nil + } + if err == cache.ErrNotFound { + log.Debugw("failed to find schema in cache, will try to lookup from APISIX", + zap.String("name", name), + zap.Error(err), + ) + } else { + log.Errorw("failed to find schema in cache, will try to lookup from APISIX", + zap.String("name", name), + zap.Error(err), + ) + } + // Dashboard uses /apisix/admin/plugins/{plugin_name} instead of /apisix/admin/schema/{plugin_name} to get schema + url := strings.Replace(sc.url, "schema", name, 1) + content, err := sc.cluster.getSchema(ctx, url, "schema") + if err != nil { + log.Errorw("failed to get schema from APISIX", + zap.String("name", name), + zap.String("url", url), + zap.String("cluster", sc.cluster.name), + zap.Error(err), + ) + return nil, err + } + schema = &v1.Schema{ + Name: name, + Content: content, + } + if err := sc.cluster.cache.InsertSchema(schema); err != nil { + log.Errorf("failed to reflect schema create to cache: %s", err) + return nil, err + } + return schema, nil +} + +// GetPluginSchema returns plugin's schema. +func (sc schemaClient) GetPluginSchema(ctx context.Context, pluginName string) (*v1.Schema, error) { + return sc.getSchema(ctx, "plugins/"+pluginName) +} + +// GetRouteSchema returns route's schema. +func (sc schemaClient) GetRouteSchema(ctx context.Context) (*v1.Schema, error) { + return sc.getSchema(ctx, "route") +} + +// GetUpstreamSchema returns upstream's schema. +func (sc schemaClient) GetUpstreamSchema(ctx context.Context) (*v1.Schema, error) { + return sc.getSchema(ctx, "upstream") +} + +// GetConsumerSchema returns consumer's schema. +func (sc schemaClient) GetConsumerSchema(ctx context.Context) (*v1.Schema, error) { + return sc.getSchema(ctx, "consumer") +} + +// GetSslSchema returns SSL's schema. +func (sc schemaClient) GetSslSchema(ctx context.Context) (*v1.Schema, error) { + return sc.getSchema(ctx, "ssl") +} + +// GetPluginConfigSchema returns PluginConfig's schema. +func (sc schemaClient) GetPluginConfigSchema(ctx context.Context) (*v1.Schema, error) { + return sc.getSchema(ctx, "pluginConfig") +} diff --git a/pkg/dashboard/service.go b/pkg/dashboard/service.go new file mode 100644 index 000000000..565c5e821 --- /dev/null +++ b/pkg/dashboard/service.go @@ -0,0 +1,192 @@ +// 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 dashboard + +import ( + "context" + "encoding/json" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" + "github.com/apache/apisix-ingress-controller/pkg/id" +) + +type serviceClient struct { + url string + cluster *cluster +} + +func newServiceClient(c *cluster) Service { + return &serviceClient{ + url: c.baseURL + "/services", + cluster: c, + } +} + +func (u *serviceClient) Get(ctx context.Context, name string) (*v1.Service, error) { + return getFromCacheOrAPI( + ctx, + id.GenID(name), + u.url, + u.cluster.cache.GetService, + u.cluster.cache.InsertService, + u.cluster.GetService, + ) +} + +type ListFrom string + +var ( + ListFromCache ListFrom = "cache" + ListFromRemote ListFrom = "remote" +) + +type ListOptions struct { + From ListFrom + KindLabel ListByKindLabelOptions +} + +type ListByKindLabelOptions struct { + Kind string + Namespace string + Name string +} + +// List is only used in cache warming up. So here just pass through +// to APISIX. +func (u *serviceClient) List(ctx context.Context, listOptions ...any) ([]*v1.Service, error) { + var options ListOptions + if len(listOptions) > 0 { + options = listOptions[0].(ListOptions) + } + + if options.From == ListFromCache { + log.Debugw("try to list services in cache", + zap.String("cluster", u.cluster.name), + zap.String("url", u.url), + ) + return u.cluster.cache.ListServices("label", + options.KindLabel.Kind, + options.KindLabel.Namespace, + options.KindLabel.Name) + } + + log.Debugw("try to list upstreams in APISIX", + zap.String("url", u.url), + zap.String("cluster", u.cluster.name), + ) + upsItems, err := u.cluster.listResource(ctx, u.url, "service") + if err != nil { + log.Errorf("failed to list upstreams: %s", err) + return nil, err + } + + items := make([]*v1.Service, 0, len(upsItems.List)) + for _, item := range upsItems.List { + ups, err := item.service() + if err != nil { + log.Errorw("failed to convert upstream item", + zap.String("url", u.url), + zap.Error(err), + ) + return nil, err + } + items = append(items, ups) + } + return items, nil +} + +func (u *serviceClient) Create(ctx context.Context, obj *v1.Service) (*v1.Service, error) { + log.Debugw("try to create upstream", + zap.String("name", obj.Name), + zap.String("url", u.url), + zap.String("cluster", u.cluster.name), + ) + + if err := u.cluster.HasSynced(ctx); err != nil { + return nil, err + } + serviceObj := *obj + body, err := json.Marshal(serviceObj) + if err != nil { + return nil, err + } + url := u.url + "/" + obj.ID + log.Debugw("creating service", zap.ByteString("body", body), zap.String("url", url)) + resp, err := u.cluster.createResource(ctx, url, "service", body) + if err != nil { + log.Errorf("failed to create upstream: %s", err) + return nil, err + } + ups, err := resp.service() + if err != nil { + return nil, err + } + if err := u.cluster.cache.InsertService(ups); err != nil { + log.Errorf("failed to reflect upstream create to cache: %s", err) + return nil, err + } + return ups, err +} + +func (u *serviceClient) Delete(ctx context.Context, obj *v1.Service) error { + log.Debugw("try to delete upstream", + zap.String("id", obj.ID), + zap.String("name", obj.Name), + zap.String("cluster", u.cluster.name), + zap.String("url", u.url), + ) + err := u.cluster.cache.CheckServiceReference(obj) + if err != nil { + log.Warnw("deletion for upstream: " + obj.Name + " aborted as it is still in use.") + return err + } + if err := u.cluster.HasSynced(ctx); err != nil { + return err + } + url := u.url + "/" + obj.ID + if err := u.cluster.deleteResource(ctx, url, "service"); err != nil { + return err + } + if err := u.cluster.cache.DeleteService(obj); err != nil { + log.Errorf("failed to reflect upstream delete to cache: %s", err.Error()) + if err != cache.ErrNotFound { + return err + } + } + return nil +} + +func (u *serviceClient) Update(ctx context.Context, obj *v1.Service) (*v1.Service, error) { + url := u.url + "/" + obj.ID + log.Debugw("try to update service", zap.Any("service", obj), zap.String("url", url)) + return updateResource( + ctx, + obj, + url, + "service", + u.cluster.updateResource, + u.cluster.cache.InsertService, + func(resp *getResponse) (*v1.Service, error) { + return resp.service() + }, + ) +} diff --git a/pkg/dashboard/ssl.go b/pkg/dashboard/ssl.go new file mode 100644 index 000000000..b60c2485b --- /dev/null +++ b/pkg/dashboard/ssl.go @@ -0,0 +1,170 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package dashboard + +import ( + "context" + "encoding/json" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" + "github.com/apache/apisix-ingress-controller/pkg/id" +) + +type sslClient struct { + url string + cluster *cluster +} + +func newSSLClient(c *cluster) SSL { + return &sslClient{ + url: c.baseURL + "/ssls", + cluster: c, + } +} + +// name is namespace_sslname +func (s *sslClient) Get(ctx context.Context, name string) (*v1.Ssl, error) { + return getFromCacheOrAPI( + ctx, + id.GenID(name), + s.url, + s.cluster.cache.GetSSL, + s.cluster.cache.InsertSSL, + s.cluster.GetSSL, + ) +} + +// List is only used in cache warming up. So here just pass through +// to APISIX. +func (s *sslClient) List(ctx context.Context, listOptions ...any) ([]*v1.Ssl, error) { + var options ListOptions + if len(listOptions) > 0 { + options = listOptions[0].(ListOptions) + } + if options.From == ListFromCache { + log.Debugw("try to list ssls in cache", + zap.String("cluster", s.cluster.name), + zap.String("url", s.url), + ) + return s.cluster.cache.ListSSL( + "label", + options.KindLabel.Kind, + options.KindLabel.Namespace, + options.KindLabel.Name, + ) + } + log.Debugw("try to list ssl in APISIX", + zap.String("url", s.url), + zap.String("cluster", s.cluster.name), + ) + url := s.url + sslItems, err := s.cluster.listResource(ctx, url, "ssls") + if err != nil { + log.Errorf("failed to list ssl: %s", err) + return nil, err + } + + items := make([]*v1.Ssl, 0, len(sslItems.List)) + for _, item := range sslItems.List { + ssl, err := item.ssl() + if err != nil { + log.Errorw("failed to convert ssl item", + zap.String("url", url), + zap.Error(err), + ) + return nil, err + } + + items = append(items, ssl) + } + + return items, nil +} + +func (s *sslClient) Create(ctx context.Context, obj *v1.Ssl) (*v1.Ssl, error) { + log.Debugw("try to create ssl", + zap.String("cluster", s.cluster.name), + zap.String("url", s.url), + zap.String("id", obj.ID), + ) + if err := s.cluster.HasSynced(ctx); err != nil { + return nil, err + } + data, err := json.Marshal(obj) + if err != nil { + return nil, err + } + url := s.url + "/" + obj.ID + log.Debugw("creating ssl", zap.ByteString("body", data), zap.String("url", url)) + resp, err := s.cluster.createResource(ctx, url, "ssls", data) + if err != nil { + log.Errorf("failed to create ssl: %s", err) + return nil, err + } + + ssl, err := resp.ssl() + if err != nil { + return nil, err + } + if err := s.cluster.cache.InsertSSL(ssl); err != nil { + log.Errorf("failed to reflect ssl create to cache: %s", err) + return nil, err + } + return ssl, nil +} + +func (s *sslClient) Delete(ctx context.Context, obj *v1.Ssl) error { + log.Debugw("try to delete ssl", + zap.String("id", obj.ID), + zap.String("cluster", s.cluster.name), + zap.String("url", s.url), + ) + if err := s.cluster.HasSynced(ctx); err != nil { + return err + } + url := s.url + "/" + obj.ID + if err := s.cluster.deleteResource(ctx, url, "ssls"); err != nil { + return err + } + if err := s.cluster.cache.DeleteSSL(obj); err != nil { + log.Errorf("failed to reflect ssl delete to cache: %s", err) + if err != cache.ErrNotFound { + return err + } + } + return nil +} + +func (s *sslClient) Update(ctx context.Context, obj *v1.Ssl) (*v1.Ssl, error) { + url := s.url + "/" + obj.ID + return updateResource( + ctx, + obj, + url, + "ssls", + s.cluster.updateResource, + s.cluster.cache.InsertSSL, + func(resp *getResponse) (*v1.Ssl, error) { + return resp.ssl() + }, + ) +} diff --git a/pkg/dashboard/stream_route.go b/pkg/dashboard/stream_route.go new file mode 100644 index 000000000..e54e06841 --- /dev/null +++ b/pkg/dashboard/stream_route.go @@ -0,0 +1,164 @@ +// 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 dashboard + +import ( + "context" + "encoding/json" + + "github.com/api7/gopkg/pkg/log" + "go.uber.org/zap" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" + "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" + "github.com/apache/apisix-ingress-controller/pkg/id" +) + +type streamRouteClient struct { + url string + cluster *cluster +} + +func newStreamRouteClient(c *cluster) StreamRoute { + url := c.baseURL + "/stream_routes" + _, err := c.listResource(context.Background(), url, "streamRoute") + if err == ErrFunctionDisabled { + log.Infow("resource stream_routes is disabled") + return &noopClient{} + } + return &streamRouteClient{ + url: url, + cluster: c, + } +} + +// Get returns the StreamRoute. +// FIXME, currently if caller pass a non-existent resource, the Get always passes +// through cache. +func (r *streamRouteClient) Get(ctx context.Context, name string) (*v1.StreamRoute, error) { + return getFromCacheOrAPI( + ctx, + id.GenID(name), + r.url, + r.cluster.cache.GetStreamRoute, + r.cluster.cache.InsertStreamRoute, + r.cluster.GetStreamRoute, + ) +} + +// List is only used in cache warming up. So here just pass through +// to APISIX. +func (r *streamRouteClient) List(ctx context.Context) ([]*v1.StreamRoute, error) { + log.Debugw("try to list stream_routes in APISIX", + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + streamRouteItems, err := r.cluster.listResource(ctx, r.url, "streamRoute") + if err != nil { + log.Errorf("failed to list stream_routes: %s", err) + return nil, err + } + + items := make([]*v1.StreamRoute, 0, len(streamRouteItems.List)) + for _, item := range streamRouteItems.List { + streamRoute, err := item.streamRoute() + if err != nil { + log.Errorw("failed to convert stream_route item", + zap.String("url", r.url), + zap.Error(err), + ) + return nil, err + } + + items = append(items, streamRoute) + } + return items, nil +} + +func (r *streamRouteClient) Create(ctx context.Context, obj *v1.StreamRoute) (*v1.StreamRoute, error) { + log.Debugw("try to create stream_route", + zap.String("id", obj.ID), + zap.Int32("server_port", obj.ServerPort), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + zap.String("sni", obj.SNI), + ) + + if err := r.cluster.HasSynced(ctx); err != nil { + return nil, err + } + data, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + url := r.url + "/" + obj.ID + log.Infow("creating stream_route", zap.ByteString("body", data), zap.String("url", url)) + resp, err := r.cluster.createResource(ctx, url, "streamRoute", data) + if err != nil { + log.Errorf("failed to create stream_route: %s", err) + return nil, err + } + + streamRoute, err := resp.streamRoute() + if err != nil { + return nil, err + } + if err := r.cluster.cache.InsertStreamRoute(streamRoute); err != nil { + log.Errorf("failed to reflect stream_route create to cache: %s", err) + return nil, err + } + return streamRoute, nil +} + +func (r *streamRouteClient) Delete(ctx context.Context, obj *v1.StreamRoute) error { + log.Debugw("try to delete stream_route", + zap.String("id", obj.ID), + zap.String("cluster", r.cluster.name), + zap.String("url", r.url), + ) + if err := r.cluster.HasSynced(ctx); err != nil { + return err + } + url := r.url + "/" + obj.ID + if err := r.cluster.deleteResource(ctx, url, "streamRoute"); err != nil { + return err + } + if err := r.cluster.cache.DeleteStreamRoute(obj); err != nil { + log.Errorf("failed to reflect stream_route delete to cache: %s", err) + if err != cache.ErrNotFound { + return err + } + } + return nil +} + +func (r *streamRouteClient) Update(ctx context.Context, obj *v1.StreamRoute) (*v1.StreamRoute, error) { + url := r.url + "/" + obj.ID + return updateResource( + ctx, + obj, + url, + "streamRoute", + r.cluster.updateResource, + r.cluster.cache.InsertStreamRoute, + func(resp *getResponse) (*v1.StreamRoute, error) { + return resp.streamRoute() + }, + ) +} diff --git a/pkg/dashboard/utils.go b/pkg/dashboard/utils.go new file mode 100644 index 000000000..38fd09aef --- /dev/null +++ b/pkg/dashboard/utils.go @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package dashboard + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "errors" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +var ( + ErrUnknownApisixResourceType = errors.New("unknown apisix resource type") +) + +type ResourceTypes interface { + *v1.Route | *v1.Ssl | *v1.Service | *v1.StreamRoute | *v1.GlobalRule | *v1.Consumer | *v1.PluginConfig +} + +func PKCS5Padding(plaintext []byte, blockSize int) []byte { + padding := blockSize - len(plaintext)%blockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(plaintext, padtext...) +} + +func PKCS5UnPadding(origData []byte) []byte { + length := len(origData) + unpadding := int(origData[length-1]) + return origData[:(length - unpadding)] +} + +func AesEncrypt(origData, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + blockSize := block.BlockSize() + origData = PKCS5Padding(origData, blockSize) + blockMode := cipher.NewCBCEncrypter(block, key[:blockSize]) + crypted := make([]byte, len(origData)) + blockMode.CryptBlocks(crypted, origData) + return crypted, nil +} + +func AesDecrypt(crypted, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + blockSize := block.BlockSize() + blockMode := cipher.NewCBCDecrypter(block, key[:blockSize]) + origData := make([]byte, len(crypted)) + blockMode.CryptBlocks(origData, crypted) + origData = PKCS5UnPadding(origData) + return origData, nil +} + +func AesEencryptPrivatekey(data []byte, aeskey []byte) (string, error) { + xcode, err := AesEncrypt(data, aeskey) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(xcode), nil +} diff --git a/pkg/dashboard/validator.go b/pkg/dashboard/validator.go new file mode 100644 index 000000000..dbd3e9c01 --- /dev/null +++ b/pkg/dashboard/validator.go @@ -0,0 +1,136 @@ +// 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 dashboard + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/hashicorp/go-multierror" + "github.com/xeipuuv/gojsonschema" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +type APISIXSchema struct { + Plugins map[string]SchemaPlugin `json:"plugins"` + StreamPlugins map[string]SchemaPlugin `json:"stream_plugins"` +} + +type SchemaPlugin struct { + SchemaContent any `json:"schema"` +} + +type PluginSchemaDef map[string]gojsonschema.JSONLoader + +type apisixSchemaReferenceValidator struct { + StreamPlugins PluginSchemaDef + HTTPPlugins PluginSchemaDef +} + +func NewReferenceFile(source string) (APISIXSchemaValidator, error) { + data, err := os.ReadFile(source) + if err != nil { + return nil, fmt.Errorf("error reading file: %w", err) + } + + var schemadef APISIXSchema + err = json.Unmarshal(data, &schemadef) + if err != nil { + return nil, fmt.Errorf("error parsing JSON: %w", err) + } + + validator := &apisixSchemaReferenceValidator{ + HTTPPlugins: make(PluginSchemaDef), + StreamPlugins: make(PluginSchemaDef), + } + + for _, plugin := range []struct { + name string + schema map[string]SchemaPlugin + }{ + {name: "HTTPPlugins", schema: schemadef.Plugins}, + {name: "StreamPlugins", schema: schemadef.StreamPlugins}, + } { + for k, v := range plugin.schema { + switch plugin.name { + case "HTTPPlugins": + validator.HTTPPlugins[k] = gojsonschema.NewGoLoader(v.SchemaContent) + case "StreamPlugins": + validator.StreamPlugins[k] = gojsonschema.NewGoLoader(v.SchemaContent) + } + } + } + + return validator, nil +} + +func (asv *apisixSchemaReferenceValidator) ValidateHTTPPluginSchema(plugins v1.Plugins) (bool, error) { + var resultErrs error + + for pluginName, pluginConfig := range plugins { + schema, ok := asv.HTTPPlugins[pluginName] + if !ok { + return false, fmt.Errorf("unknown plugin [%s]", pluginName) + } + result, err := gojsonschema.Validate(schema, gojsonschema.NewGoLoader(pluginConfig)) + if err != nil { + return false, err + } + + if result.Valid() { + continue + } + + resultErrs = multierror.Append(resultErrs, fmt.Errorf("plugin [%s] config is invalid", pluginName)) + for _, desc := range result.Errors() { + resultErrs = multierror.Append(resultErrs, fmt.Errorf("- %s", desc)) + } + return false, resultErrs + } + + return true, nil +} + +func (asv *apisixSchemaReferenceValidator) ValidateStreamPluginSchema(plugins v1.Plugins) (bool, error) { + var resultErrs error + + for pluginName, pluginConfig := range plugins { + schema, ok := asv.StreamPlugins[pluginName] + if !ok { + return false, fmt.Errorf("unknown stream plugin [%s]", pluginName) + } + result, err := gojsonschema.Validate(schema, gojsonschema.NewGoLoader(pluginConfig)) + if err != nil { + return false, err + } + + if result.Valid() { + continue + } + + resultErrs = multierror.Append(resultErrs, fmt.Errorf("stream plugin [%s] config is invalid", pluginName)) + for _, desc := range result.Errors() { + resultErrs = multierror.Append(resultErrs, fmt.Errorf("- %s", desc)) + } + return false, resultErrs + } + + return true, nil +} diff --git a/test/conformance/conformance_test.go b/test/conformance/conformance_test.go new file mode 100644 index 000000000..f9582f614 --- /dev/null +++ b/test/conformance/conformance_test.go @@ -0,0 +1,95 @@ +// 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. + +//go:build conformance +// +build conformance + +package conformance + +import ( + "flag" + "os" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/gateway-api/conformance" + conformancev1 "sigs.k8s.io/gateway-api/conformance/apis/v1" + "sigs.k8s.io/gateway-api/conformance/tests" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" + "sigs.k8s.io/yaml" +) + +var skippedTestsForSSL = []string{ + // Reason: https://github.com/kubernetes-sigs/gateway-api/blob/5c5fc388829d24e8071071b01e8313ada8f15d9f/conformance/utils/suite/suite.go#L358. SAN includes '*' + tests.HTTPRouteHTTPSListener.ShortName, + tests.HTTPRouteRedirectPortAndScheme.ShortName, +} + +// TODO: HTTPRoute hostname intersection and listener hostname matching + +var gatewaySupportedFeatures = []features.FeatureName{ + features.SupportGateway, + features.SupportHTTPRoute, + // features.SupportHTTPRouteMethodMatching, + // features.SupportHTTPRouteResponseHeaderModification, + // features.SupportHTTPRouteRequestMirror, + // features.SupportHTTPRouteBackendRequestHeaderModification, + // features.SupportHTTPRouteHostRewrite, +} + +func TestGatewayAPIConformance(t *testing.T) { + flag.Parse() + + opts := conformance.DefaultOptions(t) + opts.Debug = true + opts.CleanupBaseResources = true + opts.GatewayClassName = gatewayClassName + opts.SupportedFeatures = sets.New(gatewaySupportedFeatures...) + opts.SkipTests = skippedTestsForSSL + opts.Implementation = conformancev1.Implementation{ + Organization: "APISIX", + Project: "apisix-ingress-controller", + URL: "https://github.com/apache/apisix-ingress-controller.git", + Version: "v2.0.0", + } + opts.ConformanceProfiles = sets.New(suite.GatewayHTTPConformanceProfileName) + + cSuite, err := suite.NewConformanceTestSuite(opts) + require.NoError(t, err) + + t.Log("starting the gateway conformance test suite") + cSuite.Setup(t, tests.ConformanceTests) + + if err := cSuite.Run(t, tests.ConformanceTests); err != nil { + t.Fatalf("failed to run the gateway conformance test suite: %v", err) + } + + const reportFileName = "apisix-ingress-controller-conformance-report.yaml" + report, err := cSuite.Report() + if err != nil { + t.Fatalf("failed to get the gateway conformance test report: %v", err) + } + + rawReport, err := yaml.Marshal(report) + if err != nil { + t.Fatalf("failed to marshal the gateway conformance test report: %v", err) + } + // Save report in the root of the repository, file name is in .gitignore. + require.NoError(t, os.WriteFile("../../"+reportFileName, rawReport, 0o600)) +} diff --git a/test/conformance/suite_test.go b/test/conformance/suite_test.go new file mode 100644 index 000000000..59649c3b2 --- /dev/null +++ b/test/conformance/suite_test.go @@ -0,0 +1,260 @@ +// 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 conformance + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/gruntwork-io/terratest/modules/k8s" + "github.com/gruntwork-io/terratest/modules/retry" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/apache/apisix-ingress-controller/test/e2e/framework" +) + +var gatewayClassName = "apisix" +var controllerName = "apisix.apache.org/apisix-ingress-controller" + +var gatewayClass = fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: %s +spec: + controllerName: %s +`, gatewayClassName, controllerName) + +var gatewayProxyYaml = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: conformance-gateway-proxy + namespace: %s +spec: + statusAddress: + - %s + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: %s +` + +type GatewayProxyOpts struct { + StatusAddress string + AdminKey string + AdminEndpoint string +} + +var defaultGatewayProxyOpts GatewayProxyOpts + +func deleteNamespace(kubectl *k8s.KubectlOptions) { + // gateway api conformance test namespaces + namespacesToDelete := []string{ + "gateway-conformance-infra", + "gateway-conformance-web-backend", + "gateway-conformance-app-backend", + "apisix-conformance-test", + } + + for _, ns := range namespacesToDelete { + _, err := k8s.GetNamespaceE(GinkgoT(), kubectl, ns) + if err == nil { + // Namespace exists, delete it + GinkgoT().Logf("Deleting existing namespace: %s", ns) + err := k8s.DeleteNamespaceE(GinkgoT(), kubectl, ns) + if err != nil { + GinkgoT().Logf("Error deleting namespace %s: %v", ns, err) + continue + } + + // Wait for deletion to complete by checking until GetNamespaceE returns an error + _, err = retry.DoWithRetryE( + GinkgoT(), + fmt.Sprintf("Waiting for namespace %s to be deleted", ns), + 30, + 5*time.Second, + func() (string, error) { + _, err := k8s.GetNamespaceE(GinkgoT(), kubectl, ns) + if err != nil { + // Namespace is gone, which is what we want + return "Namespace deleted", nil + } + return "", fmt.Errorf("namespace %s still exists", ns) + }, + ) + + if err != nil { + GinkgoT().Logf("Error waiting for namespace %s to be deleted: %v", ns, err) + } + } else { + GinkgoT().Logf("Namespace %s does not exist or cannot be accessed", ns) + } + } +} + +func TestMain(m *testing.M) { + RegisterFailHandler(Fail) + f := framework.NewFramework() + + f.BeforeSuite() + + // Check and delete specific namespaces if they exist + kubectl := k8s.NewKubectlOptions("", "", "default") + deleteNamespace(kubectl) + + namespace := "apisix-conformance-test" + + k8s.KubectlApplyFromString(GinkgoT(), kubectl, gatewayClass) + defer k8s.KubectlDeleteFromString(GinkgoT(), kubectl, gatewayClass) + k8s.CreateNamespace(GinkgoT(), kubectl, namespace) + defer k8s.DeleteNamespace(GinkgoT(), kubectl, namespace) + + gatewayGroupId := f.CreateNewGatewayGroupWithIngress() + adminKey := f.GetAdminKey(gatewayGroupId) + + svc := f.DeployGateway(framework.API7DeployOptions{ + Namespace: namespace, + GatewayGroupID: gatewayGroupId, + DPManagerEndpoint: framework.DPManagerTLSEndpoint, + SetEnv: true, + SSLKey: framework.TestKey, + SSLCert: framework.TestCert, + TLSEnabled: true, + ForIngressGatewayGroup: true, + ServiceType: "LoadBalancer", + ServiceHTTPPort: 80, + ServiceHTTPSPort: 443, + }) + + if len(svc.Status.LoadBalancer.Ingress) == 0 { + Fail("No LoadBalancer found for the service") + } + + address := svc.Status.LoadBalancer.Ingress[0].IP + + f.DeployIngress(framework.IngressDeployOpts{ + ControllerName: "apisix.apache.org/apisix-ingress-controller", + Namespace: namespace, + StatusAddress: address, + InitSyncDelay: 1 * time.Minute, + ProviderType: "api7ee", + }) + + defaultGatewayProxyOpts = GatewayProxyOpts{ + StatusAddress: address, + AdminKey: adminKey, + AdminEndpoint: framework.DashboardTLSEndpoint, + } + + patchGatewaysForConformanceTest(context.Background(), f.K8sClient) + + code := m.Run() + + f.AfterSuite() + + os.Exit(code) +} + +func patchGatewaysForConformanceTest(ctx context.Context, k8sClient client.Client) { + var gatewayProxyMap = make(map[string]bool) + + // list all gateways and patch them + patchGateway := func(ctx context.Context, k8sClient client.Client) bool { + gatewayList := &gatewayv1.GatewayList{} + if err := k8sClient.List(ctx, gatewayList); err != nil { + return false + } + + patched := false + for i := range gatewayList.Items { + gateway := &gatewayList.Items[i] + + // check if the gateway already has infrastructure.parametersRef + if gateway.Spec.Infrastructure != nil && + gateway.Spec.Infrastructure.ParametersRef != nil { + continue + } + + GinkgoT().Logf("Patching Gateway %s", gateway.Name) + // check if the gateway proxy has been created, if not, create it + if !gatewayProxyMap[gateway.Namespace] { + gatewayProxy := fmt.Sprintf(gatewayProxyYaml, + gateway.Namespace, + defaultGatewayProxyOpts.StatusAddress, + defaultGatewayProxyOpts.AdminEndpoint, + defaultGatewayProxyOpts.AdminKey) + kubectl := k8s.NewKubectlOptions("", "", gateway.Namespace) + k8s.KubectlApplyFromString(GinkgoT(), kubectl, gatewayProxy) + + // Mark this namespace as having a GatewayProxy + gatewayProxyMap[gateway.Namespace] = true + } + + // add infrastructure.parametersRef + gateway.Spec.Infrastructure = &gatewayv1.GatewayInfrastructure{ + ParametersRef: &gatewayv1.LocalParametersReference{ + Group: "apisix.apache.org", + Kind: "GatewayProxy", + Name: "conformance-gateway-proxy", + }, + } + + if err := k8sClient.Update(ctx, gateway); err != nil { + GinkgoT().Logf("Failed to patch Gateway %s: %v", gateway.Name, err) + continue + } + + patched = true + GinkgoT().Logf("Successfully patched Gateway %s with GatewayProxy reference", gateway.Name) + } + + return patched + } + + // continuously monitor and patch gateway resources + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + // clean up the gateway proxy + for namespace := range gatewayProxyMap { + kubectl := k8s.NewKubectlOptions("", "", namespace) + _ = k8s.RunKubectlE(GinkgoT(), kubectl, "delete", "gatewayproxy", "conformance-gateway-proxy") + } + return + case <-ticker.C: + patchGateway(ctx, k8sClient) + } + } + }() +} diff --git a/test/e2e/api7/gatewayproxy.go b/test/e2e/api7/gatewayproxy.go new file mode 100644 index 000000000..1198399af --- /dev/null +++ b/test/e2e/api7/gatewayproxy.go @@ -0,0 +1,272 @@ +// 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 gatewayapi + +import ( + "fmt" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +var _ = Describe("Test GatewayProxy", Label("apisix.apache.org", "v1alpha1", "gatewayproxy"), func() { + s := scaffold.NewDefaultScaffold() + + var defaultGatewayClass = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: %s +spec: + controllerName: %s +` + + var gatewayWithProxy = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: apisix +spec: + gatewayClassName: %s + listeners: + - name: http + protocol: HTTP + port: 80 + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +` + + var gatewayProxyWithEnabledPlugin = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" + plugins: + - name: response-rewrite + enabled: true + config: + headers: + X-Proxy-Test: "enabled" +` + var ( + gatewayProxyWithPluginMetadata0 = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" + plugins: + - name: error-page + enabled: true + config: {} + pluginMetadata: + error-page: { + "enable": true, + "error_404": { + "body": "404 from plugin metadata", + "content-type": "text/plain" + } + } +` + gatewayProxyWithPluginMetadata1 = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" + plugins: + - name: error-page + enabled: true + config: {} + pluginMetadata: + error-page: { + "enable": false, + "error_404": { + "body": "404 from plugin metadata", + "content-type": "text/plain" + } + } +` + ) + + var httpRouteForTest = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: test-route +spec: + parentRefs: + - name: %s + hostnames: + - example.com + rules: + - matches: + - path: + type: Exact + value: /get + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + + var resourceApplied = func(resourceType, resourceName, resourceRaw string, observedGeneration int) { + Expect(s.CreateResourceFromString(resourceRaw)). + NotTo(HaveOccurred(), fmt.Sprintf("creating %s", resourceType)) + + Eventually(func() string { + hryaml, err := s.GetResourceYaml(resourceType, resourceName) + Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("getting %s yaml", resourceType)) + return hryaml + }).WithTimeout(8*time.Second).ProbeEvery(2*time.Second). + Should( + SatisfyAll( + ContainSubstring(`status: "True"`), + ContainSubstring(fmt.Sprintf("observedGeneration: %d", observedGeneration)), + ), + fmt.Sprintf("checking %s condition status", resourceType), + ) + time.Sleep(3 * time.Second) + } + + var ( + gatewayClassName string + ) + + BeforeEach(func() { + By("Create GatewayClass") + gatewayClassName = fmt.Sprintf("apisix-%d", time.Now().Unix()) + err := s.CreateResourceFromStringWithNamespace(fmt.Sprintf(defaultGatewayClass, gatewayClassName, s.GetControllerName()), "") + Expect(err).NotTo(HaveOccurred(), "creating GatewayClass") + time.Sleep(5 * time.Second) + + By("Check GatewayClass condition") + gcYaml, err := s.GetResourceYaml("GatewayClass", gatewayClassName) + Expect(err).NotTo(HaveOccurred(), "getting GatewayClass yaml") + Expect(gcYaml).To(ContainSubstring(`status: "True"`), "checking GatewayClass condition status") + Expect(gcYaml).To(ContainSubstring("message: the gatewayclass has been accepted by the apisix-ingress-controller"), "checking GatewayClass condition message") + + By("Create GatewayProxy with enabled plugin") + err = s.CreateResourceFromString(fmt.Sprintf(gatewayProxyWithEnabledPlugin, s.Deployer.GetAdminEndpoint(), s.AdminKey())) + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy with enabled plugin") + time.Sleep(5 * time.Second) + + By("Create Gateway with GatewayProxy") + err = s.CreateResourceFromStringWithNamespace(fmt.Sprintf(gatewayWithProxy, gatewayClassName), s.Namespace()) + Expect(err).NotTo(HaveOccurred(), "creating Gateway with GatewayProxy") + time.Sleep(5 * time.Second) + + By("check Gateway condition") + gwyaml, err := s.GetResourceYaml("Gateway", "apisix") + Expect(err).NotTo(HaveOccurred(), "getting Gateway yaml") + Expect(gwyaml).To(ContainSubstring(`status: "True"`), "checking Gateway condition status") + Expect(gwyaml).To(ContainSubstring("message: the gateway has been accepted by the apisix-ingress-controller"), "checking Gateway condition message") + }) + + AfterEach(func() { + By("Clean up resources") + _ = s.DeleteResourceFromString(fmt.Sprintf(httpRouteForTest, "apisix")) + _ = s.DeleteResourceFromString(fmt.Sprintf(gatewayWithProxy, gatewayClassName)) + _ = s.DeleteResourceFromString(fmt.Sprintf(gatewayProxyWithEnabledPlugin, s.Deployer.GetAdminEndpoint(), s.AdminKey())) + }) + + Context("Test Gateway with PluginMetadata", func() { + var ( + err error + ) + + PIt("Should work OK with error-page", func() { + By("Update GatewayProxy with PluginMetadata") + err = s.CreateResourceFromString(fmt.Sprintf(gatewayProxyWithPluginMetadata0, s.Deployer.GetAdminEndpoint(), s.AdminKey())) + Expect(err).ShouldNot(HaveOccurred()) + time.Sleep(5 * time.Second) + + By("Create HTTPRoute for Gateway with GatewayProxy") + resourceApplied("HTTPRoute", "test-route", fmt.Sprintf(httpRouteForTest, "apisix"), 1) + + time.Sleep(5 * time.Second) + By("Check PluginMetadata working") + s.NewAPISIXClient(). + GET("/not-found"). + WithHost("example.com"). + Expect(). + Status(http.StatusNotFound). + Body().Contains("404 from plugin metadata") + + By("Update GatewayProxy with PluginMetadata") + err = s.CreateResourceFromString(fmt.Sprintf(gatewayProxyWithPluginMetadata1, s.Deployer.GetAdminEndpoint(), s.AdminKey())) + Expect(err).ShouldNot(HaveOccurred()) + time.Sleep(5 * time.Second) + + By("Check PluginMetadata working") + s.NewAPISIXClient(). + GET("/not-found"). + WithHost("example.com"). + Expect(). + Status(http.StatusNotFound). + Body().Contains(`{"error_msg":"404 Route Not Found"}`) + + By("Delete GatewayProxy") + err = s.DeleteResourceFromString(fmt.Sprintf(gatewayProxyWithPluginMetadata0, s.Deployer.GetAdminEndpoint(), s.AdminKey())) + Expect(err).ShouldNot(HaveOccurred()) + time.Sleep(5 * time.Second) + + By("Check PluginMetadata is not working") + s.NewAPISIXClient(). + GET("/not-found"). + WithHost("example.com"). + Expect(). + Status(http.StatusNotFound). + Body().Contains(`{"error_msg":"404 Route Not Found"}`) + }) + }) +}) diff --git a/test/e2e/apisix/e2e_test.go b/test/e2e/apisix/e2e_test.go index da357c5d8..03fe0ca61 100644 --- a/test/e2e/apisix/e2e_test.go +++ b/test/e2e/apisix/e2e_test.go @@ -24,7 +24,8 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - _ "github.com/apache/apisix-ingress-controller/test/e2e/crds" + _ "github.com/apache/apisix-ingress-controller/test/e2e/crds/v1alpha1" + _ "github.com/apache/apisix-ingress-controller/test/e2e/crds/v2" "github.com/apache/apisix-ingress-controller/test/e2e/framework" _ "github.com/apache/apisix-ingress-controller/test/e2e/gatewayapi" _ "github.com/apache/apisix-ingress-controller/test/e2e/ingress" @@ -38,9 +39,7 @@ func TestAPISIXE2E(t *testing.T) { _ = framework.NewFramework() // init newDeployer function - scaffold.NewDeployer = func(s *scaffold.Scaffold) scaffold.Deployer { - return scaffold.NewAPISIXDeployer(s) - } + scaffold.NewDeployer = scaffold.NewAPISIXDeployer _, _ = fmt.Fprintf(GinkgoWriter, "Starting APISIX standalone e2e suite\n") RunSpecs(t, "apisix standalone e2e suite") diff --git a/test/e2e/crds/v1alpha1/backendtrafficpolicy.go b/test/e2e/crds/v1alpha1/backendtrafficpolicy.go new file mode 100644 index 000000000..4dc2ca922 --- /dev/null +++ b/test/e2e/crds/v1alpha1/backendtrafficpolicy.go @@ -0,0 +1,298 @@ +// 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 gatewayapi + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +var _ = Describe("Test BackendTrafficPolicy base on HTTPRoute", Label("apisix.apache.org", "v1alpha1", "backendtrafficpolicy"), func() { + s := scaffold.NewDefaultScaffold() + + var defaultGatewayProxy = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" +` + + var defaultGatewayClass = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: %s +spec: + controllerName: %s +` + + var defaultGateway = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: apisix +spec: + gatewayClassName: %s + listeners: + - name: http1 + protocol: HTTP + port: 80 + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +` + + var defaultHTTPRoute = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httpbin +spec: + parentRefs: + - name: apisix + hostnames: + - "httpbin.org" + rules: + - matches: + - path: + type: Exact + value: /get + - path: + type: Exact + value: /headers + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + Context("Rewrite Upstream Host", func() { + var createUpstreamHost = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: httpbin +spec: + targetRefs: + - name: httpbin-service-e2e-test + kind: Service + group: "" + passHost: rewrite + upstreamHost: httpbin.example.com +` + + var updateUpstreamHost = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: httpbin +spec: + targetRefs: + - name: httpbin-service-e2e-test + kind: Service + group: "" + passHost: rewrite + upstreamHost: httpbin.update.example.com +` + + BeforeEach(func() { + s.ApplyDefaultGatewayResource(defaultGatewayProxy, defaultGatewayClass, defaultGateway, defaultHTTPRoute) + }) + It("should rewrite upstream host", func() { + s.ResourceApplied("BackendTrafficPolicy", "httpbin", createUpstreamHost, 1) + s.NewAPISIXClient(). + GET("/headers"). + WithHost("httpbin.org"). + Expect(). + Status(200). + Body().Contains("httpbin.example.com") + + s.ResourceApplied("BackendTrafficPolicy", "httpbin", updateUpstreamHost, 2) + s.NewAPISIXClient(). + GET("/headers"). + WithHost("httpbin.org"). + Expect(). + Status(200). + Body().Contains("httpbin.update.example.com") + + err := s.DeleteResourceFromString(createUpstreamHost) + Expect(err).NotTo(HaveOccurred(), "deleting BackendTrafficPolicy") + time.Sleep(5 * time.Second) + + s.NewAPISIXClient(). + GET("/headers"). + WithHost("httpbin.org"). + Expect(). + Status(200). + Body(). + NotContains("httpbin.update.example.com"). + NotContains("httpbin.example.com") + }) + }) +}) + +var _ = Describe("Test BackendTrafficPolicy base on Ingress", Label("apisix.apache.org", "v1alpha1", "backendtrafficpolicy"), func() { + s := scaffold.NewScaffold(&scaffold.Options{ + ControllerName: "apisix.apache.org/apisix-ingress-controller", + }) + + var defaultGatewayProxy = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config + namespace: default +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" +` + var defaultIngressClass = ` +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: apisix-default + annotations: + ingressclass.kubernetes.io/is-default-class: "true" +spec: + controller: "apisix.apache.org/apisix-ingress-controller" + parameters: + apiGroup: "apisix.apache.org" + kind: "GatewayProxy" + name: "apisix-proxy-config" + namespace: "default" + scope: "Namespace" +` + + var defaultIngress = ` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: apisix-ingress-default +spec: + rules: + - host: httpbin.org + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: httpbin-service-e2e-test + port: + number: 80 +` + var beforeEach = func() { + By("create GatewayProxy") + gatewayProxy := fmt.Sprintf(defaultGatewayProxy, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.CreateResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy") + + By("create IngressClass with GatewayProxy reference") + err = s.CreateResourceFromStringWithNamespace(defaultIngressClass, "") + Expect(err).NotTo(HaveOccurred(), "creating IngressClass with GatewayProxy") + + By("create Ingress with GatewayProxy IngressClass") + err = s.CreateResourceFromString(defaultIngress) + Expect(err).NotTo(HaveOccurred(), "creating Ingress with GatewayProxy IngressClass") + time.Sleep(5 * time.Second) + } + + Context("Rewrite Upstream Host", func() { + var createUpstreamHost = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: httpbin +spec: + targetRefs: + - name: httpbin-service-e2e-test + kind: Service + group: "" + passHost: rewrite + upstreamHost: httpbin.example.com +` + + var updateUpstreamHost = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: httpbin +spec: + targetRefs: + - name: httpbin-service-e2e-test + kind: Service + group: "" + passHost: rewrite + upstreamHost: httpbin.update.example.com +` + + BeforeEach(beforeEach) + It("should rewrite upstream host", func() { + s.ResourceApplied("BackendTrafficPolicy", "httpbin", createUpstreamHost, 1) + s.NewAPISIXClient(). + GET("/headers"). + WithHost("httpbin.org"). + Expect(). + Status(200). + Body().Contains("httpbin.example.com") + + s.ResourceApplied("BackendTrafficPolicy", "httpbin", updateUpstreamHost, 2) + s.NewAPISIXClient(). + GET("/headers"). + WithHost("httpbin.org"). + Expect(). + Status(200). + Body().Contains("httpbin.update.example.com") + + err := s.DeleteResourceFromString(createUpstreamHost) + Expect(err).NotTo(HaveOccurred(), "deleting BackendTrafficPolicy") + time.Sleep(5 * time.Second) + + s.NewAPISIXClient(). + GET("/headers"). + WithHost("httpbin.org"). + Expect(). + Status(200). + Body(). + NotContains("httpbin.update.example.com"). + NotContains("httpbin.example.com") + }) + }) +}) diff --git a/test/e2e/crds/v1alpha1/consumer.go b/test/e2e/crds/v1alpha1/consumer.go new file mode 100644 index 000000000..879d60489 --- /dev/null +++ b/test/e2e/crds/v1alpha1/consumer.go @@ -0,0 +1,515 @@ +// 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 gatewayapi + +import ( + "fmt" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +var _ = Describe("Test Consumer", Label("apisix.apache.org", "v1alpha1", "consumer"), func() { + s := scaffold.NewDefaultScaffold() + + var defaultGatewayProxy = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" +` + + var defaultGatewayClass = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: %s +spec: + controllerName: %s +` + + var defaultGateway = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: apisix +spec: + gatewayClassName: %s + listeners: + - name: http1 + protocol: HTTP + port: 80 + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +` + + var defaultHTTPRoute = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: PluginConfig +metadata: + name: auth-plugin-config +spec: + plugins: + - name: multi-auth + config: + auth_plugins: + - basic-auth: {} + - key-auth: + header: apikey +--- + +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httpbin +spec: + parentRefs: + - name: apisix + hostnames: + - "httpbin.org" + rules: + - matches: + - path: + type: Exact + value: /get + filters: + - type: ExtensionRef + extensionRef: + group: apisix.apache.org + kind: PluginConfig + name: auth-plugin-config + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + + Context("Consumer plugins", func() { + var limitCountConsumer = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: Consumer +metadata: + name: consumer-sample +spec: + gatewayRef: + name: apisix + credentials: + - type: key-auth + name: key-auth-sample + config: + key: sample-key + plugins: + - name: limit-count + config: + count: 2 + time_window: 60 + rejected_code: 503 + key: remote_addr +` + + var unlimitConsumer = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: Consumer +metadata: + name: consumer-sample2 +spec: + gatewayRef: + name: apisix + credentials: + - type: key-auth + name: key-auth-sample + config: + key: sample-key2 +` + + BeforeEach(func() { + s.ApplyDefaultGatewayResource(defaultGatewayProxy, defaultGatewayClass, defaultGateway, defaultHTTPRoute) + }) + + It("limit-count plugin", func() { + s.ResourceApplied("Consumer", "consumer-sample", limitCountConsumer, 1) + s.ResourceApplied("Consumer", "consumer-sample2", unlimitConsumer, 1) + + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + By("trigger limit-count") + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key"). + WithHost("httpbin.org"). + Expect(). + Status(503) + + for i := 0; i < 10; i++ { + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key2"). + WithHost("httpbin.org"). + Expect(). + Status(200) + } + }) + }) + + Context("Credential", func() { + var defaultCredential = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: Consumer +metadata: + name: consumer-sample +spec: + gatewayRef: + name: apisix + credentials: + - type: basic-auth + name: basic-auth-sample + config: + username: sample-user + password: sample-password + - type: key-auth + name: key-auth-sample + config: + key: sample-key + - type: key-auth + name: key-auth-sample2 + config: + key: sample-key2 +` + var updateCredential = `apiVersion: apisix.apache.org/v1alpha1 +kind: Consumer +metadata: + name: consumer-sample +spec: + gatewayRef: + name: apisix + credentials: + - type: basic-auth + name: basic-auth-sample + config: + username: sample-user + password: sample-password + plugins: + - name: key-auth + config: + key: consumer-key +` + + BeforeEach(func() { + s.ApplyDefaultGatewayResource(defaultGatewayProxy, defaultGatewayClass, defaultGateway, defaultHTTPRoute) + }) + + It("Create/Update/Delete", func() { + s.ResourceApplied("Consumer", "consumer-sample", defaultCredential, 1) + + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key2"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + s.NewAPISIXClient(). + GET("/get"). + WithBasicAuth("sample-user", "sample-password"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + By("update Consumer") + s.ResourceApplied("Consumer", "consumer-sample", updateCredential, 2) + + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key"). + WithHost("httpbin.org"). + Expect(). + Status(401) + + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key2"). + WithHost("httpbin.org"). + Expect(). + Status(401) + + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "consumer-key"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + s.NewAPISIXClient(). + GET("/get"). + WithBasicAuth("sample-user", "sample-password"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + By("delete Consumer") + err := s.DeleteResourceFromString(updateCredential) + Expect(err).NotTo(HaveOccurred(), "deleting Consumer") + time.Sleep(5 * time.Second) + + s.NewAPISIXClient(). + GET("/get"). + WithBasicAuth("sample-user", "sample-password"). + WithHost("httpbin.org"). + Expect(). + Status(401) + }) + }) + + Context("SecretRef", func() { + var keyAuthSecret = ` +apiVersion: v1 +kind: Secret +metadata: + name: key-auth-secret +data: + key: c2FtcGxlLWtleQ== +` + var basicAuthSecret = ` +apiVersion: v1 +kind: Secret +metadata: + name: basic-auth-secret +data: + username: c2FtcGxlLXVzZXI= + password: c2FtcGxlLXBhc3N3b3Jk +` + const basicAuthSecret2 = ` +apiVersion: v1 +kind: Secret +metadata: + name: basic-auth-secret +data: + username: c2FtcGxlLXVzZXI= + password: c2FtcGxlLXBhc3N3b3JkLW5ldw== +` + var defaultConsumer = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: Consumer +metadata: + name: consumer-sample +spec: + gatewayRef: + name: apisix + credentials: + - type: basic-auth + name: basic-auth-sample + secretRef: + name: basic-auth-secret + - type: key-auth + name: key-auth-sample + secretRef: + name: key-auth-secret + - type: key-auth + name: key-auth-sample2 + config: + key: sample-key2 +` + + BeforeEach(func() { + s.ApplyDefaultGatewayResource(defaultGatewayProxy, defaultGatewayClass, defaultGateway, defaultHTTPRoute) + }) + It("Create/Update/Delete", func() { + err := s.CreateResourceFromString(keyAuthSecret) + Expect(err).NotTo(HaveOccurred(), "creating key-auth secret") + err = s.CreateResourceFromString(basicAuthSecret) + Expect(err).NotTo(HaveOccurred(), "creating basic-auth secret") + s.ResourceApplied("Consumer", "consumer-sample", defaultConsumer, 1) + + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + s.NewAPISIXClient(). + GET("/get"). + WithBasicAuth("sample-user", "sample-password"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + // update basic-auth password + err = s.CreateResourceFromString(basicAuthSecret2) + Expect(err).NotTo(HaveOccurred(), "creating basic-auth secret") + + // use the old password will get 401 + Eventually(func() int { + return s.NewAPISIXClient(). + GET("/get"). + WithBasicAuth("sample-user", "sample-password"). + WithHost("httpbin.org"). + Expect(). + Raw().StatusCode + }).WithTimeout(8 * time.Second).ProbeEvery(time.Second). + Should(Equal(http.StatusUnauthorized)) + + // use the new password will get 200 + s.NewAPISIXClient(). + GET("/get"). + WithBasicAuth("sample-user", "sample-password-new"). + WithHost("httpbin.org"). + Expect(). + Status(http.StatusOK) + + By("delete consumer") + err = s.DeleteResourceFromString(defaultConsumer) + Expect(err).NotTo(HaveOccurred(), "deleting consumer") + time.Sleep(5 * time.Second) + + s.NewAPISIXClient(). + GET("/get"). + WithHeader("apikey", "sample-key"). + WithHost("httpbin.org"). + Expect(). + Status(401) + + s.NewAPISIXClient(). + GET("/get"). + WithBasicAuth("sample-user", "sample-password"). + WithHost("httpbin.org"). + Expect(). + Status(401) + }) + }) + + Context("Consumer with GatewayProxy Update", func() { + var additionalGatewayGroupID string + + var defaultCredential = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: Consumer +metadata: + name: consumer-sample +spec: + gatewayRef: + name: apisix + credentials: + - type: basic-auth + name: basic-auth-sample + config: + username: sample-user + password: sample-password +` + var updatedGatewayProxy = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" +` + + BeforeEach(func() { + s.ApplyDefaultGatewayResource(defaultGatewayProxy, defaultGatewayClass, defaultGateway, defaultHTTPRoute) + }) + + It("Should sync consumer when GatewayProxy is updated", func() { + s.ResourceApplied("Consumer", "consumer-sample", defaultCredential, 1) + + // verify basic-auth works + s.NewAPISIXClient(). + GET("/get"). + WithBasicAuth("sample-user", "sample-password"). + WithHost("httpbin.org"). + Expect(). + Status(200) + + By("create additional gateway group to get new admin key") + var err error + additionalGatewayGroupID, _, err = s.Deployer.CreateAdditionalGateway("gateway-proxy-update") + Expect(err).NotTo(HaveOccurred(), "creating additional gateway group") + + resources, exists := s.GetAdditionalGateway(additionalGatewayGroupID) + Expect(exists).To(BeTrue(), "additional gateway group should exist") + + client, err := s.NewAPISIXClientForGateway(additionalGatewayGroupID) + Expect(err).NotTo(HaveOccurred(), "creating APISIX client for additional gateway group") + + By("Consumer not found for additional gateway group") + client. + GET("/get"). + WithBasicAuth("sample-user", "sample-password"). + WithHost("httpbin.org"). + Expect(). + Status(404) + + By("update GatewayProxy with new admin key") + updatedProxy := fmt.Sprintf(updatedGatewayProxy, s.Deployer.GetAdminEndpoint(resources.DataplaneService), resources.AdminAPIKey) + err = s.CreateResourceFromString(updatedProxy) + Expect(err).NotTo(HaveOccurred(), "updating GatewayProxy") + time.Sleep(5 * time.Second) + + By("verify Consumer works for additional gateway group") + client. + GET("/get"). + WithBasicAuth("sample-user", "sample-password"). + WithHost("httpbin.org"). + Expect(). + Status(200) + }) + }) +}) diff --git a/test/e2e/crds/v2/consumer.go b/test/e2e/crds/v2/consumer.go new file mode 100644 index 000000000..31668c246 --- /dev/null +++ b/test/e2e/crds/v2/consumer.go @@ -0,0 +1,344 @@ +// 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 apisix + +import ( + "fmt" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" + + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/test/e2e/framework" + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +type Headers map[string]string + +var _ = Describe("Test ApisixConsumer", Label("apisix.apache.org", "v2", "apisixconsumer"), func() { + var ( + s = scaffold.NewScaffold(&scaffold.Options{ + ControllerName: "apisix.apache.org/apisix-ingress-controller", + }) + applier = framework.NewApplier(s.GinkgoT, s.K8sClient, s.CreateResourceFromString) + ) + + BeforeEach(func() { + By("create GatewayProxy") + gatewayProxy := fmt.Sprintf(gatewayProxyYaml, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.CreateResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy") + time.Sleep(5 * time.Second) + + By("create IngressClass") + err = s.CreateResourceFromStringWithNamespace(ingressClassYaml, "") + Expect(err).NotTo(HaveOccurred(), "creating IngressClass") + time.Sleep(5 * time.Second) + }) + + Context("Test KeyAuth", func() { + const ( + keyAuth = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixConsumer +metadata: + name: test-consumer +spec: + ingressClassName: apisix + authParameter: + keyAuth: + value: + key: test-key +` + defaultApisixRoute = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + hosts: + - httpbin + paths: + - /get + - /headers + - /anything + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + authentication: + enable: true + type: keyAuth +` + secret = ` +apiVersion: v1 +kind: Secret +metadata: + name: keyauth +data: + # foo-key + key: Zm9vLWtleQ== +` + secretUpdated = ` +apiVersion: v1 +kind: Secret +metadata: + name: keyauth +data: + # foo2-key + key: Zm9vMi1rZXk= +` + keyAuthWiwhSecret = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixConsumer +metadata: + name: test-consumer +spec: + ingressClassName: apisix + authParameter: + keyAuth: + secretRef: + name: keyauth +` + ) + request := func(path string, headers Headers) int { + return s.NewAPISIXClient().GET(path).WithHeaders(headers).WithHost("httpbin").Expect().Raw().StatusCode + } + + It("Basic tests", func() { + By("apply ApisixRoute") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apiv2.ApisixRoute{}, defaultApisixRoute) + + By("apply ApisixConsumer") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-consumer"}, &apiv2.ApisixConsumer{}, keyAuth) + + By("verify ApisixRoute with ApisixConsumer") + Eventually(request).WithArguments("/get", Headers{ + "apikey": "invalid-key", + }).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + + Eventually(request).WithArguments("/get", Headers{ + "apikey": "test-key", + }).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("Delete ApisixConsumer") + err := s.DeleteResource("ApisixConsumer", "test-consumer") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixConsumer") + Eventually(request).WithArguments("/get", Headers{ + "apikey": "test-key", + }).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + + By("delete ApisixRoute") + err = s.DeleteResource("ApisixRoute", "default") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + Eventually(request).WithArguments("/headers", Headers{}).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound)) + }) + + It("SecretRef tests", func() { + By("apply ApisixRoute") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apiv2.ApisixRoute{}, defaultApisixRoute) + + By("apply Secret") + err := s.CreateResourceFromString(secret) + Expect(err).ShouldNot(HaveOccurred(), "creating Secret for ApisixConsumer") + + By("apply ApisixConsumer") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-consumer"}, &apiv2.ApisixConsumer{}, keyAuthWiwhSecret) + + By("verify ApisixRoute with ApisixConsumer") + Eventually(request).WithArguments("/get", Headers{ + "apikey": "invalid-key", + }).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + + Eventually(request).WithArguments("/get", Headers{ + "apikey": "foo-key", + }).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("update Secret") + err = s.CreateResourceFromString(secretUpdated) + Expect(err).ShouldNot(HaveOccurred(), "updating Secret for ApisixConsumer") + + Eventually(request).WithArguments("/get", Headers{ + "apikey": "foo-key", + }).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + + Eventually(request).WithArguments("/get", Headers{ + "apikey": "foo2-key", + }).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("Delete ApisixConsumer") + err = s.DeleteResource("ApisixConsumer", "test-consumer") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixConsumer") + Eventually(request).WithArguments("/get", Headers{ + "apikey": "test-key", + }).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + + By("delete ApisixRoute") + err = s.DeleteResource("ApisixRoute", "default") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + Eventually(request).WithArguments("/headers", Headers{}).WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound)) + }) + }) + + Context("Test BasicAuth", func() { + const ( + basicAuth = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixConsumer +metadata: + name: test-consumer +spec: + ingressClassName: apisix + authParameter: + basicAuth: + value: + username: test-user + password: test-password +` + defaultApisixRoute = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + hosts: + - httpbin + paths: + - /get + - /headers + - /anything + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + authentication: + enable: true + type: basicAuth +` + + secret = ` +apiVersion: v1 +kind: Secret +metadata: + name: basic +data: + # foo:bar + username: Zm9v + password: YmFy +` + secretUpdated = ` +apiVersion: v1 +kind: Secret +metadata: + name: basic +data: + # foo-new-user:bar-new-password + username: Zm9vLW5ldy11c2Vy + password: YmFyLW5ldy1wYXNzd29yZA== +` + + basicAuthWithSecret = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixConsumer +metadata: + name: test-consumer +spec: + ingressClassName: apisix + authParameter: + basicAuth: + secretRef: + name: basic +` + ) + + request := func(path string, username, password string) int { + return s.NewAPISIXClient().GET(path).WithBasicAuth(username, password).WithHost("httpbin").Expect().Raw().StatusCode + } + It("Basic tests", func() { + By("apply ApisixRoute") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apiv2.ApisixRoute{}, defaultApisixRoute) + + By("apply ApisixConsumer") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-consumer"}, &apiv2.ApisixConsumer{}, basicAuth) + + By("verify ApisixRoute with ApisixConsumer") + Eventually(request).WithArguments("/get", "invalid-username", "invalid-password"). + WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + + Eventually(request).WithArguments("/get", "test-user", "test-password").WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("Delete ApisixConsumer") + err := s.DeleteResource("ApisixConsumer", "test-consumer") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixConsumer") + Eventually(request).WithArguments("/get", "test-user", "test-password"). + WithTimeout(5 * time.Second).ProbeEvery(time.Second). + Should(Equal(http.StatusUnauthorized)) + + By("delete ApisixRoute") + err = s.DeleteResource("ApisixRoute", "default") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + Eventually(request).WithArguments("/headers", "", "").WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound)) + }) + + It("SecretRef tests", func() { + By("apply ApisixRoute") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apiv2.ApisixRoute{}, defaultApisixRoute) + + By("apply Secret") + err := s.CreateResourceFromString(secret) + Expect(err).ShouldNot(HaveOccurred(), "creating Secret for ApisixConsumer") + + By("apply ApisixConsumer") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-consumer"}, &apiv2.ApisixConsumer{}, basicAuthWithSecret) + + By("verify ApisixRoute with ApisixConsumer") + Eventually(request).WithArguments("/get", "", "").WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + Eventually(request).WithArguments("/get", "foo", "bar").WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("update Secret") + err = s.CreateResourceFromString(secretUpdated) + Expect(err).ShouldNot(HaveOccurred(), "updating Secret for ApisixConsumer") + + Eventually(request).WithArguments("/get", "foo", "bar").WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusUnauthorized)) + Eventually(request).WithArguments("/get", "foo-new-user", "bar-new-password"). + WithTimeout(5 * time.Second).ProbeEvery(time.Second). + Should(Equal(http.StatusOK)) + + By("Delete ApisixConsumer") + err = s.DeleteResource("ApisixConsumer", "test-consumer") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixConsumer") + Eventually(request).WithArguments("/get", "foo-new-user", "bar-new-password"). + WithTimeout(5 * time.Second).ProbeEvery(time.Second). + Should(Equal(http.StatusUnauthorized)) + + By("delete ApisixRoute") + err = s.DeleteResource("ApisixRoute", "default") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + Eventually(request).WithArguments("/get", "", "").WithTimeout(5 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound)) + }) + }) +}) diff --git a/test/e2e/crds/v2/globalrule.go b/test/e2e/crds/v2/globalrule.go new file mode 100644 index 000000000..978b1e8ce --- /dev/null +++ b/test/e2e/crds/v2/globalrule.go @@ -0,0 +1,345 @@ +// 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 apisix + +import ( + "fmt" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +const gatewayProxyYaml = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config + namespace: default +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" +` + +const ingressClassYaml = ` +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: apisix +spec: + controller: "apisix.apache.org/apisix-ingress-controller" + parameters: + apiGroup: "apisix.apache.org" + kind: "GatewayProxy" + name: "apisix-proxy-config" + namespace: "default" + scope: "Namespace" +` + +var _ = Describe("Test GlobalRule", Label("apisix.apache.org", "v2", "apisixglobalrule"), func() { + s := scaffold.NewScaffold(&scaffold.Options{ + ControllerName: "apisix.apache.org/apisix-ingress-controller", + }) + + var ingressYaml = ` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: test-ingress +spec: + ingressClassName: apisix + rules: + - host: globalrule.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: httpbin-service-e2e-test + port: + number: 80 +` + + Context("ApisixGlobalRule Basic Operations", func() { + BeforeEach(func() { + if s.Deployer.Name() == "api7ee" { + Skip("GlobalRule is not supported in api7ee") + } + By("create GatewayProxy") + gatewayProxy := fmt.Sprintf(gatewayProxyYaml, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.CreateResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy") + time.Sleep(5 * time.Second) + + By("create IngressClass") + err = s.CreateResourceFromStringWithNamespace(ingressClassYaml, "") + Expect(err).NotTo(HaveOccurred(), "creating IngressClass") + time.Sleep(5 * time.Second) + + By("create Ingress") + err = s.CreateResourceFromString(ingressYaml) + Expect(err).NotTo(HaveOccurred(), "creating Ingress") + time.Sleep(5 * time.Second) + + By("verify Ingress works") + Eventually(func() int { + return s.NewAPISIXClient(). + GET("/get"). + WithHost("globalrule.example.com"). + Expect().Raw().StatusCode + }).WithTimeout(8 * time.Second).ProbeEvery(time.Second). + Should(Equal(http.StatusOK)) + }) + + It("Test GlobalRule with response-rewrite plugin", func() { + globalRuleYaml := ` +apiVersion: apisix.apache.org/v2 +kind: ApisixGlobalRule +metadata: + name: test-global-rule-response-rewrite +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Global-Rule: "test-response-rewrite" + X-Global-Test: "enabled" +` + + By("create ApisixGlobalRule with response-rewrite plugin") + err := s.CreateResourceFromString(globalRuleYaml) + Expect(err).NotTo(HaveOccurred(), "creating ApisixGlobalRule") + + By("verify ApisixGlobalRule status condition") + time.Sleep(5 * time.Second) + gryaml, err := s.GetResourceYaml("ApisixGlobalRule", "test-global-rule-response-rewrite") + Expect(err).NotTo(HaveOccurred(), "getting ApisixGlobalRule yaml") + Expect(gryaml).To(ContainSubstring(`status: "True"`)) + Expect(gryaml).To(ContainSubstring("message: The global rule has been accepted and synced to APISIX")) + + By("verify global rule is applied - response should have custom headers") + resp := s.NewAPISIXClient(). + GET("/get"). + WithHost("globalrule.example.com"). + Expect(). + Status(http.StatusOK) + resp.Header("X-Global-Rule").IsEqual("test-response-rewrite") + resp.Header("X-Global-Test").IsEqual("enabled") + + By("delete ApisixGlobalRule") + err = s.DeleteResource("ApisixGlobalRule", "test-global-rule-response-rewrite") + Expect(err).NotTo(HaveOccurred(), "deleting ApisixGlobalRule") + time.Sleep(5 * time.Second) + + By("verify global rule is removed - response should not have custom headers") + resp = s.NewAPISIXClient(). + GET("/get"). + WithHost("globalrule.example.com"). + Expect(). + Status(http.StatusOK) + resp.Header("X-Global-Rule").IsEmpty() + resp.Header("X-Global-Test").IsEmpty() + }) + + It("Test GlobalRule update", func() { + globalRuleYaml := ` +apiVersion: apisix.apache.org/v2 +kind: ApisixGlobalRule +metadata: + name: test-global-rule-update +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Update-Test: "version1" +` + + updatedGlobalRuleYaml := ` +apiVersion: apisix.apache.org/v2 +kind: ApisixGlobalRule +metadata: + name: test-global-rule-update +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Update-Test: "version2" + X-New-Header: "added" +` + + By("create initial ApisixGlobalRule") + err := s.CreateResourceFromString(globalRuleYaml) + Expect(err).NotTo(HaveOccurred(), "creating ApisixGlobalRule") + + By("verify initial ApisixGlobalRule status condition") + time.Sleep(5 * time.Second) + gryaml, err := s.GetResourceYaml("ApisixGlobalRule", "test-global-rule-update") + Expect(err).NotTo(HaveOccurred(), "getting ApisixGlobalRule yaml") + Expect(gryaml).To(ContainSubstring(`status: "True"`)) + Expect(gryaml).To(ContainSubstring("message: The global rule has been accepted and synced to APISIX")) + + By("verify initial configuration") + resp := s.NewAPISIXClient(). + GET("/get"). + WithHost("globalrule.example.com"). + Expect(). + Status(http.StatusOK) + resp.Header("X-Update-Test").IsEqual("version1") + resp.Header("X-New-Header").IsEmpty() + + By("update ApisixGlobalRule") + err = s.CreateResourceFromString(updatedGlobalRuleYaml) + Expect(err).NotTo(HaveOccurred(), "updating ApisixGlobalRule") + + By("verify updated ApisixGlobalRule status condition") + time.Sleep(5 * time.Second) + gryaml, err = s.GetResourceYaml("ApisixGlobalRule", "test-global-rule-update") + Expect(err).NotTo(HaveOccurred(), "getting updated ApisixGlobalRule yaml") + Expect(gryaml).To(ContainSubstring(`status: "True"`)) + Expect(gryaml).To(ContainSubstring("message: The global rule has been accepted and synced to APISIX")) + Expect(gryaml).To(ContainSubstring("observedGeneration: 2")) + + By("verify updated configuration") + resp = s.NewAPISIXClient(). + GET("/get"). + WithHost("globalrule.example.com"). + Expect(). + Status(http.StatusOK) + resp.Header("X-Update-Test").IsEqual("version2") + resp.Header("X-New-Header").IsEqual("added") + + By("delete ApisixGlobalRule") + err = s.DeleteResource("ApisixGlobalRule", "test-global-rule-update") + Expect(err).NotTo(HaveOccurred(), "deleting ApisixGlobalRule") + }) + + It("Test multiple GlobalRules with different plugins", func() { + proxyRewriteGlobalRuleYaml := ` +apiVersion: apisix.apache.org/v2 +kind: ApisixGlobalRule +metadata: + name: test-global-rule-proxy-rewrite +spec: + ingressClassName: apisix + plugins: + - name: proxy-rewrite + enable: true + config: + headers: + add: + X-Global-Proxy: "test" +` + + responseRewriteGlobalRuleYaml := ` +apiVersion: apisix.apache.org/v2 +kind: ApisixGlobalRule +metadata: + name: test-global-rule-response-rewrite-multi +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Global-Multi: "test-multi-rule" + X-Response-Type: "rewrite" +` + + By("create ApisixGlobalRule with proxy-rewrite plugin") + err := s.CreateResourceFromString(proxyRewriteGlobalRuleYaml) + Expect(err).NotTo(HaveOccurred(), "creating ApisixGlobalRule with proxy-rewrite") + + By("create ApisixGlobalRule with response-rewrite plugin") + err = s.CreateResourceFromString(responseRewriteGlobalRuleYaml) + Expect(err).NotTo(HaveOccurred(), "creating ApisixGlobalRule with response-rewrite") + + By("verify both ApisixGlobalRule status conditions") + time.Sleep(5 * time.Second) + + proxyRewriteYaml, err := s.GetResourceYaml("ApisixGlobalRule", "test-global-rule-proxy-rewrite") + Expect(err).NotTo(HaveOccurred(), "getting proxy-rewrite ApisixGlobalRule yaml") + Expect(proxyRewriteYaml).To(ContainSubstring(`status: "True"`)) + Expect(proxyRewriteYaml).To(ContainSubstring("message: The global rule has been accepted and synced to APISIX")) + + responseRewriteYaml, err := s.GetResourceYaml("ApisixGlobalRule", "test-global-rule-response-rewrite-multi") + Expect(err).NotTo(HaveOccurred(), "getting response-rewrite ApisixGlobalRule yaml") + Expect(responseRewriteYaml).To(ContainSubstring(`status: "True"`)) + Expect(responseRewriteYaml).To(ContainSubstring("message: The global rule has been accepted and synced to APISIX")) + + By("verify both global rules are applied on GET request") + getResp := s.NewAPISIXClient(). + GET("/get"). + WithHost("globalrule.example.com"). + Expect(). + Status(http.StatusOK) + getResp.Header("X-Global-Multi").IsEqual("test-multi-rule") + getResp.Header("X-Response-Type").IsEqual("rewrite") + getResp.Body().Contains(`"X-Global-Proxy": "test"`) + + By("delete proxy-rewrite ApisixGlobalRule") + err = s.DeleteResource("ApisixGlobalRule", "test-global-rule-proxy-rewrite") + Expect(err).NotTo(HaveOccurred(), "deleting proxy-rewrite ApisixGlobalRule") + time.Sleep(5 * time.Second) + + By("verify only response-rewrite global rule remains - proxy-rewrite headers should be removed") + getRespAfterProxyDelete := s.NewAPISIXClient(). + GET("/get"). + WithHost("globalrule.example.com"). + Expect(). + Status(http.StatusOK) + getRespAfterProxyDelete.Header("X-Global-Multi").IsEqual("test-multi-rule") + getRespAfterProxyDelete.Header("X-Response-Type").IsEqual("rewrite") + getRespAfterProxyDelete.Body().NotContains(`"X-Global-Proxy": "test"`) + + By("delete response-rewrite ApisixGlobalRule") + err = s.DeleteResource("ApisixGlobalRule", "test-global-rule-response-rewrite-multi") + Expect(err).NotTo(HaveOccurred(), "deleting response-rewrite ApisixGlobalRule") + time.Sleep(5 * time.Second) + + By("verify all global rules are removed") + finalResp := s.NewAPISIXClient(). + GET("/get"). + WithHost("globalrule.example.com"). + Expect(). + Status(http.StatusOK) + finalResp.Header("X-Global-Multi").IsEmpty() + finalResp.Header("X-Response-Type").IsEmpty() + finalResp.Body().NotContains(`"X-Global-Proxy": "test"`) + }) + }) +}) diff --git a/test/e2e/crds/v2/pluginconfig.go b/test/e2e/crds/v2/pluginconfig.go new file mode 100644 index 000000000..2c4c27fc5 --- /dev/null +++ b/test/e2e/crds/v2/pluginconfig.go @@ -0,0 +1,514 @@ +// 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 apisix + +import ( + "fmt" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" + + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/test/e2e/framework" + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +const gatewayProxyYamlPluginConfig = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-config + namespace: default +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" +` + +const ingressClassYamlPluginConfig = ` +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: apisix +spec: + controller: "apisix.apache.org/apisix-ingress-controller" + parameters: + apiGroup: "apisix.apache.org" + kind: "GatewayProxy" + name: "apisix-proxy-config" + namespace: "default" + scope: "Namespace" +` + +var _ = Describe("Test ApisixPluginConfig", Label("apisix.apache.org", "v2", "apisixpluginconfig"), func() { + var ( + s = scaffold.NewScaffold(&scaffold.Options{ + ControllerName: "apisix.apache.org/apisix-ingress-controller", + }) + applier = framework.NewApplier(s.GinkgoT, s.K8sClient, s.CreateResourceFromString) + ) + + Context("Test ApisixPluginConfig", func() { + BeforeEach(func() { + By("create GatewayProxy") + gatewayProxy := fmt.Sprintf(gatewayProxyYamlPluginConfig, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.CreateResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy") + time.Sleep(5 * time.Second) + + By("create IngressClass") + err = s.CreateResourceFromStringWithNamespace(ingressClassYamlPluginConfig, "") + Expect(err).NotTo(HaveOccurred(), "creating IngressClass") + time.Sleep(5 * time.Second) + }) + + It("Basic ApisixPluginConfig test", func() { + const apisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Plugin-Config: "test-response-rewrite" + X-Plugin-Test: "enabled" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config +` + + By("apply ApisixPluginConfig") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config"}, &apisixPluginConfig, apisixPluginConfigSpec) + + By("apply ApisixRoute that references ApisixPluginConfig") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works with plugin config") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify plugin from ApisixPluginConfig works") + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Plugin-Config").IsEqual("test-response-rewrite") + resp.Header("X-Plugin-Test").IsEqual("enabled") + + By("delete ApisixRoute") + err := s.DeleteResource("ApisixRoute", "test-route") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + + By("delete ApisixPluginConfig") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound)) + }) + + It("Test ApisixPluginConfig update", func() { + const apisixPluginConfigSpecV1 = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-update +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Version: "v1" +` + + const apisixPluginConfigSpecV2 = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-update +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Version: "v2" + X-Updated: "true" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-update +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config-update +` + + By("apply initial ApisixPluginConfig") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-update"}, &apisixPluginConfig, apisixPluginConfigSpecV1) + + By("apply ApisixRoute that references ApisixPluginConfig") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-update"}, &apisixRoute, apisixRouteSpec) + + By("verify initial plugin config works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Version").IsEqual("v1") + resp.Header("X-Updated").IsEmpty() + + By("update ApisixPluginConfig") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-update"}, &apisixPluginConfig, apisixPluginConfigSpecV2) + time.Sleep(5 * time.Second) + + By("verify updated plugin config works") + resp = s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Version").IsEqual("v2") + resp.Header("X-Updated").IsEqual("true") + + By("delete resources") + err := s.DeleteResource("ApisixRoute", "test-route-update") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config-update") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + }) + + It("Test ApisixPluginConfig with disabled plugin", func() { + const apisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-disabled +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: false + config: + headers: + X-Should-Not-Exist: "disabled" + - name: cors + enable: true + config: + allow_origins: "*" + allow_methods: "GET,POST" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-disabled +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config-disabled +` + + By("apply ApisixPluginConfig with disabled plugin") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-disabled"}, &apisixPluginConfig, apisixPluginConfigSpec) + + By("apply ApisixRoute that references ApisixPluginConfig") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-disabled"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify disabled plugin is not applied") + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Should-Not-Exist").IsEmpty() + + By("verify enabled plugin is applied") + resp.Header("Access-Control-Allow-Origin").IsEqual("*") + + By("delete resources") + err := s.DeleteResource("ApisixRoute", "test-route-disabled") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config-disabled") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + }) + + It("Test ApisixPluginConfig overridden by route plugins", func() { + const apisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-override +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-From-Config: "plugin-config" + X-Shared: "from-config" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-override +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config-override + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-From-Route: "route" + X-Shared: "from-route" +` + + By("apply ApisixPluginConfig") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-override"}, &apisixPluginConfig, apisixPluginConfigSpec) + + By("apply ApisixRoute with overriding plugins") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-override"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify route plugins override plugin config") + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-From-Config").IsEmpty() + resp.Header("X-From-Route").IsEqual("route") + resp.Header("X-Shared").IsEqual("from-route") + + By("delete resources") + err := s.DeleteResource("ApisixRoute", "test-route-override") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config-override") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + }) + + It("Test cross-namespace ApisixPluginConfig reference", func() { + const crossNamespaceApisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: cross-ns-plugin-config + namespace: default +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Cross-Namespace: "true" + X-Namespace: "default" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-cross-ns +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: cross-ns-plugin-config + plugin_config_namespace: default +` + + By("apply ApisixPluginConfig in default namespace") + err := s.CreateResourceFromStringWithNamespace(crossNamespaceApisixPluginConfigSpec, "default") + Expect(err).NotTo(HaveOccurred(), "creating default/cross-ns-plugin-config") + time.Sleep(5 * time.Second) + + By("apply ApisixRoute in test namespace that references ApisixPluginConfig in default namespace") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-cross-ns"}, &apisixRoute, apisixRouteSpec) + + By("verify cross-namespace reference works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Cross-Namespace").IsEqual("true") + resp.Header("X-Namespace").IsEqual("default") + + By("delete resources") + err = s.DeleteResource("ApisixRoute", "test-route-cross-ns") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResourceFromStringWithNamespace(crossNamespaceApisixPluginConfigSpec, "default") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + }) + + It("Test ApisixPluginConfig with SecretRef", func() { + const secretSpec = ` +apiVersion: v1 +kind: Secret +metadata: + name: plugin-secret +type: Opaque +data: + key: dGVzdC1rZXk= + username: dGVzdC11c2Vy + password: dGVzdC1wYXNzd29yZA== +` + + const apisixPluginConfigSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixPluginConfig +metadata: + name: test-plugin-config-secret +spec: + ingressClassName: apisix + plugins: + - name: response-rewrite + enable: true + secretRef: plugin-secret + config: + headers: + X-Secret-Ref: "true" +` + + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-secret +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + plugin_config_name: test-plugin-config-secret +` + + By("apply Secret") + err := s.CreateResourceFromStringWithNamespace(secretSpec, s.Namespace()) + Expect(err).NotTo(HaveOccurred(), "creating Secret") + + By("apply ApisixPluginConfig with SecretRef") + var apisixPluginConfig apiv2.ApisixPluginConfig + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-plugin-config-secret"}, &apisixPluginConfig, apisixPluginConfigSpec) + + By("apply ApisixRoute that references ApisixPluginConfig") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-secret"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works with SecretRef") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Secret-Ref").IsEqual("true") + + By("delete resources") + err = s.DeleteResource("ApisixRoute", "test-route-secret") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + err = s.DeleteResource("ApisixPluginConfig", "test-plugin-config-secret") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixPluginConfig") + err = s.DeleteResource("Secret", "plugin-secret") + Expect(err).ShouldNot(HaveOccurred(), "deleting Secret") + }) + }) +}) diff --git a/test/e2e/crds/v2/route.go b/test/e2e/crds/v2/route.go new file mode 100644 index 000000000..253803129 --- /dev/null +++ b/test/e2e/crds/v2/route.go @@ -0,0 +1,523 @@ +// 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 apisix + +import ( + "fmt" + "net" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" + + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/test/e2e/framework" + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +var _ = Describe("Test ApisixRoute", Label("apisix.apache.org", "v2", "apisixroute"), func() { + var ( + s = scaffold.NewScaffold(&scaffold.Options{ + ControllerName: "apisix.apache.org/apisix-ingress-controller", + }) + applier = framework.NewApplier(s.GinkgoT, s.K8sClient, s.CreateResourceFromString) + ) + + BeforeEach(func() { + By("create GatewayProxy") + gatewayProxy := fmt.Sprintf(gatewayProxyYaml, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.CreateResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy") + time.Sleep(5 * time.Second) + + By("create IngressClass") + err = s.CreateResourceFromStringWithNamespace(ingressClassYaml, "") + Expect(err).NotTo(HaveOccurred(), "creating IngressClass") + time.Sleep(5 * time.Second) + }) + + Context("Test ApisixRoute", func() { + + It("Basic tests", func() { + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + hosts: + - httpbin + paths: + - %s + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 +` + request := func(path string) int { + return s.NewAPISIXClient().GET(path).WithHost("httpbin").Expect().Raw().StatusCode + } + + By("apply ApisixRoute") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, fmt.Sprintf(apisixRouteSpec, "/get")) + + By("verify ApisixRoute works") + Eventually(request).WithArguments("/get").WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("update ApisixRoute") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, fmt.Sprintf(apisixRouteSpec, "/headers")) + Eventually(request).WithArguments("/get").WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound)) + s.NewAPISIXClient().GET("/headers").WithHost("httpbin").Expect().Status(http.StatusOK) + + By("delete ApisixRoute") + err := s.DeleteResource("ApisixRoute", "default") + Expect(err).ShouldNot(HaveOccurred(), "deleting ApisixRoute") + Eventually(request).WithArguments("/headers").WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound)) + }) + + It("Test plugins in ApisixRoute", func() { + const apisixRouteSpecPart0 = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 +` + const apisixRouteSpecPart1 = ` + plugins: + - name: response-rewrite + enable: true + config: + headers: + X-Global-Rule: "test-response-rewrite" + X-Global-Test: "enabled" +` + By("apply ApisixRoute without plugins") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, apisixRouteSpecPart0) + + By("verify ApisixRoute works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("apply ApisixRoute with plugins") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, apisixRouteSpecPart0+apisixRouteSpecPart1) + time.Sleep(5 * time.Second) + + By("verify plugin works") + resp := s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Global-Rule").IsEqual("test-response-rewrite") + resp.Header("X-Global-Test").IsEqual("enabled") + + By("remove plugin") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, apisixRouteSpecPart0) + time.Sleep(5 * time.Second) + + By("verify no plugin works") + resp = s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusOK) + resp.Header("X-Global-Rule").IsEmpty() + resp.Header("X-Global-Test").IsEmpty() + }) + + It("Test ApisixRoute match by vars", func() { + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + exprs: + - subject: + scope: Header + name: X-Foo + op: Equal + value: bar + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 +` + By("apply ApisixRoute") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works") + request := func() int { + return s.NewAPISIXClient().GET("/get"). + WithHeader("X-Foo", "bar"). + Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusNotFound) + }) + + It("Test ApisixRoute filterFunc", func() { + if s.Deployer.Name() == "api7ee" { + Skip("filterFunc is not supported in api7ee") + } + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + filter_func: | + function(vars) + local core = require ('apisix.core') + local body, err = core.request.get_body() + if not body then + return false + end + local data, err = core.json.decode(body) + if not data then + return false + end + if data['foo'] == 'bar' then + return true + end + return false + end + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 +` + By("apply ApisixRoute") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works") + request := func() int { + return s.NewAPISIXClient().GET("/get"). + WithJSON(map[string]string{"foo": "bar"}). + Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusNotFound) + }) + + It("Test ApisixRoute service not found", func() { + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + hosts: + - httpbin + paths: + - %s + backends: + - serviceName: service-not-found + servicePort: 80 +` + request := func(path string) int { + return s.NewAPISIXClient().GET(path).WithHost("httpbin").Expect().Raw().StatusCode + } + + By("apply ApisixRoute") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, fmt.Sprintf(apisixRouteSpec, "/get")) + + By("when there is no replica got 500 by fault-injection") + err := s.ScaleHTTPBIN(0) + Expect(err).ShouldNot(HaveOccurred(), "scale httpbin to 0") + Eventually(request).WithArguments("/get").WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusInternalServerError)) + s.NewAPISIXClient().GET("/get").WithHost("httpbin").Expect().Body().IsEqual("No existing backendRef provided") + }) + + It("Test ApisixRoute resolveGranularity", func() { + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + resolveGranularity: service + plugins: + - name: response-rewrite + enable: true + config: + headers: + set: + "X-Upstream-IP": "$upstream_addr" +` + By("apply ApisixRoute") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, apisixRouteSpec) + + By("verify ApisixRoute works") + request := func() int { + return s.NewAPISIXClient().GET("/get").Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("assert that the request is proxied to the Service ClusterIP") + service, err := s.GetServiceByName("httpbin-service-e2e-test") + Expect(err).ShouldNot(HaveOccurred(), "get service") + clusterIP := net.JoinHostPort(service.Spec.ClusterIP, "80") + s.NewAPISIXClient().GET("/get").Expect().Header("X-Upstream-IP").IsEqual(clusterIP) + }) + + It("Test ApisixRoute subset", func() { + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + hosts: + - httpbin + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + subset: test-subset +` + const apisixUpstreamSpec0 = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixUpstream +metadata: + name: httpbin-service-e2e-test +spec: + ingressClassName: apisix + subsets: + - name: test-subset + labels: + unknown-key: unknown-value +` + const apisixUpstreamSpec1 = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixUpstream +metadata: + name: httpbin-service-e2e-test +spec: + ingressClassName: apisix + subsets: + - name: test-subset + labels: + app: httpbin-deployment-e2e-test +` + request := func() int { + return s.NewAPISIXClient().GET("/get").WithHost("httpbin").Expect().Raw().StatusCode + } + By("apply ApisixRoute") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, &apisixRoute, apisixRouteSpec) + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + // no pod matches the subset label "unknown-key: unknown-value" so there will be no node in the upstream, + // to request the route will get http.StatusServiceUnavailable + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "httpbin-service-e2e-test"}, new(apiv2.ApisixUpstream), apisixUpstreamSpec0) + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusServiceUnavailable)) + + // the pod matches the subset label "app: httpbin-deployment-e2e-test", + // to request the route will be OK + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "httpbin-service-e2e-test"}, new(apiv2.ApisixUpstream), apisixUpstreamSpec1) + Eventually(request).WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + }) + }) + + Context("Test ApisixRoute reference ApisixUpstream", func() { + It("Test reference ApisixUpstream", func() { + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + upstreams: + - name: default-upstream +` + const apisixUpstreamSpec0 = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixUpstream +metadata: + name: default-upstream +spec: + ingressClassName: apisix + externalNodes: + - type: Service + name: httpbin-service-e2e-test +` + const apisixUpstreamSpec1 = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixUpstream +metadata: + name: default-upstream +spec: + ingressClassName: apisix + externalNodes: + - type: Service + name: alias-httpbin-service-e2e-test +` + const serviceSpec = ` +apiVersion: v1 +kind: Service +metadata: + name: alias-httpbin-service-e2e-test +spec: + type: ExternalName + externalName: httpbin-service-e2e-test +` + By("create Service, ApisixUpstream and ApisixRoute") + err := s.CreateResourceFromString(serviceSpec) + Expect(err).ShouldNot(HaveOccurred(), "apply service") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default-upstream"}, new(apiv2.ApisixUpstream), apisixUpstreamSpec0) + + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, new(apiv2.ApisixRoute), apisixRouteSpec) + + By("verify that the ApisixUpstream reference a Service which is not ExternalName should not request OK") + request := func(path string) int { + return s.NewAPISIXClient().GET(path).WithHost("httpbin").Expect().Raw().StatusCode + } + Eventually(request).WithArguments("/get").WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusServiceUnavailable)) + + By("verify that ApisixUpstream reference a Service which is ExternalName should request OK") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default-upstream"}, new(apiv2.ApisixUpstream), apisixUpstreamSpec1) + Eventually(request).WithArguments("/get").WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + }) + + It("Test a Mix of Backends and Upstreams", func() { + // apisixUpstreamSpec is an ApisixUpstream reference to the Service httpbin-service-e2e-test + const apisixUpstreamSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixUpstream +metadata: + name: default-upstream +spec: + ingressClassName: apisix + externalNodes: + - type: Domain + name: httpbin-service-e2e-test + passHost: node +` + // apisixRouteSpec is an ApisixUpstream uses a backend and reference an upstream. + // It contains a plugin response-rewrite that lets us know what upstream the gateway forwards the request to. + const apisixRouteSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: default +spec: + ingressClassName: apisix + http: + - name: rule0 + match: + paths: + - /* + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 + upstreams: + - name: default-upstream + plugins: + - name: response-rewrite + enable: true + config: + headers: + set: + "X-Upstream-Host": "$upstream_addr" +` + By("apply ApisixUpstream") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default-upstream"}, new(apiv2.ApisixUpstream), apisixUpstreamSpec) + + By("apply ApisixRoute") + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"}, new(apiv2.ApisixRoute), apisixRouteSpec) + + By("verify ApisixRoute works") + request := func(path string) int { + return s.NewAPISIXClient().GET(path).Expect().Raw().StatusCode + } + Eventually(request).WithArguments("/get").WithTimeout(8 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify the backends and the upstreams work commonly") + // .backends -> Service httpbin-service-e2e-test -> Endpoint httpbin-service-e2e-test, so the $upstream_addr value we get is the Endpoint IP. + // .upstreams -> Service httpbin-service-e2e-test, so the $upstream_addr value we get is the Service ClusterIP. + var upstreamAddrs = make(map[string]struct{}) + for range 10 { + upstreamAddr := s.NewAPISIXClient().GET("/get").Expect().Raw().Header.Get("X-Upstream-Host") + upstreamAddrs[upstreamAddr] = struct{}{} + } + + endpoints, err := s.GetServiceEndpoints(types.NamespacedName{Namespace: s.Namespace(), Name: "httpbin-service-e2e-test"}) + Expect(err).ShouldNot(HaveOccurred(), "get endpoints") + Expect(endpoints).Should(HaveLen(1)) + endpoint := net.JoinHostPort(endpoints[0], "80") + + service, err := s.GetServiceByName("httpbin-service-e2e-test") + Expect(err).ShouldNot(HaveOccurred(), "get service") + clusterIP := net.JoinHostPort(service.Spec.ClusterIP, "80") + + Expect(upstreamAddrs).Should(HaveLen(2)) + Eventually(upstreamAddrs).Should(HaveKey(endpoint)) + Eventually(upstreamAddrs).Should(HaveKey(clusterIP)) + }) + }) +}) diff --git a/test/e2e/crds/v2/tls.go b/test/e2e/crds/v2/tls.go new file mode 100644 index 000000000..4a6359379 --- /dev/null +++ b/test/e2e/crds/v2/tls.go @@ -0,0 +1,267 @@ +// 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 apisix + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/types" + + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" + "github.com/apache/apisix-ingress-controller/test/e2e/framework" + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +const gatewayProxyYamlTls = ` +apiVersion: apisix.apache.org/v1alpha1 +kind: GatewayProxy +metadata: + name: apisix-proxy-tls + namespace: default +spec: + provider: + type: ControlPlane + controlPlane: + endpoints: + - %s + auth: + type: AdminKey + adminKey: + value: "%s" +` + +const ingressClassYamlTls = ` +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: apisix-tls +spec: + controller: "apisix.apache.org/apisix-ingress-controller" + parameters: + apiGroup: "apisix.apache.org" + kind: "GatewayProxy" + name: "apisix-proxy-tls" + namespace: "default" + scope: "Namespace" +` + +const apisixRouteYamlTls = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixRoute +metadata: + name: test-route-tls +spec: + ingressClassName: apisix-tls + http: + - name: rule0 + match: + paths: + - /* + hosts: + - api6.com + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 +` + +var Cert = strings.TrimSpace(framework.TestServerCert) + +var Key = strings.TrimSpace(framework.TestServerKey) + +var _ = Describe("Test ApisixTls", Label("apisix.apache.org", "v2", "apisixtls"), func() { + var ( + s = scaffold.NewScaffold(&scaffold.Options{ + ControllerName: "apisix.apache.org/apisix-ingress-controller", + }) + applier = framework.NewApplier(s.GinkgoT, s.K8sClient, s.CreateResourceFromString) + ) + + Context("Test ApisixTls", func() { + BeforeEach(func() { + By("create GatewayProxy") + gatewayProxy := fmt.Sprintf(gatewayProxyYamlTls, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.CreateResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy") + time.Sleep(5 * time.Second) + + By("create IngressClass") + err = s.CreateResourceFromStringWithNamespace(ingressClassYamlTls, "") + Expect(err).NotTo(HaveOccurred(), "creating IngressClass") + time.Sleep(5 * time.Second) + + By("create ApisixRoute for TLS testing") + var apisixRoute apiv2.ApisixRoute + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-route-tls"}, &apisixRoute, apisixRouteYamlTls) + }) + + AfterEach(func() { + By("delete GatewayProxy") + gatewayProxy := fmt.Sprintf(gatewayProxyYamlTls, s.Deployer.GetAdminEndpoint(), s.AdminKey()) + err := s.DeleteResourceFromStringWithNamespace(gatewayProxy, "default") + Expect(err).ShouldNot(HaveOccurred(), "deleting GatewayProxy") + + By("delete IngressClass") + err = s.DeleteResourceFromStringWithNamespace(ingressClassYamlTls, "") + Expect(err).ShouldNot(HaveOccurred(), "deleting IngressClass") + }) + normalizePEM := func(s string) string { + return strings.TrimSpace(s) + } + + It("Basic ApisixTls test", func() { + const host = "api6.com" + + By("create TLS secret") + err := s.NewKubeTlsSecret("test-tls-secret", Cert, Key) + Expect(err).NotTo(HaveOccurred(), "creating TLS secret") + + const apisixTlsSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: test-tls +spec: + ingressClassName: apisix-tls + hosts: + - api6.com + secret: + name: test-tls-secret + namespace: %s +` + + By("apply ApisixTls") + var apisixTls apiv2.ApisixTls + tlsSpec := fmt.Sprintf(apisixTlsSpec, s.Namespace()) + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-tls"}, &apisixTls, tlsSpec) + + By("verify TLS configuration in control plane") + Eventually(func() bool { + tls, err := s.DefaultDataplaneResource().SSL().List(context.Background()) + if err != nil { + return false + } + if len(tls) != 1 { + return false + } + if len(tls[0].Certificates) != 1 { + return false + } + return true + }).WithTimeout(30 * time.Second).ProbeEvery(2 * time.Second).Should(BeTrue()) + + tls, err := s.DefaultDataplaneResource().SSL().List(context.Background()) + assert.Nil(GinkgoT(), err, "list tls error") + assert.Len(GinkgoT(), tls, 1, "tls number not expect") + assert.Len(GinkgoT(), tls[0].Certificates, 1, "length of certificates not expect") + assert.Equal(GinkgoT(), Cert, tls[0].Certificates[0].Certificate, "tls cert not expect") + assert.ElementsMatch(GinkgoT(), []string{host}, tls[0].Snis) + + By("test HTTPS request to dataplane") + Eventually(func() int { + return s.NewAPISIXHttpsClient("api6.com"). + GET("/get"). + WithHost("api6.com"). + Expect(). + Raw().StatusCode + }).WithTimeout(30 * time.Second).ProbeEvery(2 * time.Second).Should(Equal(http.StatusOK)) + + s.NewAPISIXHttpsClient("api6.com"). + GET("/get"). + WithHost("api6.com"). + Expect(). + Status(200) + }) + + It("ApisixTls with mTLS test", func() { + const host = "api6.com" + + By("generate mTLS certificates") + caCertBytes, serverCertBytes, serverKeyBytes, _, _ := s.GenerateMACert(GinkgoT(), []string{host}) + caCert := caCertBytes.String() + serverCert := serverCertBytes.String() + serverKey := serverKeyBytes.String() + + By("create TLS secret") + err := s.NewKubeTlsSecret("test-mtls-secret", serverCert, serverKey) + Expect(err).NotTo(HaveOccurred(), "creating TLS secret") + + By("create CA secret") + err = s.NewClientCASecret("test-ca-secret", caCert, "") + Expect(err).NotTo(HaveOccurred(), "creating CA secret") + + const apisixTlsSpec = ` +apiVersion: apisix.apache.org/v2 +kind: ApisixTls +metadata: + name: test-mtls +spec: + ingressClassName: apisix-tls + hosts: + - api6.com + secret: + name: test-mtls-secret + namespace: %s + client: + caSecret: + name: test-ca-secret + namespace: %s + depth: 1 +` + + By("apply ApisixTls with mTLS") + var apisixTls apiv2.ApisixTls + tlsSpec := fmt.Sprintf(apisixTlsSpec, s.Namespace(), s.Namespace()) + applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "test-mtls"}, &apisixTls, tlsSpec) + + By("verify mTLS configuration in control plane") + Eventually(func() bool { + tls, err := s.DefaultDataplaneResource().SSL().List(context.Background()) + if err != nil { + return false + } + if len(tls) != 1 { + return false + } + if len(tls[0].Certificates) != 1 { + return false + } + // Check if client CA is configured + return tls[0].Client != nil && tls[0].Client.CA != "" + }).WithTimeout(30 * time.Second).ProbeEvery(2 * time.Second).Should(BeTrue()) + + tls, err := s.DefaultDataplaneResource().SSL().List(context.Background()) + assert.Nil(GinkgoT(), err, "list tls error") + assert.Len(GinkgoT(), tls, 1, "tls number not expect") + assert.Len(GinkgoT(), tls[0].Certificates, 1, "length of certificates not expect") + assert.Equal(GinkgoT(), normalizePEM(serverCert), normalizePEM(tls[0].Certificates[0].Certificate), "tls cert not expect") + assert.ElementsMatch(GinkgoT(), []string{host}, tls[0].Snis) + assert.NotNil(GinkgoT(), tls[0].Client, "client configuration should not be nil") + assert.NotEmpty(GinkgoT(), tls[0].Client.CA, "client CA should not be empty") + assert.Equal(GinkgoT(), normalizePEM(caCert), normalizePEM(tls[0].Client.CA), "client CA should be test-ca-secret") + assert.Equal(GinkgoT(), int64(1), *tls[0].Client.Depth, "client depth should be 1") + }) + + }) +}) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 000000000..e65f4214b --- /dev/null +++ b/test/e2e/e2e_test.go @@ -0,0 +1,49 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package e2e + +import ( + "fmt" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + _ "github.com/apache/apisix-ingress-controller/test/e2e/api7" + _ "github.com/apache/apisix-ingress-controller/test/e2e/crds/v1alpha1" + _ "github.com/apache/apisix-ingress-controller/test/e2e/crds/v2" + "github.com/apache/apisix-ingress-controller/test/e2e/framework" + _ "github.com/apache/apisix-ingress-controller/test/e2e/gatewayapi" + _ "github.com/apache/apisix-ingress-controller/test/e2e/ingress" + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +// Run e2e tests using the Ginkgo runner. +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + f := framework.NewFramework() + + // init newDeployer function + scaffold.NewDeployer = scaffold.NewAPI7Deployer + + BeforeSuite(f.BeforeSuite) + AfterSuite(f.AfterSuite) + + _, _ = fmt.Fprintf(GinkgoWriter, "Starting apisix-ingress suite\n") + RunSpecs(t, "e2e suite") +} diff --git a/test/e2e/framework/api7_consts.go b/test/e2e/framework/api7_consts.go new file mode 100644 index 000000000..0d7975a9d --- /dev/null +++ b/test/e2e/framework/api7_consts.go @@ -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 framework + +import ( + _ "embed" +) + +const ( + postgres = "postgres" + oceanbase = "oceanbase" + mysql = "mysql" + postgresDSN = "postgres://api7ee:changeme@api7-postgresql:5432/api7ee" + oceanbaseDSN = "mysql://root@tcp(oceanbase:2881)/api7ee" + mysqlDSN = "mysql://root:changeme@tcp(mysql:3306)/api7ee" +) + +const ( + DashboardEndpoint = "http://api7ee3-dashboard.api7-ee-e2e:7080" + DashboardTLSEndpoint = "https://api7ee3-dashboard.api7-ee-e2e:7443" + DPManagerTLSEndpoint = "https://api7ee3-dp-manager.api7-ee-e2e:7943" +) diff --git a/test/e2e/framework/api7_dashboard.go b/test/e2e/framework/api7_dashboard.go new file mode 100644 index 000000000..dbcce286a --- /dev/null +++ b/test/e2e/framework/api7_dashboard.go @@ -0,0 +1,455 @@ +// 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 framework + +import ( + _ "embed" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "text/template" + + "github.com/gavv/httpexpect/v2" + "github.com/google/uuid" + . "github.com/onsi/gomega" + + v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" +) + +var ( + valuesTemplate *template.Template + _db string +) + +func init() { + _db = os.Getenv("DB") + if _db == "" { + _db = postgres + } + tmpl, err := template.New("values.yaml").Parse(` +dashboard: + image: + repository: hkccr.ccs.tencentyun.com/api7-dev/api7-ee-3-integrated + pullPolicy: IfNotPresent + tag: {{ .Tag }} + extraEnvVars: + - name: GOCOVERDIR + value: /app/covdatafiles + extraVolumes: + - name: cover + hostPath: + path: /tmp/covdatafiles + type: DirectoryOrCreate + extraVolumeMounts: + - name: cover + mountPath: /app/covdatafiles +dp_manager: + image: + repository: hkccr.ccs.tencentyun.com/api7-dev/api7-ee-dp-manager + pullPolicy: IfNotPresent + tag: {{ .Tag }} + extraEnvVars: + - name: GOCOVERDIR + value: /app/covdatafiles + extraVolumes: + - name: cover + hostPath: + path: /tmp/covdatafiles + type: DirectoryOrCreate + extraVolumeMounts: + - name: cover + mountPath: /app/covdatafiles +fullnameOverride: api7ee3 +podSecurityContext: + runAsUser: 0 +dashboard_configuration: + log: + level: debug + database: + dsn: {{ .DSN }} + server: + listen: + disable: false + host: "0.0.0.0" + port: 7080 + tls: + disable: false + host: "0.0.0.0" + port: 7443 + status: + host: "0.0.0.0" + cron_spec: "@every 1s" + plugins: + - error-page + - real-ip + #- ai + - error-page + - client-control + - proxy-control + - zipkin + - skywalking + - ext-plugin-pre-req + - mocking + - serverless-pre-function + - batch-requests + - ua-restriction + - referer-restriction + - uri-blocker + - request-validation + - authz-casbin + - authz-casdoor + - wolf-rbac + - multi-auth + - ldap-auth + - forward-auth + - saml-auth + - opa + - authz-keycloak + #- error-log-logger + - proxy-mirror + - proxy-cache + - api-breaker + - limit-req + #- node-status + - gzip + - kafka-proxy + #- dubbo-proxy + - grpc-transcode + - grpc-web + - public-api + - data-mask + - opentelemetry + - datadog + - echo + - loggly + - splunk-hec-logging + - skywalking-logger + - google-cloud-logging + - sls-logger + - tcp-logger + - rocketmq-logger + - udp-logger + - file-logger + - clickhouse-logger + - ext-plugin-post-resp + - serverless-post-function + - azure-functions + - aws-lambda + - openwhisk + - consumer-restriction + - acl + - basic-auth + - cors + - csrf + - fault-injection + - hmac-auth + - jwt-auth + - key-auth + - openid-connect + - limit-count + - redirect + - request-id + - proxy-rewrite + - response-rewrite + - workflow + - proxy-buffering + - tencent-cloud-cls + - openfunction + - graphql-proxy-cache + - ext-plugin-post-req + #- log-rotate + - graphql-limit-count + - elasticsearch-logger + - kafka-logger + - body-transformer + - traffic-split + - degraphql + - http-logger + - cas-auth + - traffic-label + - oas-validator + - api7-traffic-split + - limit-conn + - prometheus + - syslog + - ip-restriction +dp_manager_configuration: + api_call_flush_period: 1s + server: + status: + host: "0.0.0.0" + log: + level: debug + database: + dsn: {{ .DSN }} +prometheus: + server: + persistence: + enabled: false +postgresql: +{{- if ne .DB "postgres" }} + builtin: false +{{- end }} + primary: + persistence: + enabled: false + readReplicas: + persistence: + enabled: false +developer_portal_configuration: + enable: false +dashboard_service: + type: ClusterIP + annotations: {} + port: 7080 + tlsPort: 7443 + ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: dashboard.local + paths: + - path: / + pathType: ImplementationSpecific + # backend: + # service: + # name: api7ee3-dashboard + # port: + # number: 7943 + tls: [] +api_usage: + service: + ingress: + enabled: false +`) + if err != nil { + panic(err) + } + valuesTemplate = tmpl +} + +// DatabaseConfig is the database related configuration entrypoint. +type DatabaseConfig struct { + DSN string `json:"dsn" yaml:"dsn" mapstructure:"dsn"` + + MaxOpenConns int `json:"max_open_conns" yaml:"max_open_conns" mapstructure:"max_open_conns"` + MaxIdleConns int `json:"max_idle_conns" yaml:"max_idle_conns" mapstructure:"max_idle_conns"` +} + +type LogOptions struct { + // Level is the minimum logging level that a logging message should have + // to output itself. + Level string `json:"level" yaml:"level"` + // Output defines the destination file path to output logging messages. + // Two keywords "stderr" and "stdout" can be specified so that message will + // be written to stderr or stdout. + Output string `json:"output" yaml:"output"` +} + +func (conf *DatabaseConfig) GetType() string { + parts := strings.SplitN(conf.DSN, "://", 2) + if len(parts) > 1 { + return parts[0] + } + return "" +} + +//nolint:unused +func getDSN() string { + switch _db { + case postgres: + return postgresDSN + case oceanbase: + return oceanbaseDSN + case mysql: + return mysqlDSN + } + panic("unknown database") +} + +type responseCreateGateway struct { + Value responseCreateGatewayValue `json:"value"` + ErrorMsg string `json:"error_msg"` +} + +type responseCreateGatewayValue struct { + ID string `json:"id"` + TokenPlainText string `json:"token_plain_text"` + Key string `json:"key"` +} + +func (f *Framework) GetDataplaneCertificates(gatewayGroupID string) *v1.DataplaneCertificate { + respExp := f.DashboardHTTPClient(). + POST("/api/gateway_groups/"+gatewayGroupID+"/dp_client_certificates"). + WithBasicAuth("admin", "admin"). + WithHeader("Content-Type", "application/json"). + WithBytes([]byte(`{}`)). + Expect() + + f.Logger.Logf(f.GinkgoT, "dataplane certificates issuer response: %s", respExp.Body().Raw()) + + respExp.Status(200).Body().Contains("certificate").Contains("private_key").Contains("ca_certificate") + body := respExp.Body().Raw() + + var dpCertResp struct { + Value v1.DataplaneCertificate `json:"value"` + } + err := json.Unmarshal([]byte(body), &dpCertResp) + Expect(err).ToNot(HaveOccurred()) + + return &dpCertResp.Value +} + +func (s *Framework) GetAdminKey(gatewayGroupID string) string { + respExp := s.DashboardHTTPClient().PUT("/api/gateway_groups/"+gatewayGroupID+"/admin_key"). + WithHeader("Content-Type", "application/json"). + WithBasicAuth("admin", "admin"). + Expect() + + respExp.Status(200).Body().Contains("key") + + body := respExp.Body().Raw() + + var response responseCreateGateway + err := json.Unmarshal([]byte(body), &response) + Expect(err).ToNot(HaveOccurred(), "unmarshal response") + return response.Value.Key +} + +func (f *Framework) DeleteGatewayGroup(gatewayGroupID string) { + respExp := f.DashboardHTTPClient(). + DELETE("/api/gateway_groups/"+gatewayGroupID). + WithHeader("Content-Type", "application/json"). + WithBasicAuth("admin", "admin"). + Expect() + + body := respExp.Body().Raw() + + // unmarshal into responseCreateGateway + var response responseCreateGateway + err := json.Unmarshal([]byte(body), &response) + Expect(err).ToNot(HaveOccurred()) +} + +func (f *Framework) CreateNewGatewayGroupWithIngress() string { + gid, err := f.CreateNewGatewayGroupWithIngressE() + Expect(err).ToNot(HaveOccurred()) + return gid +} + +func (f *Framework) CreateNewGatewayGroupWithIngressE() (string, error) { + gatewayGroupName := uuid.NewString() + payload := []byte(fmt.Sprintf( + `{"name":"%s","description":"","labels":{},"type":"api7_ingress_controller"}`, + gatewayGroupName, + )) + + respExp := f.DashboardHTTPClient(). + POST("/api/gateway_groups"). + WithBasicAuth("admin", "admin"). + WithHeader("Content-Type", "application/json"). + WithBytes(payload). + Expect() + + f.Logger.Logf(f.GinkgoT, "create gateway group response: %s", respExp.Body().Raw()) + + respExp.Status(200).Body().Contains("id") + + body := respExp.Body().Raw() + + var response responseCreateGateway + + err := json.Unmarshal([]byte(body), &response) + if err != nil { + return "", err + } + + if response.ErrorMsg != "" { + return "", fmt.Errorf("error creating gateway group: %s", response.ErrorMsg) + } + return response.Value.ID, nil +} + +func (f *Framework) setDpManagerEndpoints() { + payload := []byte(fmt.Sprintf(`{"control_plane_address":["%s"]}`, DPManagerTLSEndpoint)) + + respExp := f.DashboardHTTPClient(). + PUT("/api/system_settings"). + WithBasicAuth("admin", "admin"). + WithHeader("Content-Type", "application/json"). + WithBytes(payload). + Expect() + + respExp.Raw() + f.Logf("set dp manager endpoints response: %s", respExp.Body().Raw()) + + respExp.Status(200). + Body().Contains("control_plane_address") +} + +func (f *Framework) GetDashboardEndpoint() string { + return _dashboardHTTPTunnel.Endpoint() +} + +func (f *Framework) GetDashboardEndpointHTTPS() string { + return _dashboardHTTPSTunnel.Endpoint() +} + +func (f *Framework) DashboardHTTPClient() *httpexpect.Expect { + u := url.URL{ + Scheme: "http", + Host: f.GetDashboardEndpoint(), + } + return httpexpect.WithConfig(httpexpect.Config{ + BaseURL: u.String(), + Client: &http.Client{ + Transport: &http.Transport{}, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + Reporter: httpexpect.NewAssertReporter( + httpexpect.NewAssertReporter(f.GinkgoT), + ), + }) +} + +func (f *Framework) DashboardHTTPSClient() *httpexpect.Expect { + u := url.URL{ + Scheme: "https", + Host: f.GetDashboardEndpointHTTPS(), + } + return httpexpect.WithConfig(httpexpect.Config{ + BaseURL: u.String(), + Client: &http.Client{ + Transport: &http.Transport{}, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + Reporter: httpexpect.NewAssertReporter( + httpexpect.NewAssertReporter(f.GinkgoT), + ), + }) +} diff --git a/test/e2e/framework/api7_framework.go b/test/e2e/framework/api7_framework.go new file mode 100644 index 000000000..68bfe5c9c --- /dev/null +++ b/test/e2e/framework/api7_framework.go @@ -0,0 +1,216 @@ +// 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 framework + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/api7/gopkg/pkg/log" + "github.com/gruntwork-io/terratest/modules/k8s" + . "github.com/onsi/ginkgo/v2" //nolint:staticcheck + . "github.com/onsi/gomega" //nolint:staticcheck + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/kube" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +var ( + API7EELicense string + + dashboardVersion string +) + +func (f *Framework) BeforeSuite() { + // init license and dashboard version + API7EELicense = os.Getenv("API7_EE_LICENSE") + if API7EELicense == "" { + panic("env {API7_EE_LICENSE} is required") + } + + dashboardVersion = os.Getenv("DASHBOARD_VERSION") + if dashboardVersion == "" { + dashboardVersion = "dev" + } + + _ = k8s.DeleteNamespaceE(GinkgoT(), f.kubectlOpts, _namespace) + + Eventually(func() error { + _, err := k8s.GetNamespaceE(GinkgoT(), f.kubectlOpts, _namespace) + if k8serrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("namespace %s still exists", _namespace) + }, "1m", "2s").Should(Succeed()) + + k8s.CreateNamespace(GinkgoT(), f.kubectlOpts, _namespace) + + f.DeployComponents() + + time.Sleep(1 * time.Minute) + err := f.newDashboardTunnel() + f.Logf("Dashboard HTTP Tunnel:" + _dashboardHTTPTunnel.Endpoint()) + Expect(err).ShouldNot(HaveOccurred(), "creating dashboard tunnel") + + f.UploadLicense() + + f.setDpManagerEndpoints() +} + +func (f *Framework) AfterSuite() { + f.shutdownDashboardTunnel() +} + +// DeployComponents deploy necessary components +func (f *Framework) DeployComponents() { + f.deploy() + f.initDashboard() +} + +func (f *Framework) UploadLicense() { + payload := map[string]any{"data": API7EELicense} + payloadBytes, err := json.Marshal(payload) + assert.Nil(f.GinkgoT, err) + + respExpect := f.DashboardHTTPClient().PUT("/api/license"). + WithBasicAuth("admin", "admin"). + WithHeader("Content-Type", "application/json"). + WithBytes(payloadBytes). + Expect() + + body := respExpect.Body().Raw() + f.Logf("request /api/license, response body: %s", body) + + respExpect.Status(200) +} + +func (f *Framework) deploy() { + debug := func(format string, v ...any) { + log.Infof(format, v...) + } + + kubeConfigPath := os.Getenv("KUBECONFIG") + actionConfig := new(action.Configuration) + + err := actionConfig.Init( + kube.GetConfig(kubeConfigPath, "", f.kubectlOpts.Namespace), + f.kubectlOpts.Namespace, + "memory", + debug, + ) + f.GomegaT.Expect(err).ShouldNot(HaveOccurred(), "init helm action config") + + install := action.NewInstall(actionConfig) + install.Namespace = f.kubectlOpts.Namespace + install.ReleaseName = "api7ee3" + + chartPath, err := install.LocateChart("api7/api7ee3", cli.New()) + f.GomegaT.Expect(err).ShouldNot(HaveOccurred(), "locate helm chart") + + chart, err := loader.Load(chartPath) + f.GomegaT.Expect(err).ShouldNot(HaveOccurred(), "load helm chart") + + buf := bytes.NewBuffer(nil) + _ = valuesTemplate.Execute(buf, map[string]any{ + "DB": _db, + "DSN": getDSN(), + "Tag": dashboardVersion, + }) + + f.Logf("values: %s", buf.String()) + + var v map[string]any + err = yaml.Unmarshal(buf.Bytes(), &v) + f.GomegaT.Expect(err).ShouldNot(HaveOccurred(), "unmarshal values") + _, err = install.Run(chart, v) + if err != nil { + f.Logf("install dashboard failed, err: %v", err) + } + f.GomegaT.Expect(err).ShouldNot(HaveOccurred(), "install dashboard") + + err = f.ensureService("api7ee3-dashboard", _namespace, 1) + f.GomegaT.Expect(err).ShouldNot(HaveOccurred(), "ensuring dashboard service") + + err = f.ensureService("api7-postgresql", _namespace, 1) + f.GomegaT.Expect(err).ShouldNot(HaveOccurred(), "ensuring postgres service") + + err = f.ensureService("api7-prometheus-server", _namespace, 1) + f.GomegaT.Expect(err).ShouldNot(HaveOccurred(), "ensuring prometheus-server service") +} + +func (f *Framework) initDashboard() { + f.deletePods("app.kubernetes.io/name=api7ee3") + time.Sleep(5 * time.Second) +} + +var ( + _dashboardHTTPTunnel *k8s.Tunnel + _dashboardHTTPSTunnel *k8s.Tunnel +) + +func (f *Framework) newDashboardTunnel() error { + var ( + httpNodePort int + httpsNodePort int + httpPort int + httpsPort int + ) + + service := k8s.GetService(f.GinkgoT, f.kubectlOpts, "api7ee3-dashboard") + + for _, port := range service.Spec.Ports { + switch port.Name { + case "http": + httpNodePort = int(port.NodePort) + httpPort = int(port.Port) + case "https": + httpsNodePort = int(port.NodePort) + httpsPort = int(port.Port) + } + } + + _dashboardHTTPTunnel = k8s.NewTunnel(f.kubectlOpts, k8s.ResourceTypeService, "api7ee3-dashboard", + httpNodePort, httpPort) + _dashboardHTTPSTunnel = k8s.NewTunnel(f.kubectlOpts, k8s.ResourceTypeService, "api7ee3-dashboard", + httpsNodePort, httpsPort) + + if err := _dashboardHTTPTunnel.ForwardPortE(f.GinkgoT); err != nil { + return err + } + if err := _dashboardHTTPSTunnel.ForwardPortE(f.GinkgoT); err != nil { + return err + } + + return nil +} + +func (f *Framework) shutdownDashboardTunnel() { + if _dashboardHTTPTunnel != nil { + _dashboardHTTPTunnel.Close() + } + if _dashboardHTTPSTunnel != nil { + _dashboardHTTPSTunnel.Close() + } +} diff --git a/test/e2e/framework/api7_gateway.go b/test/e2e/framework/api7_gateway.go new file mode 100644 index 000000000..b39c5addc --- /dev/null +++ b/test/e2e/framework/api7_gateway.go @@ -0,0 +1,114 @@ +// 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 framework + +import ( + "bytes" + _ "embed" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/gruntwork-io/terratest/modules/k8s" + . "github.com/onsi/gomega" //nolint:staticcheck + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + //go:embed manifests/dp.yaml + _dpSpec string + DPSpecTpl *template.Template +) + +func init() { + tpl, err := template.New("dp").Funcs(sprig.TxtFuncMap()).Parse(_dpSpec) + if err != nil { + panic(err) + } + DPSpecTpl = tpl +} + +type API7DeployOptions struct { + Namespace string + Name string + + GatewayGroupID string + TLSEnabled bool + SSLKey string + SSLCert string + DPManagerEndpoint string + SetEnv bool + ForIngressGatewayGroup bool + + ServiceName string + ServiceType string + ServiceHTTPPort int + ServiceHTTPSPort int +} + +func (f *Framework) DeployGateway(opts API7DeployOptions) *corev1.Service { + if opts.ServiceName == "" { + opts.ServiceName = "api7ee3-apisix-gateway-mtls" + } + + if opts.ServiceHTTPPort == 0 { + opts.ServiceHTTPPort = 80 + } + + if opts.ServiceHTTPSPort == 0 { + opts.ServiceHTTPSPort = 443 + } + + dpCert := f.GetDataplaneCertificates(opts.GatewayGroupID) + + f.applySSLSecret(opts.Namespace, + "dp-ssl", + []byte(dpCert.Certificate), + []byte(dpCert.PrivateKey), + []byte(dpCert.CACertificate), + ) + + buf := bytes.NewBuffer(nil) + + _ = DPSpecTpl.Execute(buf, opts) + + kubectlOpts := k8s.NewKubectlOptions("", "", opts.Namespace) + + k8s.KubectlApplyFromString(f.GinkgoT, kubectlOpts, buf.String()) + + err := WaitPodsAvailable(f.GinkgoT, kubectlOpts, metav1.ListOptions{ + LabelSelector: "app.kubernetes.io/name=apisix", + }) + Expect(err).ToNot(HaveOccurred(), "waiting for gateway pod ready") + + Eventually(func() bool { + svc, err := k8s.GetServiceE(f.GinkgoT, kubectlOpts, opts.ServiceName) + if err != nil { + f.Logf("failed to get service %s: %v", opts.ServiceName, err) + return false + } + if svc.Spec.Type == corev1.ServiceTypeLoadBalancer { + return len(svc.Status.LoadBalancer.Ingress) > 0 + } + return true + }, "20s", "4s").Should(BeTrue(), "waiting for LoadBalancer IP") + + svc, err := k8s.GetServiceE(f.GinkgoT, kubectlOpts, opts.ServiceName) + Expect(err).ToNot(HaveOccurred(), "failed to get service %s: %v", opts.ServiceName, err) + return svc +} diff --git a/test/e2e/framework/manifests/dp.yaml b/test/e2e/framework/manifests/dp.yaml new file mode 100644 index 000000000..f188363c4 --- /dev/null +++ b/test/e2e/framework/manifests/dp.yaml @@ -0,0 +1,284 @@ +# 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 +data: + config.yaml: |- + api7ee: + healthcheck_report_interval: 1 + apisix: + node_listen: + - 9080 + - enable_http2: true + port: 9081 + enable_admin: true + ssl: + enabled: true + {{- if .TLSEnabled }} + ssl_trusted_certificate: /opts/etcd/ca.crt + {{- end }} + stream_proxy: + tcp: + - 9100 + nginx_config: + worker_processes: 2 + error_log_level: debug + deployment: + role: traditional + role_traditional: + config_provider: etcd + etcd: + host: + - "{{ .DPManagerEndpoint }}" + timeout: 30 + resync_delay: 0 + {{- if .TLSEnabled }} + tls: + verify: true + cert: /opts/etcd/tls.crt + key: /opts/etcd/tls.key + {{- end }} + admin: + allow_admin: + - all + plugins: + - error-page + - real-ip + - ai + - client-control + - proxy-buffering + - proxy-control + - request-id + - zipkin + - skywalking + - opentelemetry + - ext-plugin-pre-req + - fault-injection + - mocking + - serverless-pre-function + - cors + - ip-restriction + - ua-restriction + - referer-restriction + - csrf + - uri-blocker + - request-validation + - openid-connect + - saml-auth + - cas-auth + - authz-casbin + - authz-casdoor + - wolf-rbac + - ldap-auth + - hmac-auth + - basic-auth + - jwt-auth + - key-auth + - multi-auth + - acl + - consumer-restriction + - forward-auth + - opa + - authz-keycloak + - data-mask + - proxy-cache + - graphql-proxy-cache + - body-transformer + - proxy-mirror + - proxy-rewrite + - workflow + - api-breaker + - graphql-limit-count + - limit-conn + - limit-count + - limit-req + - traffic-label + - gzip + - server-info + - api7-traffic-split + - traffic-split + - redirect + - response-rewrite + - oas-validator + - degraphql + - kafka-proxy + - grpc-transcode + - grpc-web + - public-api + - prometheus + - datadog + - elasticsearch-logger + - echo + - loggly + - http-logger + - splunk-hec-logging + - skywalking-logger + - google-cloud-logging + - sls-logger + - tcp-logger + - kafka-logger + - rocketmq-logger + - syslog + - udp-logger + - file-logger + - clickhouse-logger + - tencent-cloud-cls + - example-plugin + - aws-lambda + - azure-functions + - openwhisk + - openfunction + - serverless-post-function + - ext-plugin-post-req + - ext-plugin-post-resp + +kind: ConfigMap +metadata: + name: api7ee3-apisix{{- if .TLSEnabled }}-mtls{{- end }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/instance: api7ee3 + app.kubernetes.io/name: apisix + name: api7ee3-apisix{{- if .TLSEnabled }}-mtls{{- end }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: api7ee3 + app.kubernetes.io/name: apisix + {{- if .TLSEnabled }} + cp-connection: mtls + {{- end }} + template: + metadata: + labels: + app.kubernetes.io/instance: api7ee3 + app.kubernetes.io/name: apisix + {{- if .TLSEnabled }} + cp-connection: mtls + {{- end }} + spec: + #serviceAccountName: ginkgo + containers: + - image: hkccr.ccs.tencentyun.com/api7-dev/api7-ee-3-gateway:dev + imagePullPolicy: IfNotPresent + env: + {{- if not .TLSEnabled }} + - name: API7_CONTROL_PLANE_TOKEN + value: "{{ .ControlPlaneToken }}" + {{else}} + - name: API7_CONTROL_PLANE_TOKEN + value: "a7ee-placeholder" + {{- end }} + {{- if .SetEnv }} + - name: JACK_AUTH_KEY + value: auth-one + - name: SSL_CERT + value: | + {{- .SSLCert | nindent 12 }} + - name: SSL_KEY + value: | + {{- .SSLKey | nindent 12 }} + {{- end }} + name: apisix + ports: + - containerPort: 9080 + name: http + protocol: TCP + - containerPort: 9081 + name: http2 + protocol: TCP + - containerPort: 9180 + name: admin + protocol: TCP + - containerPort: 9443 + name: tls + protocol: TCP + - containerPort: 9090 + name: control-api + protocol: TCP + - containerPort: 9100 + name: stream-route + protocol: TCP + readinessProbe: + failureThreshold: 10 + initialDelaySeconds: 3 + periodSeconds: 3 + successThreshold: 1 + tcpSocket: + port: 9080 + timeoutSeconds: 1 + volumeMounts: + - mountPath: /usr/local/apisix/conf/config.yaml + name: apisix-config + subPath: config.yaml + {{- if .TLSEnabled }} + - mountPath: /opts/etcd + name: dp-ssl + {{- end }} + securityContext: + runAsNonRoot: false + runAsUser: 0 + dnsPolicy: ClusterFirst + volumes: + - configMap: + defaultMode: 420 + name: api7ee3-apisix{{- if .TLSEnabled }}-mtls{{- end }} + name: apisix-config + {{- if .TLSEnabled }} + - secret: + secretName: dp-ssl + name: dp-ssl + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/instance: api7ee3 + app.kubernetes.io/name: apisix + app.kubernetes.io/service: apisix-gateway + name: {{ .ServiceName }} +spec: + ports: + - name: http + port: {{ .ServiceHTTPPort }} + protocol: TCP + targetPort: 9080 + - name: http2 + port: 9081 + protocol: TCP + targetPort: 9081 + - name: https + port: {{ .ServiceHTTPSPort }} + protocol: TCP + targetPort: 9443 + - name: control-api + port: 9090 + protocol: TCP + targetPort: 9090 + - name: tcp + port: 9100 + protocol: TCP + selector: + app.kubernetes.io/instance: api7ee3 + app.kubernetes.io/name: apisix + cp-connection: mtls + type: {{ .ServiceType | default "NodePort" }} diff --git a/test/e2e/framework/manifests/ingress.yaml b/test/e2e/framework/manifests/ingress.yaml index 1ed00c10e..5eb469251 100644 --- a/test/e2e/framework/manifests/ingress.yaml +++ b/test/e2e/framework/manifests/ingress.yaml @@ -378,7 +378,7 @@ spec: control-plane: controller-manager spec: containers: - - image: apache/apisix-ingress-controller:dev + - image: api7/api7-ingress-controller:dev env: - name: POD_NAMESPACE valueFrom: diff --git a/test/e2e/scaffold/api7_deployer.go b/test/e2e/scaffold/api7_deployer.go new file mode 100644 index 000000000..9b30bbc7d --- /dev/null +++ b/test/e2e/scaffold/api7_deployer.go @@ -0,0 +1,310 @@ +// 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 scaffold + +import ( + "fmt" + "os" + "time" + + "github.com/gruntwork-io/terratest/modules/k8s" + . "github.com/onsi/ginkgo/v2" //nolint:staticcheck + . "github.com/onsi/gomega" //nolint:staticcheck + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/apache/apisix-ingress-controller/pkg/utils" + "github.com/apache/apisix-ingress-controller/test/e2e/framework" +) + +type API7Deployer struct { + *Scaffold + + gatewayGroupID string +} + +func NewAPI7Deployer(s *Scaffold) Deployer { + return &API7Deployer{ + Scaffold: s, + } +} + +func (s *API7Deployer) BeforeEach() { + var err error + s.UploadLicense() + s.namespace = fmt.Sprintf("ingress-apisix-e2e-tests-%s-%d", s.opts.Name, time.Now().Nanosecond()) + s.kubectlOptions = &k8s.KubectlOptions{ + ConfigPath: s.opts.Kubeconfig, + Namespace: s.namespace, + } + if s.opts.ControllerName == "" { + s.opts.ControllerName = fmt.Sprintf("%s/%d", DefaultControllerName, time.Now().Nanosecond()) + } + s.finalizers = nil + if s.label == nil { + s.label = make(map[string]string) + } + if s.opts.NamespaceSelectorLabel != nil { + for k, v := range s.opts.NamespaceSelectorLabel { + if len(v) > 0 { + s.label[k] = v[0] + } + } + } else { + s.label["apisix.ingress.watch"] = s.namespace + } + + // Initialize additionalGatewayGroups map + s.additionalGateways = make(map[string]*GatewayResources) + + var nsLabel map[string]string + if !s.opts.DisableNamespaceLabel { + nsLabel = s.label + } + k8s.CreateNamespaceWithMetadata(s.t, s.kubectlOptions, metav1.ObjectMeta{Name: s.namespace, Labels: nsLabel}) + + s.nodes, err = k8s.GetReadyNodesE(s.t, s.kubectlOptions) + Expect(err).NotTo(HaveOccurred(), "getting ready nodes") + + s.gatewayGroupID = s.CreateNewGatewayGroupWithIngress() + s.Logf("gateway group id: %s", s.gatewayGroupID) + + s.opts.APISIXAdminAPIKey = s.GetAdminKey(s.gatewayGroupID) + + s.Logf("apisix admin api key: %s", s.opts.APISIXAdminAPIKey) + + e := utils.ParallelExecutor{} + + e.Add(func() { + s.DeployDataplane(DeployDataplaneOptions{}) + s.DeployIngress() + }) + e.Add(s.DeployTestService) + e.Wait() +} + +func (s *API7Deployer) AfterEach() { + defer GinkgoRecover() + s.DeleteGatewayGroup(s.gatewayGroupID) + + if CurrentSpecReport().Failed() { + if os.Getenv("TEST_ENV") == "CI" { + _, _ = fmt.Fprintln(GinkgoWriter, "Dumping namespace contents") + _, _ = k8s.RunKubectlAndGetOutputE(GinkgoT(), s.kubectlOptions, "get", "deploy,sts,svc,pods,gatewayproxy") + _, _ = k8s.RunKubectlAndGetOutputE(GinkgoT(), s.kubectlOptions, "describe", "pods") + } + + output := s.GetDeploymentLogs("apisix-ingress-controller") + if output != "" { + _, _ = fmt.Fprintln(GinkgoWriter, output) + } + } + + // Delete all additional namespaces + for identifier := range s.additionalGateways { + err := s.CleanupAdditionalGateway(identifier) + Expect(err).NotTo(HaveOccurred(), "cleaning up additional gateway group") + } + + // if the test case is successful, just delete namespace + err := k8s.DeleteNamespaceE(s.t, s.kubectlOptions, s.namespace) + Expect(err).NotTo(HaveOccurred(), "deleting namespace "+s.namespace) + + for i := len(s.finalizers) - 1; i >= 0; i-- { + runWithRecover(s.finalizers[i]) + } + + // Wait for a while to prevent the worker node being overwhelming + // (new cases will be run). + time.Sleep(3 * time.Second) +} + +func (s *API7Deployer) DeployDataplane(deployOpts DeployDataplaneOptions) { + opts := framework.API7DeployOptions{ + GatewayGroupID: s.gatewayGroupID, + Namespace: s.namespace, + Name: "api7ee3-apisix-gateway-mtls", + DPManagerEndpoint: framework.DPManagerTLSEndpoint, + SetEnv: true, + SSLKey: framework.TestKey, + SSLCert: framework.TestCert, + TLSEnabled: true, + ForIngressGatewayGroup: true, + ServiceHTTPPort: 9080, + ServiceHTTPSPort: 9443, + } + if deployOpts.Namespace != "" { + opts.Namespace = deployOpts.Namespace + } + if deployOpts.ServiceType != "" { + opts.ServiceType = deployOpts.ServiceType + } + if deployOpts.ServiceHTTPPort != 0 { + opts.ServiceHTTPPort = deployOpts.ServiceHTTPPort + } + if deployOpts.ServiceHTTPSPort != 0 { + opts.ServiceHTTPSPort = deployOpts.ServiceHTTPSPort + } + + svc := s.DeployGateway(opts) + + s.dataplaneService = svc + + if !deployOpts.SkipCreateTunnels { + err := s.newAPISIXTunnels() + Expect(err).ToNot(HaveOccurred(), "creating apisix tunnels") + } +} + +func (s *API7Deployer) newAPISIXTunnels() error { + serviceName := "api7ee3-apisix-gateway-mtls" + httpTunnel, httpsTunnel, err := s.createDataplaneTunnels(s.dataplaneService, s.kubectlOptions, serviceName) + if err != nil { + return err + } + + s.apisixHttpTunnel = httpTunnel + s.apisixHttpsTunnel = httpsTunnel + return nil +} + +func (s *API7Deployer) DeployIngress() { + s.Framework.DeployIngress(framework.IngressDeployOpts{ + ProviderType: "api7ee", + ControllerName: s.opts.ControllerName, + Namespace: s.namespace, + Replicas: 1, + }) +} + +func (s *API7Deployer) ScaleIngress(replicas int) { + s.Framework.DeployIngress(framework.IngressDeployOpts{ + ProviderType: "api7ee", + ControllerName: s.opts.ControllerName, + Namespace: s.namespace, + Replicas: replicas, + }) +} + +// CreateAdditionalGateway creates a new gateway group and deploys a dataplane for it. +// It returns the gateway group ID and namespace name where the dataplane is deployed. +func (s *API7Deployer) CreateAdditionalGateway(namePrefix string) (string, *corev1.Service, error) { + // Create a new namespace for this gateway group + additionalNS := fmt.Sprintf("%s-%d", namePrefix, time.Now().Unix()) + + // Create namespace with the same labels + var nsLabel map[string]string + if !s.opts.DisableNamespaceLabel { + nsLabel = s.label + } + k8s.CreateNamespaceWithMetadata(s.t, s.kubectlOptions, metav1.ObjectMeta{Name: additionalNS, Labels: nsLabel}) + + // Create new kubectl options for the new namespace + kubectlOpts := &k8s.KubectlOptions{ + ConfigPath: s.opts.Kubeconfig, + Namespace: additionalNS, + } + + // Create a new gateway group + gatewayGroupID := s.CreateNewGatewayGroupWithIngress() + s.Logf("additional gateway group id: %s in namespace %s", gatewayGroupID, additionalNS) + + // Get the admin key for this gateway group + adminKey := s.GetAdminKey(gatewayGroupID) + s.Logf("additional gateway group admin api key: %s", adminKey) + + // Store gateway group info + resources := &GatewayResources{ + Namespace: additionalNS, + AdminAPIKey: adminKey, + } + + serviceName := fmt.Sprintf("api7ee3-apisix-gateway-%s", namePrefix) + + // Deploy dataplane for this gateway group + svc := s.DeployGateway(framework.API7DeployOptions{ + GatewayGroupID: gatewayGroupID, + Namespace: additionalNS, + Name: serviceName, + ServiceName: serviceName, + DPManagerEndpoint: framework.DPManagerTLSEndpoint, + SetEnv: true, + SSLKey: framework.TestKey, + SSLCert: framework.TestCert, + TLSEnabled: true, + ForIngressGatewayGroup: true, + ServiceHTTPPort: 9080, + ServiceHTTPSPort: 9443, + }) + + resources.DataplaneService = svc + + // Create tunnels for the dataplane + httpTunnel, httpsTunnel, err := s.createDataplaneTunnels(svc, kubectlOpts, serviceName) + if err != nil { + return "", nil, err + } + + resources.HttpTunnel = httpTunnel + resources.HttpsTunnel = httpsTunnel + + // Store in the map + s.additionalGateways[gatewayGroupID] = resources + + return gatewayGroupID, svc, nil +} + +// CleanupAdditionalGateway cleans up resources associated with a specific Gateway group +func (s *API7Deployer) CleanupAdditionalGateway(gatewayGroupID string) error { + resources, exists := s.additionalGateways[gatewayGroupID] + if !exists { + return fmt.Errorf("gateway group %s not found", gatewayGroupID) + } + + // Delete the gateway group + s.DeleteGatewayGroup(gatewayGroupID) + + // Delete the namespace + err := k8s.DeleteNamespaceE(s.t, &k8s.KubectlOptions{ + ConfigPath: s.opts.Kubeconfig, + Namespace: resources.Namespace, + }, resources.Namespace) + + // Remove from the map + delete(s.additionalGateways, gatewayGroupID) + + return err +} + +func (s *API7Deployer) GetAdminEndpoint(_ ...*corev1.Service) string { + // always return the default dashboard endpoint + return framework.DashboardTLSEndpoint +} + +func (s *API7Deployer) DefaultDataplaneResource() DataplaneResource { + return newADCDataplaneResource( + "api7ee", + fmt.Sprintf("http://%s", s.GetDashboardEndpoint()), + s.AdminKey(), + false, + ) +} + +func (s *API7Deployer) Name() string { + return "api7ee" +} diff --git a/test/e2e/scaffold/apisix_deployer.go b/test/e2e/scaffold/apisix_deployer.go index a0e2d531a..34179b4f2 100644 --- a/test/e2e/scaffold/apisix_deployer.go +++ b/test/e2e/scaffold/apisix_deployer.go @@ -53,7 +53,7 @@ type APISIXDeployer struct { adminTunnel *k8s.Tunnel } -func NewAPISIXDeployer(s *Scaffold) *APISIXDeployer { +func NewAPISIXDeployer(s *Scaffold) Deployer { return &APISIXDeployer{ Scaffold: s, } @@ -409,3 +409,7 @@ func (s *APISIXDeployer) DefaultDataplaneResource() DataplaneResource { false, // tlsVerify ) } + +func (s *APISIXDeployer) Name() string { + return "apisix" +} diff --git a/test/e2e/scaffold/deployer.go b/test/e2e/scaffold/deployer.go index cfbe40434..a1e411859 100644 --- a/test/e2e/scaffold/deployer.go +++ b/test/e2e/scaffold/deployer.go @@ -32,6 +32,7 @@ type Deployer interface { CleanupAdditionalGateway(identifier string) error GetAdminEndpoint(...*corev1.Service) string DefaultDataplaneResource() DataplaneResource + Name() string } var NewDeployer func(*Scaffold) Deployer diff --git a/test/e2e/scaffold/scaffold.go b/test/e2e/scaffold/scaffold.go index f2f97a2db..8a2a00d04 100644 --- a/test/e2e/scaffold/scaffold.go +++ b/test/e2e/scaffold/scaffold.go @@ -59,6 +59,7 @@ type Scaffold struct { kubectlOptions *k8s.KubectlOptions namespace string t testing.TestingT + nodes []corev1.Node dataplaneService *corev1.Service httpbinService *corev1.Service From 81556d991e609e2c2b1fb9645fdd2d86a2777b08 Mon Sep 17 00:00:00 2001 From: rongxin Date: Fri, 4 Jul 2025 11:35:18 +0800 Subject: [PATCH 2/3] patch --- .../provider/controlplane/controlplane.go | 192 ---- internal/provider/controlplane/manifest.go | 18 - .../controlplane/translator/gateway.go | 166 --- .../controlplane/translator/httproute.go | 474 -------- .../controlplane/translator/translator.go | 55 - pkg/dashboard/cache/cache.go | 97 -- pkg/dashboard/cache/memdb.go | 368 ------ pkg/dashboard/cache/memdb_test.go | 390 ------- pkg/dashboard/cache/noop_db.go | 166 --- pkg/dashboard/cache/schema.go | 229 ---- pkg/dashboard/cluster.go | 1000 ----------------- pkg/dashboard/consumer.go | 156 --- pkg/dashboard/consumer_test.go | 242 ---- pkg/dashboard/dashboard.go | 255 ----- pkg/dashboard/global_rule.go | 170 --- pkg/dashboard/nonexistentclient.go | 378 ------- pkg/dashboard/noop.go | 51 - pkg/dashboard/plugin.go | 53 - pkg/dashboard/plugin_metadata.go | 154 --- pkg/dashboard/plugin_test.go | 121 -- pkg/dashboard/pluginconfig.go | 164 --- pkg/dashboard/resource.go | 386 ------- pkg/dashboard/route.go | 159 --- pkg/dashboard/schema.go | 119 -- pkg/dashboard/service.go | 192 ---- pkg/dashboard/ssl.go | 170 --- pkg/dashboard/stream_route.go | 164 --- pkg/dashboard/utils.go | 85 -- pkg/dashboard/validator.go | 136 --- 29 files changed, 6310 deletions(-) delete mode 100644 internal/provider/controlplane/controlplane.go delete mode 100644 internal/provider/controlplane/manifest.go delete mode 100644 internal/provider/controlplane/translator/gateway.go delete mode 100644 internal/provider/controlplane/translator/httproute.go delete mode 100644 internal/provider/controlplane/translator/translator.go delete mode 100644 pkg/dashboard/cache/cache.go delete mode 100644 pkg/dashboard/cache/memdb.go delete mode 100644 pkg/dashboard/cache/memdb_test.go delete mode 100644 pkg/dashboard/cache/noop_db.go delete mode 100644 pkg/dashboard/cache/schema.go delete mode 100644 pkg/dashboard/cluster.go delete mode 100644 pkg/dashboard/consumer.go delete mode 100644 pkg/dashboard/consumer_test.go delete mode 100644 pkg/dashboard/dashboard.go delete mode 100644 pkg/dashboard/global_rule.go delete mode 100644 pkg/dashboard/nonexistentclient.go delete mode 100644 pkg/dashboard/noop.go delete mode 100644 pkg/dashboard/plugin.go delete mode 100644 pkg/dashboard/plugin_metadata.go delete mode 100644 pkg/dashboard/plugin_test.go delete mode 100644 pkg/dashboard/pluginconfig.go delete mode 100644 pkg/dashboard/resource.go delete mode 100644 pkg/dashboard/route.go delete mode 100644 pkg/dashboard/schema.go delete mode 100644 pkg/dashboard/service.go delete mode 100644 pkg/dashboard/ssl.go delete mode 100644 pkg/dashboard/stream_route.go delete mode 100644 pkg/dashboard/utils.go delete mode 100644 pkg/dashboard/validator.go diff --git a/internal/provider/controlplane/controlplane.go b/internal/provider/controlplane/controlplane.go deleted file mode 100644 index 4a6e38f92..000000000 --- a/internal/provider/controlplane/controlplane.go +++ /dev/null @@ -1,192 +0,0 @@ -// 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 controlplane - -import ( - "context" - "fmt" - - "github.com/api7/gopkg/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/client" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" - - "github.com/apache/apisix-ingress-controller/internal/controller/config" - "github.com/apache/apisix-ingress-controller/internal/provider" - "github.com/apache/apisix-ingress-controller/internal/provider/controlplane/translator" - "github.com/apache/apisix-ingress-controller/pkg/dashboard" -) - -type dashboardProvider struct { - translator *translator.Translator - c dashboard.Dashboard -} - -//nolint:unused -func NewDashboard() (provider.Provider, error) { - control, err := dashboard.NewClient() - if err != nil { - return nil, err - } - - if err := control.AddCluster(context.TODO(), &dashboard.ClusterOptions{ - Name: "default", - Labels: map[string]string{ - "k8s/controller-name": config.ControllerConfig.ControllerName, - }, - ControllerName: config.ControllerConfig.ControllerName, - SyncCache: true, - }); err != nil { - return nil, err - } - - return &dashboardProvider{ - translator: &translator.Translator{}, - c: control, - }, nil -} - -func (d *dashboardProvider) Update(ctx context.Context, tctx *provider.TranslateContext, obj client.Object) error { - var result *translator.TranslateResult - var err error - switch obj := obj.(type) { - case *gatewayv1.HTTPRoute: - result, err = d.translator.TranslateHTTPRoute(tctx, obj.DeepCopy()) - case *gatewayv1.Gateway: - result, err = d.translator.TranslateGateway(tctx, obj.DeepCopy()) - } - if err != nil { - return err - } - // TODO: support diff resources - name := "default" - for _, service := range result.Services { - if _, err := d.c.Cluster(name).Service().Update(ctx, service); err != nil { - return err - } - } - for _, route := range result.Routes { - if _, err := d.c.Cluster(name).Route().Update(ctx, route); err != nil { - return err - } - } - for _, ssl := range result.SSL { - // to avoid duplication - ssl.Snis = arrayUniqueElements(ssl.Snis, []string{}) - if len(ssl.Snis) == 1 && ssl.Snis[0] == "*" { - log.Warnf("wildcard hostname is not allowed in ssl object. Skipping SSL creation for %s: %s", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName()) - return nil - } - ssl.Snis = removeWildcard(ssl.Snis) - oldssl, err := d.c.Cluster(name).SSL().Get(ctx, ssl.Cert) - if err != nil || oldssl == nil { - if _, err := d.c.Cluster(name).SSL().Create(ctx, ssl); err != nil { - return fmt.Errorf("failed to create ssl for sni %+v: %w", ssl.Snis, err) - } - } else { - // array union is done to avoid host duplication - ssl.Snis = arrayUniqueElements(ssl.Snis, oldssl.Snis) - if _, err := d.c.Cluster(name).SSL().Update(ctx, ssl); err != nil { - return fmt.Errorf("failed to update ssl for sni %+v: %w", ssl.Snis, err) - } - } - } - return nil -} - -func removeWildcard(snis []string) []string { - newSni := make([]string, 0) - for _, sni := range snis { - if sni != "*" { - newSni = append(newSni, sni) - } - } - return newSni -} - -func arrayUniqueElements(arr1 []string, arr2 []string) []string { - // return a union of elements from both array - presentEle := make(map[string]bool) - newArr := make([]string, 0) - for _, ele := range arr1 { - if !presentEle[ele] { - presentEle[ele] = true - newArr = append(newArr, ele) - } - } - for _, ele := range arr2 { - if !presentEle[ele] { - presentEle[ele] = true - newArr = append(newArr, ele) - } - } - return newArr -} - -func (d *dashboardProvider) Delete(ctx context.Context, obj client.Object) error { - clusters := d.c.ListClusters() - kindLabel := dashboard.ListByKindLabelOptions{ - Kind: obj.GetObjectKind().GroupVersionKind().Kind, - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - } - for _, cluster := range clusters { - switch obj.(type) { - case *gatewayv1.Gateway: - ssls, _ := cluster.SSL().List(ctx, dashboard.ListOptions{ - From: dashboard.ListFromCache, - KindLabel: kindLabel, - }) - for _, ssl := range ssls { - if err := cluster.SSL().Delete(ctx, ssl); err != nil { - return err - } - } - case *gatewayv1.HTTPRoute: - routes, _ := cluster.Route().List(ctx, dashboard.ListOptions{ - From: dashboard.ListFromCache, - KindLabel: kindLabel, - }) - - for _, route := range routes { - if err := cluster.Route().Delete(ctx, route); err != nil { - return err - } - } - - services, _ := cluster.Service().List(ctx, dashboard.ListOptions{ - From: dashboard.ListFromCache, - KindLabel: kindLabel, - }) - - for _, service := range services { - if err := cluster.Service().Delete(ctx, service); err != nil { - return err - } - } - } - } - return nil -} - -func (d *dashboardProvider) Sync(ctx context.Context) error { - return nil -} - -func (d *dashboardProvider) Start(ctx context.Context) error { - return nil -} diff --git a/internal/provider/controlplane/manifest.go b/internal/provider/controlplane/manifest.go deleted file mode 100644 index 32a16b4f0..000000000 --- a/internal/provider/controlplane/manifest.go +++ /dev/null @@ -1,18 +0,0 @@ -// 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 controlplane diff --git a/internal/provider/controlplane/translator/gateway.go b/internal/provider/controlplane/translator/gateway.go deleted file mode 100644 index bc1b3560e..000000000 --- a/internal/provider/controlplane/translator/gateway.go +++ /dev/null @@ -1,166 +0,0 @@ -// 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 translator - -import ( - "crypto/x509" - "encoding/pem" - "fmt" - - "github.com/api7/gopkg/pkg/log" - "github.com/pkg/errors" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/internal/controller/label" - "github.com/apache/apisix-ingress-controller/internal/id" - "github.com/apache/apisix-ingress-controller/internal/provider" -) - -func (t *Translator) TranslateGateway(tctx *provider.TranslateContext, obj *gatewayv1.Gateway) (*TranslateResult, error) { - result := &TranslateResult{} - for _, listener := range obj.Spec.Listeners { - if listener.TLS != nil { - tctx.GatewayTLSConfig = append(tctx.GatewayTLSConfig, *listener.TLS) - ssl, err := t.translateSecret(tctx, listener, obj) - if err != nil { - return nil, fmt.Errorf("failed to translate secret: %w", err) - } - result.SSL = append(result.SSL, ssl...) - } - } - return result, nil -} - -func (t *Translator) translateSecret(tctx *provider.TranslateContext, listener gatewayv1.Listener, obj *gatewayv1.Gateway) ([]*v1.Ssl, error) { - if tctx.Secrets == nil { - return nil, nil - } - if listener.TLS.CertificateRefs == nil { - return nil, fmt.Errorf("no certificateRefs found in listener %s", listener.Name) - } - sslObjs := make([]*v1.Ssl, 0) - switch *listener.TLS.Mode { - case gatewayv1.TLSModeTerminate: - for _, ref := range listener.TLS.CertificateRefs { - ns := obj.GetNamespace() - if ref.Namespace != nil { - ns = string(*ref.Namespace) - } - if listener.TLS.CertificateRefs[0].Kind != nil && *listener.TLS.CertificateRefs[0].Kind == "Secret" { - sslObj := &v1.Ssl{ - Snis: []string{}, - } - name := listener.TLS.CertificateRefs[0].Name - secret := tctx.Secrets[types.NamespacedName{Namespace: ns, Name: string(ref.Name)}] - if secret == nil { - continue - } - if secret.Data == nil { - log.Error("secret data is nil", "secret", secret) - return nil, fmt.Errorf("no secret data found for %s/%s", ns, name) - } - cert, key, err := extractKeyPair(secret, true) - if err != nil { - return nil, err - } - sslObj.Cert = string(cert) - sslObj.Key = string(key) - // Dashboard doesn't allow wildcard hostname - if listener.Hostname != nil && *listener.Hostname != "" { - sslObj.Snis = append(sslObj.Snis, string(*listener.Hostname)) - } - hosts, err := extractHost(cert) - if err != nil { - return nil, err - } - sslObj.Snis = append(sslObj.Snis, hosts...) - // Note: Dashboard doesn't allow duplicate certificate across ssl objects - sslObj.ID = id.GenID(sslObj.Cert) - sslObj.Labels = label.GenLabel(obj) - sslObjs = append(sslObjs, sslObj) - } - - } - // Only supported on TLSRoute. The certificateRefs field is ignored in this mode. - case gatewayv1.TLSModePassthrough: - return sslObjs, nil - default: - return nil, fmt.Errorf("unknown TLS mode %s", *listener.TLS.Mode) - } - - return sslObjs, nil -} - -func extractHost(cert []byte) ([]string, error) { - block, _ := pem.Decode(cert) - if block == nil { - return nil, errors.New("parse certificate: not in PEM format") - } - der, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, errors.Wrap(err, "parse certificate") - } - return der.DNSNames, nil -} - -func extractKeyPair(s *corev1.Secret, hasPrivateKey bool) ([]byte, []byte, error) { - if _, ok := s.Data["cert"]; ok { - return extractApisixSecretKeyPair(s, hasPrivateKey) - } else if _, ok := s.Data[corev1.TLSCertKey]; ok { - return extractKubeSecretKeyPair(s, hasPrivateKey) - } else if ca, ok := s.Data[corev1.ServiceAccountRootCAKey]; ok && !hasPrivateKey { - return ca, nil, nil - } else { - return nil, nil, errors.New("unknown secret format") - } -} - -func extractApisixSecretKeyPair(s *corev1.Secret, hasPrivateKey bool) (cert []byte, key []byte, err error) { - var ok bool - cert, ok = s.Data["cert"] - if !ok { - return nil, nil, errors.New("missing cert field") - } - - if hasPrivateKey { - key, ok = s.Data["key"] - if !ok { - return nil, nil, errors.New("missing key field") - } - } - return -} - -func extractKubeSecretKeyPair(s *corev1.Secret, hasPrivateKey bool) (cert []byte, key []byte, err error) { - var ok bool - cert, ok = s.Data[corev1.TLSCertKey] - if !ok { - return nil, nil, errors.New("missing cert field") - } - - if hasPrivateKey { - key, ok = s.Data[corev1.TLSPrivateKeyKey] - if !ok { - return nil, nil, errors.New("missing key field") - } - } - return -} diff --git a/internal/provider/controlplane/translator/httproute.go b/internal/provider/controlplane/translator/httproute.go deleted file mode 100644 index 9127b09ca..000000000 --- a/internal/provider/controlplane/translator/httproute.go +++ /dev/null @@ -1,474 +0,0 @@ -// 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 translator - -import ( - "fmt" - "strings" - - "github.com/api7/gopkg/pkg/log" - "github.com/pkg/errors" - "go.uber.org/zap" - discoveryv1 "k8s.io/api/discovery/v1" - "k8s.io/apimachinery/pkg/types" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/internal/controller/label" - "github.com/apache/apisix-ingress-controller/internal/id" - "github.com/apache/apisix-ingress-controller/internal/provider" -) - -func (t *Translator) fillPluginsFromHTTPRouteFilters( - plugins v1.Plugins, - namespace string, - filters []gatewayv1.HTTPRouteFilter, - matches []gatewayv1.HTTPRouteMatch, - tctx *provider.TranslateContext, -) { - for _, filter := range filters { - switch filter.Type { - case gatewayv1.HTTPRouteFilterRequestHeaderModifier: - t.fillPluginFromHTTPRequestHeaderFilter(plugins, filter.RequestHeaderModifier) - case gatewayv1.HTTPRouteFilterRequestRedirect: - t.fillPluginFromHTTPRequestRedirectFilter(plugins, filter.RequestRedirect) - case gatewayv1.HTTPRouteFilterRequestMirror: - t.fillPluginFromHTTPRequestMirrorFilter(plugins, namespace, filter.RequestMirror) - case gatewayv1.HTTPRouteFilterURLRewrite: - t.fillPluginFromURLRewriteFilter(plugins, filter.URLRewrite, matches) - case gatewayv1.HTTPRouteFilterResponseHeaderModifier: - t.fillPluginFromHTTPResponseHeaderFilter(plugins, filter.ResponseHeaderModifier) - case gatewayv1.HTTPRouteFilterExtensionRef: - t.fillPluginFromExtensionRef(plugins, namespace, filter.ExtensionRef, tctx) - } - } -} - -func (t *Translator) fillPluginFromExtensionRef(plugins v1.Plugins, namespace string, extensionRef *gatewayv1.LocalObjectReference, tctx *provider.TranslateContext) { - if extensionRef == nil { - return - } - if extensionRef.Kind == "PluginConfig" { - pluginconfig := tctx.PluginConfigs[types.NamespacedName{ - Namespace: namespace, - Name: string(extensionRef.Name), - }] - for _, plugin := range pluginconfig.Spec.Plugins { - pluginName := plugin.Name - plugins[pluginName] = plugin.Config - log.Errorw("plugin config", zap.String("namespace", namespace), zap.Any("plugin_config", plugin)) - } - log.Errorw("plugin config", zap.String("namespace", namespace), zap.Any("plugins", plugins)) - } -} - -func (t *Translator) fillPluginFromURLRewriteFilter(plugins v1.Plugins, urlRewrite *gatewayv1.HTTPURLRewriteFilter, matches []gatewayv1.HTTPRouteMatch) { - pluginName := v1.PluginProxyRewrite - obj := plugins[pluginName] - var plugin *v1.RewriteConfig - if obj == nil { - plugin = &v1.RewriteConfig{} - plugins[pluginName] = plugin - } else { - plugin = obj.(*v1.RewriteConfig) - } - if urlRewrite.Hostname != nil { - plugin.Host = string(*urlRewrite.Hostname) - } - - if urlRewrite.Path != nil { - switch urlRewrite.Path.Type { - case gatewayv1.FullPathHTTPPathModifier: - plugin.RewriteTarget = *urlRewrite.Path.ReplaceFullPath - case gatewayv1.PrefixMatchHTTPPathModifier: - prefixPaths := make([]string, 0, len(matches)) - for _, match := range matches { - if match.Path == nil || match.Path.Type == nil || *match.Path.Type != gatewayv1.PathMatchPathPrefix { - continue - } - prefixPaths = append(prefixPaths, *match.Path.Value) - } - regexPattern := "^(" + strings.Join(prefixPaths, "|") + ")" + "/(.*)" - replaceTarget := *urlRewrite.Path.ReplacePrefixMatch - regexTarget := replaceTarget + "/$2" - - plugin.RewriteTargetRegex = []string{ - regexPattern, - regexTarget, - } - } - } -} - -func (t *Translator) fillPluginFromHTTPRequestHeaderFilter(plugins v1.Plugins, reqHeaderModifier *gatewayv1.HTTPHeaderFilter) { - pluginName := v1.PluginProxyRewrite - obj := plugins[pluginName] - var plugin *v1.RewriteConfig - if obj == nil { - plugin = &v1.RewriteConfig{ - Headers: &v1.Headers{ - Add: make(map[string]string, len(reqHeaderModifier.Add)), - Set: make(map[string]string, len(reqHeaderModifier.Set)), - Remove: make([]string, 0, len(reqHeaderModifier.Remove)), - }, - } - plugins[pluginName] = plugin - } else { - plugin = obj.(*v1.RewriteConfig) - } - for _, header := range reqHeaderModifier.Add { - val := plugin.Headers.Add[string(header.Name)] - if val != "" { - val += ", " + header.Value - } else { - val = header.Value - } - plugin.Headers.Add[string(header.Name)] = val - } - for _, header := range reqHeaderModifier.Set { - plugin.Headers.Set[string(header.Name)] = header.Value - } - plugin.Headers.Remove = append(plugin.Headers.Remove, reqHeaderModifier.Remove...) -} - -func (t *Translator) fillPluginFromHTTPResponseHeaderFilter(plugins v1.Plugins, respHeaderModifier *gatewayv1.HTTPHeaderFilter) { - pluginName := v1.PluginResponseRewrite - obj := plugins[pluginName] - var plugin *v1.ResponseRewriteConfig - if obj == nil { - plugin = &v1.ResponseRewriteConfig{ - Headers: &v1.ResponseHeaders{ - Add: make([]string, 0, len(respHeaderModifier.Add)), - Set: make(map[string]string, len(respHeaderModifier.Set)), - Remove: make([]string, 0, len(respHeaderModifier.Remove)), - }, - } - plugins[pluginName] = plugin - } else { - plugin = obj.(*v1.ResponseRewriteConfig) - } - for _, header := range respHeaderModifier.Add { - plugin.Headers.Add = append(plugin.Headers.Add, fmt.Sprintf("%s: %s", header.Name, header.Value)) - } - for _, header := range respHeaderModifier.Set { - plugin.Headers.Set[string(header.Name)] = header.Value - } - plugin.Headers.Remove = append(plugin.Headers.Remove, respHeaderModifier.Remove...) -} - -func (t *Translator) fillPluginFromHTTPRequestMirrorFilter(plugins v1.Plugins, namespace string, reqMirror *gatewayv1.HTTPRequestMirrorFilter) { - pluginName := v1.PluginProxyMirror - obj := plugins[pluginName] - - var plugin *v1.RequestMirror - if obj == nil { - plugin = &v1.RequestMirror{} - plugins[pluginName] = plugin - } else { - plugin = obj.(*v1.RequestMirror) - } - - var ( - port = 80 - ns = namespace - ) - if reqMirror.BackendRef.Port != nil { - port = int(*reqMirror.BackendRef.Port) - } - if reqMirror.BackendRef.Namespace != nil { - ns = string(*reqMirror.BackendRef.Namespace) - } - - host := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", reqMirror.BackendRef.Name, ns, port) - - plugin.Host = host -} - -func (t *Translator) fillPluginFromHTTPRequestRedirectFilter(plugins v1.Plugins, reqRedirect *gatewayv1.HTTPRequestRedirectFilter) { - pluginName := v1.PluginRedirect - obj := plugins[pluginName] - - var plugin *v1.RedirectConfig - if obj == nil { - plugin = &v1.RedirectConfig{} - plugins[pluginName] = plugin - } else { - plugin = obj.(*v1.RedirectConfig) - } - var uri string - - code := 302 - if reqRedirect.StatusCode != nil { - code = *reqRedirect.StatusCode - } - - hostname := "$host" - if reqRedirect.Hostname != nil { - hostname = string(*reqRedirect.Hostname) - } - - scheme := "$scheme" - if reqRedirect.Scheme != nil { - scheme = *reqRedirect.Scheme - } - - if reqRedirect.Port != nil { - uri = fmt.Sprintf("%s://%s:%d$request_uri", scheme, hostname, int(*reqRedirect.Port)) - } else { - uri = fmt.Sprintf("%s://%s$request_uri", scheme, hostname) - } - plugin.RetCode = code - plugin.URI = uri -} - -func (t *Translator) translateEndpointSlice(endpointSlices []discoveryv1.EndpointSlice) v1.UpstreamNodes { - var nodes v1.UpstreamNodes - if len(endpointSlices) == 0 { - return nodes - } - for _, endpointSlice := range endpointSlices { - for _, port := range endpointSlice.Ports { - for _, endpoint := range endpointSlice.Endpoints { - for _, addr := range endpoint.Addresses { - node := v1.UpstreamNode{ - Host: addr, - Port: int(*port.Port), - Weight: 1, - } - nodes = append(nodes, node) - } - } - } - } - - return nodes -} - -func (t *Translator) translateBackendRef(tctx *provider.TranslateContext, ref gatewayv1.BackendRef) *v1.Upstream { - upstream := v1.NewDefaultUpstream() - endpointSlices := tctx.EndpointSlices[types.NamespacedName{ - Namespace: string(*ref.Namespace), - Name: string(ref.Name), - }] - - upstream.Nodes = t.translateEndpointSlice(endpointSlices) - return upstream -} - -func (t *Translator) TranslateHTTPRoute(tctx *provider.TranslateContext, httpRoute *gatewayv1.HTTPRoute) (*TranslateResult, error) { - result := &TranslateResult{} - - hosts := make([]string, 0, len(httpRoute.Spec.Hostnames)) - for _, hostname := range httpRoute.Spec.Hostnames { - hosts = append(hosts, string(hostname)) - } - - rules := httpRoute.Spec.Rules - - for i, rule := range rules { - - var weightedUpstreams []v1.TrafficSplitConfigRuleWeightedUpstream - upstreams := []*v1.Upstream{} - for _, backend := range rule.BackendRefs { - if backend.Namespace == nil { - namespace := gatewayv1.Namespace(httpRoute.Namespace) - backend.Namespace = &namespace - } - upstream := t.translateBackendRef(tctx, backend.BackendRef) - upstream.Labels["name"] = string(backend.Name) - upstream.Labels["namespace"] = string(*backend.Namespace) - upstreams = append(upstreams, upstream) - if len(upstream.Nodes) == 0 { - upstream.Nodes = v1.UpstreamNodes{ - { - Host: "0.0.0.0", - Port: 80, - Weight: 100, - }, - } - } - - weight := 100 - if backend.Weight != nil { - weight = int(*backend.Weight) - } - weightedUpstreams = append(weightedUpstreams, v1.TrafficSplitConfigRuleWeightedUpstream{ - Upstream: upstream, - Weight: weight, - }) - } - - if len(upstreams) == 0 { - upstream := v1.NewDefaultUpstream() - upstream.Nodes = v1.UpstreamNodes{ - { - Host: "0.0.0.0", - Port: 80, - Weight: 100, - }, - } - upstreams = append(upstreams, upstream) - } - - service := v1.NewDefaultService() - service.Upstream = upstreams[0] - if len(weightedUpstreams) > 1 { - weightedUpstreams[0].Upstream = nil - service.Plugins["traffic-split"] = &v1.TrafficSplitConfig{ - Rules: []v1.TrafficSplitConfigRule{ - { - WeightedUpstreams: weightedUpstreams, - }, - }, - } - } - - service.Name = v1.ComposeServiceNameWithRule(httpRoute.Namespace, httpRoute.Name, fmt.Sprintf("%d", i)) - service.ID = id.GenID(service.Name) - service.Labels = label.GenLabel(httpRoute) - service.Hosts = hosts - t.fillPluginsFromHTTPRouteFilters(service.Plugins, httpRoute.GetNamespace(), rule.Filters, rule.Matches, tctx) - - result.Services = append(result.Services, service) - - matches := rule.Matches - if len(matches) == 0 { - defaultType := gatewayv1.PathMatchPathPrefix - defaultValue := "/" - matches = []gatewayv1.HTTPRouteMatch{ - { - Path: &gatewayv1.HTTPPathMatch{ - Type: &defaultType, - Value: &defaultValue, - }, - }, - } - } - - for j, match := range matches { - route, err := t.translateGatewayHTTPRouteMatch(&match) - if err != nil { - return nil, err - } - - name := v1.ComposeRouteName(httpRoute.Namespace, httpRoute.Name, fmt.Sprintf("%d-%d", i, j)) - route.Name = name - route.ID = id.GenID(name) - route.Labels = label.GenLabel(httpRoute) - route.ServiceID = service.ID - result.Routes = append(result.Routes, route) - } - } - - return result, nil -} - -func (t *Translator) translateGatewayHTTPRouteMatch(match *gatewayv1.HTTPRouteMatch) (*v1.Route, error) { - route := v1.NewDefaultRoute() - - if match.Path != nil { - switch *match.Path.Type { - case gatewayv1.PathMatchExact: - route.Paths = []string{*match.Path.Value} - case gatewayv1.PathMatchPathPrefix: - route.Paths = []string{*match.Path.Value + "*"} - case gatewayv1.PathMatchRegularExpression: - var this []v1.StringOrSlice - this = append(this, v1.StringOrSlice{ - StrVal: "uri", - }) - this = append(this, v1.StringOrSlice{ - StrVal: "~~", - }) - this = append(this, v1.StringOrSlice{ - StrVal: *match.Path.Value, - }) - - route.Vars = append(route.Vars, this) - default: - return nil, errors.New("unknown path match type " + string(*match.Path.Type)) - } - } - - if len(match.Headers) > 0 { - for _, header := range match.Headers { - name := strings.ToLower(string(header.Name)) - name = strings.ReplaceAll(name, "-", "_") - - var this []v1.StringOrSlice - this = append(this, v1.StringOrSlice{ - StrVal: "http_" + name, - }) - - switch *header.Type { - case gatewayv1.HeaderMatchExact: - this = append(this, v1.StringOrSlice{ - StrVal: "==", - }) - case gatewayv1.HeaderMatchRegularExpression: - this = append(this, v1.StringOrSlice{ - StrVal: "~~", - }) - default: - return nil, errors.New("unknown header match type " + string(*header.Type)) - } - - this = append(this, v1.StringOrSlice{ - StrVal: header.Value, - }) - - route.Vars = append(route.Vars, this) - } - } - - if len(match.QueryParams) > 0 { - for _, query := range match.QueryParams { - var this []v1.StringOrSlice - this = append(this, v1.StringOrSlice{ - StrVal: "arg_" + strings.ToLower(fmt.Sprintf("%v", query.Name)), - }) - - switch *query.Type { - case gatewayv1.QueryParamMatchExact: - this = append(this, v1.StringOrSlice{ - StrVal: "==", - }) - case gatewayv1.QueryParamMatchRegularExpression: - this = append(this, v1.StringOrSlice{ - StrVal: "~~", - }) - default: - return nil, errors.New("unknown query match type " + string(*query.Type)) - } - - this = append(this, v1.StringOrSlice{ - StrVal: query.Value, - }) - - route.Vars = append(route.Vars, this) - } - } - - if match.Method != nil { - route.Methods = []string{ - string(*match.Method), - } - } - - return route, nil -} diff --git a/internal/provider/controlplane/translator/translator.go b/internal/provider/controlplane/translator/translator.go deleted file mode 100644 index 8a817ccda..000000000 --- a/internal/provider/controlplane/translator/translator.go +++ /dev/null @@ -1,55 +0,0 @@ -// 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 translator - -import ( - "github.com/go-logr/logr" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - "k8s.io/apimachinery/pkg/types" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/api/v1alpha1" -) - -type Translator struct { - Log logr.Logger -} - -type TranslateContext struct { - BackendRefs []gatewayv1.BackendRef - GatewayTLSConfig []gatewayv1.GatewayTLSConfig - EndpointSlices map[types.NamespacedName][]discoveryv1.EndpointSlice - Secrets map[types.NamespacedName]*corev1.Secret - PluginConfigs map[types.NamespacedName]*v1alpha1.PluginConfig -} - -type TranslateResult struct { - Routes []*v1.Route - Services []*v1.Service - SSL []*v1.Ssl -} - -func NewDefaultTranslateContext() *TranslateContext { - return &TranslateContext{ - EndpointSlices: make(map[types.NamespacedName][]discoveryv1.EndpointSlice), - Secrets: make(map[types.NamespacedName]*corev1.Secret), - PluginConfigs: make(map[types.NamespacedName]*v1alpha1.PluginConfig), - } -} diff --git a/pkg/dashboard/cache/cache.go b/pkg/dashboard/cache/cache.go deleted file mode 100644 index 7319a2e18..000000000 --- a/pkg/dashboard/cache/cache.go +++ /dev/null @@ -1,97 +0,0 @@ -// 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 cache - -import v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - -// Cache defines the necessary behaviors that the cache object should have. -// Note this interface is for APISIX, not for generic purpose, it supports -// standard APISIX resources, i.e. Route, Upstream, and SSL. -// Cache implementations should copy the target objects before/after read/write -// operations for the sake of avoiding data corrupted by other writers. -type Cache interface { - // InsertRoute adds or updates route to cache. - InsertRoute(*v1.Route) error - // InsertStreamRoute adds or updates stream_route to cache. - InsertStreamRoute(*v1.StreamRoute) error - // InsertSSL adds or updates ssl to cache. - InsertSSL(*v1.Ssl) error - // InsertUpstream adds or updates upstream to cache. - InsertService(*v1.Service) error - // InsertGlobalRule adds or updates global_rule to cache. - InsertGlobalRule(*v1.GlobalRule) error - // InsertConsumer adds or updates consumer to cache. - InsertConsumer(*v1.Consumer) error - // InsertSchema adds or updates schema to cache. - InsertSchema(*v1.Schema) error - // InsertPluginConfig adds or updates plugin_config to cache. - InsertPluginConfig(*v1.PluginConfig) error - - // GetRoute finds the route from cache according to the primary index (id). - GetRoute(string) (*v1.Route, error) - GetStreamRoute(string) (*v1.StreamRoute, error) - // GetSSL finds the ssl from cache according to the primary index (id). - GetSSL(string) (*v1.Ssl, error) - // GetUpstream finds the upstream from cache according to the primary index (id). - GetService(string) (*v1.Service, error) - // GetGlobalRule finds the global_rule from cache according to the primary index (id). - GetGlobalRule(string) (*v1.GlobalRule, error) - // GetConsumer finds the consumer from cache according to the primary index (username). - GetConsumer(string) (*v1.Consumer, error) - // GetSchema finds the scheme from cache according to the primary index (name). - GetSchema(string) (*v1.Schema, error) - // GetPluginConfig finds the plugin_config from cache according to the primary index (id). - GetPluginConfig(string) (*v1.PluginConfig, error) - - // ListRoutes lists all routes in cache. - ListRoutes(...any) ([]*v1.Route, error) - // ListStreamRoutes lists all stream_route objects in cache. - ListStreamRoutes() ([]*v1.StreamRoute, error) - // ListSSL lists all ssl objects in cache. - ListSSL(...any) ([]*v1.Ssl, error) - // ListUpstreams lists all upstreams in cache. - ListServices(...any) ([]*v1.Service, error) - // ListGlobalRules lists all global_rule objects in cache. - ListGlobalRules() ([]*v1.GlobalRule, error) - // ListConsumers lists all consumer objects in cache. - ListConsumers() ([]*v1.Consumer, error) - // ListSchema lists all schema in cache. - ListSchema() ([]*v1.Schema, error) - // ListPluginConfigs lists all plugin_config in cache. - ListPluginConfigs() ([]*v1.PluginConfig, error) - - // DeleteRoute deletes the specified route in cache. - DeleteRoute(*v1.Route) error - // DeleteStreamRoute deletes the specified stream_route in cache. - DeleteStreamRoute(*v1.StreamRoute) error - // DeleteSSL deletes the specified ssl in cache. - DeleteSSL(*v1.Ssl) error - // DeleteUpstream deletes the specified upstream in cache. - DeleteService(*v1.Service) error - // DeleteGlobalRule deletes the specified stream_route in cache. - DeleteGlobalRule(*v1.GlobalRule) error - // DeleteConsumer deletes the specified consumer in cache. - DeleteConsumer(*v1.Consumer) error - // DeleteSchema deletes the specified schema in cache. - DeleteSchema(*v1.Schema) error - // DeletePluginConfig deletes the specified plugin_config in cache. - DeletePluginConfig(*v1.PluginConfig) error - - CheckServiceReference(*v1.Service) error - CheckPluginConfigReference(*v1.PluginConfig) error -} diff --git a/pkg/dashboard/cache/memdb.go b/pkg/dashboard/cache/memdb.go deleted file mode 100644 index 403b7aa28..000000000 --- a/pkg/dashboard/cache/memdb.go +++ /dev/null @@ -1,368 +0,0 @@ -// 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 cache - -import ( - "errors" - - "github.com/hashicorp/go-memdb" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -var ( - // ErrStillInUse means an object is still in use. - ErrStillInUse = errors.New("still in use") - // ErrNotFound is returned when the requested item is not found. - ErrNotFound = memdb.ErrNotFound -) - -type dbCache struct { - db *memdb.MemDB -} - -// NewMemDBCache creates a Cache object backs with a memory DB. -func NewMemDBCache() (Cache, error) { - db, err := memdb.NewMemDB(_schema) - if err != nil { - return nil, err - } - return &dbCache{ - db: db, - }, nil -} - -func (c *dbCache) InsertRoute(r *v1.Route) error { - route := r.DeepCopy() - return c.insert("route", route) -} - -func (c *dbCache) InsertSSL(ssl *v1.Ssl) error { - return c.insert("ssl", ssl.DeepCopy()) -} - -func (c *dbCache) InsertService(u *v1.Service) error { - return c.insert("service", u.DeepCopy()) -} - -func (c *dbCache) InsertGlobalRule(gr *v1.GlobalRule) error { - return c.insert("global_rule", gr.DeepCopy()) -} - -func (c *dbCache) InsertConsumer(consumer *v1.Consumer) error { - return c.insert("consumer", consumer.DeepCopy()) -} -func (c *dbCache) InsertStreamRoute(sr *v1.StreamRoute) error { - return c.insert("stream_route", sr.DeepCopy()) -} - -func (c *dbCache) InsertSchema(schema *v1.Schema) error { - return c.insert("schema", schema.DeepCopy()) -} - -func (c *dbCache) InsertPluginConfig(pc *v1.PluginConfig) error { - return c.insert("plugin_config", pc.DeepCopy()) -} - -func (c *dbCache) insert(table string, obj any) error { - txn := c.db.Txn(true) - defer txn.Abort() - if err := txn.Insert(table, obj); err != nil { - return err - } - txn.Commit() - return nil -} - -func (c *dbCache) GetRoute(id string) (*v1.Route, error) { - obj, err := c.get("route", id) - if err != nil { - return nil, err - } - return obj.(*v1.Route).DeepCopy(), nil -} - -func (c *dbCache) GetSSL(id string) (*v1.Ssl, error) { - obj, err := c.get("ssl", id) - if err != nil { - return nil, err - } - return obj.(*v1.Ssl).DeepCopy(), nil -} - -func (c *dbCache) GetService(id string) (*v1.Service, error) { - obj, err := c.get("service", id) - if err != nil { - return nil, err - } - return obj.(*v1.Service).DeepCopy(), nil -} - -func (c *dbCache) GetGlobalRule(id string) (*v1.GlobalRule, error) { - obj, err := c.get("global_rule", id) - if err != nil { - return nil, err - } - return obj.(*v1.GlobalRule).DeepCopy(), nil -} - -func (c *dbCache) GetConsumer(username string) (*v1.Consumer, error) { - obj, err := c.get("consumer", username) - if err != nil { - return nil, err - } - return obj.(*v1.Consumer).DeepCopy(), nil -} - -func (c *dbCache) GetStreamRoute(id string) (*v1.StreamRoute, error) { - obj, err := c.get("stream_route", id) - if err != nil { - return nil, err - } - return obj.(*v1.StreamRoute).DeepCopy(), nil -} - -func (c *dbCache) GetSchema(name string) (*v1.Schema, error) { - obj, err := c.get("schema", name) - if err != nil { - return nil, err - } - return obj.(*v1.Schema).DeepCopy(), nil -} - -func (c *dbCache) GetPluginConfig(name string) (*v1.PluginConfig, error) { - obj, err := c.get("plugin_config", name) - if err != nil { - return nil, err - } - return obj.(*v1.PluginConfig).DeepCopy(), nil -} - -func (c *dbCache) get(table, id string) (any, error) { - txn := c.db.Txn(false) - defer txn.Abort() - obj, err := txn.First(table, "id", id) - if err != nil { - if err == memdb.ErrNotFound { - return nil, ErrNotFound - } - return nil, err - } - if obj == nil { - return nil, ErrNotFound - } - return obj, nil -} - -func (c *dbCache) ListRoutes(args ...any) ([]*v1.Route, error) { - raws, err := c.list("route", args...) - if err != nil { - return nil, err - } - routes := make([]*v1.Route, 0, len(raws)) - for _, raw := range raws { - routes = append(routes, raw.(*v1.Route).DeepCopy()) - } - return routes, nil -} - -func (c *dbCache) ListSSL(args ...any) ([]*v1.Ssl, error) { - raws, err := c.list("ssl", args...) - if err != nil { - return nil, err - } - ssl := make([]*v1.Ssl, 0, len(raws)) - for _, raw := range raws { - ssl = append(ssl, raw.(*v1.Ssl).DeepCopy()) - } - return ssl, nil -} - -func (c *dbCache) ListServices(args ...any) ([]*v1.Service, error) { - raws, err := c.list("service", args...) - if err != nil { - return nil, err - } - services := make([]*v1.Service, 0, len(raws)) - for _, raw := range raws { - services = append(services, raw.(*v1.Service).DeepCopy()) - } - return services, nil -} - -func (c *dbCache) ListGlobalRules() ([]*v1.GlobalRule, error) { - raws, err := c.list("global_rule") - if err != nil { - return nil, err - } - globalRules := make([]*v1.GlobalRule, 0, len(raws)) - for _, raw := range raws { - globalRules = append(globalRules, raw.(*v1.GlobalRule).DeepCopy()) - } - return globalRules, nil -} - -func (c *dbCache) ListStreamRoutes() ([]*v1.StreamRoute, error) { - raws, err := c.list("stream_route") - if err != nil { - return nil, err - } - streamRoutes := make([]*v1.StreamRoute, 0, len(raws)) - for _, raw := range raws { - streamRoutes = append(streamRoutes, raw.(*v1.StreamRoute).DeepCopy()) - } - return streamRoutes, nil -} - -func (c *dbCache) ListConsumers() ([]*v1.Consumer, error) { - raws, err := c.list("consumer") - if err != nil { - return nil, err - } - consumers := make([]*v1.Consumer, 0, len(raws)) - for _, raw := range raws { - consumers = append(consumers, raw.(*v1.Consumer).DeepCopy()) - } - return consumers, nil -} - -func (c *dbCache) ListSchema() ([]*v1.Schema, error) { - raws, err := c.list("schema") - if err != nil { - return nil, err - } - schemaList := make([]*v1.Schema, 0, len(raws)) - for _, raw := range raws { - schemaList = append(schemaList, raw.(*v1.Schema).DeepCopy()) - } - return schemaList, nil -} - -func (c *dbCache) ListPluginConfigs() ([]*v1.PluginConfig, error) { - raws, err := c.list("plugin_config") - if err != nil { - return nil, err - } - pluginConfigs := make([]*v1.PluginConfig, 0, len(raws)) - for _, raw := range raws { - pluginConfigs = append(pluginConfigs, raw.(*v1.PluginConfig).DeepCopy()) - } - return pluginConfigs, nil -} - -func (c *dbCache) list(table string, args ...any) ([]any, error) { - txn := c.db.Txn(false) - defer txn.Abort() - index := "id" - if len(args) > 0 { - idx, ok := args[0].(string) - if !ok { - return nil, errors.New("unexpected index type") - } - index = idx - args = args[1:] - } - iter, err := txn.Get(table, index, args...) - if err != nil { - return nil, err - } - var objs []any - for obj := iter.Next(); obj != nil; obj = iter.Next() { - objs = append(objs, obj) - } - return objs, nil -} - -func (c *dbCache) DeleteRoute(r *v1.Route) error { - return c.delete("route", r) -} - -func (c *dbCache) DeleteSSL(ssl *v1.Ssl) error { - return c.delete("ssl", ssl) -} - -func (c *dbCache) DeleteService(u *v1.Service) error { - if err := c.CheckServiceReference(u); err != nil { - return err - } - return c.delete("service", u) -} - -func (c *dbCache) DeleteStreamRoute(sr *v1.StreamRoute) error { - return c.delete("stream_route", sr) -} - -func (c *dbCache) DeleteGlobalRule(gr *v1.GlobalRule) error { - return c.delete("global_rule", gr) -} - -func (c *dbCache) DeleteConsumer(consumer *v1.Consumer) error { - return c.delete("consumer", consumer) -} - -func (c *dbCache) DeleteSchema(schema *v1.Schema) error { - return c.delete("schema", schema) -} - -func (c *dbCache) DeletePluginConfig(pc *v1.PluginConfig) error { - if err := c.CheckPluginConfigReference(pc); err != nil { - return err - } - return c.delete("plugin_config", pc) -} - -func (c *dbCache) delete(table string, obj any) error { - txn := c.db.Txn(true) - defer txn.Abort() - if err := txn.Delete(table, obj); err != nil { - if err == memdb.ErrNotFound { - return ErrNotFound - } - return err - } - txn.Commit() - return nil -} - -func (c *dbCache) CheckServiceReference(u *v1.Service) error { - // Upstream is referenced by Route. - txn := c.db.Txn(false) - defer txn.Abort() - obj, err := txn.First("route", "service_id", u.ID) - if err != nil && err != memdb.ErrNotFound { - return err - } - if obj != nil { - return ErrStillInUse - } - return nil -} - -func (c *dbCache) CheckPluginConfigReference(u *v1.PluginConfig) error { - // PluginConfig is referenced by Route. - txn := c.db.Txn(false) - defer txn.Abort() - obj, err := txn.First("route", "plugin_config_id", u.ID) - if err != nil && err != memdb.ErrNotFound { - return err - } - if obj != nil { - return ErrStillInUse - } - return nil -} diff --git a/pkg/dashboard/cache/memdb_test.go b/pkg/dashboard/cache/memdb_test.go deleted file mode 100644 index ae1bc3c07..000000000 --- a/pkg/dashboard/cache/memdb_test.go +++ /dev/null @@ -1,390 +0,0 @@ -// 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 cache - -import ( - "testing" - - "github.com/hashicorp/go-memdb" - "github.com/stretchr/testify/assert" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -func TestMemDBCacheRoute(t *testing.T) { - c, err := NewMemDBCache() - assert.Nil(t, err, "NewMemDBCache") - - r1 := &v1.Route{ - Metadata: v1.Metadata{ - ID: "1", - Name: "abc", - }, - } - assert.Nil(t, c.InsertRoute(r1), "inserting route 1") - - r, err := c.GetRoute("1") - assert.Nil(t, err) - assert.Equal(t, r1, r) - - r2 := &v1.Route{ - Metadata: v1.Metadata{ - ID: "2", - Name: "def", - }, - } - r3 := &v1.Route{ - Metadata: v1.Metadata{ - ID: "3", - Name: "ghi", - }, - } - assert.Nil(t, c.InsertRoute(r2), "inserting route r2") - assert.Nil(t, c.InsertRoute(r3), "inserting route r3") - - r, err = c.GetRoute("3") - assert.Nil(t, err) - assert.Equal(t, r3, r) - - assert.Nil(t, c.DeleteRoute(r3), "delete route r3") - - routes, err := c.ListRoutes() - assert.Nil(t, err, "listing routes") - - if routes[0].Name > routes[1].Name { - routes[0], routes[1] = routes[1], routes[0] - } - assert.Equal(t, r1, routes[0]) - assert.Equal(t, r2, routes[1]) - - r4 := &v1.Route{ - Metadata: v1.Metadata{ - ID: "4", - Name: "name4", - }, - } - assert.Error(t, ErrNotFound, c.DeleteRoute(r4)) -} - -func TestMemDBCacheSSL(t *testing.T) { - c, err := NewMemDBCache() - assert.Nil(t, err, "NewMemDBCache") - - s1 := &v1.Ssl{ - ID: "abc", - } - assert.Nil(t, c.InsertSSL(s1), "inserting ssl 1") - - s, err := c.GetSSL("abc") - assert.Nil(t, err) - assert.Equal(t, s1, s) - - s2 := &v1.Ssl{ - ID: "def", - } - s3 := &v1.Ssl{ - ID: "ghi", - } - assert.Nil(t, c.InsertSSL(s2), "inserting ssl 2") - assert.Nil(t, c.InsertSSL(s3), "inserting ssl 3") - - s, err = c.GetSSL("ghi") - assert.Nil(t, err) - assert.Equal(t, s3, s) - - assert.Nil(t, c.DeleteSSL(s3), "delete ssl 3") - - ssl, err := c.ListSSL() - assert.Nil(t, err, "listing ssl") - - if ssl[0].ID > ssl[1].ID { - ssl[0], ssl[1] = ssl[1], ssl[0] - } - assert.Equal(t, s1, ssl[0]) - assert.Equal(t, s2, ssl[1]) - - s4 := &v1.Ssl{ - ID: "id4", - } - assert.Error(t, ErrNotFound, c.DeleteSSL(s4)) -} - -func TestMemDBCacheUpstream(t *testing.T) { - c, err := NewMemDBCache() - assert.Nil(t, err, "NewMemDBCache") - - u1 := &v1.Service{ - Metadata: v1.Metadata{ - ID: "1", - Name: "abc", - }, - } - err = c.InsertService(u1) - assert.Nil(t, err, "inserting upstream 1") - - u, err := c.GetService("1") - assert.Nil(t, err) - assert.Equal(t, u1, u) - - u2 := &v1.Service{ - Metadata: v1.Metadata{ - Name: "def", - ID: "2", - }, - } - u3 := &v1.Service{ - Metadata: v1.Metadata{ - Name: "ghi", - ID: "3", - }, - } - assert.Nil(t, c.InsertService(u2), "inserting upstream 2") - assert.Nil(t, c.InsertService(u3), "inserting upstream 3") - - u, err = c.GetService("3") - assert.Nil(t, err) - assert.Equal(t, u3, u) - - assert.Nil(t, c.DeleteService(u3), "delete upstream 3") - - upstreams, err := c.ListServices() - assert.Nil(t, err, "listing upstreams") - - if upstreams[0].Name > upstreams[1].Name { - upstreams[0], upstreams[1] = upstreams[1], upstreams[0] - } - assert.Equal(t, u1, upstreams[0]) - assert.Equal(t, u2, upstreams[1]) - - u4 := &v1.Service{ - Metadata: v1.Metadata{ - Name: "name4", - ID: "4", - }, - } - assert.Error(t, ErrNotFound, c.DeleteService(u4)) -} - -func TestMemDBCacheReference(t *testing.T) { - r := &v1.Route{ - Metadata: v1.Metadata{ - Name: "route", - ID: "1", - }, - ServiceID: "1", - PluginConfigId: "1", - } - u := &v1.Service{ - Metadata: v1.Metadata{ - ID: "1", - Name: "upstream", - }, - } - pc := &v1.PluginConfig{ - Metadata: v1.Metadata{ - ID: "1", - Name: "pluginConfig", - }, - } - pc2 := &v1.PluginConfig{ - Metadata: v1.Metadata{ - ID: "2", - Name: "pluginConfig", - }, - } - - db, err := NewMemDBCache() - assert.Nil(t, err, "NewMemDBCache") - assert.Nil(t, db.InsertRoute(r)) - assert.Nil(t, db.InsertService(u)) - assert.Nil(t, db.InsertPluginConfig(pc)) - - assert.Error(t, ErrStillInUse, db.DeleteService(u)) - assert.Error(t, ErrStillInUse, db.DeletePluginConfig(pc)) - assert.Equal(t, memdb.ErrNotFound, db.DeletePluginConfig(pc2)) - assert.Nil(t, db.DeleteRoute(r)) - assert.Nil(t, db.DeleteService(u)) - assert.Nil(t, db.DeletePluginConfig(pc)) -} - -func testInsertAndGetGlobalRule(t *testing.T, c Cache, id string) { - gr1 := &v1.GlobalRule{ - ID: id, - } - assert.Nil(t, c.InsertGlobalRule(gr1), "inserting global rule "+id) - - gr, err := c.GetGlobalRule(id) - assert.Nil(t, err) - assert.Equal(t, gr1, gr) -} - -func TestMemDBCacheGlobalRule(t *testing.T) { - c, err := NewMemDBCache() - assert.Nil(t, err, "NewMemDBCache") - - testInsertAndGetGlobalRule(t, c, "1") - testInsertAndGetGlobalRule(t, c, "2") - testInsertAndGetGlobalRule(t, c, "3") - - grs, err := c.ListGlobalRules() - assert.Nil(t, err, "listing global rules") - assert.Len(t, grs, 3) - assert.ElementsMatch(t, []string{"1", "2", "3"}, []string{grs[0].ID, grs[1].ID, grs[2].ID}) - - assert.Error(t, ErrNotFound, c.DeleteGlobalRule(&v1.GlobalRule{ - ID: "4", - })) -} - -func testInsertAndGetConsumer(t *testing.T, c Cache, username string) { - c1 := &v1.Consumer{ - Username: username, - } - assert.Nil(t, c.InsertConsumer(c1), "inserting consumer "+username) - - c11, err := c.GetConsumer(username) - assert.Nil(t, err) - assert.Equal(t, c1, c11) -} - -func TestMemDBCacheConsumer(t *testing.T) { - c, err := NewMemDBCache() - assert.Nil(t, err, "NewMemDBCache") - - testInsertAndGetConsumer(t, c, "jack") - testInsertAndGetConsumer(t, c, "tom") - testInsertAndGetConsumer(t, c, "jerry") - consumers, err := c.ListConsumers() - assert.Nil(t, err, "listing consumers") - assert.Len(t, consumers, 3) - - assert.Nil(t, c.DeleteConsumer( - &v1.Consumer{ - Username: "jerry", - }), "delete consumer jerry") - - consumers, err = c.ListConsumers() - assert.Nil(t, err, "listing consumers") - assert.Len(t, consumers, 2) - assert.ElementsMatch(t, []string{"jack", "tom"}, []string{consumers[0].Username, consumers[1].Username}) - - assert.Error(t, ErrNotFound, c.DeleteConsumer( - &v1.Consumer{ - Username: "chandler", - }, - )) -} - -func TestMemDBCacheSchema(t *testing.T) { - c, err := NewMemDBCache() - assert.Nil(t, err, "NewMemDBCache") - - s1 := &v1.Schema{ - Name: "plugins/p1", - Content: "plugin schema", - } - assert.Nil(t, c.InsertSchema(s1), "inserting schema s1") - - s11, err := c.GetSchema("plugins/p1") - assert.Nil(t, err) - assert.Equal(t, s1, s11) - - s2 := &v1.Schema{ - Name: "plugins/p2", - } - s3 := &v1.Schema{ - Name: "plugins/p3", - } - assert.Nil(t, c.InsertSchema(s2), "inserting schema s2") - assert.Nil(t, c.InsertSchema(s3), "inserting schema s3") - - s22, err := c.GetSchema("plugins/p2") - assert.Nil(t, err) - assert.Equal(t, s2, s22) - - assert.Nil(t, c.DeleteSchema(s3), "delete schema s3") - - schemaList, err := c.ListSchema() - assert.Nil(t, err, "listing schema") - - if schemaList[0].Name > schemaList[1].Name { - schemaList[0], schemaList[1] = schemaList[1], schemaList[0] - } - assert.Equal(t, s1, schemaList[0]) - assert.Equal(t, s2, schemaList[1]) - - s4 := &v1.Schema{ - Name: "plugins/p4", - } - assert.Error(t, ErrNotFound, c.DeleteSchema(s4)) -} - -func TestMemDBCachePluginConfig(t *testing.T) { - c, err := NewMemDBCache() - assert.Nil(t, err, "NewMemDBCache") - - pc1 := &v1.PluginConfig{ - Metadata: v1.Metadata{ - ID: "1", - Name: "name1", - }, - } - assert.Nil(t, c.InsertPluginConfig(pc1), "inserting plugin_config pc1") - - pc11, err := c.GetPluginConfig("1") - assert.Nil(t, err) - assert.Equal(t, pc1, pc11) - - pc2 := &v1.PluginConfig{ - Metadata: v1.Metadata{ - ID: "2", - Name: "name2", - }, - } - pc3 := &v1.PluginConfig{ - Metadata: v1.Metadata{ - ID: "3", - Name: "name3", - }, - } - assert.Nil(t, c.InsertPluginConfig(pc2), "inserting plugin_config pc2") - assert.Nil(t, c.InsertPluginConfig(pc3), "inserting plugin_config pc3") - - pc22, err := c.GetPluginConfig("2") - assert.Nil(t, err) - assert.Equal(t, pc2, pc22) - - assert.Nil(t, c.DeletePluginConfig(pc3), "delete plugin_config pc3") - - pcList, err := c.ListPluginConfigs() - assert.Nil(t, err, "listing plugin_config") - - if pcList[0].Name > pcList[1].Name { - pcList[0], pcList[1] = pcList[1], pcList[0] - } - assert.Equal(t, pcList[0], pc1) - assert.Equal(t, pcList[1], pc2) - - pc4 := &v1.PluginConfig{ - Metadata: v1.Metadata{ - ID: "4", - Name: "name4", - }, - } - assert.Error(t, ErrNotFound, c.DeletePluginConfig(pc4)) -} diff --git a/pkg/dashboard/cache/noop_db.go b/pkg/dashboard/cache/noop_db.go deleted file mode 100644 index 1f4c0e8e3..000000000 --- a/pkg/dashboard/cache/noop_db.go +++ /dev/null @@ -1,166 +0,0 @@ -// 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 cache - -import ( - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -type noopCache struct { -} - -// NewMemDBCache creates a Cache object backs with a memory DB. -func NewNoopDBCache() (Cache, error) { - return &noopCache{}, nil -} - -func (c *noopCache) InsertRoute(r *v1.Route) error { - return nil -} - -func (c *noopCache) InsertSSL(ssl *v1.Ssl) error { - return nil -} - -func (c *noopCache) InsertService(u *v1.Service) error { - return nil -} - -func (c *noopCache) InsertStreamRoute(sr *v1.StreamRoute) error { - return nil -} - -func (c *noopCache) InsertGlobalRule(gr *v1.GlobalRule) error { - return nil -} - -func (c *noopCache) InsertConsumer(consumer *v1.Consumer) error { - return nil -} - -func (c *noopCache) InsertSchema(schema *v1.Schema) error { - return nil -} - -func (c *noopCache) InsertPluginConfig(pc *v1.PluginConfig) error { - return nil -} - -func (c *noopCache) GetRoute(id string) (*v1.Route, error) { - return nil, nil -} - -func (c *noopCache) GetSSL(id string) (*v1.Ssl, error) { - return nil, nil -} - -func (c *noopCache) GetService(id string) (*v1.Service, error) { - return nil, nil -} - -func (c *noopCache) GetStreamRoute(id string) (*v1.StreamRoute, error) { - return nil, nil -} - -func (c *noopCache) GetGlobalRule(id string) (*v1.GlobalRule, error) { - return nil, nil -} - -func (c *noopCache) GetConsumer(username string) (*v1.Consumer, error) { - return nil, nil -} - -func (c *noopCache) GetSchema(name string) (*v1.Schema, error) { - return nil, nil -} - -func (c *noopCache) GetPluginConfig(name string) (*v1.PluginConfig, error) { - return nil, nil -} - -func (c *noopCache) ListRoutes(...any) ([]*v1.Route, error) { - return nil, nil -} - -func (c *noopCache) ListSSL(...any) ([]*v1.Ssl, error) { - return nil, nil -} - -func (c *noopCache) ListServices(...any) ([]*v1.Service, error) { - return nil, nil -} - -func (c *noopCache) ListStreamRoutes() ([]*v1.StreamRoute, error) { - return nil, nil -} - -func (c *noopCache) ListGlobalRules() ([]*v1.GlobalRule, error) { - return nil, nil -} - -func (c *noopCache) ListConsumers() ([]*v1.Consumer, error) { - return nil, nil -} - -func (c *noopCache) ListSchema() ([]*v1.Schema, error) { - return nil, nil -} - -func (c *noopCache) ListPluginConfigs() ([]*v1.PluginConfig, error) { - return nil, nil -} - -func (c *noopCache) DeleteRoute(r *v1.Route) error { - return nil -} - -func (c *noopCache) DeleteSSL(ssl *v1.Ssl) error { - return nil -} - -func (c *noopCache) DeleteService(u *v1.Service) error { - return nil -} - -func (c *noopCache) DeleteStreamRoute(sr *v1.StreamRoute) error { - return nil -} - -func (c *noopCache) DeleteGlobalRule(gr *v1.GlobalRule) error { - return nil -} - -func (c *noopCache) DeleteConsumer(consumer *v1.Consumer) error { - return nil -} - -func (c *noopCache) DeleteSchema(schema *v1.Schema) error { - return nil -} - -func (c *noopCache) DeletePluginConfig(pc *v1.PluginConfig) error { - return nil -} - -func (c *noopCache) CheckServiceReference(u *v1.Service) error { - return nil -} - -func (c *noopCache) CheckPluginConfigReference(pc *v1.PluginConfig) error { - return nil -} diff --git a/pkg/dashboard/cache/schema.go b/pkg/dashboard/cache/schema.go deleted file mode 100644 index 10ec84cee..000000000 --- a/pkg/dashboard/cache/schema.go +++ /dev/null @@ -1,229 +0,0 @@ -// 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 cache - -import ( - "fmt" - "strings" - - "github.com/hashicorp/go-memdb" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -var ( - _schema = &memdb.DBSchema{ - Tables: map[string]*memdb.TableSchema{ - "route": { - Name: "route", - Indexes: map[string]*memdb.IndexSchema{ - "id": { - Name: "id", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "ID"}, - }, - "name": { - Name: "name", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "Name"}, - AllowMissing: true, - }, - "service_id": { - Name: "service_id", - Unique: false, - Indexer: &memdb.StringFieldIndex{Field: "ServiceID"}, - AllowMissing: true, - }, - "plugin_config_id": { - Name: "plugin_config_id", - Unique: false, - Indexer: &memdb.StringFieldIndex{Field: "PluginConfigId"}, - AllowMissing: true, - }, - "label": { - Name: "label", - Unique: false, - AllowMissing: true, - Indexer: &LabelIndexer{ - LabelKeys: []string{"kind", "namespace", "name"}, - GetLabels: func(obj any) map[string]string { - service, ok := obj.(*v1.Route) - if !ok { - return nil - } - return service.Labels - }, - }, - }, - }, - }, - "service": { - Name: "service", - Indexes: map[string]*memdb.IndexSchema{ - "id": { - Name: "id", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "ID"}, - }, - "name": { - Name: "name", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "Name"}, - AllowMissing: true, - }, - "label": { - Name: "label", - Unique: false, - AllowMissing: true, - Indexer: &LabelIndexer{ - LabelKeys: []string{"kind", "namespace", "name"}, - GetLabels: func(obj any) map[string]string { - service, ok := obj.(*v1.Service) - if !ok { - return nil - } - return service.Labels - }, - }, - }, - }, - }, - "ssl": { - Name: "ssl", - Indexes: map[string]*memdb.IndexSchema{ - "id": { - Name: "id", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "ID"}, - }, - }, - }, - "stream_route": { - Name: "stream_route", - Indexes: map[string]*memdb.IndexSchema{ - "id": { - Name: "id", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "ID"}, - }, - "service_id": { - Name: "service_id", - Unique: false, - Indexer: &memdb.StringFieldIndex{Field: "ServiceID"}, - AllowMissing: true, - }, - }, - }, - "global_rule": { - Name: "global_rule", - Indexes: map[string]*memdb.IndexSchema{ - "id": { - Name: "id", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "ID"}, - }, - }, - }, - "consumer": { - Name: "consumer", - Indexes: map[string]*memdb.IndexSchema{ - "id": { - Name: "id", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "Username"}, - }, - }, - }, - "schema": { - Name: "schema", - Indexes: map[string]*memdb.IndexSchema{ - "id": { - Name: "id", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "Name"}, - }, - }, - }, - "plugin_config": { - Name: "plugin_config", - Indexes: map[string]*memdb.IndexSchema{ - "id": { - Name: "id", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "ID"}, - }, - "name": { - Name: "name", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "Name"}, - AllowMissing: true, - }, - }, - }, - "upstream_service": { - Name: "upstream_service", - Indexes: map[string]*memdb.IndexSchema{ - "id": { - Name: "id", - Unique: true, - Indexer: &memdb.StringFieldIndex{Field: "ServiceName"}, - }, - }, - }, - }, - } -) - -// LabelIndexer is a custom indexer for exact match indexing -type LabelIndexer struct { - LabelKeys []string - GetLabels func(any) map[string]string -} - -func (emi *LabelIndexer) FromObject(obj any) (bool, []byte, error) { - labels := emi.GetLabels(obj) - var labelValues []string - for _, key := range emi.LabelKeys { - if value, exists := labels[key]; exists { - labelValues = append(labelValues, value) - } - } - - if len(labelValues) == 0 { - return false, nil, nil - } - - return true, []byte(strings.Join(labelValues, "/")), nil -} - -func (emi *LabelIndexer) FromArgs(args ...any) ([]byte, error) { - if len(args) != len(emi.LabelKeys) { - return nil, fmt.Errorf("expected %d arguments, got %d", len(emi.LabelKeys), len(args)) - } - - labelValues := make([]string, 0, len(args)) - for _, arg := range args { - value, ok := arg.(string) - if !ok { - return nil, fmt.Errorf("argument is not a string") - } - labelValues = append(labelValues, value) - } - - return []byte(strings.Join(labelValues, "/")), nil -} diff --git a/pkg/dashboard/cluster.go b/pkg/dashboard/cluster.go deleted file mode 100644 index a3f8613db..000000000 --- a/pkg/dashboard/cluster.go +++ /dev/null @@ -1,1000 +0,0 @@ -// 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 dashboard - -import ( - "bytes" - "context" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strings" - "sync/atomic" - "time" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/multierr" - "go.uber.org/zap" - "k8s.io/apimachinery/pkg/util/wait" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" -) - -const ( - _defaultTimeout = 5 * time.Second - _defaultSyncInterval = 6 * time.Hour - - _cacheSyncing = iota - _cacheSynced -) - -var ( - // ErrClusterNotExist means a cluster doesn't exist. - ErrClusterNotExist = errors.New("cluster not exist") - // ErrDuplicatedCluster means the cluster adding request was - // rejected since the cluster was already created. - ErrDuplicatedCluster = errors.New("duplicated cluster") - // ErrFunctionDisabled means the APISIX function is disabled - ErrFunctionDisabled = errors.New("function disabled") - - DefaultLabelsManagedBy map[string]string = map[string]string{ - "managed-by": "apisix-ingress-controller", - } - - // ErrRouteNotFound means the [route, ssl, upstream] was not found. - ErrNotFound = cache.ErrNotFound - - errReadOnClosedResBody = errors.New("http: read on closed response body") -) - -// ClusterOptions contains parameters to customize APISIX client. -type ClusterOptions struct { - ControllerName string - AdminAPIVersion string - Name string - AdminKey string - BaseURL string - Timeout time.Duration - // SyncInterval is the interval to sync schema. - SyncComparison bool - EnableEtcdServer bool - Prefix string - ListenAddress string - SchemaSynced bool - SyncCache bool - SSLKeyEncryptSalt string - SkipTLSVerify bool - Labels map[string]string -} - -type cluster struct { - labels map[string]string - controllerName string - adminVersion string - name string - baseURL string - baseURLHost string - adminKey string - prefix string - cli *http.Client - cacheState int32 - cache cache.Cache - cacheSynced chan struct{} - cacheSyncErr error - route Route - service Service - ssl SSL - streamRoute StreamRoute - globalRules GlobalRule - consumer Consumer - plugin Plugin - schema Schema - pluginConfig PluginConfig - pluginMetadata PluginMetadata - waitforCacheSync bool - validator APISIXSchemaValidator - sslKeyEncryptSalt string -} - -func newCluster(ctx context.Context, o *ClusterOptions) (Cluster, error) { - if o.BaseURL == "" { - return nil, errors.New("empty base url") - } - if o.Timeout == time.Duration(0) { - o.Timeout = _defaultTimeout - } - o.BaseURL = strings.TrimSuffix(o.BaseURL, "/") - - u, err := url.Parse(o.BaseURL) - if err != nil { - return nil, err - } - - switch u.Scheme { - case "http": - if u.Port() == "" { - u.Host = u.Host + ":80" - } - case "https": - if u.Port() == "" { - u.Host = u.Host + ":443" - } - } - - // if the version is not v3, then fallback to v2 - adminVersion := o.AdminAPIVersion - c := &cluster{ - labels: o.Labels, - controllerName: o.ControllerName, - adminVersion: adminVersion, - name: o.Name, - baseURL: o.BaseURL, - baseURLHost: u.Host, - adminKey: o.AdminKey, - prefix: o.Prefix, - cli: &http.Client{ - Timeout: o.Timeout, - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ - Timeout: 3 * time.Second, - }).Dial, - DialContext: (&net.Dialer{ - Timeout: 3 * time.Second, - }).DialContext, - ResponseHeaderTimeout: 30 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - TLSClientConfig: &tls.Config{InsecureSkipVerify: o.SkipTLSVerify}, - }, - }, - cacheState: _cacheSyncing, // default state - cacheSynced: make(chan struct{}), - sslKeyEncryptSalt: o.SSLKeyEncryptSalt, - } - - c.route = newRouteClient(c) - c.service = newServiceClient(c) - c.ssl = newSSLClient(c) - c.streamRoute = newStreamRouteClient(c) - c.globalRules = newGlobalRuleClient(c) - c.consumer = newConsumerClient(c) - c.plugin = newPluginClient(c) - c.schema = newSchemaClient(c) - c.pluginConfig = newPluginConfigClient(c) - c.pluginMetadata = newPluginMetadataClient(c) - c.validator = newDummyValidator() - - c.cache, err = cache.NewMemDBCache() - if err != nil { - return nil, err - } - if o.SyncCache { - c.waitforCacheSync = true - go c.syncCache(ctx) - } - - return c, nil -} - -func (c *cluster) syncCache(ctx context.Context) { - log.Infow("syncing cache", zap.String("cluster", c.name)) - now := time.Now() - defer func() { - if c.cacheSyncErr == nil { - log.Infow("cache synced", - zap.String("cost_time", time.Since(now).String()), - zap.String("cluster", c.name), - ) - } else { - log.Errorw("failed to sync cache", - zap.String("cost_time", time.Since(now).String()), - zap.String("cluster", c.name), - ) - } - }() - - backoff := wait.Backoff{ - Duration: 2 * time.Second, - Factor: 1, - Steps: 5, - } - var lastSyncErr error - err := wait.ExponentialBackoff(backoff, func() (done bool, err error) { - // impossibly return: false, nil - // so can safe used - done, lastSyncErr = c.syncCacheOnce(ctx) - select { - case <-ctx.Done(): - err = context.Canceled - default: - break - } - return - }) - if err != nil { - // if ErrWaitTimeout then set lastSyncErr - c.cacheSyncErr = lastSyncErr - } - close(c.cacheSynced) - - if !atomic.CompareAndSwapInt32(&c.cacheState, _cacheSyncing, _cacheSynced) { - panic("dubious state when sync cache") - } -} - -func (c *cluster) syncCacheOnce(ctx context.Context) (bool, error) { - routes, err := c.route.List(ctx) - if err != nil { - log.Errorf("failed to list routes in APISIX: %s", err) - return false, err - } - ssl, err := c.ssl.List(ctx) - if err != nil { - log.Errorf("failed to list ssl in APISIX: %s", err) - return false, err - } - globalRules, err := c.globalRules.List(ctx) - if err != nil { - log.Errorf("failed to list global_rules in APISIX: %s", err) - return false, err - } - consumers, err := c.consumer.List(ctx) - if err != nil { - log.Errorf("failed to list consumers in APISIX: %s", err) - return false, err - } - - for _, r := range routes { - log.Debug("syncing route with labels", r.Labels) - if err := c.cache.InsertRoute(r); err != nil { - log.Errorw("failed to insert route to cache", - zap.String("route", r.ID), - zap.String("cluster", c.name), - zap.String("error", err.Error()), - ) - return false, err - } - } - for _, s := range ssl { - log.Debug("syncing ssl with labels", s.Labels) - if err := c.cache.InsertSSL(s); err != nil { - log.Errorw("failed to insert ssl to cache", - zap.String("ssl", s.ID), - zap.String("cluster", c.name), - zap.String("error", err.Error()), - ) - return false, err - } - } - for _, gr := range globalRules { - if err := c.cache.InsertGlobalRule(gr); err != nil { - log.Errorw("failed to insert global_rule to cache", - zap.Any("global_rule", gr), - zap.String("cluster", c.name), - zap.String("error", err.Error()), - ) - return false, err - } - } - for _, consumer := range consumers { - log.Debug("syncing consumer with labels", consumer.Labels) - if err := c.cache.InsertConsumer(consumer); err != nil { - log.Errorw("failed to insert consumer to cache", - zap.Any("consumer", consumer), - zap.String("cluster", c.name), - zap.String("error", err.Error()), - ) - } - } - log.Info("All cache synced successfully") - // for _, u := range pluginConfigs { - // if err := c.cache.InsertPluginConfig(u); err != nil { - // log.Errorw("failed to insert pluginConfig to cache", - // zap.String("pluginConfig", u.ID), - // zap.String("cluster", c.name), - // zap.String("error", err.Error()), - // ) - // return false, err - // } - // } - return true, nil -} - -// String implements Cluster.String method. -func (c *cluster) String() string { - return fmt.Sprintf("name=%s; base_url=%s", c.name, c.baseURL) -} - -// HasSynced implements Cluster.HasSynced method. -func (c *cluster) HasSynced(ctx context.Context) error { - if !c.waitforCacheSync { - return nil - } - if c.cacheSyncErr != nil { - return c.cacheSyncErr - } - if atomic.LoadInt32(&c.cacheState) == _cacheSynced { - return nil - } - - // still in sync - now := time.Now() - log.Warnf("waiting cluster %s to ready, it may takes a while", c.name) - select { - case <-ctx.Done(): - log.Errorf("failed to wait cluster to ready: %s", ctx.Err()) - return ctx.Err() - case <-c.cacheSynced: - if c.cacheSyncErr != nil { - // See https://github.com/apache/apisix-ingress-controller/issues/448 - // for more details. - return c.cacheSyncErr - } - log.Warnf("cluster %s now is ready, cost time %s", c.name, time.Since(now).String()) - return nil - } -} - -// Route implements Cluster.Route method. -func (c *cluster) Route() Route { - return c.route -} - -// Upstream implements Cluster.Upstream method. -func (c *cluster) Service() Service { - return c.service -} - -// SSL implements Cluster.SSL method. -func (c *cluster) SSL() SSL { - return c.ssl -} - -// StreamRoute implements Cluster.StreamRoute method. -func (c *cluster) StreamRoute() StreamRoute { - return c.streamRoute -} - -// GlobalRule implements Cluster.GlobalRule method. -func (c *cluster) GlobalRule() GlobalRule { - return c.globalRules -} - -// Consumer implements Cluster.Consumer method. -func (c *cluster) Consumer() Consumer { - return c.consumer -} - -// Plugin implements Cluster.Plugin method. -func (c *cluster) Plugin() Plugin { - return c.plugin -} - -// PluginConfig implements Cluster.PluginConfig method. -func (c *cluster) PluginConfig() PluginConfig { - return c.pluginConfig -} - -// Schema implements Cluster.Schema method. -func (c *cluster) Schema() Schema { - return c.schema -} - -func (c *cluster) PluginMetadata() PluginMetadata { - return c.pluginMetadata -} - -func (c *cluster) Validator() APISIXSchemaValidator { - return c.validator -} - -// HealthCheck implements Cluster.HealthCheck method. -// -// It checks the health of an APISIX cluster by performing a TCP socket probe -// against the baseURLHost. It will retry up to 3 times with exponential backoff -// before returning an error. -// -// Parameters: -// -// ctx: The context for the health check. -// -// Returns: -// -// err: Any error encountered while performing the health check. -func (c *cluster) HealthCheck(ctx context.Context) (err error) { - // Retry three times in a row, and exit if all of them fail. - backoff := wait.Backoff{ - Duration: 5 * time.Second, - Factor: 1, - Steps: 3, - } - - err = wait.ExponentialBackoffWithContext(ctx, backoff, func(ctx context.Context) (done bool, _ error) { - if lastCheckErr := c.healthCheck(ctx); lastCheckErr != nil { - log.Warnf("failed to check health for cluster %s: %s, will retry", c.name, lastCheckErr) - return - } - done = true - return - }) - - return err -} - -func (c *cluster) healthCheck(ctx context.Context) (err error) { - // tcp socket probe - d := net.Dialer{Timeout: 3 * time.Second} - conn, err := d.DialContext(ctx, "tcp", c.baseURLHost) - if err != nil { - return err - } - defer func(conn net.Conn) { - err := conn.Close() - if err != nil { - log.Warnw("failed to close tcp probe connection", - zap.Error(err), - zap.String("cluster", c.name), - ) - } - }(conn) - - return -} - -func (c *cluster) applyAuth(req *http.Request) { - if c.adminKey != "" { - req.Header.Set("X-API-Key", c.adminKey) - } -} - -func (c *cluster) do(req *http.Request) (*http.Response, error) { - c.applyAuth(req) - return c.cli.Do(req) -} - -func (c *cluster) isFunctionDisabled(body string) bool { - return strings.Contains(body, "is disabled") -} - -func (c *cluster) getResource(ctx context.Context, url, resource string) (*getResponse, error) { - log.Debugw("get resource in cluster", - zap.String("cluster_name", c.name), - zap.String("name", resource), - zap.String("url", url), - ) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - resp, err := c.do(req) - if err != nil { - return nil, err - } - - defer drainBody(resp.Body, url) - if resp.StatusCode != http.StatusOK { - body := readBody(resp.Body, url) - if c.isFunctionDisabled(body) { - return nil, ErrFunctionDisabled - } - if resp.StatusCode == http.StatusNotFound { - return nil, cache.ErrNotFound - } else { - err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) - err = multierr.Append(err, fmt.Errorf("error message: %s", body)) - } - return nil, err - } - - if c.adminVersion == "v3" { - var res getResponse - - dec := json.NewDecoder(resp.Body) - if err := dec.Decode(&res); err != nil { - return nil, err - } - return &res, nil - } - var res getResponse - - dec := json.NewDecoder(resp.Body) - if err := dec.Decode(&res); err != nil { - return nil, err - } - return &res, nil -} - -func addQueryParam(urlStr string, labels map[string]string) string { - parsedUrl, err := url.Parse(urlStr) - if err != nil { - return urlStr - } - query := parsedUrl.Query() - for key, value := range labels { - query.Add(fmt.Sprintf("labels[%s]", key), value) - } - parsedUrl.RawQuery = query.Encode() - return parsedUrl.String() -} - -func (c *cluster) listResource(ctx context.Context, url, resource string) (listResponse, error) { - var list listResponse - err := c.listResourceToResponse(ctx, url, resource, &list) - return list, err -} - -func (c *cluster) listResourceToResponse(ctx context.Context, url, resource string, listResponse any) error { - log.Debugw("list resource in cluster", - zap.String("cluster_name", c.name), - zap.String("name", resource), - zap.String("url", url), - ) - if c.labels != nil { - url = addQueryParam(url, c.labels) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - resp, err := c.do(req) - if err != nil { - return err - } - - defer drainBody(resp.Body, url) - if resp.StatusCode != http.StatusOK { - body := readBody(resp.Body, url) - if c.isFunctionDisabled(body) { - return ErrFunctionDisabled - } - err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) - err = multierr.Append(err, fmt.Errorf("error message: %s", body)) - return err - } - - return json.NewDecoder(resp.Body).Decode(listResponse) -} - -func (c *cluster) createResource(ctx context.Context, url, resource string, body []byte) (*getResponse, error) { - log.Debugw("creating resource in cluster", - zap.String("cluster_name", c.name), - zap.String("name", resource), - zap.String("url", url), - zap.ByteString("body", body), - ) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - resp, err := c.do(req) - if err != nil { - return nil, err - } - defer drainBody(resp.Body, url) - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - body := readBody(resp.Body, url) - if c.isFunctionDisabled(body) { - return nil, ErrFunctionDisabled - } - err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) - err = multierr.Append(err, fmt.Errorf("error message: %s", body)) - return nil, err - } - - var cr getResponse - byt, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if err := json.Unmarshal(byt, &cr); err != nil { - return nil, err - } - return &cr, nil -} - -func (c *cluster) updateResource(ctx context.Context, url, resource string, body []byte) (*getResponse, error) { - log.Debugw("updating resource in cluster", - zap.String("cluster_name", c.name), - zap.String("name", resource), - zap.String("url", url), - zap.ByteString("body", body), - ) - - req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - resp, err := c.do(req) - if err != nil { - return nil, err - } - - defer drainBody(resp.Body, url) - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - body := readBody(resp.Body, url) - log.Debugw("update response", - zap.Int("status code %d", resp.StatusCode), - zap.String("body %s", body), - ) - if c.isFunctionDisabled(body) { - return nil, ErrFunctionDisabled - } - err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) - err = multierr.Append(err, fmt.Errorf("error message: %s", body)) - return nil, err - } - if c.adminVersion == "v3" { - var ur updateResponseV3 - - dec := json.NewDecoder(resp.Body) - if err := dec.Decode(&ur); err != nil { - return nil, err - } - - return &ur, nil - } - var ur updateResponse - dec := json.NewDecoder(resp.Body) - if err := dec.Decode(&ur); err != nil { - return nil, err - } - return &ur, nil -} - -func (c *cluster) deleteResource(ctx context.Context, url, resource string) error { - log.Debugw("deleting resource in cluster", - zap.String("cluster_name", c.name), - zap.String("name", resource), - zap.String("url", url), - ) - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) - if err != nil { - return err - } - resp, err := c.do(req) - if err != nil { - return err - } - - defer drainBody(resp.Body, url) - - if resp.StatusCode != http.StatusOK && - resp.StatusCode != http.StatusNoContent && - resp.StatusCode != http.StatusNotFound { - message := readBody(resp.Body, url) - if c.isFunctionDisabled(message) { - return ErrFunctionDisabled - } - err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) - err = multierr.Append(err, fmt.Errorf("error message: %s", message)) - if strings.Contains(message, "still using") { - return cache.ErrStillInUse - } - return err - } - return nil -} - -// drainBody reads whole data until EOF from r, then close it. -func drainBody(r io.ReadCloser, url string) { - _, err := io.Copy(io.Discard, r) - if err != nil { - if err.Error() != errReadOnClosedResBody.Error() { - log.Warnw("failed to drain body (read)", - zap.String("url", url), - zap.Error(err), - ) - } - } - - if err := r.Close(); err != nil { - log.Warnw("failed to drain body (close)", - zap.String("url", url), - zap.Error(err), - ) - } -} - -func readBody(r io.ReadCloser, url string) string { - defer func() { - if err := r.Close(); err != nil { - log.Warnw("failed to close body", zap.String("url", url), zap.Error(err)) - } - }() - data, err := io.ReadAll(r) - if err != nil { - log.Warnw("failed to read body", zap.String("url", url), zap.Error(err)) - return "" - } - return string(data) -} - -// getSchema returns the schema of APISIX object. -func (c *cluster) getSchema(_ context.Context, url, resource string) (string, error) { - log.Debugw("get schema in cluster", - zap.String("url", url), - zap.String("cluster", c.name), - zap.String("resource", resource), - ) - // TODO: fixme The above passed context gets cancelled for some reason. Investigate - req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil) - if err != nil { - return "", err - } - resp, err := c.do(req) - if err != nil { - return "", err - } - - defer drainBody(resp.Body, url) - if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusNotFound { - return "", cache.ErrNotFound - } else { - err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) - err = multierr.Append(err, fmt.Errorf("error message: %s", readBody(resp.Body, url))) - } - return "", err - } - - return readBody(resp.Body, url), nil -} - -// getList returns a list of string. -func (c *cluster) getList(ctx context.Context, url, resource string) ([]string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - resp, err := c.do(req) - if err != nil { - return nil, err - } - - defer drainBody(resp.Body, url) - if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusNotFound { - return nil, cache.ErrNotFound - } else { - err = multierr.Append(err, fmt.Errorf("unexpected status code %d", resp.StatusCode)) - err = multierr.Append(err, fmt.Errorf("error message: %s", readBody(resp.Body, url))) - } - return nil, err - } - - // In EE, for plugins the response is an array of string and not an object. - // sent to /list - if resource == "plugin" { - byt, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - var listResponse []string - err = json.Unmarshal(byt, &listResponse) - if err != nil { - return nil, err - } - return listResponse, nil - } - var listResponse map[string]any - dec := json.NewDecoder(resp.Body) - if err := dec.Decode(&listResponse); err != nil { - return nil, err - } - res := make([]string, 0, len(listResponse)) - - for name := range listResponse { - res = append(res, name) - } - return res, nil -} - -func (c *cluster) GetGlobalRule(ctx context.Context, baseUrl, id string) (*v1.GlobalRule, error) { - url := baseUrl + "/" + id - resp, err := c.getResource(ctx, url, "globalRule") - if err != nil { - return nil, err - } - - globalRule, err := resp.globalRule() - if err != nil { - log.Errorw("failed to convert global_rule item", - zap.String("url", url), - zap.Error(err), - ) - return nil, err - } - - return globalRule, nil -} - -func (c *cluster) GetConsumer(ctx context.Context, baseUrl, name string) (*v1.Consumer, error) { - url := baseUrl + "/" + name - resp, err := c.getResource(ctx, url, "consumer") - if err != nil { - return nil, err - } - - consumer, err := resp.consumer() - if err != nil { - log.Errorw("failed to convert consumer item", - zap.String("url", url), - zap.Error(err), - ) - return nil, err - } - return consumer, nil -} - -func (c *cluster) GetPluginConfig(ctx context.Context, baseUrl, id string) (*v1.PluginConfig, error) { - url := baseUrl + "/" + id - resp, err := c.getResource(ctx, url, "pluginConfig") - if err != nil { - return nil, err - } - - pluginConfig, err := resp.pluginConfig() - if err != nil { - log.Errorw("failed to convert pluginConfig item", - zap.String("url", url), - zap.Error(err), - ) - return nil, err - } - return pluginConfig, nil -} - -func (c *cluster) GetRoute(ctx context.Context, baseUrl, id string) (*v1.Route, error) { - url := baseUrl + "/" + id - resp, err := c.getResource(ctx, url, "route") - if err != nil { - return nil, err - } - - route, err := resp.route() - if err != nil { - log.Errorw("failed to convert route item", - zap.String("url", url), - zap.Error(err), - ) - return nil, err - } - return route, nil -} - -func (c *cluster) GetStreamRoute(ctx context.Context, baseUrl, id string) (*v1.StreamRoute, error) { - url := baseUrl + "/" + id - resp, err := c.getResource(ctx, url, "streamRoute") - if err != nil { - return nil, err - } - - streamRoute, err := resp.streamRoute() - if err != nil { - log.Errorw("failed to convert stream_route item", - zap.String("url", url), - zap.Error(err), - ) - return nil, err - } - return streamRoute, nil -} - -func (c *cluster) GetService(ctx context.Context, baseUrl, id string) (*v1.Service, error) { - url := baseUrl + "/" + id - resp, err := c.getResource(ctx, url, "service") - if err != nil { - return nil, err - } - svc, err := resp.service() - if err != nil { - log.Errorw("failed to convert service item", - zap.String("url", url), - zap.Error(err), - ) - return nil, err - } - return svc, nil -} - -func (c *cluster) GetSSL(ctx context.Context, baseUrl, id string) (*v1.Ssl, error) { - url := baseUrl + "/" + id - resp, err := c.getResource(ctx, url, "ssl") - if err != nil { - return nil, err - } - ssl, err := resp.ssl() - if err != nil { - return nil, err - } - return ssl, nil -} - -func getFromCacheOrAPI[T any]( - ctx context.Context, - id string, - url string, - cacheGet func(string) (T, error), - cacheInsert func(T) error, - apiGet func(context.Context, string, string) (T, error), -) (T, error) { - item, err := cacheGet(id) - if err == nil { - return item, nil - } - if err != cache.ErrNotFound { - log.Errorw("failed to find in cache, will try to lookup from APISIX", - zap.Error(err), - ) - } else { - log.Debugw("not found in cache, will try to lookup from APISIX", - zap.Error(err), - ) - } - - // TODO Add mutex here to avoid dog-pile effect. - item, err = apiGet(ctx, url, id) - if err != nil { - return item, err - } - - if err := cacheInsert(item); err != nil { - log.Errorf("failed to reflect create to cache: %s", err) - return item, err - } - return item, nil -} - -func updateResource[T any]( - ctx context.Context, - obj T, - url string, - resourceType string, - apiUpdate func(context.Context, string, string, []byte) (*getResponse, error), - cacheInsert func(T) error, - parseResponse func(*getResponse) (T, error), -) (T, error) { - var val T - body, err := json.Marshal(obj) - if err != nil { - return val, err - } - resp, err := apiUpdate(ctx, url, resourceType, body) - if err != nil { - return val, err - } - val, err = parseResponse(resp) - if err != nil { - return val, err - } - if err := cacheInsert(val); err != nil { - log.Errorf("failed to reflect update to cache: %s", err) - return val, err - } - return val, nil -} diff --git a/pkg/dashboard/consumer.go b/pkg/dashboard/consumer.go deleted file mode 100644 index 12d3629a0..000000000 --- a/pkg/dashboard/consumer.go +++ /dev/null @@ -1,156 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" - "github.com/apache/apisix-ingress-controller/pkg/id" -) - -type consumerClient struct { - url string - cluster *cluster -} - -func newConsumerClient(c *cluster) Consumer { - return &consumerClient{ - url: c.baseURL + "/consumers", - cluster: c, - } -} - -// Get returns the Consumer. -// FIXME, currently if caller pass a non-existent resource, the Get always passes -// through cache. -func (r *consumerClient) Get(ctx context.Context, name string) (*v1.Consumer, error) { - return getFromCacheOrAPI( - ctx, - id.GenID(name), - r.url, - r.cluster.cache.GetConsumer, - r.cluster.cache.InsertConsumer, - r.cluster.GetConsumer, - ) -} - -// List is only used in cache warming up. So here just pass through -// to APISIX. -func (r *consumerClient) List(ctx context.Context) ([]*v1.Consumer, error) { - log.Debugw("try to list consumers in APISIX", - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - url := r.url - consumerItems, err := r.cluster.listResource(ctx, url, "consumer") - if err != nil { - log.Errorf("failed to list consumers: %s", err) - return nil, err - } - items := make([]*v1.Consumer, 0, len(consumerItems.List)) - for _, item := range consumerItems.List { - consumer, err := item.consumer() - if err != nil { - log.Errorw("failed to convert consumer item", - zap.String("url", url), - zap.Error(err), - ) - return nil, err - } - - items = append(items, consumer) - } - - return items, nil -} - -func (r *consumerClient) Create(ctx context.Context, obj *v1.Consumer) (*v1.Consumer, error) { - log.Debugw("try to create consumer", - zap.String("name", obj.Username), - zap.Any("plugins", obj.Plugins), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - - if err := r.cluster.HasSynced(ctx); err != nil { - return nil, err - } - data, err := json.Marshal(obj) - if err != nil { - return nil, err - } - - url := r.url + "/" + obj.Username - resp, err := r.cluster.createResource(ctx, url, "consumer", data) - if err != nil { - log.Errorf("failed to create consumer: %s", err) - return nil, err - } - consumer, err := resp.consumer() - if err != nil { - return nil, err - } - if err := r.cluster.cache.InsertConsumer(consumer); err != nil { - log.Errorf("failed to reflect consumer create to cache: %s", err) - return nil, err - } - return consumer, nil -} - -func (r *consumerClient) Delete(ctx context.Context, obj *v1.Consumer) error { - log.Debugw("try to delete consumer", - zap.String("name", obj.Username), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - if err := r.cluster.HasSynced(ctx); err != nil { - return err - } - url := r.url + "/" + obj.Username - if err := r.cluster.deleteResource(ctx, url, "consumer"); err != nil { - return err - } - if err := r.cluster.cache.DeleteConsumer(obj); err != nil { - log.Errorf("failed to reflect consumer delete to cache: %s", err) - if err != cache.ErrNotFound { - return err - } - } - return nil -} - -func (r *consumerClient) Update(ctx context.Context, obj *v1.Consumer) (*v1.Consumer, error) { - url := r.url + "/" + obj.Username - return updateResource( - ctx, - obj, - url, - "consumer", - r.cluster.updateResource, - r.cluster.cache.InsertConsumer, - func(resp *getResponse) (*v1.Consumer, error) { - return resp.consumer() - }, - ) -} diff --git a/pkg/dashboard/consumer_test.go b/pkg/dashboard/consumer_test.go deleted file mode 100644 index 22599920c..000000000 --- a/pkg/dashboard/consumer_test.go +++ /dev/null @@ -1,242 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "golang.org/x/net/nettest" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -type fakeAPISIXConsumerSrv struct { - consumer map[string]map[string]any -} - -type Value map[string]any - -type fakeListResp struct { - Total string `json:"total"` - List []fakeListItem `json:"list"` -} - -type fakeGetCreateResp struct { - fakeGetCreateItem -} - -type fakeGetCreateItem struct { - Value Value `json:"value"` - Key string `json:"key"` -} - -type fakeListItem Value - -func (srv *fakeAPISIXConsumerSrv) ServeHTTP(w http.ResponseWriter, r *http.Request) { - defer func() { - _ = r.Body.Close() - }() - - if !strings.HasPrefix(r.URL.Path, "/apisix/admin/consumers") { - w.WriteHeader(http.StatusNotFound) - return - } - - if r.Method == http.MethodGet { - // For individual resource, the getcreate response is sent - var key string - if strings.HasPrefix(r.URL.Path, "/apisix/admin/consumers/") && - strings.TrimPrefix(r.URL.Path, "/apisix/admin/consumers/") != "" { - key = strings.TrimPrefix(r.URL.Path, "/apisix/admin/consumers/") - } - if key != "" { - resp := fakeGetCreateResp{ - fakeGetCreateItem{ - Key: key, - Value: srv.consumer[key], - }, - } - resp.Value = srv.consumer[key] - w.WriteHeader(http.StatusOK) - data, _ := json.Marshal(resp) - _, _ = w.Write(data) - } else { - resp := fakeListResp{} - resp.Total = fmt.Sprintf("%d", len(srv.consumer)) - resp.List = make([]fakeListItem, 0, len(srv.consumer)) - for _, v := range srv.consumer { - resp.List = append(resp.List, v) - } - data, _ := json.Marshal(resp) - _, _ = w.Write(data) - } - - return - } - - if r.Method == http.MethodDelete { - id := strings.TrimPrefix(r.URL.Path, "/apisix/admin/consumers/") - id = "/apisix/admin/consumers/" + id - code := http.StatusNotFound - if _, ok := srv.consumer[id]; ok { - delete(srv.consumer, id) - code = http.StatusOK - } - w.WriteHeader(code) - } - - if r.Method == http.MethodPut { - paths := strings.Split(r.URL.Path, "/") - key := fmt.Sprintf("/apisix/admin/consumers/%s", paths[len(paths)-1]) - data, _ := io.ReadAll(r.Body) - w.WriteHeader(http.StatusCreated) - consumer := make(map[string]any, 0) - _ = json.Unmarshal(data, &consumer) - srv.consumer[key] = consumer - var val Value - _ = json.Unmarshal(data, &val) - resp := fakeGetCreateResp{ - fakeGetCreateItem{ - Value: val, - Key: key, - }, - } - data, _ = json.Marshal(resp) - _, _ = w.Write(data) - return - } - - if r.Method == http.MethodPatch { - id := strings.TrimPrefix(r.URL.Path, "/apisix/admin/consumers/") - id = "/apisix/admin/consumers/" + id - if _, ok := srv.consumer[id]; !ok { - w.WriteHeader(http.StatusNotFound) - return - } - - data, _ := io.ReadAll(r.Body) - var val Value - _ = json.Unmarshal(data, &val) - consumer := make(map[string]any, 0) - _ = json.Unmarshal(data, &consumer) - srv.consumer[id] = consumer - w.WriteHeader(http.StatusOK) - resp := fakeGetCreateResp{ - fakeGetCreateItem{ - Value: val, - Key: id, - }, - } - byt, _ := json.Marshal(resp) - _, _ = w.Write(byt) - return - } -} - -func runFakeConsumerSrv(t *testing.T) *http.Server { - srv := &fakeAPISIXConsumerSrv{ - consumer: make(map[string]map[string]any), - } - - ln, _ := nettest.NewLocalListener("tcp") - - httpSrv := &http.Server{ - Addr: ln.Addr().String(), - Handler: srv, - } - - go func() { - if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { - t.Errorf("failed to run http server: %s", err) - } - }() - - return httpSrv -} - -func TestConsumerClient(t *testing.T) { - srv := runFakeConsumerSrv(t) - defer func() { - assert.Nil(t, srv.Shutdown(context.Background())) - }() - - u := url.URL{ - Scheme: "http", - Host: srv.Addr, - Path: "/apisix/admin", - } - - closedCh := make(chan struct{}) - close(closedCh) - cli := newConsumerClient(&cluster{ - baseURL: u.String(), - cli: http.DefaultClient, - cache: &dummyCache{}, - cacheSynced: closedCh, - }) - - // Create - obj, err := cli.Create(context.Background(), &v1.Consumer{ - Username: "1", - }) - assert.Nil(t, err) - assert.Equal(t, "1", obj.Username) - - obj, err = cli.Create(context.Background(), &v1.Consumer{ - Username: "2", - }) - assert.Nil(t, err) - assert.Equal(t, "2", obj.Username) - - // List - objs, err := cli.List(context.Background()) - assert.Nil(t, err) - assert.Len(t, objs, 2) - assert.ElementsMatch(t, []string{"1", "2"}, []string{objs[0].Username, objs[1].Username}) - - // Delete then List - if objs[0].Username != "1" { - objs[0], objs[1] = objs[1], objs[0] - } - assert.Nil(t, cli.Delete(context.Background(), objs[0])) - objs, err = cli.List(context.Background()) - assert.Nil(t, err) - assert.Len(t, objs, 1) - assert.Equal(t, "2", objs[0].Username) - - // Patch then List - _, err = cli.Update(context.Background(), &v1.Consumer{ - Username: "2", - Plugins: map[string]any{ - "prometheus": struct{}{}, - }, - }) - assert.Nil(t, err) - objs, err = cli.List(context.Background()) - assert.Nil(t, err) - assert.Len(t, objs, 1) - assert.Equal(t, "2", objs[0].Username) -} diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go deleted file mode 100644 index a22dd638f..000000000 --- a/pkg/dashboard/dashboard.go +++ /dev/null @@ -1,255 +0,0 @@ -// 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 dashboard - -import ( - "context" - "sync" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -type Dashboard interface { - // Cluster specifies the target cluster to talk. - Cluster(name string) Cluster - // AddCluster adds a new cluster. - AddCluster(context.Context, *ClusterOptions) error - // UpdateCluster updates an existing cluster. - UpdateCluster(context.Context, *ClusterOptions) error - // ListClusters lists all APISIX clusters. - ListClusters() []Cluster - // DeleteCluster deletes the target APISIX cluster by its name. - DeleteCluster(name string) -} - -// Cluster defines specific operations that can be applied in an APISIX -// cluster. -type Cluster interface { - // Route returns a Route interface that can operate Route resources. - Route() Route - - Service() Service - // SSL returns a SSL interface that can operate SSL resources. - SSL() SSL - // StreamRoute returns a StreamRoute interface that can operate StreamRoute resources. - StreamRoute() StreamRoute - // GlobalRule returns a GlobalRule interface that can operate GlobalRule resources. - GlobalRule() GlobalRule - // String exposes the client information in human-readable format. - String() string - // HasSynced checks whether all resources in APISIX cluster is synced to cache. - HasSynced(context.Context) error - // Consumer returns a Consumer interface that can operate Consumer resources. - Consumer() Consumer - // HealthCheck checks apisix cluster health in realtime. - HealthCheck(context.Context) error - // Plugin returns a Plugin interface that can operate Plugin resources. - Plugin() Plugin - // PluginConfig returns a PluginConfig interface that can operate PluginConfig resources. - PluginConfig() PluginConfig - // Schema returns a Schema interface that can fetch schema of APISIX objects. - Schema() Schema - - PluginMetadata() PluginMetadata - - Validator() APISIXSchemaValidator -} - -// Route is the specific client interface to take over the create, update, -// list and delete for APISIX Route resource. -type Route interface { - Get(ctx context.Context, name string) (*v1.Route, error) - List(ctx context.Context, args ...any) ([]*v1.Route, error) - Create(ctx context.Context, route *v1.Route) (*v1.Route, error) - Delete(ctx context.Context, route *v1.Route) error - Update(ctx context.Context, route *v1.Route) (*v1.Route, error) -} - -// SSL is the specific client interface to take over the create, update, -// list and delete for APISIX SSL resource. -type SSL interface { - // name is namespace_sslname - Get(ctx context.Context, name string) (*v1.Ssl, error) - List(ctx context.Context, args ...any) ([]*v1.Ssl, error) - Create(ctx context.Context, ssl *v1.Ssl) (*v1.Ssl, error) - Delete(ctx context.Context, ssl *v1.Ssl) error - Update(ctx context.Context, ssl *v1.Ssl) (*v1.Ssl, error) -} - -// Upstream is the specific client interface to take over the create, update, -// list and delete for APISIX Upstream resource. -type Service interface { - Get(ctx context.Context, name string) (*v1.Service, error) - List(ctx context.Context, args ...any) ([]*v1.Service, error) - Create(ctx context.Context, svc *v1.Service) (*v1.Service, error) - Delete(ctx context.Context, svc *v1.Service) error - Update(ctx context.Context, svc *v1.Service) (*v1.Service, error) -} - -// StreamRoute is the specific client interface to take over the create, update, -// list and delete for APISIX Stream Route resource. -type StreamRoute interface { - Get(ctx context.Context, name string) (*v1.StreamRoute, error) - List(ctx context.Context) ([]*v1.StreamRoute, error) - Create(ctx context.Context, route *v1.StreamRoute) (*v1.StreamRoute, error) - Delete(ctx context.Context, route *v1.StreamRoute) error - Update(ctx context.Context, route *v1.StreamRoute) (*v1.StreamRoute, error) -} - -// GlobalRule is the specific client interface to take over the create, update, -// list and delete for APISIX Global Rule resource. -type GlobalRule interface { - Get(ctx context.Context, id string) (*v1.GlobalRule, error) - List(ctx context.Context) ([]*v1.GlobalRule, error) - Create(ctx context.Context, rule *v1.GlobalRule) (*v1.GlobalRule, error) - Delete(ctx context.Context, rule *v1.GlobalRule) error - Update(ctx context.Context, rule *v1.GlobalRule) (*v1.GlobalRule, error) -} - -// Consumer is the specific client interface to take over the create, update, -// list and delete for APISIX Consumer resource. -type Consumer interface { - Get(ctx context.Context, name string) (*v1.Consumer, error) - List(ctx context.Context) ([]*v1.Consumer, error) - Create(ctx context.Context, consumer *v1.Consumer) (*v1.Consumer, error) - Delete(ctx context.Context, consumer *v1.Consumer) error - Update(ctx context.Context, consumer *v1.Consumer) (*v1.Consumer, error) -} - -// Plugin is the specific client interface to fetch APISIX Plugin resource. -type Plugin interface { - List(ctx context.Context) ([]string, error) -} - -// Schema is the specific client interface to fetch the schema of APISIX objects. -type Schema interface { - GetPluginSchema(ctx context.Context, pluginName string) (*v1.Schema, error) - GetRouteSchema(ctx context.Context) (*v1.Schema, error) - GetUpstreamSchema(ctx context.Context) (*v1.Schema, error) - GetConsumerSchema(ctx context.Context) (*v1.Schema, error) - GetSslSchema(ctx context.Context) (*v1.Schema, error) - GetPluginConfigSchema(ctx context.Context) (*v1.Schema, error) -} - -// PluginConfig is the specific client interface to take over the create, update, -// list and delete for APISIX PluginConfig resource. -type PluginConfig interface { - Get(ctx context.Context, name string) (*v1.PluginConfig, error) - List(ctx context.Context) ([]*v1.PluginConfig, error) - Create(ctx context.Context, plugin *v1.PluginConfig) (*v1.PluginConfig, error) - Delete(ctx context.Context, plugin *v1.PluginConfig) error - Update(ctx context.Context, plugin *v1.PluginConfig) (*v1.PluginConfig, error) -} - -type PluginMetadata interface { - Get(ctx context.Context, name string) (*v1.PluginMetadata, error) - List(ctx context.Context) ([]*v1.PluginMetadata, error) - Delete(ctx context.Context, metadata *v1.PluginMetadata) error - Update(ctx context.Context, metadata *v1.PluginMetadata) (*v1.PluginMetadata, error) - Create(ctx context.Context, metadata *v1.PluginMetadata) (*v1.PluginMetadata, error) -} - -type APISIXSchemaValidator interface { - ValidateStreamPluginSchema(plugins v1.Plugins) (bool, error) - ValidateHTTPPluginSchema(plugins v1.Plugins) (bool, error) -} - -type apisix struct { - adminVersion string - mu sync.RWMutex - nonExistentCluster Cluster - clusters map[string]Cluster -} - -// NewClient creates an api7ee Dashboard client to perform resources change pushing. -func NewClient() (Dashboard, error) { - cli := &apisix{ - nonExistentCluster: newNonExistentCluster(), - clusters: make(map[string]Cluster), - } - return cli, nil -} - -// Cluster implements APISIX.Cluster method. -func (c *apisix) Cluster(name string) Cluster { - c.mu.RLock() - defer c.mu.RUnlock() - cluster, ok := c.clusters[name] - if !ok { - return c.nonExistentCluster - } - return cluster -} - -// ListClusters implements APISIX.ListClusters method. -func (c *apisix) ListClusters() []Cluster { - c.mu.RLock() - defer c.mu.RUnlock() - clusters := make([]Cluster, 0, len(c.clusters)) - for _, cluster := range c.clusters { - clusters = append(clusters, cluster) - } - return clusters -} - -// AddCluster implements APISIX.AddCluster method. -func (c *apisix) AddCluster(ctx context.Context, co *ClusterOptions) error { - c.mu.Lock() - defer c.mu.Unlock() - _, ok := c.clusters[co.Name] - if ok { - return ErrDuplicatedCluster - } - if co.AdminAPIVersion == "" { - co.AdminAPIVersion = c.adminVersion - } - cluster, err := newCluster(ctx, co) - if err != nil { - return err - } - c.clusters[co.Name] = cluster - return nil -} - -func (c *apisix) UpdateCluster(ctx context.Context, co *ClusterOptions) error { - c.mu.Lock() - defer c.mu.Unlock() - if _, ok := c.clusters[co.Name]; !ok { - return ErrClusterNotExist - } - - if co.AdminAPIVersion == "" { - co.AdminAPIVersion = c.adminVersion - } - cluster, err := newCluster(ctx, co) - if err != nil { - return err - } - - c.clusters[co.Name] = cluster - return nil -} - -func (c *apisix) DeleteCluster(name string) { - c.mu.Lock() - defer c.mu.Unlock() - - // Don't have to close or free some resources in that cluster, so - // just delete its index. - delete(c.clusters, name) -} diff --git a/pkg/dashboard/global_rule.go b/pkg/dashboard/global_rule.go deleted file mode 100644 index f339e26af..000000000 --- a/pkg/dashboard/global_rule.go +++ /dev/null @@ -1,170 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" -) - -type globalRuleClient struct { - url string - cluster *cluster -} - -func newGlobalRuleClient(c *cluster) GlobalRule { - return &globalRuleClient{ - url: c.baseURL + "/global_rules", - cluster: c, - } -} - -// Get returns the GlobalRule. -// FIXME, currently if caller pass a non-existent resource, the Get always passes -// through cache. -func (r *globalRuleClient) Get(ctx context.Context, id string) (*v1.GlobalRule, error) { - return getFromCacheOrAPI( - ctx, - id, - r.url, - r.cluster.cache.GetGlobalRule, - r.cluster.cache.InsertGlobalRule, - r.cluster.GetGlobalRule, - ) -} - -// List is only used in cache warming up. So here just pass through -// to APISIX. -func (r *globalRuleClient) List(ctx context.Context) ([]*v1.GlobalRule, error) { - log.Debugw("try to list global_rules in APISIX", - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - url := r.url - globalRuleItems, err := r.cluster.listResource(ctx, url, "globalRule") - if err != nil { - log.Errorf("failed to list global_rules: %s", err) - return nil, err - } - - items := make([]*v1.GlobalRule, 0, len(globalRuleItems.List)) - for _, item := range globalRuleItems.List { - globalRule, err := item.globalRule() - if err != nil { - log.Errorw("failed to convert global_rule item", - zap.String("url", r.url), - zap.Error(err), - ) - return nil, err - } - - items = append(items, globalRule) - } - - return items, nil -} - -func (r *globalRuleClient) Create(ctx context.Context, obj *v1.GlobalRule) (*v1.GlobalRule, error) { - // Overwrite global rule ID with the plugin name - if len(obj.Plugins) == 0 { // This case will not happen as its handled at schema validation level - return nil, fmt.Errorf("global rule must have at least one plugin") - } - - // This is checked on dashboard that global rule id should be the plugin name - for pluginName := range obj.Plugins { - obj.ID = pluginName - break - } - - log.Debugw("try to create global_rule", - zap.String("id", obj.ID), - zap.Any("plugins", obj.Plugins), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - - if err := r.cluster.HasSynced(ctx); err != nil { - return nil, err - } - data, err := json.Marshal(obj) - if err != nil { - return nil, err - } - - url := r.url + "/" + obj.ID - log.Debugw("creating global_rule", zap.ByteString("body", data), zap.String("url", url)) - resp, err := r.cluster.createResource(ctx, url, "globalRule", data) - if err != nil { - log.Errorf("failed to create global_rule: %s", err) - return nil, err - } - - globalRules, err := resp.globalRule() - if err != nil { - return nil, err - } - if err := r.cluster.cache.InsertGlobalRule(globalRules); err != nil { - log.Errorf("failed to reflect global_rules create to cache: %s", err) - return nil, err - } - return globalRules, nil -} - -func (r *globalRuleClient) Delete(ctx context.Context, obj *v1.GlobalRule) error { - log.Debugw("try to delete global_rule", - zap.String("id", obj.ID), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - if err := r.cluster.HasSynced(ctx); err != nil { - return err - } - url := r.url + "/" + obj.ID - if err := r.cluster.deleteResource(ctx, url, "globalRule"); err != nil { - return err - } - if err := r.cluster.cache.DeleteGlobalRule(obj); err != nil { - log.Errorf("failed to reflect global_rule delete to cache: %s", err) - if err != cache.ErrNotFound { - return err - } - } - return nil -} - -func (r *globalRuleClient) Update(ctx context.Context, obj *v1.GlobalRule) (*v1.GlobalRule, error) { - url := r.url + "/" + obj.ID - return updateResource( - ctx, - obj, - url, - "globalRule", - r.cluster.updateResource, - r.cluster.cache.InsertGlobalRule, - func(gr *getResponse) (*v1.GlobalRule, error) { - return gr.globalRule() - }, - ) -} diff --git a/pkg/dashboard/nonexistentclient.go b/pkg/dashboard/nonexistentclient.go deleted file mode 100644 index c1405a0b9..000000000 --- a/pkg/dashboard/nonexistentclient.go +++ /dev/null @@ -1,378 +0,0 @@ -// 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 dashboard - -import ( - "context" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" -) - -type nonExistentCluster struct { - embedDummyResourceImplementer -} - -func newNonExistentCluster() *nonExistentCluster { - return &nonExistentCluster{ - embedDummyResourceImplementer{ - route: &dummyRoute{}, - ssl: &dummySSL{}, - service: &dummyService{}, - streamRoute: &dummyStreamRoute{}, - globalRule: &dummyGlobalRule{}, - consumer: &dummyConsumer{}, - plugin: &dummyPlugin{}, - schema: &dummySchema{}, - pluginConfig: &dummyPluginConfig{}, - pluginMetadata: &dummyPluginMetadata{}, - }, - } -} - -type embedDummyResourceImplementer struct { - route Route - ssl SSL - service Service - streamRoute StreamRoute - globalRule GlobalRule - consumer Consumer - plugin Plugin - schema Schema - pluginConfig PluginConfig - pluginMetadata PluginMetadata - validator APISIXSchemaValidator -} - -type dummyRoute struct{} - -func (f *dummyRoute) Get(_ context.Context, _ string) (*v1.Route, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyRoute) List(_ context.Context, _ ...any) ([]*v1.Route, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyRoute) Create(_ context.Context, _ *v1.Route) (*v1.Route, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyRoute) Delete(_ context.Context, _ *v1.Route) error { - return ErrClusterNotExist -} - -func (f *dummyRoute) Update(_ context.Context, _ *v1.Route) (*v1.Route, error) { - return nil, ErrClusterNotExist -} - -type dummySSL struct{} - -func (f *dummySSL) Get(_ context.Context, _ string) (*v1.Ssl, error) { - return nil, ErrClusterNotExist -} - -func (f *dummySSL) List(_ context.Context, _ ...any) ([]*v1.Ssl, error) { - return nil, ErrClusterNotExist -} - -func (f *dummySSL) Create(_ context.Context, _ *v1.Ssl) (*v1.Ssl, error) { - return nil, ErrClusterNotExist -} - -func (f *dummySSL) Delete(_ context.Context, _ *v1.Ssl) error { - return ErrClusterNotExist -} - -func (f *dummySSL) Update(_ context.Context, _ *v1.Ssl) (*v1.Ssl, error) { - return nil, ErrClusterNotExist -} - -type dummyService struct{} - -func (f *dummyService) Get(_ context.Context, _ string) (*v1.Service, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyService) List(_ context.Context, _ ...any) ([]*v1.Service, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyService) Create(_ context.Context, _ *v1.Service) (*v1.Service, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyService) Delete(_ context.Context, _ *v1.Service) error { - return ErrClusterNotExist -} - -func (f *dummyService) Update(_ context.Context, _ *v1.Service) (*v1.Service, error) { - return nil, ErrClusterNotExist -} - -type dummyStreamRoute struct{} - -func (f *dummyStreamRoute) Get(_ context.Context, _ string) (*v1.StreamRoute, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyStreamRoute) List(_ context.Context) ([]*v1.StreamRoute, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyStreamRoute) Create(_ context.Context, _ *v1.StreamRoute) (*v1.StreamRoute, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyStreamRoute) Delete(_ context.Context, _ *v1.StreamRoute) error { - return ErrClusterNotExist -} - -func (f *dummyStreamRoute) Update(_ context.Context, _ *v1.StreamRoute) (*v1.StreamRoute, error) { - return nil, ErrClusterNotExist -} - -type dummyGlobalRule struct{} - -func (f *dummyGlobalRule) Get(_ context.Context, _ string) (*v1.GlobalRule, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyGlobalRule) List(_ context.Context) ([]*v1.GlobalRule, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyGlobalRule) Create(_ context.Context, _ *v1.GlobalRule) (*v1.GlobalRule, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyGlobalRule) Delete(_ context.Context, _ *v1.GlobalRule) error { - return ErrClusterNotExist -} - -func (f *dummyGlobalRule) Update(_ context.Context, _ *v1.GlobalRule) (*v1.GlobalRule, error) { - return nil, ErrClusterNotExist -} - -type dummyConsumer struct{} - -func (f *dummyConsumer) Get(_ context.Context, _ string) (*v1.Consumer, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyConsumer) List(_ context.Context) ([]*v1.Consumer, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyConsumer) Create(_ context.Context, _ *v1.Consumer) (*v1.Consumer, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyConsumer) Delete(_ context.Context, _ *v1.Consumer) error { - return ErrClusterNotExist -} - -func (f *dummyConsumer) Update(_ context.Context, _ *v1.Consumer) (*v1.Consumer, error) { - return nil, ErrClusterNotExist -} - -type dummyPlugin struct{} - -func (f *dummyPlugin) List(_ context.Context) ([]string, error) { - return nil, ErrClusterNotExist -} - -type dummySchema struct{} - -func (f *dummySchema) GetPluginSchema(_ context.Context, _ string) (*v1.Schema, error) { - return nil, ErrClusterNotExist -} - -func (f *dummySchema) GetRouteSchema(_ context.Context) (*v1.Schema, error) { - return nil, ErrClusterNotExist -} - -func (f *dummySchema) GetUpstreamSchema(_ context.Context) (*v1.Schema, error) { - return nil, ErrClusterNotExist -} - -func (f *dummySchema) GetConsumerSchema(_ context.Context) (*v1.Schema, error) { - return nil, ErrClusterNotExist -} - -func (f *dummySchema) GetSslSchema(_ context.Context) (*v1.Schema, error) { - return nil, ErrClusterNotExist -} - -func (f *dummySchema) GetPluginConfigSchema(_ context.Context) (*v1.Schema, error) { - return nil, ErrClusterNotExist -} - -type dummyPluginConfig struct{} - -func (f *dummyPluginConfig) Get(_ context.Context, _ string) (*v1.PluginConfig, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyPluginConfig) List(_ context.Context) ([]*v1.PluginConfig, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyPluginConfig) Create(_ context.Context, _ *v1.PluginConfig) (*v1.PluginConfig, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyPluginConfig) Delete(_ context.Context, _ *v1.PluginConfig) error { - return ErrClusterNotExist -} - -func (f *dummyPluginConfig) Update(_ context.Context, _ *v1.PluginConfig) (*v1.PluginConfig, error) { - return nil, ErrClusterNotExist -} - -type dummyPluginMetadata struct { -} - -func (f *dummyPluginMetadata) Get(_ context.Context, _ string) (*v1.PluginMetadata, error) { - return nil, ErrClusterNotExist -} - -func (f *dummyPluginMetadata) List(_ context.Context) ([]*v1.PluginMetadata, error) { - return nil, ErrClusterNotExist -} -func (f *dummyPluginMetadata) Delete(_ context.Context, _ *v1.PluginMetadata) error { - return ErrClusterNotExist -} -func (f *dummyPluginMetadata) Update(_ context.Context, _ *v1.PluginMetadata) (*v1.PluginMetadata, error) { - return nil, ErrClusterNotExist -} -func (f *dummyPluginMetadata) Create(_ context.Context, _ *v1.PluginMetadata) (*v1.PluginMetadata, error) { - return nil, ErrClusterNotExist -} - -type dummyValidator struct{} - -func newDummyValidator() APISIXSchemaValidator { - return &dummyValidator{} -} - -func (d *dummyValidator) ValidateHTTPPluginSchema(plugins v1.Plugins) (bool, error) { - return true, nil -} - -func (d *dummyValidator) ValidateStreamPluginSchema(plugins v1.Plugins) (bool, error) { - return true, nil -} - -func (nc *nonExistentCluster) Route() Route { - return nc.route -} - -func (nc *nonExistentCluster) SSL() SSL { - return nc.ssl -} - -func (nc *nonExistentCluster) Service() Service { - return nc.service -} - -func (nc *nonExistentCluster) StreamRoute() StreamRoute { - return nc.streamRoute -} - -func (nc *nonExistentCluster) GlobalRule() GlobalRule { - return nc.globalRule -} - -func (nc *nonExistentCluster) Consumer() Consumer { - return nc.consumer -} - -func (nc *nonExistentCluster) Plugin() Plugin { - return nc.plugin -} - -func (nc *nonExistentCluster) Validator() APISIXSchemaValidator { - return nc.validator -} - -func (nc *nonExistentCluster) PluginConfig() PluginConfig { - return nc.pluginConfig -} - -func (nc *nonExistentCluster) Schema() Schema { - return nc.schema -} -func (nc *nonExistentCluster) PluginMetadata() PluginMetadata { - return nc.pluginMetadata -} - -func (nc *nonExistentCluster) HasSynced(_ context.Context) error { - return nil -} - -func (nc *nonExistentCluster) HealthCheck(_ context.Context) error { - return nil -} - -func (nc *nonExistentCluster) String() string { - return "non-existent cluster" -} - -type dummyCache struct{} - -var _ cache.Cache = &dummyCache{} - -func (c *dummyCache) InsertRoute(_ *v1.Route) error { return nil } -func (c *dummyCache) InsertSSL(_ *v1.Ssl) error { return nil } -func (c *dummyCache) InsertService(_ *v1.Service) error { return nil } -func (c *dummyCache) InsertStreamRoute(_ *v1.StreamRoute) error { return nil } -func (c *dummyCache) InsertGlobalRule(_ *v1.GlobalRule) error { return nil } -func (c *dummyCache) InsertConsumer(_ *v1.Consumer) error { return nil } -func (c *dummyCache) InsertSchema(_ *v1.Schema) error { return nil } -func (c *dummyCache) InsertPluginConfig(_ *v1.PluginConfig) error { return nil } -func (c *dummyCache) GetRoute(_ string) (*v1.Route, error) { return nil, cache.ErrNotFound } -func (c *dummyCache) GetSSL(_ string) (*v1.Ssl, error) { return nil, cache.ErrNotFound } -func (c *dummyCache) GetService(_ string) (*v1.Service, error) { return nil, cache.ErrNotFound } -func (c *dummyCache) GetStreamRoute(_ string) (*v1.StreamRoute, error) { return nil, cache.ErrNotFound } -func (c *dummyCache) GetGlobalRule(_ string) (*v1.GlobalRule, error) { return nil, cache.ErrNotFound } -func (c *dummyCache) GetConsumer(_ string) (*v1.Consumer, error) { return nil, cache.ErrNotFound } -func (c *dummyCache) GetSchema(_ string) (*v1.Schema, error) { return nil, cache.ErrNotFound } -func (c *dummyCache) GetPluginConfig(_ string) (*v1.PluginConfig, error) { - return nil, cache.ErrNotFound -} - -func (c *dummyCache) ListRoutes(...any) ([]*v1.Route, error) { return nil, nil } -func (c *dummyCache) ListSSL(_ ...any) ([]*v1.Ssl, error) { return nil, nil } -func (c *dummyCache) ListServices(...any) ([]*v1.Service, error) { return nil, nil } -func (c *dummyCache) ListStreamRoutes() ([]*v1.StreamRoute, error) { return nil, nil } -func (c *dummyCache) ListGlobalRules() ([]*v1.GlobalRule, error) { return nil, nil } -func (c *dummyCache) ListConsumers() ([]*v1.Consumer, error) { return nil, nil } -func (c *dummyCache) ListSchema() ([]*v1.Schema, error) { return nil, nil } -func (c *dummyCache) ListPluginConfigs() ([]*v1.PluginConfig, error) { return nil, nil } - -func (c *dummyCache) DeleteRoute(_ *v1.Route) error { return nil } -func (c *dummyCache) DeleteSSL(_ *v1.Ssl) error { return nil } -func (c *dummyCache) DeleteService(_ *v1.Service) error { return nil } -func (c *dummyCache) DeleteStreamRoute(_ *v1.StreamRoute) error { return nil } -func (c *dummyCache) DeleteGlobalRule(_ *v1.GlobalRule) error { return nil } -func (c *dummyCache) DeleteConsumer(_ *v1.Consumer) error { return nil } -func (c *dummyCache) DeleteSchema(_ *v1.Schema) error { return nil } -func (c *dummyCache) DeletePluginConfig(_ *v1.PluginConfig) error { return nil } -func (c *dummyCache) CheckServiceReference(_ *v1.Service) error { return nil } -func (c *dummyCache) CheckPluginConfigReference(_ *v1.PluginConfig) error { return nil } diff --git a/pkg/dashboard/noop.go b/pkg/dashboard/noop.go deleted file mode 100644 index d5906bb47..000000000 --- a/pkg/dashboard/noop.go +++ /dev/null @@ -1,51 +0,0 @@ -// 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 dashboard - -import ( - "context" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -var ( - _ StreamRoute = (*noopClient)(nil) -) - -type noopClient struct { -} - -func (r *noopClient) Get(ctx context.Context, name string) (*v1.StreamRoute, error) { - return nil, nil -} - -func (r *noopClient) List(ctx context.Context) ([]*v1.StreamRoute, error) { - return nil, nil -} - -func (r *noopClient) Create(ctx context.Context, obj *v1.StreamRoute) (*v1.StreamRoute, error) { - return nil, nil -} - -func (r *noopClient) Delete(ctx context.Context, obj *v1.StreamRoute) error { - return nil -} - -func (r *noopClient) Update(ctx context.Context, obj *v1.StreamRoute) (*v1.StreamRoute, error) { - return nil, nil -} diff --git a/pkg/dashboard/plugin.go b/pkg/dashboard/plugin.go deleted file mode 100644 index cb8b8128b..000000000 --- a/pkg/dashboard/plugin.go +++ /dev/null @@ -1,53 +0,0 @@ -// 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 dashboard - -import ( - "context" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" -) - -type pluginClient struct { - url string - cluster *cluster -} - -func newPluginClient(c *cluster) Plugin { - return &pluginClient{ - url: c.baseURL + "/plugins", - cluster: c, - } -} - -// List returns the names of all plugins. -func (p *pluginClient) List(ctx context.Context) ([]string, error) { - log.Debugw("try to list plugin names in APISIX", - zap.String("cluster", p.cluster.name), - zap.String("url", p.url), - ) - url := p.url + "/list" - pluginList, err := p.cluster.getList(ctx, url, "plugin") - if err != nil { - log.Errorf("failed to list plugin names: %s", err) - return nil, err - } - log.Debugf("plugin list: %v", pluginList) - return pluginList, nil -} diff --git a/pkg/dashboard/plugin_metadata.go b/pkg/dashboard/plugin_metadata.go deleted file mode 100644 index c389bed75..000000000 --- a/pkg/dashboard/plugin_metadata.go +++ /dev/null @@ -1,154 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -type pluginMetadataClient struct { - url string - cluster *cluster -} - -func newPluginMetadataClient(c *cluster) *pluginMetadataClient { - return &pluginMetadataClient{ - url: c.baseURL + "/plugin_metadata", - cluster: c, - } -} - -func (r *pluginMetadataClient) Get(ctx context.Context, name string) (*v1.PluginMetadata, error) { - log.Debugw("try to look up pluginMetadata", - zap.String("name", name), - zap.String("url", r.url), - zap.String("cluster", r.cluster.name), - ) - - // TODO Add mutex here to avoid dog-pile effect. - url := r.url + "/" + name - resp, err := r.cluster.getResource(ctx, url, "pluginMetadata") - if err != nil { - log.Errorw("failed to get pluginMetadata from APISIX", - zap.String("name", name), - zap.String("url", url), - zap.String("cluster", r.cluster.name), - zap.Error(err), - ) - return nil, err - } - - pluginMetadata, err := resp.pluginMetadata() - if err != nil { - log.Errorw("failed to convert pluginMetadata item", - zap.String("url", r.url), - zap.Error(err), - ) - return nil, err - } - return pluginMetadata, nil -} - -func (r *pluginMetadataClient) List(ctx context.Context) (list []*v1.PluginMetadata, err error) { - log.Debugw("try to list pluginMetadatas in APISIX", - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - var resp = struct { - Value map[string]map[string]any - }{} - err = r.cluster.listResourceToResponse(ctx, r.url, "plugin_metadata", &resp) - if err != nil { - log.Errorf("failed to list pluginMetadatas: %s", err) - return nil, err - } - for name, metadata := range resp.Value { - list = append(list, &v1.PluginMetadata{ - Name: name, - Metadata: metadata, - }) - } - - return -} - -func (r *pluginMetadataClient) Delete(ctx context.Context, obj *v1.PluginMetadata) error { - log.Debugw("try to delete pluginMetadata", - zap.String("name", obj.Name), - zap.Any("metadata", obj.Metadata), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - if err := r.cluster.HasSynced(ctx); err != nil { - return err - } - url := r.url + "/" + obj.Name - if err := r.cluster.deleteResource(ctx, url, "pluginMetadata"); err != nil { - return err - } - return nil -} - -func (r *pluginMetadataClient) Update(ctx context.Context, obj *v1.PluginMetadata) (*v1.PluginMetadata, error) { - url := r.url + "/" + obj.Name - return updateResource( - ctx, - obj, - url, - "pluginMetadata", - r.cluster.updateResource, - func(obj *v1.PluginMetadata) error { - return nil - }, - func(resp *getResponse) (*v1.PluginMetadata, error) { - return resp.pluginMetadata() - }, - ) -} - -func (r *pluginMetadataClient) Create(ctx context.Context, obj *v1.PluginMetadata) (*v1.PluginMetadata, error) { - log.Debugw("try to create pluginMetadata", - zap.String("name", obj.Name), - zap.Any("metadata", obj.Metadata), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - if err := r.cluster.HasSynced(ctx); err != nil { - return nil, err - } - body, err := json.Marshal(obj.Metadata) - if err != nil { - return nil, err - } - url := r.url + "/" + obj.Name - resp, err := r.cluster.updateResource(ctx, url, "pluginMetadata", body) - if err != nil { - return nil, err - } - pluginMetadata, err := resp.pluginMetadata() - if err != nil { - return nil, err - } - return pluginMetadata, nil -} diff --git a/pkg/dashboard/plugin_test.go b/pkg/dashboard/plugin_test.go deleted file mode 100644 index 36b6b890f..000000000 --- a/pkg/dashboard/plugin_test.go +++ /dev/null @@ -1,121 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - "net/http" - "net/url" - "sort" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "golang.org/x/net/nettest" -) - -type fakeAPISIXPluginSrv struct { - plugins []string -} - -var fakePluginNames = []string{ - "plugin-1", - "plugin-2", - "plugin-3", -} - -func (srv *fakeAPISIXPluginSrv) ServeHTTP(w http.ResponseWriter, r *http.Request) { - defer func() { - _ = r.Body.Close() - }() - - if !strings.HasPrefix(r.URL.Path, "/apisix/admin/plugins") { - w.WriteHeader(http.StatusNotFound) - return - } - if strings.HasPrefix(r.URL.Path, "/apisix/admin/plugins/list") { - byt, _ := json.Marshal(fakePluginNames) - _, _ = w.Write(byt) - return - } - fakePluginsResp := make(map[string]any, len(srv.plugins)) - for _, fp := range srv.plugins { - fakePluginsResp[fp] = struct{}{} - } - - if r.Method == http.MethodGet { - data, _ := json.Marshal(fakePluginsResp) - _, _ = w.Write(data) - w.WriteHeader(http.StatusOK) - return - } -} - -func runFakePluginSrv(t *testing.T) *http.Server { - srv := &fakeAPISIXPluginSrv{ - plugins: fakePluginNames, - } - - ln, _ := nettest.NewLocalListener("tcp") - - httpSrv := &http.Server{ - Addr: ln.Addr().String(), - Handler: srv, - } - - go func() { - if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { - t.Errorf("failed to run http server: %s", err) - } - }() - - return httpSrv -} - -func TestPluginClient(t *testing.T) { - srv := runFakePluginSrv(t) - defer func() { - assert.Nil(t, srv.Shutdown(context.Background())) - }() - - u := url.URL{ - Scheme: "http", - Host: srv.Addr, - Path: "/apisix/admin", - } - - closedCh := make(chan struct{}) - close(closedCh) - cli := newPluginClient(&cluster{ - baseURL: u.String(), - cli: http.DefaultClient, - cache: &dummyCache{}, - cacheSynced: closedCh, - }) - - // List - objs, err := cli.List(context.Background()) - assert.Nil(t, err) - assert.Len(t, objs, len(fakePluginNames)) - sort.Strings(fakePluginNames) - sort.Strings(objs) - for i := range fakePluginNames { - assert.Equal(t, fakePluginNames[i], objs[i]) - } -} diff --git a/pkg/dashboard/pluginconfig.go b/pkg/dashboard/pluginconfig.go deleted file mode 100644 index f846c595f..000000000 --- a/pkg/dashboard/pluginconfig.go +++ /dev/null @@ -1,164 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" - "github.com/apache/apisix-ingress-controller/pkg/id" -) - -type pluginConfigClient struct { - url string - cluster *cluster -} - -func newPluginConfigClient(c *cluster) PluginConfig { - return &pluginConfigClient{ - url: c.baseURL + "/plugin_configs", - cluster: c, - } -} - -// Get returns the v1.PluginConfig. -// FIXME, currently if caller pass a non-existent resource, the Get always passes -// through cache. -func (pc *pluginConfigClient) Get(ctx context.Context, name string) (*v1.PluginConfig, error) { - return getFromCacheOrAPI( - ctx, - id.GenID(name), - pc.url, - pc.cluster.cache.GetPluginConfig, - pc.cluster.cache.InsertPluginConfig, - pc.cluster.GetPluginConfig, - ) -} - -// List is only used in cache warming up. So here just pass through -// to APISIX. -func (pc *pluginConfigClient) List(ctx context.Context) ([]*v1.PluginConfig, error) { - log.Debugw("try to list pluginConfig in APISIX", - zap.String("cluster", pc.cluster.name), - zap.String("url", pc.url), - ) - pluginConfigItems, err := pc.cluster.listResource(ctx, pc.url, "pluginConfig") - if err != nil { - log.Errorf("failed to list pluginConfig: %s", err) - return nil, err - } - - items := make([]*v1.PluginConfig, 0, len(pluginConfigItems.List)) - for _, item := range pluginConfigItems.List { - pluginConfig, err := item.pluginConfig() - if err != nil { - log.Errorw("failed to convert pluginConfig item", - zap.String("url", pc.url), - zap.Error(err), - ) - return nil, err - } - - items = append(items, pluginConfig) - } - - return items, nil -} - -func (pc *pluginConfigClient) Create(ctx context.Context, obj *v1.PluginConfig) (*v1.PluginConfig, error) { - log.Debugw("try to create pluginConfig", - zap.String("name", obj.Name), - zap.Any("plugins", obj.Plugins), - zap.String("cluster", pc.cluster.name), - zap.String("url", pc.url), - ) - - if err := pc.cluster.HasSynced(ctx); err != nil { - return nil, err - } - data, err := json.Marshal(obj) - if err != nil { - return nil, err - } - - url := pc.url + "/" + obj.ID - log.Debugw("creating pluginConfig", zap.ByteString("body", data), zap.String("url", url)) - resp, err := pc.cluster.createResource(ctx, url, "pluginConfig", data) - if err != nil { - log.Errorf("failed to create pluginConfig: %s", err) - return nil, err - } - - pluginConfig, err := resp.pluginConfig() - if err != nil { - return nil, err - } - if err := pc.cluster.cache.InsertPluginConfig(pluginConfig); err != nil { - log.Errorf("failed to reflect pluginConfig create to cache: %s", err) - return nil, err - } - return pluginConfig, nil -} - -func (pc *pluginConfigClient) Delete(ctx context.Context, obj *v1.PluginConfig) error { - log.Debugw("try to delete pluginConfig", - zap.String("id", obj.ID), - zap.String("name", obj.Name), - zap.String("cluster", pc.cluster.name), - zap.String("url", pc.url), - ) - err := pc.cluster.cache.CheckPluginConfigReference(obj) - if err != nil { - log.Warnw("deletion for plugin config: " + obj.Name + " aborted as it is still in use.") - return err - } - if err := pc.cluster.HasSynced(ctx); err != nil { - return err - } - url := pc.url + "/" + obj.ID - if err := pc.cluster.deleteResource(ctx, url, "pluginConfig"); err != nil { - return err - } - if err := pc.cluster.cache.DeletePluginConfig(obj); err != nil { - log.Errorf("failed to reflect pluginConfig delete to cache: %s", err) - if err != cache.ErrNotFound { - return err - } - } - return nil -} - -func (pc *pluginConfigClient) Update(ctx context.Context, obj *v1.PluginConfig) (*v1.PluginConfig, error) { - url := pc.url + "/" + obj.ID - return updateResource( - ctx, - obj, - url, - "pluginConfig", - pc.cluster.updateResource, - pc.cluster.cache.InsertPluginConfig, - func(resp *getResponse) (*v1.PluginConfig, error) { - return resp.pluginConfig() - }, - ) -} diff --git a/pkg/dashboard/resource.go b/pkg/dashboard/resource.go deleted file mode 100644 index 972d9c694..000000000 --- a/pkg/dashboard/resource.go +++ /dev/null @@ -1,386 +0,0 @@ -// 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 dashboard - -import ( - "encoding/json" - "strconv" - "strings" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -type getResponse struct { - Key string `json:"key"` - Value map[string]any `json:"value"` -} - -type listResponse struct { - Total IntOrString `json:"total"` - List listItems `json:"list"` -} - -type listItems []listItem - -type listItem map[string]any - -// IntOrString processing number and string types, after json deserialization will output int -type IntOrString struct { - IntValue int `json:"int_value"` -} - -func (ios *IntOrString) UnmarshalJSON(p []byte) error { - result := strings.Trim(string(p), "\"") - count, err := strconv.Atoi(result) - if err != nil { - return err - } - ios.IntValue = count - return nil -} - -type updateResponse = getResponse - -type updateResponseV3 = getResponse - -// type node struct { -// Key string `json:"key"` -// Items items `json:"nodes"` -// } - -// type items []item - -// // UnmarshalJSON implements json.Unmarshaler interface. -// // lua-cjson doesn't distinguish empty array and table, -// // and by default empty array will be encoded as '{}'. -// // We have to maintain the compatibility. -// func (items *items) UnmarshalJSON(p []byte) error { -// if p[0] == '{' { -// if len(p) != 2 { -// return errors.New("unexpected non-empty object") -// } -// return nil -// } -// var data []item -// if err := json.Unmarshal(p, &data); err != nil { -// return err -// } -// *items = data -// return nil -// } - -// type item struct { -// Key string `json:"key"` -// Value json.RawMessage `json:"value"` -// } - -// // route decodes item.Value and converts it to v1.Route. -// func (i *item) route() (*v1.Route, error) { -// log.Debugf("got route: %s", string(i.Value)) -// list := strings.Split(i.Key, "/") -// if len(list) < 1 { -// return nil, fmt.Errorf("bad route config key: %s", i.Key) -// } - -// var route v1.Route -// if err := json.Unmarshal(i.Value, &route); err != nil { -// return nil, err -// } -// return &route, nil -// } - -func (i *getResponse) route() (*v1.Route, error) { - byt, err := json.Marshal(i.Value) - if err != nil { - return nil, err - } - var route v1.Route - if err := json.Unmarshal(byt, &route); err != nil { - return nil, err - } - return &route, nil -} - -func (i *listItem) route() (*v1.Route, error) { - byt, err := json.Marshal(i) - if err != nil { - return nil, err - } - var route v1.Route - if err := json.Unmarshal(byt, &route); err != nil { - return nil, err - } - return &route, nil -} - -func (i *listItem) streamRoute() (*v1.StreamRoute, error) { - byt, err := json.Marshal(i) - if err != nil { - return nil, err - } - var streamRoute v1.StreamRoute - if err := json.Unmarshal(byt, &streamRoute); err != nil { - return nil, err - } - return &streamRoute, nil -} - -func (i *getResponse) streamRoute() (*v1.StreamRoute, error) { - byt, err := json.Marshal(i.Value) - if err != nil { - return nil, err - } - var streamRoute v1.StreamRoute - if err := json.Unmarshal(byt, &streamRoute); err != nil { - return nil, err - } - return &streamRoute, nil -} - -// upstream decodes item.Value and converts it to v1.Upstream. -// func (i *item) upstream() (*v1.Upstream, error) { -// log.Debugf("got upstream: %s", string(i.Value)) -// list := strings.Split(i.Key, "/") -// if len(list) < 1 { -// return nil, fmt.Errorf("bad upstream config key: %s", i.Key) -// } - -// var ups v1.Upstream -// if err := json.Unmarshal(i.Value, &ups); err != nil { -// return nil, err -// } - -// // This is a workaround scheme to avoid APISIX's -// // health check schema about the health checker intervals. -// if ups.Checks != nil && ups.Checks.Active != nil { -// if ups.Checks.Active.Healthy.Interval == 0 { -// ups.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) -// } -// if ups.Checks.Active.Unhealthy.Interval == 0 { -// ups.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) -// } -// } -// return &ups, nil -// } - -// upstream decodes response and converts it to v1.Upstream. -func (i *getResponse) service() (*v1.Service, error) { - byt, err := json.Marshal(i.Value) - if err != nil { - return nil, err - } - var svc v1.Service - if err := json.Unmarshal(byt, &svc); err != nil { - return nil, err - } - ups := svc.Upstream - ups.ID = svc.ID - // This is a workaround scheme to avoid APISIX's - // health check schema about the health checker intervals. - if ups.Checks != nil && ups.Checks.Active != nil { - if ups.Checks.Active.Healthy.Interval == 0 { - ups.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) - } - if ups.Checks.Active.Unhealthy.Interval == 0 { - ups.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) - } - } - svc.Upstream = ups - return &svc, nil -} - -func (i *listItem) service() (*v1.Service, error) { - byt, err := json.Marshal(i) - if err != nil { - return nil, err - } - var svc v1.Service - if err := json.Unmarshal(byt, &svc); err != nil { - return nil, err - } - // This is a workaround scheme to avoid APISIX's - // health check schema about the health checker intervals. - if svc.Upstream.Checks != nil && svc.Upstream.Checks.Active != nil { - if svc.Upstream.Checks.Active.Healthy.Interval == 0 { - svc.Upstream.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) - } - if svc.Upstream.Checks.Active.Unhealthy.Interval == 0 { - svc.Upstream.Checks.Active.Healthy.Interval = int(v1.ActiveHealthCheckMinInterval.Seconds()) - } - } - return &svc, nil -} - -// ssl decodes item.Value and converts it to v1.Ssl. -// func (i *item) ssl() (*v1.Ssl, error) { -// log.Debugf("got ssl: %s", string(i.Value)) -// var ssl v1.Ssl -// if err := json.Unmarshal(i.Value, &ssl); err != nil { -// return nil, err -// } -// return &ssl, nil -// } - -func (i *getResponse) ssl() (*v1.Ssl, error) { - byt, err := json.Marshal(i.Value) - if err != nil { - return nil, err - } - var ssl v1.Ssl - if err := json.Unmarshal(byt, &ssl); err != nil { - return nil, err - } - return &ssl, nil -} - -func (i *listItem) ssl() (*v1.Ssl, error) { - byt, err := json.Marshal(i) - if err != nil { - return nil, err - } - var ssl v1.Ssl - if err := json.Unmarshal(byt, &ssl); err != nil { - return nil, err - } - return &ssl, nil -} - -// globalRule decodes item.Value and converts it to v1.GlobalRule. -// func (i *item) globalRule() (*v1.GlobalRule, error) { -// log.Debugf("got global_rule: %s", string(i.Value)) -// var globalRule v1.GlobalRule -// if err := json.Unmarshal(i.Value, &globalRule); err != nil { -// return nil, err -// } -// return &globalRule, nil -// } - -func (i *getResponse) globalRule() (*v1.GlobalRule, error) { - byt, err := json.Marshal(i.Value) - if err != nil { - return nil, err - } - var globalRule v1.GlobalRule - if err := json.Unmarshal(byt, &globalRule); err != nil { - return nil, err - } - return &globalRule, nil -} - -func (i *listItem) globalRule() (*v1.GlobalRule, error) { - byt, err := json.Marshal(i) - if err != nil { - return nil, err - } - var globalRule v1.GlobalRule - if err := json.Unmarshal(byt, &globalRule); err != nil { - return nil, err - } - return &globalRule, nil -} - -// consumer decodes item.Value and converts it to v1.Consumer. -// func (i *item) consumer() (*v1.Consumer, error) { -// log.Debugf("got consumer: %s", string(i.Value)) -// var consumer v1.Consumer -// if err := json.Unmarshal(i.Value, &consumer); err != nil { -// return nil, err -// } -// return &consumer, nil -// } - -func (i *getResponse) consumer() (*v1.Consumer, error) { - byt, err := json.Marshal(i.Value) - if err != nil { - return nil, err - } - var consumer v1.Consumer - if err := json.Unmarshal(byt, &consumer); err != nil { - return nil, err - } - return &consumer, nil -} - -func (i *listItem) consumer() (*v1.Consumer, error) { - byt, err := json.Marshal(i) - if err != nil { - return nil, err - } - var consumer v1.Consumer - if err := json.Unmarshal(byt, &consumer); err != nil { - return nil, err - } - return &consumer, nil -} - -// func (i *item) pluginMetadata() (*v1.PluginMetadata, error) { -// log.Debugf("got pluginMetadata: %s", string(i.Value)) -// var pluginMetadata v1.PluginMetadata -// if err := json.Unmarshal(i.Value, &pluginMetadata.Metadata); err != nil { -// return nil, err -// } -// keys := strings.Split(i.Key, "/") -// pluginMetadata.Name = keys[len(keys)-1] -// return &pluginMetadata, nil -// } - -func (i *getResponse) pluginMetadata() (*v1.PluginMetadata, error) { - byt, err := json.Marshal(i.Value) - if err != nil { - return nil, err - } - var pluginMetadata v1.PluginMetadata - if err := json.Unmarshal(byt, &pluginMetadata.Metadata); err != nil { - return nil, err - } - return &pluginMetadata, nil -} - -// // pluginConfig decodes item.Value and converts it to v1.PluginConfig. -// func (i *item) pluginConfig() (*v1.PluginConfig, error) { -// log.Debugf("got pluginConfig: %s", string(i.Value)) -// var pluginConfig v1.PluginConfig -// if err := json.Unmarshal(i.Value, &pluginConfig); err != nil { -// return nil, err -// } -// return &pluginConfig, nil -// } - -func (i *getResponse) pluginConfig() (*v1.PluginConfig, error) { - byt, err := json.Marshal(i.Value) - if err != nil { - return nil, err - } - var pluginConfig v1.PluginConfig - if err := json.Unmarshal(byt, &pluginConfig); err != nil { - return nil, err - } - return &pluginConfig, nil -} - -func (i *listItem) pluginConfig() (*v1.PluginConfig, error) { - byt, err := json.Marshal(i) - if err != nil { - return nil, err - } - var pluginConfig v1.PluginConfig - if err := json.Unmarshal(byt, &pluginConfig); err != nil { - return nil, err - } - return &pluginConfig, nil -} diff --git a/pkg/dashboard/route.go b/pkg/dashboard/route.go deleted file mode 100644 index bea628310..000000000 --- a/pkg/dashboard/route.go +++ /dev/null @@ -1,159 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" - "github.com/apache/apisix-ingress-controller/pkg/id" -) - -type routeClient struct { - url string - cluster *cluster -} - -func newRouteClient(c *cluster) Route { - return &routeClient{ - url: c.baseURL + "/routes", - cluster: c, - } -} - -// Get returns the Route. -// FIXME, currently if caller pass a non-existent resource, the Get always passes -// through cache. -func (r *routeClient) Get(ctx context.Context, name string) (*v1.Route, error) { - return getFromCacheOrAPI( - ctx, - id.GenID(name), - r.url, - r.cluster.cache.GetRoute, - r.cluster.cache.InsertRoute, - r.cluster.GetRoute, - ) -} - -// List is only used in cache warming up. So here just pass through -// to APISIX. -func (r *routeClient) List(ctx context.Context, args ...any) ([]*v1.Route, error) { - log.Debugw("try to list routes in APISIX", - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - routeItems, err := r.cluster.listResource(ctx, r.url, "route") - if err != nil { - log.Errorf("failed to list routes: %s", err) - return nil, err - } - - items := make([]*v1.Route, 0, len(routeItems.List)) - for _, item := range routeItems.List { - route, err := item.route() - if err != nil { - log.Errorw("failed to convert route item", - zap.String("url", r.url), - zap.Error(err), - ) - return nil, err - } - - items = append(items, route) - } - - return items, nil -} - -func (r *routeClient) Create(ctx context.Context, obj *v1.Route) (*v1.Route, error) { - obj.Name = obj.ID - log.Debugw("try to create route", - zap.Strings("hosts", obj.Hosts), - zap.String("name", obj.Name), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - - if err := r.cluster.HasSynced(ctx); err != nil { - return nil, err - } - data, err := json.Marshal(obj) - if err != nil { - return nil, err - } - - url := r.url + "/" + obj.ID - resp, err := r.cluster.createResource(ctx, url, "route", data) - if err != nil { - log.Errorf("failed to create route: %s", err) - return nil, err - } - - route, err := resp.route() - if err != nil { - return nil, err - } - if err := r.cluster.cache.InsertRoute(route); err != nil { - log.Errorf("failed to reflect route create to cache: %s", err) - return nil, err - } - return route, nil -} - -func (r *routeClient) Delete(ctx context.Context, obj *v1.Route) error { - log.Debugw("try to delete route", - zap.String("id", obj.ID), - zap.String("name", obj.Name), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - if err := r.cluster.HasSynced(ctx); err != nil { - return err - } - url := r.url + "/" + obj.ID - if err := r.cluster.deleteResource(ctx, url, "route"); err != nil { - return err - } - if err := r.cluster.cache.DeleteRoute(obj); err != nil { - log.Errorf("failed to reflect route delete to cache: %s", err) - if err != cache.ErrNotFound { - return err - } - } - return nil -} - -func (r *routeClient) Update(ctx context.Context, obj *v1.Route) (*v1.Route, error) { - url := r.url + "/" + obj.ID - return updateResource( - ctx, - obj, - url, - "route", - r.cluster.updateResource, - r.cluster.cache.InsertRoute, - func(resp *getResponse) (*v1.Route, error) { - return resp.route() - }, - ) -} diff --git a/pkg/dashboard/schema.go b/pkg/dashboard/schema.go deleted file mode 100644 index 5b82ef466..000000000 --- a/pkg/dashboard/schema.go +++ /dev/null @@ -1,119 +0,0 @@ -// 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 dashboard - -import ( - "context" - "strings" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" - "github.com/apache/apisix-ingress-controller/pkg/id" -) - -type schemaClient struct { - url string - cluster *cluster -} - -func newSchemaClient(c *cluster) Schema { - return &schemaClient{ - url: c.baseURL + "/schema", - cluster: c, - } -} - -// GetSchema returns APISIX object's schema. -func (sc schemaClient) getSchema(ctx context.Context, name string) (*v1.Schema, error) { - log.Debugw("try to look up schema", - zap.String("name", name), - zap.String("url", sc.url), - zap.String("cluster", sc.cluster.name), - ) - - sid := id.GenID(name) - schema, err := sc.cluster.cache.GetSchema(sid) - if err == nil { - return schema, nil - } - if err == cache.ErrNotFound { - log.Debugw("failed to find schema in cache, will try to lookup from APISIX", - zap.String("name", name), - zap.Error(err), - ) - } else { - log.Errorw("failed to find schema in cache, will try to lookup from APISIX", - zap.String("name", name), - zap.Error(err), - ) - } - // Dashboard uses /apisix/admin/plugins/{plugin_name} instead of /apisix/admin/schema/{plugin_name} to get schema - url := strings.Replace(sc.url, "schema", name, 1) - content, err := sc.cluster.getSchema(ctx, url, "schema") - if err != nil { - log.Errorw("failed to get schema from APISIX", - zap.String("name", name), - zap.String("url", url), - zap.String("cluster", sc.cluster.name), - zap.Error(err), - ) - return nil, err - } - schema = &v1.Schema{ - Name: name, - Content: content, - } - if err := sc.cluster.cache.InsertSchema(schema); err != nil { - log.Errorf("failed to reflect schema create to cache: %s", err) - return nil, err - } - return schema, nil -} - -// GetPluginSchema returns plugin's schema. -func (sc schemaClient) GetPluginSchema(ctx context.Context, pluginName string) (*v1.Schema, error) { - return sc.getSchema(ctx, "plugins/"+pluginName) -} - -// GetRouteSchema returns route's schema. -func (sc schemaClient) GetRouteSchema(ctx context.Context) (*v1.Schema, error) { - return sc.getSchema(ctx, "route") -} - -// GetUpstreamSchema returns upstream's schema. -func (sc schemaClient) GetUpstreamSchema(ctx context.Context) (*v1.Schema, error) { - return sc.getSchema(ctx, "upstream") -} - -// GetConsumerSchema returns consumer's schema. -func (sc schemaClient) GetConsumerSchema(ctx context.Context) (*v1.Schema, error) { - return sc.getSchema(ctx, "consumer") -} - -// GetSslSchema returns SSL's schema. -func (sc schemaClient) GetSslSchema(ctx context.Context) (*v1.Schema, error) { - return sc.getSchema(ctx, "ssl") -} - -// GetPluginConfigSchema returns PluginConfig's schema. -func (sc schemaClient) GetPluginConfigSchema(ctx context.Context) (*v1.Schema, error) { - return sc.getSchema(ctx, "pluginConfig") -} diff --git a/pkg/dashboard/service.go b/pkg/dashboard/service.go deleted file mode 100644 index 565c5e821..000000000 --- a/pkg/dashboard/service.go +++ /dev/null @@ -1,192 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" - "github.com/apache/apisix-ingress-controller/pkg/id" -) - -type serviceClient struct { - url string - cluster *cluster -} - -func newServiceClient(c *cluster) Service { - return &serviceClient{ - url: c.baseURL + "/services", - cluster: c, - } -} - -func (u *serviceClient) Get(ctx context.Context, name string) (*v1.Service, error) { - return getFromCacheOrAPI( - ctx, - id.GenID(name), - u.url, - u.cluster.cache.GetService, - u.cluster.cache.InsertService, - u.cluster.GetService, - ) -} - -type ListFrom string - -var ( - ListFromCache ListFrom = "cache" - ListFromRemote ListFrom = "remote" -) - -type ListOptions struct { - From ListFrom - KindLabel ListByKindLabelOptions -} - -type ListByKindLabelOptions struct { - Kind string - Namespace string - Name string -} - -// List is only used in cache warming up. So here just pass through -// to APISIX. -func (u *serviceClient) List(ctx context.Context, listOptions ...any) ([]*v1.Service, error) { - var options ListOptions - if len(listOptions) > 0 { - options = listOptions[0].(ListOptions) - } - - if options.From == ListFromCache { - log.Debugw("try to list services in cache", - zap.String("cluster", u.cluster.name), - zap.String("url", u.url), - ) - return u.cluster.cache.ListServices("label", - options.KindLabel.Kind, - options.KindLabel.Namespace, - options.KindLabel.Name) - } - - log.Debugw("try to list upstreams in APISIX", - zap.String("url", u.url), - zap.String("cluster", u.cluster.name), - ) - upsItems, err := u.cluster.listResource(ctx, u.url, "service") - if err != nil { - log.Errorf("failed to list upstreams: %s", err) - return nil, err - } - - items := make([]*v1.Service, 0, len(upsItems.List)) - for _, item := range upsItems.List { - ups, err := item.service() - if err != nil { - log.Errorw("failed to convert upstream item", - zap.String("url", u.url), - zap.Error(err), - ) - return nil, err - } - items = append(items, ups) - } - return items, nil -} - -func (u *serviceClient) Create(ctx context.Context, obj *v1.Service) (*v1.Service, error) { - log.Debugw("try to create upstream", - zap.String("name", obj.Name), - zap.String("url", u.url), - zap.String("cluster", u.cluster.name), - ) - - if err := u.cluster.HasSynced(ctx); err != nil { - return nil, err - } - serviceObj := *obj - body, err := json.Marshal(serviceObj) - if err != nil { - return nil, err - } - url := u.url + "/" + obj.ID - log.Debugw("creating service", zap.ByteString("body", body), zap.String("url", url)) - resp, err := u.cluster.createResource(ctx, url, "service", body) - if err != nil { - log.Errorf("failed to create upstream: %s", err) - return nil, err - } - ups, err := resp.service() - if err != nil { - return nil, err - } - if err := u.cluster.cache.InsertService(ups); err != nil { - log.Errorf("failed to reflect upstream create to cache: %s", err) - return nil, err - } - return ups, err -} - -func (u *serviceClient) Delete(ctx context.Context, obj *v1.Service) error { - log.Debugw("try to delete upstream", - zap.String("id", obj.ID), - zap.String("name", obj.Name), - zap.String("cluster", u.cluster.name), - zap.String("url", u.url), - ) - err := u.cluster.cache.CheckServiceReference(obj) - if err != nil { - log.Warnw("deletion for upstream: " + obj.Name + " aborted as it is still in use.") - return err - } - if err := u.cluster.HasSynced(ctx); err != nil { - return err - } - url := u.url + "/" + obj.ID - if err := u.cluster.deleteResource(ctx, url, "service"); err != nil { - return err - } - if err := u.cluster.cache.DeleteService(obj); err != nil { - log.Errorf("failed to reflect upstream delete to cache: %s", err.Error()) - if err != cache.ErrNotFound { - return err - } - } - return nil -} - -func (u *serviceClient) Update(ctx context.Context, obj *v1.Service) (*v1.Service, error) { - url := u.url + "/" + obj.ID - log.Debugw("try to update service", zap.Any("service", obj), zap.String("url", url)) - return updateResource( - ctx, - obj, - url, - "service", - u.cluster.updateResource, - u.cluster.cache.InsertService, - func(resp *getResponse) (*v1.Service, error) { - return resp.service() - }, - ) -} diff --git a/pkg/dashboard/ssl.go b/pkg/dashboard/ssl.go deleted file mode 100644 index b60c2485b..000000000 --- a/pkg/dashboard/ssl.go +++ /dev/null @@ -1,170 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" - "github.com/apache/apisix-ingress-controller/pkg/id" -) - -type sslClient struct { - url string - cluster *cluster -} - -func newSSLClient(c *cluster) SSL { - return &sslClient{ - url: c.baseURL + "/ssls", - cluster: c, - } -} - -// name is namespace_sslname -func (s *sslClient) Get(ctx context.Context, name string) (*v1.Ssl, error) { - return getFromCacheOrAPI( - ctx, - id.GenID(name), - s.url, - s.cluster.cache.GetSSL, - s.cluster.cache.InsertSSL, - s.cluster.GetSSL, - ) -} - -// List is only used in cache warming up. So here just pass through -// to APISIX. -func (s *sslClient) List(ctx context.Context, listOptions ...any) ([]*v1.Ssl, error) { - var options ListOptions - if len(listOptions) > 0 { - options = listOptions[0].(ListOptions) - } - if options.From == ListFromCache { - log.Debugw("try to list ssls in cache", - zap.String("cluster", s.cluster.name), - zap.String("url", s.url), - ) - return s.cluster.cache.ListSSL( - "label", - options.KindLabel.Kind, - options.KindLabel.Namespace, - options.KindLabel.Name, - ) - } - log.Debugw("try to list ssl in APISIX", - zap.String("url", s.url), - zap.String("cluster", s.cluster.name), - ) - url := s.url - sslItems, err := s.cluster.listResource(ctx, url, "ssls") - if err != nil { - log.Errorf("failed to list ssl: %s", err) - return nil, err - } - - items := make([]*v1.Ssl, 0, len(sslItems.List)) - for _, item := range sslItems.List { - ssl, err := item.ssl() - if err != nil { - log.Errorw("failed to convert ssl item", - zap.String("url", url), - zap.Error(err), - ) - return nil, err - } - - items = append(items, ssl) - } - - return items, nil -} - -func (s *sslClient) Create(ctx context.Context, obj *v1.Ssl) (*v1.Ssl, error) { - log.Debugw("try to create ssl", - zap.String("cluster", s.cluster.name), - zap.String("url", s.url), - zap.String("id", obj.ID), - ) - if err := s.cluster.HasSynced(ctx); err != nil { - return nil, err - } - data, err := json.Marshal(obj) - if err != nil { - return nil, err - } - url := s.url + "/" + obj.ID - log.Debugw("creating ssl", zap.ByteString("body", data), zap.String("url", url)) - resp, err := s.cluster.createResource(ctx, url, "ssls", data) - if err != nil { - log.Errorf("failed to create ssl: %s", err) - return nil, err - } - - ssl, err := resp.ssl() - if err != nil { - return nil, err - } - if err := s.cluster.cache.InsertSSL(ssl); err != nil { - log.Errorf("failed to reflect ssl create to cache: %s", err) - return nil, err - } - return ssl, nil -} - -func (s *sslClient) Delete(ctx context.Context, obj *v1.Ssl) error { - log.Debugw("try to delete ssl", - zap.String("id", obj.ID), - zap.String("cluster", s.cluster.name), - zap.String("url", s.url), - ) - if err := s.cluster.HasSynced(ctx); err != nil { - return err - } - url := s.url + "/" + obj.ID - if err := s.cluster.deleteResource(ctx, url, "ssls"); err != nil { - return err - } - if err := s.cluster.cache.DeleteSSL(obj); err != nil { - log.Errorf("failed to reflect ssl delete to cache: %s", err) - if err != cache.ErrNotFound { - return err - } - } - return nil -} - -func (s *sslClient) Update(ctx context.Context, obj *v1.Ssl) (*v1.Ssl, error) { - url := s.url + "/" + obj.ID - return updateResource( - ctx, - obj, - url, - "ssls", - s.cluster.updateResource, - s.cluster.cache.InsertSSL, - func(resp *getResponse) (*v1.Ssl, error) { - return resp.ssl() - }, - ) -} diff --git a/pkg/dashboard/stream_route.go b/pkg/dashboard/stream_route.go deleted file mode 100644 index e54e06841..000000000 --- a/pkg/dashboard/stream_route.go +++ /dev/null @@ -1,164 +0,0 @@ -// 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 dashboard - -import ( - "context" - "encoding/json" - - "github.com/api7/gopkg/pkg/log" - "go.uber.org/zap" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" - "github.com/apache/apisix-ingress-controller/pkg/dashboard/cache" - "github.com/apache/apisix-ingress-controller/pkg/id" -) - -type streamRouteClient struct { - url string - cluster *cluster -} - -func newStreamRouteClient(c *cluster) StreamRoute { - url := c.baseURL + "/stream_routes" - _, err := c.listResource(context.Background(), url, "streamRoute") - if err == ErrFunctionDisabled { - log.Infow("resource stream_routes is disabled") - return &noopClient{} - } - return &streamRouteClient{ - url: url, - cluster: c, - } -} - -// Get returns the StreamRoute. -// FIXME, currently if caller pass a non-existent resource, the Get always passes -// through cache. -func (r *streamRouteClient) Get(ctx context.Context, name string) (*v1.StreamRoute, error) { - return getFromCacheOrAPI( - ctx, - id.GenID(name), - r.url, - r.cluster.cache.GetStreamRoute, - r.cluster.cache.InsertStreamRoute, - r.cluster.GetStreamRoute, - ) -} - -// List is only used in cache warming up. So here just pass through -// to APISIX. -func (r *streamRouteClient) List(ctx context.Context) ([]*v1.StreamRoute, error) { - log.Debugw("try to list stream_routes in APISIX", - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - streamRouteItems, err := r.cluster.listResource(ctx, r.url, "streamRoute") - if err != nil { - log.Errorf("failed to list stream_routes: %s", err) - return nil, err - } - - items := make([]*v1.StreamRoute, 0, len(streamRouteItems.List)) - for _, item := range streamRouteItems.List { - streamRoute, err := item.streamRoute() - if err != nil { - log.Errorw("failed to convert stream_route item", - zap.String("url", r.url), - zap.Error(err), - ) - return nil, err - } - - items = append(items, streamRoute) - } - return items, nil -} - -func (r *streamRouteClient) Create(ctx context.Context, obj *v1.StreamRoute) (*v1.StreamRoute, error) { - log.Debugw("try to create stream_route", - zap.String("id", obj.ID), - zap.Int32("server_port", obj.ServerPort), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - zap.String("sni", obj.SNI), - ) - - if err := r.cluster.HasSynced(ctx); err != nil { - return nil, err - } - data, err := json.Marshal(obj) - if err != nil { - return nil, err - } - - url := r.url + "/" + obj.ID - log.Infow("creating stream_route", zap.ByteString("body", data), zap.String("url", url)) - resp, err := r.cluster.createResource(ctx, url, "streamRoute", data) - if err != nil { - log.Errorf("failed to create stream_route: %s", err) - return nil, err - } - - streamRoute, err := resp.streamRoute() - if err != nil { - return nil, err - } - if err := r.cluster.cache.InsertStreamRoute(streamRoute); err != nil { - log.Errorf("failed to reflect stream_route create to cache: %s", err) - return nil, err - } - return streamRoute, nil -} - -func (r *streamRouteClient) Delete(ctx context.Context, obj *v1.StreamRoute) error { - log.Debugw("try to delete stream_route", - zap.String("id", obj.ID), - zap.String("cluster", r.cluster.name), - zap.String("url", r.url), - ) - if err := r.cluster.HasSynced(ctx); err != nil { - return err - } - url := r.url + "/" + obj.ID - if err := r.cluster.deleteResource(ctx, url, "streamRoute"); err != nil { - return err - } - if err := r.cluster.cache.DeleteStreamRoute(obj); err != nil { - log.Errorf("failed to reflect stream_route delete to cache: %s", err) - if err != cache.ErrNotFound { - return err - } - } - return nil -} - -func (r *streamRouteClient) Update(ctx context.Context, obj *v1.StreamRoute) (*v1.StreamRoute, error) { - url := r.url + "/" + obj.ID - return updateResource( - ctx, - obj, - url, - "streamRoute", - r.cluster.updateResource, - r.cluster.cache.InsertStreamRoute, - func(resp *getResponse) (*v1.StreamRoute, error) { - return resp.streamRoute() - }, - ) -} diff --git a/pkg/dashboard/utils.go b/pkg/dashboard/utils.go deleted file mode 100644 index 38fd09aef..000000000 --- a/pkg/dashboard/utils.go +++ /dev/null @@ -1,85 +0,0 @@ -// 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 dashboard - -import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "encoding/base64" - "errors" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -var ( - ErrUnknownApisixResourceType = errors.New("unknown apisix resource type") -) - -type ResourceTypes interface { - *v1.Route | *v1.Ssl | *v1.Service | *v1.StreamRoute | *v1.GlobalRule | *v1.Consumer | *v1.PluginConfig -} - -func PKCS5Padding(plaintext []byte, blockSize int) []byte { - padding := blockSize - len(plaintext)%blockSize - padtext := bytes.Repeat([]byte{byte(padding)}, padding) - return append(plaintext, padtext...) -} - -func PKCS5UnPadding(origData []byte) []byte { - length := len(origData) - unpadding := int(origData[length-1]) - return origData[:(length - unpadding)] -} - -func AesEncrypt(origData, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - blockSize := block.BlockSize() - origData = PKCS5Padding(origData, blockSize) - blockMode := cipher.NewCBCEncrypter(block, key[:blockSize]) - crypted := make([]byte, len(origData)) - blockMode.CryptBlocks(crypted, origData) - return crypted, nil -} - -func AesDecrypt(crypted, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - blockSize := block.BlockSize() - blockMode := cipher.NewCBCDecrypter(block, key[:blockSize]) - origData := make([]byte, len(crypted)) - blockMode.CryptBlocks(origData, crypted) - origData = PKCS5UnPadding(origData) - return origData, nil -} - -func AesEencryptPrivatekey(data []byte, aeskey []byte) (string, error) { - xcode, err := AesEncrypt(data, aeskey) - if err != nil { - return "", err - } - - return base64.StdEncoding.EncodeToString(xcode), nil -} diff --git a/pkg/dashboard/validator.go b/pkg/dashboard/validator.go deleted file mode 100644 index dbd3e9c01..000000000 --- a/pkg/dashboard/validator.go +++ /dev/null @@ -1,136 +0,0 @@ -// 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 dashboard - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/hashicorp/go-multierror" - "github.com/xeipuuv/gojsonschema" - - v1 "github.com/apache/apisix-ingress-controller/api/dashboard/v1" -) - -type APISIXSchema struct { - Plugins map[string]SchemaPlugin `json:"plugins"` - StreamPlugins map[string]SchemaPlugin `json:"stream_plugins"` -} - -type SchemaPlugin struct { - SchemaContent any `json:"schema"` -} - -type PluginSchemaDef map[string]gojsonschema.JSONLoader - -type apisixSchemaReferenceValidator struct { - StreamPlugins PluginSchemaDef - HTTPPlugins PluginSchemaDef -} - -func NewReferenceFile(source string) (APISIXSchemaValidator, error) { - data, err := os.ReadFile(source) - if err != nil { - return nil, fmt.Errorf("error reading file: %w", err) - } - - var schemadef APISIXSchema - err = json.Unmarshal(data, &schemadef) - if err != nil { - return nil, fmt.Errorf("error parsing JSON: %w", err) - } - - validator := &apisixSchemaReferenceValidator{ - HTTPPlugins: make(PluginSchemaDef), - StreamPlugins: make(PluginSchemaDef), - } - - for _, plugin := range []struct { - name string - schema map[string]SchemaPlugin - }{ - {name: "HTTPPlugins", schema: schemadef.Plugins}, - {name: "StreamPlugins", schema: schemadef.StreamPlugins}, - } { - for k, v := range plugin.schema { - switch plugin.name { - case "HTTPPlugins": - validator.HTTPPlugins[k] = gojsonschema.NewGoLoader(v.SchemaContent) - case "StreamPlugins": - validator.StreamPlugins[k] = gojsonschema.NewGoLoader(v.SchemaContent) - } - } - } - - return validator, nil -} - -func (asv *apisixSchemaReferenceValidator) ValidateHTTPPluginSchema(plugins v1.Plugins) (bool, error) { - var resultErrs error - - for pluginName, pluginConfig := range plugins { - schema, ok := asv.HTTPPlugins[pluginName] - if !ok { - return false, fmt.Errorf("unknown plugin [%s]", pluginName) - } - result, err := gojsonschema.Validate(schema, gojsonschema.NewGoLoader(pluginConfig)) - if err != nil { - return false, err - } - - if result.Valid() { - continue - } - - resultErrs = multierror.Append(resultErrs, fmt.Errorf("plugin [%s] config is invalid", pluginName)) - for _, desc := range result.Errors() { - resultErrs = multierror.Append(resultErrs, fmt.Errorf("- %s", desc)) - } - return false, resultErrs - } - - return true, nil -} - -func (asv *apisixSchemaReferenceValidator) ValidateStreamPluginSchema(plugins v1.Plugins) (bool, error) { - var resultErrs error - - for pluginName, pluginConfig := range plugins { - schema, ok := asv.StreamPlugins[pluginName] - if !ok { - return false, fmt.Errorf("unknown stream plugin [%s]", pluginName) - } - result, err := gojsonschema.Validate(schema, gojsonschema.NewGoLoader(pluginConfig)) - if err != nil { - return false, err - } - - if result.Valid() { - continue - } - - resultErrs = multierror.Append(resultErrs, fmt.Errorf("stream plugin [%s] config is invalid", pluginName)) - for _, desc := range result.Errors() { - resultErrs = multierror.Append(resultErrs, fmt.Errorf("- %s", desc)) - } - return false, resultErrs - } - - return true, nil -} From 9686a47363711cbbd23ce15a3abba78ec1e70215 Mon Sep 17 00:00:00 2001 From: rongxin Date: Fri, 4 Jul 2025 11:46:05 +0800 Subject: [PATCH 3/3] patch docs --- docs/concepts.md | 27 - docs/configure.md | 30 - docs/crd/api.md | 1587 -------------------------------- docs/crd/config.yaml | 30 - docs/gateway-api.md | 82 -- docs/quickstart.md | 60 -- docs/template/gv_details.tpl | 57 -- docs/template/gv_list.tpl | 40 - docs/template/type.tpl | 61 -- docs/template/type_members.tpl | 28 - docs/upgrade-guide.md | 171 ---- 11 files changed, 2173 deletions(-) delete mode 100644 docs/concepts.md delete mode 100644 docs/configure.md delete mode 100644 docs/crd/api.md delete mode 100644 docs/crd/config.yaml delete mode 100644 docs/gateway-api.md delete mode 100644 docs/quickstart.md delete mode 100644 docs/template/gv_details.tpl delete mode 100644 docs/template/gv_list.tpl delete mode 100644 docs/template/type.tpl delete mode 100644 docs/template/type_members.tpl delete mode 100644 docs/upgrade-guide.md diff --git a/docs/concepts.md b/docs/concepts.md deleted file mode 100644 index c320753a6..000000000 --- a/docs/concepts.md +++ /dev/null @@ -1,27 +0,0 @@ -# Concepts - -The APISIX Ingress Controller is used to manage the APISIX Gateway as either a standalone application or a Kubernetes-based application. It dynamically configures and manages the API7 Gateway using Gateway API resources. - -## Architecture - -![APISIX Ingress Controller Architecture](./assets/images/api7-ingress-controller-architecture.png) - -## Kubernetes Resources - -### Service - -In Kubernetes, a Service is a method to expose network applications running on a set of Pods as network services. - -When proxying ingress traffic, APISIX Gateway by default directs traffic directly to the Pods instead of through kube-proxy. - -### EndpointSlicea - -EndpointSlice objects represent subsets (slices) of backend network endpoints for a Service. - -The APISIX Ingress Controller continuously tracks matching EndpointSlice objects, and whenever the set of Pods in a Service changes, the set of Pods proxied by the APISIX Gateway will also update accordingly. - -## Gateway API - -Gateway API is an official Kubernetes project focused on L4 and L7 routing in Kubernetes. This project represents the next generation of Kubernetes Ingress, Load Balancing, and Service Mesh APIs. - -For more information on supporting Gateway API, please refer to [Gateway API](./gateway-api.md). diff --git a/docs/configure.md b/docs/configure.md deleted file mode 100644 index 06c69d5da..000000000 --- a/docs/configure.md +++ /dev/null @@ -1,30 +0,0 @@ -# Configure - -The APISIX Ingress Controller is a Kubernetes Ingress Controller that implements the Gateway API. This document describes how to configure the APISIX Ingress Controller. - -## Example - -```yaml -log_level: "info" # The log level of the APISIX Ingress Controller. - # the default value is "info". - -controller_name: apisix.apache.org/apisix-ingress-controller # The controller name of the APISIX Ingress Controller, - # which is used to identify the controller in the GatewayClass. - # The default value is "apisix.apache.org/apisix-ingress-controller". - -leader_election_id: "apisix-ingress-controller-leader" # The leader election ID for the APISIX Ingress Controller. - # The default value is "apisix-ingress-controller-leader". -``` - -### Controller Name - -The `controller_name` field is used to identify the `controllerName` in the GatewayClass. - -```yaml -apiVersion: gateway.networking.k8s.io/v1 -kind: GatewayClass -metadata: - name: apisix -spec: - controllerName: "apisix.apache.org/apisix-ingress-controller" -``` diff --git a/docs/crd/api.md b/docs/crd/api.md deleted file mode 100644 index eb1a1bea9..000000000 --- a/docs/crd/api.md +++ /dev/null @@ -1,1587 +0,0 @@ ---- -title: Custom Resource Definitions API Reference -slug: /reference/apisix-ingress-controller/crd-reference -description: Explore detailed reference documentation for the custom resource definitions (CRDs) supported by the APISIX Ingress Controller. ---- - -This document provides the API resource description the API7 Ingress Controller custom resource definitions (CRDs). - -## Packages -- [apisix.apache.org/v1alpha1](#apisixapacheorgv1alpha1) -- [apisix.apache.org/v2](#apisixapacheorgv2) - - -## apisix.apache.org/v1alpha1 - -Package v1alpha1 contains API Schema definitions for the apisix.apache.org v1alpha1 API group - -- [BackendTrafficPolicy](#backendtrafficpolicy) -- [Consumer](#consumer) -- [GatewayProxy](#gatewayproxy) -- [HTTPRoutePolicy](#httproutepolicy) -- [PluginConfig](#pluginconfig) -### BackendTrafficPolicy - - - - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` -| `kind` _string_ | `BackendTrafficPolicy` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[BackendTrafficPolicySpec](#backendtrafficpolicyspec)_ | BackendTrafficPolicySpec defines traffic handling policies applied to backend services, such as load balancing strategy, connection settings, and failover behavior. | - - - -### Consumer - - - - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` -| `kind` _string_ | `Consumer` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[ConsumerSpec](#consumerspec)_ | ConsumerSpec defines the configuration for a consumer, including consumer name, authentication credentials, and plugin settings. | - - - -### GatewayProxy - - -GatewayProxy is the Schema for the gatewayproxies API. - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` -| `kind` _string_ | `GatewayProxy` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[GatewayProxySpec](#gatewayproxyspec)_ | GatewayProxySpec defines the desired state and configuration of a GatewayProxy, including networking settings, global plugins, and plugin metadata. | - - - -### HTTPRoutePolicy - - -HTTPRoutePolicy is the Schema for the httproutepolicies API. - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` -| `kind` _string_ | `HTTPRoutePolicy` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[HTTPRoutePolicySpec](#httproutepolicyspec)_ | HTTPRoutePolicySpec defines the desired state and configuration of a HTTPRoutePolicy, including route priority and request matching conditions. | - - - -### PluginConfig - - -PluginConfig is the Schema for the PluginConfigs API. - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v1alpha1` -| `kind` _string_ | `PluginConfig` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[PluginConfigSpec](#pluginconfigspec)_ | PluginConfigSpec defines the desired state of a PluginConfig, in which plugins and their configurations are specified. | - - - -### Types - -In this section you will find types that the CRDs rely on. -#### AdminKeyAuth - - -AdminKeyAuth defines the admin key authentication configuration. - - - -| Field | Description | -| --- | --- | -| `value` _string_ | Value sets the admin key value explicitly (not recommended for production). | -| `valueFrom` _[AdminKeyValueFrom](#adminkeyvaluefrom)_ | ValueFrom specifies the source of the admin key. | - - -_Appears in:_ -- [ControlPlaneAuth](#controlplaneauth) - -#### AdminKeyValueFrom - - -AdminKeyValueFrom defines the source of the admin key. - - - -| Field | Description | -| --- | --- | -| `secretKeyRef` _[SecretKeySelector](#secretkeyselector)_ | SecretKeyRef references a key in a Secret. | - - -_Appears in:_ -- [AdminKeyAuth](#adminkeyauth) - -#### AuthType -_Base type:_ `string` - -AuthType defines the type of authentication. - - - - - -_Appears in:_ -- [ControlPlaneAuth](#controlplaneauth) - -#### BackendPolicyTargetReferenceWithSectionName -_Base type:_ `LocalPolicyTargetReferenceWithSectionName` - - - - - -| Field | Description | -| --- | --- | -| `group` _[Group](#group)_ | Group is the group of the target resource. | -| `kind` _[Kind](#kind)_ | Kind is kind of the target resource. | -| `name` _[ObjectName](#objectname)_ | Name is the name of the target resource. | -| `sectionName` _[SectionName](#sectionname)_ | SectionName is the name of a section within the target resource. When unspecified, this targetRef targets the entire resource. In the following resources, SectionName is interpreted as the following:

• Gateway: Listener name
• HTTPRoute: HTTPRouteRule name
• Service: Port name

If a SectionName is specified, but does not exist on the targeted object, the Policy must fail to attach, and the policy implementation should record a `ResolvedRefs` or similar Condition in the Policy's status. | - - -_Appears in:_ -- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) - -#### BackendTrafficPolicySpec - - - - - - -| Field | Description | -| --- | --- | -| `targetRefs` _[BackendPolicyTargetReferenceWithSectionName](#backendpolicytargetreferencewithsectionname) array_ | TargetRef identifies an API object to apply policy to. Currently, Backends (i.e. Service, ServiceImport, or any implementation-specific backendRef) are the only valid API target references. | -| `loadbalancer` _[LoadBalancer](#loadbalancer)_ | LoadBalancer represents the load balancer configuration for Kubernetes Service. The default strategy is round robin. | -| `scheme` _string_ | Scheme is the protocol used to communicate with the upstream. Default is `http`. Can be one of `http`, `https`, `grpc`, or `grpcs`. | -| `retries` _integer_ | Retries specify the number of times the gateway should retry sending requests when errors such as timeouts or 502 errors occur. | -| `timeout` _[Timeout](#timeout)_ | Timeout sets the read, send, and connect timeouts to the upstream. | -| `passHost` _string_ | PassHost configures how the host header should be determined when a request is forwarded to the upstream. Default is `pass`. Can be one of `pass`, `node` or `rewrite`. | -| `upstreamHost` _[Hostname](#hostname)_ | UpstreamHost specifies the host of the Upstream request. Used only if passHost is set to `rewrite`. | - - -_Appears in:_ -- [BackendTrafficPolicy](#backendtrafficpolicy) - -#### ConsumerSpec - - - - - - -| Field | Description | -| --- | --- | -| `gatewayRef` _[GatewayRef](#gatewayref)_ | GatewayRef specifies the gateway details. | -| `credentials` _[Credential](#credential) array_ | Credentials specifies the credential details of a consumer. | -| `plugins` _[Plugin](#plugin) array_ | Plugins define the plugins associated with a consumer. | - - -_Appears in:_ -- [Consumer](#consumer) - - - -#### ControlPlaneAuth - - -ControlPlaneAuth defines the authentication configuration for control plane. - - - -| Field | Description | -| --- | --- | -| `type` _[AuthType](#authtype)_ | Type specifies the type of authentication. Can only be `AdminKey`. | -| `adminKey` _[AdminKeyAuth](#adminkeyauth)_ | AdminKey specifies the admin key authentication configuration. | - - -_Appears in:_ -- [ControlPlaneProvider](#controlplaneprovider) - -#### ControlPlaneProvider - - -ControlPlaneProvider defines the configuration for control plane provider. - - - -| Field | Description | -| --- | --- | -| `endpoints` _string array_ | Endpoints specifies the list of control plane endpoints. | -| `service` _[ProviderService](#providerservice)_ | | -| `tlsVerify` _boolean_ | TlsVerify specifies whether to verify the TLS certificate of the control plane. | -| `auth` _[ControlPlaneAuth](#controlplaneauth)_ | Auth specifies the authentication configurations. | - - -_Appears in:_ -- [GatewayProxyProvider](#gatewayproxyprovider) - -#### Credential - - - - - - -| Field | Description | -| --- | --- | -| `type` _string_ | Type specifies the type of authentication to configure credentials for. Can be one of `jwt-auth`, `basic-auth`, `key-auth`, or `hmac-auth`. | -| `config` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io)_ | Config specifies the credential details for authentication. | -| `secretRef` _[SecretReference](#secretreference)_ | SecretRef references to the Secret that contains the credentials. | -| `name` _string_ | Name is the name of the credential. | - - -_Appears in:_ -- [ConsumerSpec](#consumerspec) - -#### GatewayProxyPlugin - - -GatewayProxyPlugin contains plugin configurations. - - - -| Field | Description | -| --- | --- | -| `name` _string_ | Name is the name of the plugin. | -| `enabled` _boolean_ | Enabled defines whether the plugin is enabled. | -| `config` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io)_ | Config defines the plugin's configuration details. | - - -_Appears in:_ -- [GatewayProxySpec](#gatewayproxyspec) - -#### GatewayProxyProvider - - -GatewayProxyProvider defines the provider configuration for GatewayProxy. - - - -| Field | Description | -| --- | --- | -| `type` _[ProviderType](#providertype)_ | Type specifies the type of provider. Can only be `ControlPlane`. | -| `controlPlane` _[ControlPlaneProvider](#controlplaneprovider)_ | ControlPlane specifies the configuration for control plane provider. | - - -_Appears in:_ -- [GatewayProxySpec](#gatewayproxyspec) - -#### GatewayProxySpec - - -GatewayProxySpec defines the desired state of GatewayProxy. - - - -| Field | Description | -| --- | --- | -| `publishService` _string_ | PublishService specifies the LoadBalancer-type Service whose external address the controller uses to update the status of Ingress resources. | -| `statusAddress` _string array_ | StatusAddress specifies the external IP addresses that the controller uses to populate the status field of GatewayProxy or Ingress resources for developers to access. | -| `provider` _[GatewayProxyProvider](#gatewayproxyprovider)_ | Provider configures the provider details. | -| `plugins` _[GatewayProxyPlugin](#gatewayproxyplugin) array_ | Plugins configure global plugins. | -| `pluginMetadata` _object (keys:string, values:[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io))_ | PluginMetadata configures common configurations shared by all plugin instances of the same name. | - - -_Appears in:_ -- [GatewayProxy](#gatewayproxy) - -#### GatewayRef - - - - - - -| Field | Description | -| --- | --- | -| `name` _string_ | Name is the name of the gateway. | -| `kind` _string_ | Kind is the type of Kubernetes object. Default is `Gateway`. | -| `group` _string_ | Group is the API group the resource belongs to. Default is `gateway.networking.k8s.io`. | -| `namespace` _string_ | Namespace is namespace of the resource. | - - -_Appears in:_ -- [ConsumerSpec](#consumerspec) - -#### HTTPRoutePolicySpec - - -HTTPRoutePolicySpec defines the desired state of HTTPRoutePolicy. - - - -| Field | Description | -| --- | --- | -| `targetRefs` _LocalPolicyTargetReferenceWithSectionName array_ | TargetRef identifies an API object (i.e. HTTPRoute, Ingress) to apply HTTPRoutePolicy to. | -| `priority` _integer_ | Priority sets the priority for route. A higher value sets a higher priority in route matching. | -| `vars` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io) array_ | Vars sets the request matching conditions. | - - -_Appears in:_ -- [HTTPRoutePolicy](#httproutepolicy) - -#### Hostname -_Base type:_ `string` - - - - - - - -_Appears in:_ -- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) - -#### LoadBalancer - - -LoadBalancer describes the load balancing parameters. - - - -| Field | Description | -| --- | --- | -| `type` _string_ | Type specifies the load balancing algorithms. Default is `roundrobin`. Can be one of `roundrobin`, `chash`, `ewma`, or `least_conn`. | -| `hashOn` _string_ | HashOn specified the type of field used for hashing, required when Type is `chash`. Default is `vars`. Can be one of `vars`, `header`, `cookie`, `consumer`, or `vars_combinations`. | -| `key` _string_ | Key is used with HashOn, generally required when Type is `chash`. When HashOn is `header` or `cookie`, specifies the name of the header or cookie. When HashOn is `consumer`, key is not required, as the consumer name is used automatically. When HashOn is `vars` or `vars_combinations`, key refers to one or a combination of [built-in variables](/enterprise/reference/built-in-variables). | - - -_Appears in:_ -- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) - -#### Plugin - - - - - - -| Field | Description | -| --- | --- | -| `name` _string_ | Name is the name of the plugin. | -| `config` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io)_ | Config is plugin configuration details. | - - -_Appears in:_ -- [ConsumerSpec](#consumerspec) -- [PluginConfigSpec](#pluginconfigspec) - -#### PluginConfigSpec - - -PluginConfigSpec defines the desired state of PluginConfig. - - - -| Field | Description | -| --- | --- | -| `plugins` _[Plugin](#plugin) array_ | Plugins are an array of plugins and their configurations to be applied. | - - -_Appears in:_ -- [PluginConfig](#pluginconfig) - - - -#### ProviderService - - - - - - -| Field | Description | -| --- | --- | -| `name` _string_ | | -| `port` _integer_ | | - - -_Appears in:_ -- [ControlPlaneProvider](#controlplaneprovider) - -#### ProviderType -_Base type:_ `string` - -ProviderType defines the type of provider. - - - - - -_Appears in:_ -- [GatewayProxyProvider](#gatewayproxyprovider) - -#### SecretKeySelector - - -SecretKeySelector defines a reference to a specific key within a Secret. - - - -| Field | Description | -| --- | --- | -| `name` _string_ | Name is the name of the secret. | -| `key` _string_ | Key is the key in the secret to retrieve the secret from. | - - -_Appears in:_ -- [AdminKeyValueFrom](#adminkeyvaluefrom) - -#### SecretReference - - - - - - -| Field | Description | -| --- | --- | -| `name` _string_ | Name is the name of the secret. | -| `namespace` _string_ | Namespace is the namespace of the secret. | - - -_Appears in:_ -- [Credential](#credential) - -#### Status - - - - - - -| Field | Description | -| --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#condition-v1-meta) array_ | | - - -_Appears in:_ -- [ConsumerStatus](#consumerstatus) - -#### Timeout - - - - - - -| Field | Description | -| --- | --- | -| `connect` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Connection timeout. Default is `60s`. | -| `send` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Send timeout. Default is `60s`. | -| `read` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Read timeout. Default is `60s`. | - - -_Appears in:_ -- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) - - -## apisix.apache.org/v2 - -Package v2 contains API Schema definitions for the apisix.apache.org v2 API group. - -- [ApisixConsumer](#apisixconsumer) -- [ApisixGlobalRule](#apisixglobalrule) -- [ApisixPluginConfig](#apisixpluginconfig) -- [ApisixRoute](#apisixroute) -- [ApisixTls](#apisixtls) -- [ApisixUpstream](#apisixupstream) -### ApisixConsumer - - -ApisixConsumer is the Schema for the apisixconsumers API. - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v2` -| `kind` _string_ | `ApisixConsumer` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[ApisixConsumerSpec](#apisixconsumerspec)_ | | - - - -### ApisixGlobalRule - - -ApisixGlobalRule is the Schema for the apisixglobalrules API. - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v2` -| `kind` _string_ | `ApisixGlobalRule` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[ApisixGlobalRuleSpec](#apisixglobalrulespec)_ | | - - - -### ApisixPluginConfig - - -ApisixPluginConfig is the Schema for the apisixpluginconfigs API. - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v2` -| `kind` _string_ | `ApisixPluginConfig` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[ApisixPluginConfigSpec](#apisixpluginconfigspec)_ | | - - - -### ApisixRoute - - -ApisixRoute is the Schema for the apisixroutes API. - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v2` -| `kind` _string_ | `ApisixRoute` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[ApisixRouteSpec](#apisixroutespec)_ | | - - - -### ApisixTls - - -ApisixTls is the Schema for the apisixtls API. - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v2` -| `kind` _string_ | `ApisixTls` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[ApisixTlsSpec](#apisixtlsspec)_ | | - - - -### ApisixUpstream - - -ApisixUpstream is the Schema for the apisixupstreams API. - - - -| Field | Description | -| --- | --- | -| `apiVersion` _string_ | `apisix.apache.org/v2` -| `kind` _string_ | `ApisixUpstream` -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Please refer to the Kubernetes API documentation for details on the `metadata` field. | -| `spec` _[ApisixUpstreamSpec](#apisixupstreamspec)_ | | - - - -### Types - -In this section you will find types that the CRDs rely on. -#### ActiveHealthCheck - - -ActiveHealthCheck defines the active kind of upstream health check. - - - -| Field | Description | -| --- | --- | -| `type` _string_ | | -| `timeout` _[Duration](#duration)_ | | -| `concurrency` _integer_ | | -| `host` _string_ | | -| `port` _integer_ | | -| `httpPath` _string_ | | -| `strictTLS` _boolean_ | | -| `requestHeaders` _string array_ | | -| `healthy` _[ActiveHealthCheckHealthy](#activehealthcheckhealthy)_ | | -| `unhealthy` _[ActiveHealthCheckUnhealthy](#activehealthcheckunhealthy)_ | | - - -_Appears in:_ -- [HealthCheck](#healthcheck) - -#### ActiveHealthCheckHealthy - - -ActiveHealthCheckHealthy defines the conditions to judge whether -an upstream node is healthy with the active manner. - - - -| Field | Description | -| --- | --- | -| `httpCodes` _integer array_ | | -| `successes` _integer_ | | -| `interval` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | - - -_Appears in:_ -- [ActiveHealthCheck](#activehealthcheck) - -#### ActiveHealthCheckUnhealthy - - -ActiveHealthCheckUnhealthy defines the conditions to judge whether -an upstream node is unhealthy with the active manager. - - - -| Field | Description | -| --- | --- | -| `httpCodes` _integer array_ | | -| `httpFailures` _integer_ | | -| `tcpFailures` _integer_ | | -| `timeout` _integer_ | | -| `interval` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | - - -_Appears in:_ -- [ActiveHealthCheck](#activehealthcheck) - -#### ApisixConsumerAuthParameter - - - - - - -| Field | Description | -| --- | --- | -| `basicAuth` _[ApisixConsumerBasicAuth](#apisixconsumerbasicauth)_ | | -| `keyAuth` _[ApisixConsumerKeyAuth](#apisixconsumerkeyauth)_ | | -| `wolfRBAC` _[ApisixConsumerWolfRBAC](#apisixconsumerwolfrbac)_ | | -| `jwtAuth` _[ApisixConsumerJwtAuth](#apisixconsumerjwtauth)_ | | -| `hmacAuth` _[ApisixConsumerHMACAuth](#apisixconsumerhmacauth)_ | | -| `ldapAuth` _[ApisixConsumerLDAPAuth](#apisixconsumerldapauth)_ | | - - -_Appears in:_ -- [ApisixConsumerSpec](#apisixconsumerspec) - -#### ApisixConsumerBasicAuth - - -ApisixConsumerBasicAuth defines the configuration for basic auth. - - - -| Field | Description | -| --- | --- | -| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | -| `value` _[ApisixConsumerBasicAuthValue](#apisixconsumerbasicauthvalue)_ | | - - -_Appears in:_ -- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) - -#### ApisixConsumerBasicAuthValue - - -ApisixConsumerBasicAuthValue defines the in-place username and password configuration for basic auth. - - - -| Field | Description | -| --- | --- | -| `username` _string_ | | -| `password` _string_ | | - - -_Appears in:_ -- [ApisixConsumerBasicAuth](#apisixconsumerbasicauth) - -#### ApisixConsumerHMACAuth - - -ApisixConsumerHMACAuth defines the configuration for the hmac auth. - - - -| Field | Description | -| --- | --- | -| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | -| `value` _[ApisixConsumerHMACAuthValue](#apisixconsumerhmacauthvalue)_ | | - - -_Appears in:_ -- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) - -#### ApisixConsumerHMACAuthValue - - -ApisixConsumerHMACAuthValue defines the in-place configuration for hmac auth. - - - -| Field | Description | -| --- | --- | -| `access_key` _string_ | | -| `secret_key` _string_ | | -| `algorithm` _string_ | | -| `clock_skew` _integer_ | | -| `signed_headers` _string array_ | | -| `keep_headers` _boolean_ | | -| `encode_uri_params` _boolean_ | | -| `validate_request_body` _boolean_ | | -| `max_req_body` _integer_ | | - - -_Appears in:_ -- [ApisixConsumerHMACAuth](#apisixconsumerhmacauth) - -#### ApisixConsumerJwtAuth - - -ApisixConsumerJwtAuth defines the configuration for the jwt auth. - - - -| Field | Description | -| --- | --- | -| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | -| `value` _[ApisixConsumerJwtAuthValue](#apisixconsumerjwtauthvalue)_ | | - - -_Appears in:_ -- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) - -#### ApisixConsumerJwtAuthValue - - -ApisixConsumerJwtAuthValue defines the in-place configuration for jwt auth. - - - -| Field | Description | -| --- | --- | -| `key` _string_ | | -| `secret` _string_ | | -| `public_key` _string_ | | -| `private_key` _string_ | | -| `algorithm` _string_ | | -| `exp` _integer_ | | -| `base64_secret` _boolean_ | | -| `lifetime_grace_period` _integer_ | | - - -_Appears in:_ -- [ApisixConsumerJwtAuth](#apisixconsumerjwtauth) - -#### ApisixConsumerKeyAuth - - -ApisixConsumerKeyAuth defines the configuration for the key auth. - - - -| Field | Description | -| --- | --- | -| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | -| `value` _[ApisixConsumerKeyAuthValue](#apisixconsumerkeyauthvalue)_ | | - - -_Appears in:_ -- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) - -#### ApisixConsumerKeyAuthValue - - -ApisixConsumerKeyAuthValue defines the in-place configuration for basic auth. - - - -| Field | Description | -| --- | --- | -| `key` _string_ | | - - -_Appears in:_ -- [ApisixConsumerKeyAuth](#apisixconsumerkeyauth) - -#### ApisixConsumerLDAPAuth - - -ApisixConsumerLDAPAuth defines the configuration for the ldap auth. - - - -| Field | Description | -| --- | --- | -| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | -| `value` _[ApisixConsumerLDAPAuthValue](#apisixconsumerldapauthvalue)_ | | - - -_Appears in:_ -- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) - -#### ApisixConsumerLDAPAuthValue - - -ApisixConsumerLDAPAuthValue defines the in-place configuration for ldap auth. - - - -| Field | Description | -| --- | --- | -| `user_dn` _string_ | | - - -_Appears in:_ -- [ApisixConsumerLDAPAuth](#apisixconsumerldapauth) - -#### ApisixConsumerSpec - - -ApisixConsumerSpec defines the desired state of ApisixConsumer. - - - -| Field | Description | -| --- | --- | -| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. controller implementations use this field to know whether they should be serving this ApisixConsumer resource, by a transitive connection (controller -> IngressClass -> ApisixConsumer resource). | -| `authParameter` _[ApisixConsumerAuthParameter](#apisixconsumerauthparameter)_ | | - - -_Appears in:_ -- [ApisixConsumer](#apisixconsumer) - -#### ApisixConsumerWolfRBAC - - -ApisixConsumerWolfRBAC defines the configuration for the wolf-rbac auth. - - - -| Field | Description | -| --- | --- | -| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | -| `value` _[ApisixConsumerWolfRBACValue](#apisixconsumerwolfrbacvalue)_ | | - - -_Appears in:_ -- [ApisixConsumerAuthParameter](#apisixconsumerauthparameter) - -#### ApisixConsumerWolfRBACValue - - -ApisixConsumerWolfRBAC defines the in-place server and appid and header_prefix configuration for wolf-rbac auth. - - - -| Field | Description | -| --- | --- | -| `server` _string_ | | -| `appid` _string_ | | -| `header_prefix` _string_ | | - - -_Appears in:_ -- [ApisixConsumerWolfRBAC](#apisixconsumerwolfrbac) - -#### ApisixGlobalRuleSpec - - -ApisixGlobalRuleSpec defines the desired state of ApisixGlobalRule. - - - -| Field | Description | -| --- | --- | -| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. The controller uses this field to decide whether the resource should be managed or not. | -| `plugins` _[ApisixRoutePlugin](#apisixrouteplugin) array_ | Plugins contains a list of ApisixRoutePlugin | - - -_Appears in:_ -- [ApisixGlobalRule](#apisixglobalrule) - -#### ApisixMutualTlsClientConfig - - -ApisixMutualTlsClientConfig describes the mutual TLS CA and verify depth - - - -| Field | Description | -| --- | --- | -| `caSecret` _[ApisixSecret](#apisixsecret)_ | | -| `depth` _integer_ | | -| `skip_mtls_uri_regex` _string array_ | | - - -_Appears in:_ -- [ApisixTlsSpec](#apisixtlsspec) - -#### ApisixPluginConfigSpec - - -ApisixPluginConfigSpec defines the desired state of ApisixPluginConfigSpec. - - - -| Field | Description | -| --- | --- | -| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. The controller uses this field to decide whether the resource should be managed or not. | -| `plugins` _[ApisixRoutePlugin](#apisixrouteplugin) array_ | Plugins contain a list of ApisixRoutePlugin | - - -_Appears in:_ -- [ApisixPluginConfig](#apisixpluginconfig) - -#### ApisixRouteAuthentication - - -ApisixRouteAuthentication is the authentication-related -configuration in ApisixRoute. - - - -| Field | Description | -| --- | --- | -| `enable` _boolean_ | | -| `type` _string_ | | -| `keyAuth` _[ApisixRouteAuthenticationKeyAuth](#apisixrouteauthenticationkeyauth)_ | | -| `jwtAuth` _[ApisixRouteAuthenticationJwtAuth](#apisixrouteauthenticationjwtauth)_ | | -| `ldapAuth` _[ApisixRouteAuthenticationLDAPAuth](#apisixrouteauthenticationldapauth)_ | | - - -_Appears in:_ -- [ApisixRouteHTTP](#apisixroutehttp) - -#### ApisixRouteAuthenticationJwtAuth - - -ApisixRouteAuthenticationJwtAuth is the jwt auth related -configuration in ApisixRouteAuthentication. - - - -| Field | Description | -| --- | --- | -| `header` _string_ | | -| `query` _string_ | | -| `cookie` _string_ | | - - -_Appears in:_ -- [ApisixRouteAuthentication](#apisixrouteauthentication) - -#### ApisixRouteAuthenticationKeyAuth - - -ApisixRouteAuthenticationKeyAuth is the keyAuth-related -configuration in ApisixRouteAuthentication. - - - -| Field | Description | -| --- | --- | -| `header` _string_ | | - - -_Appears in:_ -- [ApisixRouteAuthentication](#apisixrouteauthentication) - -#### ApisixRouteAuthenticationLDAPAuth - - -ApisixRouteAuthenticationLDAPAuth is the LDAP auth related -configuration in ApisixRouteAuthentication. - - - -| Field | Description | -| --- | --- | -| `base_dn` _string_ | | -| `ldap_uri` _string_ | | -| `use_tls` _boolean_ | | -| `uid` _string_ | | - - -_Appears in:_ -- [ApisixRouteAuthentication](#apisixrouteauthentication) - -#### ApisixRouteHTTP - - -ApisixRouteHTTP represents a single route in for HTTP traffic. - - - -| Field | Description | -| --- | --- | -| `name` _string_ | The rule name, cannot be empty. | -| `priority` _integer_ | Route priority, when multiple routes contains same URI path (for path matching), route with higher priority will take effect. | -| `timeout` _[UpstreamTimeout](#upstreamtimeout)_ | | -| `match` _[ApisixRouteHTTPMatch](#apisixroutehttpmatch)_ | | -| `backends` _[ApisixRouteHTTPBackend](#apisixroutehttpbackend) array_ | Backends represents potential backends to proxy after the route rule matched. When number of backends are more than one, traffic-split plugin in APISIX will be used to split traffic based on the backend weight. | -| `upstreams` _[ApisixRouteUpstreamReference](#apisixrouteupstreamreference) array_ | Upstreams refer to ApisixUpstream CRD | -| `websocket` _boolean_ | | -| `plugin_config_name` _string_ | | -| `plugin_config_namespace` _string_ | By default, PluginConfigNamespace will be the same as the namespace of ApisixRoute | -| `plugins` _[ApisixRoutePlugin](#apisixrouteplugin) array_ | | -| `authentication` _[ApisixRouteAuthentication](#apisixrouteauthentication)_ | | - - -_Appears in:_ -- [ApisixRouteSpec](#apisixroutespec) - -#### ApisixRouteHTTPBackend - - -ApisixRouteHTTPBackend represents an HTTP backend (a Kubernetes Service). - - - -| Field | Description | -| --- | --- | -| `serviceName` _string_ | The name (short) of the service, note cross namespace is forbidden, so be sure the ApisixRoute and Service are in the same namespace. | -| `servicePort` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#intorstring-intstr-util)_ | The service port, could be the name or the port number. | -| `resolveGranularity` _string_ | The resolve granularity, can be "endpoints" or "service", when set to "endpoints", the pod ips will be used; other wise, the service ClusterIP or ExternalIP will be used, default is endpoints. | -| `weight` _integer_ | Weight of this backend. | -| `subset` _string_ | Subset specifies a subset for the target Service. The subset should be pre-defined in ApisixUpstream about this service. | - - -_Appears in:_ -- [ApisixRouteHTTP](#apisixroutehttp) - -#### ApisixRouteHTTPMatch - - -ApisixRouteHTTPMatch represents the match condition for hitting this route. - - - -| Field | Description | -| --- | --- | -| `paths` _string array_ | URI path predicates, at least one path should be configured, path could be exact or prefix, for prefix path, append "*" after it, for instance, "/foo*". | -| `methods` _string array_ | HTTP request method predicates. | -| `hosts` _string array_ | HTTP Host predicates, host can be a wildcard domain or an exact domain. For wildcard domain, only one generic level is allowed, for instance, "*.foo.com" is valid but "*.*.foo.com" is not. | -| `remoteAddrs` _string array_ | Remote address predicates, items can be valid IPv4 address or IPv6 address or CIDR. | -| `exprs` _[ApisixRouteHTTPMatchExprs](#apisixroutehttpmatchexprs)_ | NginxVars represents generic match predicates, it uses Nginx variable systems, so any predicate like headers, querystring and etc can be leveraged here to match the route. For instance, it can be: nginxVars: - subject: "$remote_addr" op: in value: - "127.0.0.1" - "10.0.5.11" | -| `filter_func` _string_ | Matches based on a user-defined filtering function. These functions can accept an input parameter `vars` which can be used to access the Nginx variables. | - - -_Appears in:_ -- [ApisixRouteHTTP](#apisixroutehttp) - -#### ApisixRouteHTTPMatchExpr - - -ApisixRouteHTTPMatchExpr represents a binary route match expression . - - - -| Field | Description | -| --- | --- | -| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject is the expression subject, it can be any string composed by literals and nginx vars. | -| `op` _string_ | Op is the operator. | -| `set` _string array_ | Set is an array type object of the expression. It should be used when the Op is "in" or "not_in"; | -| `value` _string_ | Value is the normal type object for the expression, it should be used when the Op is not "in" and "not_in". Set and Value are exclusive so only of them can be set in the same time. | - - -_Appears in:_ -- [ApisixRouteHTTPMatchExprs](#apisixroutehttpmatchexprs) - -#### ApisixRouteHTTPMatchExprSubject - - -ApisixRouteHTTPMatchExprSubject describes the route match expression subject. - - - -| Field | Description | -| --- | --- | -| `scope` _string_ | The subject scope, can be: ScopeQuery, ScopeHeader, ScopePath when subject is ScopePath, Name field will be ignored. | -| `name` _string_ | The name of subject. | - - -_Appears in:_ -- [ApisixRouteHTTPMatchExpr](#apisixroutehttpmatchexpr) - -#### ApisixRouteHTTPMatchExprs -_Base type:_ `[ApisixRouteHTTPMatchExpr](#apisixroutehttpmatchexpr)` - - - - - -| Field | Description | -| --- | --- | -| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject is the expression subject, it can be any string composed by literals and nginx vars. | -| `op` _string_ | Op is the operator. | -| `set` _string array_ | Set is an array type object of the expression. It should be used when the Op is "in" or "not_in"; | -| `value` _string_ | Value is the normal type object for the expression, it should be used when the Op is not "in" and "not_in". Set and Value are exclusive so only of them can be set in the same time. | - - -_Appears in:_ -- [ApisixRouteHTTPMatch](#apisixroutehttpmatch) - -#### ApisixRoutePlugin - - -ApisixRoutePlugin represents an APISIX plugin. - - - -| Field | Description | -| --- | --- | -| `name` _string_ | The plugin name. | -| `enable` _boolean_ | Whether this plugin is in use, default is true. | -| `config` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io)_ | Plugin configuration. | -| `secretRef` _string_ | Plugin configuration secretRef. | - - -_Appears in:_ -- [ApisixGlobalRuleSpec](#apisixglobalrulespec) -- [ApisixPluginConfigSpec](#apisixpluginconfigspec) -- [ApisixRouteHTTP](#apisixroutehttp) -- [ApisixRouteStream](#apisixroutestream) - - - -#### ApisixRouteSpec - - -ApisixRouteSpec is the spec definition for ApisixRouteSpec. - - - -| Field | Description | -| --- | --- | -| `ingressClassName` _string_ | | -| `http` _[ApisixRouteHTTP](#apisixroutehttp) array_ | | -| `stream` _[ApisixRouteStream](#apisixroutestream) array_ | | - - -_Appears in:_ -- [ApisixRoute](#apisixroute) - -#### ApisixRouteStream - - -ApisixRouteStream is the configuration for level 4 route - - - -| Field | Description | -| --- | --- | -| `name` _string_ | The rule name cannot be empty. | -| `protocol` _string_ | | -| `match` _[ApisixRouteStreamMatch](#apisixroutestreammatch)_ | | -| `backend` _[ApisixRouteStreamBackend](#apisixroutestreambackend)_ | | -| `plugins` _[ApisixRoutePlugin](#apisixrouteplugin) array_ | | - - -_Appears in:_ -- [ApisixRouteSpec](#apisixroutespec) - -#### ApisixRouteStreamBackend - - -ApisixRouteStreamBackend represents a TCP backend (a Kubernetes Service). - - - -| Field | Description | -| --- | --- | -| `serviceName` _string_ | The name (short) of the service, note cross namespace is forbidden, so be sure the ApisixRoute and Service are in the same namespace. | -| `servicePort` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#intorstring-intstr-util)_ | The service port, could be the name or the port number. | -| `resolveGranularity` _string_ | The resolve granularity, can be "endpoints" or "service", when set to "endpoints", the pod ips will be used; other wise, the service ClusterIP or ExternalIP will be used, default is endpoints. | -| `subset` _string_ | Subset specifies a subset for the target Service. The subset should be pre-defined in ApisixUpstream about this service. | - - -_Appears in:_ -- [ApisixRouteStream](#apisixroutestream) - -#### ApisixRouteStreamMatch - - -ApisixRouteStreamMatch represents the match conditions of stream route. - - - -| Field | Description | -| --- | --- | -| `ingressPort` _integer_ | IngressPort represents the port listening on the Ingress proxy server. It should be pre-defined as APISIX doesn't support dynamic listening. | -| `host` _string_ | | - - -_Appears in:_ -- [ApisixRouteStream](#apisixroutestream) - -#### ApisixRouteUpstreamReference - - -ApisixRouteUpstreamReference contains a ApisixUpstream CRD reference - - - -| Field | Description | -| --- | --- | -| `name` _string_ | | -| `weight` _integer_ | | - - -_Appears in:_ -- [ApisixRouteHTTP](#apisixroutehttp) - -#### ApisixSecret - - -ApisixSecret describes the Kubernetes Secret name and namespace. - - - -| Field | Description | -| --- | --- | -| `name` _string_ | | -| `namespace` _string_ | | - - -_Appears in:_ -- [ApisixMutualTlsClientConfig](#apisixmutualtlsclientconfig) -- [ApisixTlsSpec](#apisixtlsspec) -- [ApisixUpstreamConfig](#apisixupstreamconfig) -- [ApisixUpstreamSpec](#apisixupstreamspec) -- [PortLevelSettings](#portlevelsettings) - - - - - - - - - - - - - - - -#### ApisixTlsSpec - - -ApisixTlsSpec defines the desired state of ApisixTls. - - - -| Field | Description | -| --- | --- | -| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. controller implementations use this field to know whether they should be serving this ApisixTls resource, by a transitive connection (controller -> IngressClass -> ApisixTls resource). | -| `hosts` _[HostType](#hosttype) array_ | | -| `secret` _[ApisixSecret](#apisixsecret)_ | | -| `client` _[ApisixMutualTlsClientConfig](#apisixmutualtlsclientconfig)_ | | - - -_Appears in:_ -- [ApisixTls](#apisixtls) - -#### ApisixUpstreamConfig - - -ApisixUpstreamConfig contains rich features on APISIX Upstream, for instance -load balancer, health check, etc. - - - -| Field | Description | -| --- | --- | -| `loadbalancer` _[LoadBalancer](#loadbalancer)_ | LoadBalancer represents the load balancer configuration for Kubernetes Service. The default strategy is round robin. | -| `scheme` _string_ | The scheme used to talk with the upstream. Now value can be http, grpc. | -| `retries` _integer_ | How many times that the proxy (Apache APISIX) should do when errors occur (error, timeout or bad http status codes like 500, 502). | -| `timeout` _[UpstreamTimeout](#upstreamtimeout)_ | Timeout settings for the read, send and connect to the upstream. | -| `healthCheck` _[HealthCheck](#healthcheck)_ | Deprecated: this is no longer support on standalone mode. The health check configurations for the upstream. | -| `tlsSecret` _[ApisixSecret](#apisixsecret)_ | Set the client certificate when connecting to TLS upstream. | -| `subsets` _[ApisixUpstreamSubset](#apisixupstreamsubset) array_ | Subsets groups the service endpoints by their labels. Usually used to differentiate service versions. | -| `passHost` _string_ | Configures the host when the request is forwarded to the upstream. Can be one of pass, node or rewrite. | -| `upstreamHost` _string_ | Specifies the host of the Upstream request. This is only valid if the pass_host is set to rewrite | -| `discovery` _[Discovery](#discovery)_ | Deprecated: this is no longer support on standalone mode. Discovery is used to configure service discovery for upstream. | - - -_Appears in:_ -- [ApisixUpstreamSpec](#apisixupstreamspec) -- [PortLevelSettings](#portlevelsettings) - -#### ApisixUpstreamExternalNode - - -ApisixUpstreamExternalNode is the external node conf - - - -| Field | Description | -| --- | --- | -| `name` _string_ | | -| `type` _[ApisixUpstreamExternalType](#apisixupstreamexternaltype)_ | | -| `weight` _integer_ | | -| `port` _integer_ | Port defines the port of the external node | - - -_Appears in:_ -- [ApisixUpstreamSpec](#apisixupstreamspec) - -#### ApisixUpstreamExternalType -_Base type:_ `string` - -ApisixUpstreamExternalType is the external service type - - - - - -_Appears in:_ -- [ApisixUpstreamExternalNode](#apisixupstreamexternalnode) - -#### ApisixUpstreamSpec - - -ApisixUpstreamSpec describes the specification of ApisixUpstream. - - - -| Field | Description | -| --- | --- | -| `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. controller implementations use this field to know whether they should be serving this ApisixUpstream resource, by a transitive connection (controller -> IngressClass -> ApisixUpstream resource). | -| `externalNodes` _[ApisixUpstreamExternalNode](#apisixupstreamexternalnode) array_ | ExternalNodes contains external nodes the Upstream should use If this field is set, the upstream will use these nodes directly without any further resolves | -| `loadbalancer` _[LoadBalancer](#loadbalancer)_ | LoadBalancer represents the load balancer configuration for Kubernetes Service. The default strategy is round robin. | -| `scheme` _string_ | The scheme used to talk with the upstream. Now value can be http, grpc. | -| `retries` _integer_ | How many times that the proxy (Apache APISIX) should do when errors occur (error, timeout or bad http status codes like 500, 502). | -| `timeout` _[UpstreamTimeout](#upstreamtimeout)_ | Timeout settings for the read, send and connect to the upstream. | -| `healthCheck` _[HealthCheck](#healthcheck)_ | Deprecated: this is no longer support on standalone mode. The health check configurations for the upstream. | -| `tlsSecret` _[ApisixSecret](#apisixsecret)_ | Set the client certificate when connecting to TLS upstream. | -| `subsets` _[ApisixUpstreamSubset](#apisixupstreamsubset) array_ | Subsets groups the service endpoints by their labels. Usually used to differentiate service versions. | -| `passHost` _string_ | Configures the host when the request is forwarded to the upstream. Can be one of pass, node or rewrite. | -| `upstreamHost` _string_ | Specifies the host of the Upstream request. This is only valid if the pass_host is set to rewrite | -| `discovery` _[Discovery](#discovery)_ | Deprecated: this is no longer support on standalone mode. Discovery is used to configure service discovery for upstream. | -| `portLevelSettings` _[PortLevelSettings](#portlevelsettings) array_ | | - - -_Appears in:_ -- [ApisixUpstream](#apisixupstream) - -#### ApisixUpstreamSubset - - -ApisixUpstreamSubset defines a single endpoints group of one Service. - - - -| Field | Description | -| --- | --- | -| `name` _string_ | Name is the name of subset. | -| `labels` _object (keys:string, values:string)_ | Labels is the label set of this subset. | - - -_Appears in:_ -- [ApisixUpstreamConfig](#apisixupstreamconfig) -- [ApisixUpstreamSpec](#apisixupstreamspec) -- [PortLevelSettings](#portlevelsettings) - -#### Discovery - - -Discovery defines Service discovery related configuration. - - - -| Field | Description | -| --- | --- | -| `serviceName` _string_ | | -| `type` _string_ | | -| `args` _object (keys:string, values:string)_ | | - - -_Appears in:_ -- [ApisixUpstreamConfig](#apisixupstreamconfig) -- [ApisixUpstreamSpec](#apisixupstreamspec) -- [PortLevelSettings](#portlevelsettings) - -#### HealthCheck - - -HealthCheck describes the upstream health check parameters. - - - -| Field | Description | -| --- | --- | -| `active` _[ActiveHealthCheck](#activehealthcheck)_ | | -| `passive` _[PassiveHealthCheck](#passivehealthcheck)_ | | - - -_Appears in:_ -- [ApisixUpstreamConfig](#apisixupstreamconfig) -- [ApisixUpstreamSpec](#apisixupstreamspec) -- [PortLevelSettings](#portlevelsettings) - -#### HostType -_Base type:_ `string` - - - - - - - -_Appears in:_ -- [ApisixTlsSpec](#apisixtlsspec) - -#### LoadBalancer - - -LoadBalancer describes the load balancing parameters. - - - -| Field | Description | -| --- | --- | -| `type` _string_ | | -| `hashOn` _string_ | The HashOn and Key fields are required when Type is "chash". HashOn represents the key fetching scope. | -| `key` _string_ | Key represents the hash key. | - - -_Appears in:_ -- [ApisixUpstreamConfig](#apisixupstreamconfig) -- [ApisixUpstreamSpec](#apisixupstreamspec) -- [PortLevelSettings](#portlevelsettings) - -#### PassiveHealthCheck - - -PassiveHealthCheck defines the conditions to judge whether -an upstream node is healthy with the passive manager. - - - -| Field | Description | -| --- | --- | -| `type` _string_ | | -| `healthy` _[PassiveHealthCheckHealthy](#passivehealthcheckhealthy)_ | | -| `unhealthy` _[PassiveHealthCheckUnhealthy](#passivehealthcheckunhealthy)_ | | - - -_Appears in:_ -- [HealthCheck](#healthcheck) - -#### PassiveHealthCheckHealthy - - -PassiveHealthCheckHealthy defines the conditions to judge whether -an upstream node is healthy with the passive manner. - - - -| Field | Description | -| --- | --- | -| `httpCodes` _integer array_ | | -| `successes` _integer_ | | - - -_Appears in:_ -- [ActiveHealthCheckHealthy](#activehealthcheckhealthy) -- [PassiveHealthCheck](#passivehealthcheck) - -#### PassiveHealthCheckUnhealthy - - -PassiveHealthCheckUnhealthy defines the conditions to judge whether -an upstream node is unhealthy with the passive manager. - - - -| Field | Description | -| --- | --- | -| `httpCodes` _integer array_ | | -| `httpFailures` _integer_ | | -| `tcpFailures` _integer_ | | -| `timeout` _integer_ | | - - -_Appears in:_ -- [ActiveHealthCheckUnhealthy](#activehealthcheckunhealthy) -- [PassiveHealthCheck](#passivehealthcheck) - -#### PortLevelSettings - - -PortLevelSettings configures the ApisixUpstreamConfig for each individual port. It inherits -configurations from the outer level (the whole Kubernetes Service) and overrides some of -them if they are set on the port level. - - - -| Field | Description | -| --- | --- | -| `loadbalancer` _[LoadBalancer](#loadbalancer)_ | LoadBalancer represents the load balancer configuration for Kubernetes Service. The default strategy is round robin. | -| `scheme` _string_ | The scheme used to talk with the upstream. Now value can be http, grpc. | -| `retries` _integer_ | How many times that the proxy (Apache APISIX) should do when errors occur (error, timeout or bad http status codes like 500, 502). | -| `timeout` _[UpstreamTimeout](#upstreamtimeout)_ | Timeout settings for the read, send and connect to the upstream. | -| `healthCheck` _[HealthCheck](#healthcheck)_ | Deprecated: this is no longer support on standalone mode. The health check configurations for the upstream. | -| `tlsSecret` _[ApisixSecret](#apisixsecret)_ | Set the client certificate when connecting to TLS upstream. | -| `subsets` _[ApisixUpstreamSubset](#apisixupstreamsubset) array_ | Subsets groups the service endpoints by their labels. Usually used to differentiate service versions. | -| `passHost` _string_ | Configures the host when the request is forwarded to the upstream. Can be one of pass, node or rewrite. | -| `upstreamHost` _string_ | Specifies the host of the Upstream request. This is only valid if the pass_host is set to rewrite | -| `discovery` _[Discovery](#discovery)_ | Deprecated: this is no longer support on standalone mode. Discovery is used to configure service discovery for upstream. | -| `port` _integer_ | Port is a Kubernetes Service port, it should be already defined. | - - -_Appears in:_ -- [ApisixUpstreamSpec](#apisixupstreamspec) - - - - - -#### UpstreamTimeout - - -UpstreamTimeout is settings for the read, send and connect to the upstream. - - - -| Field | Description | -| --- | --- | -| `connect` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | -| `send` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | -| `read` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | | - - -_Appears in:_ -- [ApisixRouteHTTP](#apisixroutehttp) -- [ApisixUpstreamConfig](#apisixupstreamconfig) -- [ApisixUpstreamSpec](#apisixupstreamspec) -- [PortLevelSettings](#portlevelsettings) - diff --git a/docs/crd/config.yaml b/docs/crd/config.yaml deleted file mode 100644 index c224cc429..000000000 --- a/docs/crd/config.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# 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. - -processor: - # RE2 regular expressions describing types that should be excluded from the generated documentation. - ignoreTypes: - - "List$" - # RE2 regular expressions describing type fields that should be excluded from the generated documentation. - ignoreFields: - - "status$" - - "TypeMeta$" - -render: - # Version of Kubernetes to use when generating links to Kubernetes API documentation. - # NOTE: Quotes are required, otherwise the value will be intepreted as a number so versions ending with `0` like 1.30 would be covreted to "1.3" in results. - kubernetesVersion: "1.30" diff --git a/docs/gateway-api.md b/docs/gateway-api.md deleted file mode 100644 index 459ff5a83..000000000 --- a/docs/gateway-api.md +++ /dev/null @@ -1,82 +0,0 @@ - -# Gateway API - -Gateway API is dedicated to achieving expressive and scalable Kubernetes service networking through various custom resources. - -By supporting Gateway API, the APISIX Ingress controller can realize richer functions, including Gateway management, multi-cluster support, and other features. It is also possible to manage running instances of the APISIX gateway through Gateway API resource management. - -## Concepts - -- **GatewayClass**: Defines a set of Gateways that share a common configuration and behavior. Each GatewayClass is handled by a single controller, although controllers may handle more than one GatewayClass. -- **Gateway**: A resource in Kubernetes that describes how traffic can be translated to services within the cluster. -- **HTTPRoute**: Can be attached to a Gateway to configure HTTP - -For more information about Gateway API, please refer to [Gateway API](https://gateway-api.sigs.k8s.io/). - -## Gateway API Support Level - -| Resource | Core Support Level | Extended Support Level | Implementation-Specific Support Level | API Version | -| ---------------- | ------------------- | ---------------------- | ------------------------------------- | ----------- | -| GatewayClass | Supported | N/A | Not supported | v1 | -| Gateway | Partially supported | Partially supported | Not supported | v1 | -| HTTPRoute | Supported | Partially supported | Not supported | v1 | -| GRPCRoute | Not supported | Not supported | Not supported | v1 | -| ReferenceGrant | Not supported | Not supported | Not supported | v1beta1 | -| TLSRoute | Not supported | Not supported | Not supported | v1alpha2 | -| TCPRoute | Not supported | Not supported | Not supported | v1alpha2 | -| UDPRoute | Not supported | Not supported | Not supported | v1alpha2 | -| BackendTLSPolicy | Not supported | Not supported | Not supported | v1alpha3 | - -## HTTPRoute - -The HTTPRoute resource allows users to configure HTTP routing by matching HTTP traffic and forwarding it to Kubernetes backends. Currently, the only backend supported by API7 Gateway is the Service resource. - -### Example - -The following example demonstrates how to configure an HTTPRoute resource to route traffic to the `httpbin` service: - -```yaml -apiVersion: gateway.networking.k8s.io/v1 -kind: GatewayClass -metadata: - name: apisix -spec: - controllerName: "apisix.apache.org/apisix-ingress-controller" - ---- - -apiVersion: gateway.networking.k8s.io/v1 -kind: Gateway -metadata: - name: apisix - namespace: default -spec: - gatewayClassName: apisix - listeners: - - name: http - protocol: HTTP - port: 80 - ---- - -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: httpbin -spec: - parentRefs: - - name: apisix - hostnames: - - backends.example - rules: - - matches: - - path: - type: Exact - value: /get - - path: - type: Exact - value: /headers - backendRefs: - - name: httpbin - port: 80 -``` diff --git a/docs/quickstart.md b/docs/quickstart.md deleted file mode 100644 index da0a3a686..000000000 --- a/docs/quickstart.md +++ /dev/null @@ -1,60 +0,0 @@ -# Quickstart - -This quickstart guide will help you get started with APISIX Ingress Controller in a few simple steps. - -## Prerequisites - -* Kubernetes -* API7 Dashboard -* API7 Gateway - -Please ensure you have deployed the API7 Dashboard control plane. - -Note: Refer to the [Gateway API Release Changelog](https://github.com/kubernetes-sigs/gateway-api/releases/tag/v1.0.0), it is recommended to use Kubernetes version 1.25+. - -## Installation - -Install the Gateway API CRDs: - -```shell -kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.1.0/standard-install.yaml - -``` - -Install The APISIX Ingress Controller: - -```shell -kubectl apply -f https://github.com/apache/apisix-ingress-controller/releases/download/install.yaml - -``` - -## Test HTTP Routing - -Install the GatewayClass, Gateway, HTTPRoute and httpbin example app: - -```shell -kubectl apply -f https://github.com/apache/apisix-ingress-controller/blob/release-v2-dev/examples/quickstart.yaml -``` - -Requests will be forwarded by the gateway to the httpbin application: - -```shell -curl http://{apisix_gateway_loadbalancer_ip}/headers -``` - -:::Note If the APISIX Gateway service without loadbalancer - -You can forward the local port to the APISIX Gateway service with the following command: - -```shell -# Listen on port 9080 locally, forwarding to 80 in the pod -kubectl port-forward svc/${apisix-gateway-svc} 9080:80 -n ${apisix_gateway_namespace} -``` - -Now you can send HTTP requests to access it: - -```shell -curl http://localhost:9080/headers -``` - -::: diff --git a/docs/template/gv_details.tpl b/docs/template/gv_details.tpl deleted file mode 100644 index 611ca1c9b..000000000 --- a/docs/template/gv_details.tpl +++ /dev/null @@ -1,57 +0,0 @@ -{* - 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. -*} - -{{- define "gvDetails" -}} -{{- $gv := . -}} - -## {{ $gv.GroupVersionString }} - -{{ $gv.Doc }} - -{{- if $gv.Kinds }} -{{- range $gv.SortedKinds }} -- {{ $gv.TypeForKind . | markdownRenderTypeLink }} -{{- end }} -{{ end }} - -{{- /* Display exported Kinds first */ -}} -{{- range $gv.SortedKinds -}} -{{- $typ := $gv.TypeForKind . }} -{{- $isKind := true -}} -{{ template "type" (dict "type" $typ "isKind" $isKind) }} -{{ end -}} - -### Types - -In this section you will find types that the CRDs rely on. - -{{- /* Display Types that are not exported Kinds */ -}} -{{- range $typ := $gv.SortedTypes -}} -{{- $isKind := false -}} -{{- range $kind := $gv.SortedKinds -}} -{{- if eq $typ.Name $kind -}} -{{- $isKind = true -}} -{{- end -}} -{{- end -}} -{{- if not $isKind }} -{{ template "type" (dict "type" $typ "isKind" $isKind) }} -{{ end -}} -{{- end }} - -{{- end -}} diff --git a/docs/template/gv_list.tpl b/docs/template/gv_list.tpl deleted file mode 100644 index 31ae052d3..000000000 --- a/docs/template/gv_list.tpl +++ /dev/null @@ -1,40 +0,0 @@ -{* - 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. -*} - -{{- define "gvList" -}} -{{- $groupVersions := . -}} - ---- -title: Custom Resource Definitions API Reference -slug: /reference/apisix-ingress-controller/crd-reference -description: Explore detailed reference documentation for the custom resource definitions (CRDs) supported by the APISIX Ingress Controller. ---- - -This document provides the API resource description the API7 Ingress Controller custom resource definitions (CRDs). - -## Packages -{{- range $groupVersions }} -- {{ markdownRenderGVLink . }} -{{- end }} - -{{ range $groupVersions }} -{{ template "gvDetails" . }} -{{ end }} - -{{- end -}} diff --git a/docs/template/type.tpl b/docs/template/type.tpl deleted file mode 100644 index 57f6a4339..000000000 --- a/docs/template/type.tpl +++ /dev/null @@ -1,61 +0,0 @@ -{* - 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. -*} - -{{- define "type" -}} -{{- $type := $.type -}} -{{- $isKind := $.isKind -}} -{{- if markdownShouldRenderType $type -}} - -{{- if $isKind -}} -### {{ $type.Name }} -{{ else -}} -#### {{ $type.Name }} -{{ end -}} - -{{ if $type.IsAlias }}_Base type:_ `{{ markdownRenderTypeLink $type.UnderlyingType }}`{{ end }} - -{{ $type.Doc | replace "\n\n" "

" }} - -{{ if $type.GVK -}} - -{{- end }} - -{{ if $type.Members -}} -| Field | Description | -| --- | --- | -{{ if $type.GVK -}} -| `apiVersion` _string_ | `{{ $type.GVK.Group }}/{{ $type.GVK.Version }}` -| `kind` _string_ | `{{ $type.GVK.Kind }}` -{{ end -}} - -{{ range $type.Members -}} -| `{{ .Name }}` _{{ markdownRenderType .Type }}_ | {{ template "type_members" . }} | -{{ end -}} - -{{ end }} - -{{ if $type.References -}} -_Appears in:_ -{{- range $type.SortedReferences }} -- {{ markdownRenderTypeLink . }} -{{- end }} -{{- end }} - -{{- end -}} -{{- end -}} diff --git a/docs/template/type_members.tpl b/docs/template/type_members.tpl deleted file mode 100644 index 6d6f88b61..000000000 --- a/docs/template/type_members.tpl +++ /dev/null @@ -1,28 +0,0 @@ -{* - 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. -*} - -{{- define "type_members" -}} -{{- $field := . -}} -{{- if eq $field.Name "metadata" -}} -Please refer to the Kubernetes API documentation for details on the `metadata` field. -{{- else -}} -{{- /* First replace makes paragraphs separated, second merges lines in paragraphs. */ -}} -{{ $field.Doc | replace "\n\n" "

" | replace "\n" " " | replace " *" "
•" | replace "


" "

" }} -{{- end -}} -{{- end -}} diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md deleted file mode 100644 index 297fff7b3..000000000 --- a/docs/upgrade-guide.md +++ /dev/null @@ -1,171 +0,0 @@ -# APISIX Ingress Controller Upgrade Guide - -## Upgrading from 1.x.x to 2.0.0: Key Changes and Considerations - -This document outlines the major updates, configuration compatibility changes, API behavior differences, and critical considerations when upgrading the APISIX Ingress Controller from version 1.x.x to 2.0.0. Please read carefully and assess the impact on your existing system before proceeding with the upgrade. - -### APISIX Version Dependency (Data Plane) - -The `apisix-standalone` mode is supported only with **APISIX 3.13.0**. When using this mode, it is mandatory to upgrade the data plane APISIX instance along with the Ingress Controller. - -### Architecture Changes - -#### Architecture in 1.x.x - -There were two main deployment architectures in 1.x.x: - -| Mode | Description | Issue | -| -------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| Admin API Mode | Runs a separate etcd instance, with APISIX Admin API managing data plane configuration | Complex to deploy; high maintenance overhead for etcd | -| Mock-ETCD Mode | APISIX and the Ingress Controller are deployed in the same Pod, mocking etcd endpoints | Stateless Ingress cannot persist revision info; may lead to data inconsistency | - -#### Architecture in 2.0.0 - -![upgrade to 2.0.0 architecture](./assets/images/upgrade-to-architecture.png) - -##### Mock-ETCD Mode Deprecated - -The mock-etcd architecture is no longer supported. This mode introduced significant reliability issues: stateless ingress controllers could not persist revision metadata, leading to memory pollution in the data plane and data inconsistencies. - -The following configuration block has been removed: - -```yaml -etcdserver: - enabled: false - listen_address: ":12379" - prefix: /apisix - ssl_key_encrypt_salt: edd1c9f0985e76a2 -``` - -##### Controller-Only Configuration Source - -In 2.0.0, all data plane configurations must originate from the Ingress Controller. Configurations via Admin API or any external methods are no longer supported and will be ignored or may cause errors. - -### Ingress Configuration Changes - -#### Configuration Path Changes - -| Old Path | New Path | -| ------------------------ | -------------------- | -| `kubernetes.election_id` | `leader_election_id` | - -#### Removed Configuration Fields - -| Configuration Path | Description | -| -------------------- | ---------------------------------------- | -| `kubernetes.*` | Multi-namespace control / sync interval | -| `plugin_metadata_cm` | Plugin metadata ConfigMap | -| `log_rotation_*` | Log rotation settings | -| `apisix.*` | Static Admin API configuration | -| `etcdserver.*` | Configuration for mock-etcd (deprecated) | - -#### Example: Legacy Configuration Removed in 2.0.0 - -```yaml -apisix: - admin_api_version: v3 - default_cluster_base_url: "http://127.0.0.1:9180/apisix/admin" - default_cluster_admin_key: "" - default_cluster_name: "default" -``` - -#### New Configuration via `GatewayProxy` CRD - -From version 2.0.0, the data plane must be connected via the `GatewayProxy` CRD: - -```yaml -apiVersion: networking.k8s.io/v1 -kind: IngressClass -metadata: - name: apisix -spec: - controller: "apisix.apache.org/apisix-ingress-controller" - parameters: - apiGroup: "apisix.apache.org" - kind: "GatewayProxy" - name: "apisix-proxy-config" - namespace: "default" - scope: "Namespace" ---- -apiVersion: apisix.apache.org/v1alpha1 -kind: GatewayProxy -metadata: - name: apisix-proxy-config - namespace: default -spec: - provider: - type: ControlPlane - controlPlane: - endpoints: - - https://127.0.0.1:9180 - auth: - type: AdminKey - adminKey: - value: "" -``` - -### API Changes - -#### `ApisixUpstream` - -Due to current limitations in the ADC (API Definition Controller) component, the following fields are not yet supported: - -* `spec.discovery`: Service Discovery -* `spec.healthCheck`: Health Checking - -More details: [ADC Backend Differences](https://github.com/api7/adc/blob/2449ca81e3c61169f8c1e59efb4c1173a766bce2/libs/backend-apisix-standalone/README.md#differences-in-upstream) - -#### Limited Support for Ingress Annotations - -Ingress annotations used in version 1.x.x are not fully supported in 2.0.0. If your existing setup relies on any of the following annotations, validate compatibility or consider delaying the upgrade. - -| Ingress Annotations | -| ------------------------------------------------------ | -| `k8s.apisix.apache.org/use-regex` | -| `k8s.apisix.apache.org/enable-websocket` | -| `k8s.apisix.apache.org/plugin-config-name` | -| `k8s.apisix.apache.org/upstream-scheme` | -| `k8s.apisix.apache.org/upstream-retries` | -| `k8s.apisix.apache.org/upstream-connect-timeout` | -| `k8s.apisix.apache.org/upstream-read-timeout` | -| `k8s.apisix.apache.org/upstream-send-timeout` | -| `k8s.apisix.apache.org/enable-cors` | -| `k8s.apisix.apache.org/cors-allow-origin` | -| `k8s.apisix.apache.org/cors-allow-headers` | -| `k8s.apisix.apache.org/cors-allow-methods` | -| `k8s.apisix.apache.org/enable-csrf` | -| `k8s.apisix.apache.org/csrf-key` | -| `k8s.apisix.apache.org/http-to-https` | -| `k8s.apisix.apache.org/http-redirect` | -| `k8s.apisix.apache.org/http-redirect-code` | -| `k8s.apisix.apache.org/rewrite-target` | -| `k8s.apisix.apache.org/rewrite-target-regex` | -| `k8s.apisix.apache.org/rewrite-target-regex-template` | -| `k8s.apisix.apache.org/enable-response-rewrite` | -| `k8s.apisix.apache.org/response-rewrite-status-code` | -| `k8s.apisix.apache.org/response-rewrite-body` | -| `k8s.apisix.apache.org/response-rewrite-body-base64` | -| `k8s.apisix.apache.org/response-rewrite-add-header` | -| `k8s.apisix.apache.org/response-rewrite-set-header` | -| `k8s.apisix.apache.org/response-rewrite-remove-header` | -| `k8s.apisix.apache.org/auth-uri` | -| `k8s.apisix.apache.org/auth-ssl-verify` | -| `k8s.apisix.apache.org/auth-request-headers` | -| `k8s.apisix.apache.org/auth-upstream-headers` | -| `k8s.apisix.apache.org/auth-client-headers` | -| `k8s.apisix.apache.org/allowlist-source-range` | -| `k8s.apisix.apache.org/blocklist-source-range` | -| `k8s.apisix.apache.org/http-allow-methods` | -| `k8s.apisix.apache.org/http-block-methods` | -| `k8s.apisix.apache.org/auth-type` | -| `k8s.apisix.apache.org/svc-namespace` | - -### Summary - -| Category | Description | -| ---------------- | ---------------------------------------------------------------------------------------------------- | -| Architecture | The `mock-etcd` component has been removed. Configuration is now centralized through the Controller. | -| Configuration | Static configuration fields have been removed. Use `GatewayProxy` CRD to configure the data plane. | -| Data Plane | Requires APISIX version 3.13.0 running in `standalone` mode. | -| API | Some fields in `Ingress Annotations` and `ApisixUpstream` are not yet supported. | -| Upgrade Strategy | Blue-green deployment or canary release is recommended before full switchover. |