Compare commits

..

5 Commits

Author SHA1 Message Date
EndlessSeeker
6393f7c1df chore: update ISTIO_LATEST_IMAGE_TAG for integration testing
Change-Id: Iaeb1583078f141e87c0d96e779715ccdd85f04e4
Co-developed-by: Claude <noreply@anthropic.com>
Signed-off-by: EndlessSeeker <1766508902@qq.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-05-21 14:14:51 +08:00
EndlessSeeker
ce494feddf chore: update ENVOY_LATEST_IMAGE_TAG to 4219c3d8
Change-Id: Id99e117b95d5a8966a882560b26b7367196f1c32
Co-developed-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-05-21 13:52:43 +08:00
EndlessSeeker
4219c3d8e9 chore: update ENVOY_PACKAGE_URL_PATTERN to v2.2.2
Change-Id: Iaa9c5095ba0d69aad806e64ba3fb58f7992aed2a
Co-developed-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-05-21 13:32:49 +08:00
EndlessSeeker
1efcfb721c chore: bump version to 2.2.2 for release
Change-Id: I76942bc4100a1ea349c00c0de7ac077fc75323ae
Co-developed-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: 如漫 <zhaoyuanxiao.zyx@alibaba-inc.com>
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-05-21 10:43:45 +08:00
EndlessSeeker
8383ad8009 chore: update envoy and istio submodules to latest commits
- envoy/envoy: 43287ff -> 0cfacca (fix custom_response buffer limit)
- istio/istio: 1778761 -> c8fbadb (fix WasmPlugin failStrategy propagation)

Change-Id: I03b3eab766e0fa33441220acf1640fb8e891b503
Co-developed-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: 如漫 <zhaoyuanxiao.zyx@alibaba-inc.com>
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-05-21 10:43:45 +08:00
223 changed files with 630 additions and 24952 deletions

View File

@@ -1,94 +0,0 @@
---
name: higress-update-envoy-gateway
description: Update Higress Envoy binary and gateway image dependencies for e2e validation. Use when Codex needs to build Envoy packages from the current Higress branch, upload those packages to higress-group/proxy releases, update Makefile.core.mk ENVOY_PACKAGE_URL_PATTERN and ENVOY_LATEST_IMAGE_TAG, run make build-gateway-local, retag the generated proxy/proxyv2 image as gateway, push the gateway image, or prepare a signed-off PR that lets Higress e2e tests consume a new Envoy build.
---
# Higress Envoy Gateway Dependency Update
## Core Workflow
Run from the Higress repo root unless a step explicitly says otherwise.
1. Verify context:
- Check `git status --short --branch`.
- Do not remove unrelated user changes or generated artifacts.
- Use `gh` for GitHub release/PR checks and always pass `--repo` for non-current repos.
2. Build Envoy packages from the current branch:
```bash
git submodule update --init
make build-envoy
```
Expected artifacts land in `external/package/`, typically:
- `envoy-alpha-<proxy-sha>.tar.gz`
- `envoy-symbol-<proxy-sha>.tar.gz`
- matching `.sha256` and `.dwp` files
3. Publish Envoy package release in `higress-group/proxy`:
- Create a new release tag by incrementing the requested RC/test tag, for example `v2.2.4-rc.2-test-cpp-host`.
- Match the reference release asset names, even if local filenames include SHAs:
- local `envoy-alpha-<sha>.tar.gz` uploads as `envoy-amd64.tar.gz`
- local `envoy-symbol-<sha>.tar.gz` uploads as `envoy-symbol-amd64.tar.gz`
- Use temporary renamed copies rather than renaming source artifacts:
```bash
cp external/package/envoy-alpha-<sha>.tar.gz /tmp/envoy-amd64.tar.gz
cp external/package/envoy-symbol-<sha>.tar.gz /tmp/envoy-symbol-amd64.tar.gz
gh release create <release-tag> /tmp/envoy-amd64.tar.gz /tmp/envoy-symbol-amd64.tar.gz \
--repo higress-group/proxy --target <target-branch> --title <release-tag> --generate-notes
gh release view <release-tag> --repo higress-group/proxy --json tagName,targetCommitish,assets,url
```
- If following an existing reference release, inspect it first with `gh release view`.
4. Update Higress Makefile dependencies:
- In `Makefile.core.mk`, set:
```make
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/<release-tag>/envoy-symbol-ARCH.tar.gz
```
- After building and pushing the gateway image, set:
```make
ENVOY_LATEST_IMAGE_TAG ?= <gateway-image-tag>
```
5. Build the local gateway image:
```bash
make build-gateway-local
```
Watch the log to confirm Envoy downloads from the new release URL. The build target may emit an image under a proxy-style repository/name, commonly:
- `higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/proxyv2:<tag>`
6. Retag proxy image as gateway:
```bash
docker tag higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/proxyv2:<tag> \
higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:<tag>
docker images --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.CreatedSince}}' \
higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway
```
Verify the `gateway:<tag>` and `proxyv2:<tag>` image IDs match.
7. Push the gateway image when requested:
```bash
docker push higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:<tag>
```
Record the pushed digest in the final response.
8. Commit and push Makefile changes:
- Commit only intended tracked files. Do not add `plugins/golang-filter/golang-filter_amd64.so` or other build outputs unless explicitly requested.
- Use DCO sign-off:
```bash
git add Makefile.core.mk
git commit -s -m "Update gateway envoy dependencies"
git push origin <branch>
```
- If DCO fails after a previous unsigned commit:
```bash
git commit --amend --no-edit --signoff
git push --force-with-lease origin <branch>
gh pr checks <pr-number> --repo higress-group/higress
```
## Practical Notes
- `make build-gateway-local` may need Docker daemon access, network access, and write access to repo/submodule state.
- If sandboxed commands fail with read-only filesystem errors, Docker socket permission errors, or network failures, rerun the same important command with elevated permissions and a concrete justification.
- The default local gateway tag usually comes from the current Git revision shown in the build log as `TAG=<sha>`.
- For e2e validation, the point of this workflow is to make `install-dev`, `install-dev-wasmplugin`, and local image update targets use the newly pushed gateway image through `ENVOY_LATEST_IMAGE_TAG`.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Higress Envoy Gateway"
short_description: "Update Envoy packages and gateway tag"
default_prompt: "Use $higress-update-envoy-gateway to update Higress Makefile Envoy dependencies and build a gateway image for e2e validation."

View File

@@ -1,35 +0,0 @@
---
name: issue-spec-apply
description: Implement PROCESS comments for an issue-spec change and keep PR traceability synchronized.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Apply
Use when the user asks for /issue-spec:apply, issue-spec apply, or implementing PROCESS/TASK scopes from an issue-spec change.
## Steps
1. Read proposal/design/implement issue context and list typed comments with issue-spec comment list --json.
2. Confirm issue-spec auth status --json includes the expected GitHub backend. Local gh-authenticated sessions can use the native gh backend; keep ISSUE_SPEC_TOKEN="$(gh auth token)" only as an older-version or forced-rest compatibility path.
3. Create or update PROCESS comments with owner agent, scope, dependencies, write ownership, and status.
4. Split non-trivial work into independent worker PROCESS nodes when file/module ownership does not overlap; execute independent workers in parallel when available.
5. Add dedicated review PROCESS nodes for non-trivial changes. Review PROCESS nodes should own review scopes such as CLI/API behavior, workflow docs, tests, compatibility, or security-sensitive surfaces.
6. Link each PROCESS to its TASK comments with issue-spec link.
7. Implement the code changes for one PROCESS scope at a time, or integrate completed worker outputs by dependency order.
8. Link every worker and review PROCESS to the PR with issue-spec pr link-process.
9. Add PR rationale comments on key changed lines with issue-spec pr rationale, each linked to a SPEC comment.
10. Mark PROCESS comments done only after implementation/review work and focused verification evidence exist.
## Coordinator DAG Execution
1. Build the ready set from PROCESS nodes whose dependencies are done.
2. Keep immediate blocking work local when the next step depends on it.
3. Spawn or assign independent worker agents only when their write ownership is disjoint.
4. Spawn or assign independent review agents only when their review scopes are disjoint.
5. Integrate completed outputs by dependency order and update PROCESS evidence before marking done.

View File

@@ -1,24 +0,0 @@
---
name: issue-spec-archive
description: Create the post-merge durable spec archive PR for an issue-spec change.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Archive
Use when the user asks for /issue-spec:archive, issue-spec archive, or creating the post-merge durable spec PR.
## Steps
1. Confirm the implementation PR is merged.
2. Create the durable spec PR:
issue-spec archive durable-spec --repo higress-group/higress --proposal <issue> --capability <capability> --create-pr --branch issue-spec/durable-spec-<capability> --json
3. Review the durable spec PR for long-lived behavior only. Do not copy process records, review findings, or verification logs into durable specs.
4. After durable spec PR merge, keep proposal/design/implement issues as audit history unless the project policy says to close them.

View File

@@ -1,58 +0,0 @@
---
name: issue-spec-github
description: Use GitHub CLI for GitHub issues, pull requests, CI runs, and API queries that issue-spec does not wrap.
license: MIT
compatibility: Requires GitHub CLI (gh).
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# GitHub CLI
Use the `gh` CLI to interact with GitHub repositories, issues, pull requests, CI, and API endpoints.
## When To Use
- Checking PR status, reviews, mergeability, or CI checks.
- Creating, viewing, updating, closing, or commenting on GitHub issues.
- Listing or inspecting pull requests, workflow runs, releases, labels, or repository metadata.
- Calling GitHub API endpoints with `gh api` when issue-spec does not provide a dedicated command.
## When Not To Use
- Local git operations such as commit, branch, fetch, merge, or push. Use `git` directly.
- Non-GitHub repositories. Use the matching provider CLI instead.
- Complex code review across local diffs. Read the repository files directly and use issue-spec review commands for traceable findings.
## Setup
```bash
gh auth login
gh auth status
```
## Common Commands
```bash
gh issue list --repo owner/repo --state open
gh issue view 42 --repo owner/repo --json number,title,state,url,body
gh issue comment 42 --repo owner/repo --body "Comment body"
gh pr list --repo owner/repo
gh pr view 17 --repo owner/repo --json number,title,state,headRefName,baseRefName,url
gh pr checks 17 --repo owner/repo
gh run list --repo owner/repo --limit 10
gh run view <run-id> --repo owner/repo --log-failed
gh api repos/owner/repo/labels --jq '.[].name'
```
## Notes
- Always pass `--repo owner/repo` when the current directory is not definitely inside the target repository.
- Use GitHub URLs directly when convenient, for example `gh pr view https://github.com/owner/repo/pull/17`.
- Prefer structured output with `--json` and `--jq` when another command or agent step consumes the result.
- issue-spec owns the proposal, design, implement, typed comment, review, verify, and archive workflow state. Use `gh` for adjacent GitHub operations that are outside issue-spec's command surface.

View File

@@ -1,37 +0,0 @@
---
name: issue-spec-propose
description: Create or continue proposal, SPEC, QUESTION, design, and TASK artifacts for an issue-spec change.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Propose
Use when the user asks for /issue-spec:propose, issue-spec propose, creating a change proposal, drafting SPEC comments, or preparing design/tasks after questions converge.
## Steps
1. Create the proposal issue:
issue-spec issue create proposal --repo higress-group/higress --change <change-name> --body-file <proposal.md>
2. If the proposal body needs revision after discussion, update it in place:
issue-spec issue update --repo higress-group/higress --issue <proposal-issue> --body-file <proposal.md> --summary "<what changed>"
3. Add SPEC comments with issue-spec comment upsert --type SPEC. SPEC comments must use MUST/SHALL and WHEN/THEN scenarios.
4. Add QUESTION comments for unresolved behavior with issue-spec question create and resolve blocking questions before design.
5. Create the design issue after SPEC/QUESTION convergence:
issue-spec issue create design --repo higress-group/higress --change <change-name> --proposal <proposal-issue-or-url> --body-file <design.md>
6. Add TASK comments with issue-spec comment upsert --type TASK and link every TASK to covered SPEC comments with issue-spec link.
7. Create the implement issue once tasks are ready:
issue-spec issue create implement --repo higress-group/higress --change <change-name> --proposal <proposal-issue-or-url> --design <design-issue-or-url> --body-file <implement.md>
8. Run issue-spec verify-links and fix missing backlinks before implementation.

View File

@@ -1,32 +0,0 @@
---
name: issue-spec-review
description: Review an issue-spec implementation PR, create PR line findings, reply after fixes, and sync REVIEW comments.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Review
Use when the user asks for /issue-spec:review, issue-spec review, or a PR review gate for an issue-spec implementation.
## Steps
1. Run issue-spec review sync --repo higress-group/higress --pr <number> --implement <issue> --id REVIEW-<n> --json to capture current rationale comments, findings, and checks.
2. For non-trivial PRs, spawn or assign dedicated review agents as review PROCESS owners. Multiple review agents can run in parallel when their review scopes are independent.
3. Give each review agent a concrete scope and expected output: actionable findings only, severity, file/line, linked SPEC, owner PROCESS, and suggested fix.
4. Create actionable PR line findings with issue-spec review finding. Use P0/P1 for blockers and P2 for non-blocking follow-up.
5. Assign every finding to a PROCESS owner. If no findings are found, record that result in REVIEW or VERIFY evidence.
6. After the worker fixes a finding, reply to the original thread with issue-spec review reply --status resolved.
7. Re-run review sync. P0/P1 findings must be resolved before final verify/archive.
## Review DAG Policy
1. Every non-trivial PR should have at least one dedicated review PROCESS node before final verify.
2. Use multiple review agents in parallel when scopes are independent, for example CLI/API behavior, workflow docs, tests, compatibility, or security-sensitive surfaces.
3. A review agent reports findings only; the coordinator converts actionable line findings into issue-spec review finding comments.
4. P0/P1 findings block final verify until the owner PROCESS fixes them and issue-spec review reply records the resolution on the original thread.
5. If a review agent finds no issues, record that result in REVIEW or VERIFY evidence before marking the review PROCESS done.

View File

@@ -1,28 +0,0 @@
---
name: issue-spec-verify
description: Run final issue-spec verification across traceability, questions, review findings, PR rationale, PR checks, and durable spec draft.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Verify
Use when the user asks for /issue-spec:verify, issue-spec verify, or final readiness evidence before merge/archive.
## Steps
1. Run focused project tests and record evidence in VERIFY comments.
2. Run issue-spec verify-links --repo higress-group/higress --proposal <issue> --design <issue> --implement <issue> --json.
3. Render a durable spec draft:
issue-spec archive durable-spec --repo higress-group/higress --proposal <issue> --capability <capability> --output /tmp/<capability>-spec.md --json
4. Run final verify:
issue-spec verify --repo higress-group/higress --proposal <issue> --design <issue> --implement <issue> --pr <pr> --durable-spec /tmp/<capability>-spec.md --json
5. Final verify must fail if blocking questions, missing links, missing PROCESS rationale, open P0/P1 findings, failed or pending PR checks, or durable spec omissions exist.

View File

@@ -1,53 +0,0 @@
---
name: issue-spec-workflow
description: Use issue-spec to run an issue-native OpenSpec-style workflow with GitHub issues, typed comments, PR review comments, final verification, and durable spec archive PRs.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Workflow
Use this skill for issue-native OpenSpec work. Active change artifacts live in GitHub issues and issue comments; durable specs are repository files created after implementation merge.
## Start
1. Run issue-spec auth status --json and confirm the active auth source and GitHub backend.
2. Run issue-spec status --repo higress-group/higress --proposal <issue> --design <issue> --implement <issue> --json when issues already exist.
3. For new work, create proposal, design, and implement issues with issue-spec issue create and pass --body-file with concrete markdown content.
4. When an issue body changes, update it in place with issue-spec issue update --body-file and include --summary for the human-readable audit trail.
5. Store requirements, tasks, process ownership, review, and verify evidence as typed comments.
## GitHub Backend
- Local agents may rely on native GitHub CLI support: when no ISSUE_SPEC_TOKEN, GH_TOKEN, GITHUB_TOKEN, keyring token, or issue-spec config token is present and gh auth status --active succeeds for the target host, issue-spec auto-selects the gh backend.
- Explicit env or stored issue-spec tokens keep the rest backend under auto selection. Set ISSUE_SPEC_GITHUB_BACKEND=rest or ISSUE_SPEC_GITHUB_BACKEND=gh only when a workflow needs deterministic backend selection.
- The gh backend proxies GitHub API operations through gh api and uses gh --hostname for Enterprise hosts. It does not replace local git commands.
- ISSUE_SPEC_API_URL applies to the rest backend. Forced gh mode should be used only with hosts that gh can address.
- Use ISSUE_SPEC_TOKEN="$(gh auth token)" only for older issue-spec versions or when deliberately forcing rest while sourcing the token from gh.
## Rules
- Create SPEC comments before design; each SPEC must be testable and include WHEN/THEN scenarios.
- Do not leave active proposal/design/implement issue bodies as TBD placeholders.
- Resolve blocking QUESTION comments before design/tasks, or explicitly record accepted assumptions.
- Link SPEC <-> TASK and TASK <-> PROCESS with issue-spec link.
- Link every PROCESS to the implementation PR with issue-spec pr link-process.
- For non-trivial changes, include review PROCESS nodes in the DAG; review agents are scheduled like worker agents and can run in parallel when their review scopes are independent.
- Small changes may stay coordinator-only, but record the serial execution decision in the implement or VERIFY evidence.
- Before human review, add PR rationale comments with issue-spec pr rationale for every active PROCESS.
- Use issue-spec review finding for PR line findings and issue-spec review reply to close the original thread.
- Run issue-spec review sync and issue-spec verify before declaring ready.
- After the implementation PR merges, create the separate durable spec PR with issue-spec archive durable-spec --create-pr.
## Coordinator DAG Execution
1. Treat PROCESS comments as DAG nodes with explicit owner, dependencies, write or review scope, PR link, and evidence.
2. Select ready PROCESS nodes whose dependencies are done and whose scopes do not overlap.
3. Dispatch independent worker PROCESS nodes in parallel when their file/module ownership is disjoint.
4. Dispatch independent review PROCESS nodes in parallel for non-trivial PRs after PR rationale exists.
5. Integrate completed worker outputs by dependency order; route P0/P1 review findings back to the owner PROCESS.
6. Mark PROCESS nodes done only after their implementation or review evidence is recorded and blocking findings are resolved.

View File

@@ -1,31 +0,0 @@
---
name: "Issue Spec: Apply"
description: "Implement PROCESS comments for an issue-spec change and keep PR traceability synchronized."
category: "Workflow"
tags: ["workflow", "issue-spec"]
---
# Issue Spec Apply
Use when the user asks for /issue-spec:apply, issue-spec apply, or implementing PROCESS/TASK scopes from an issue-spec change.
## Steps
1. Read proposal/design/implement issue context and list typed comments with issue-spec comment list --json.
2. Confirm issue-spec auth status --json includes the expected GitHub backend. Local gh-authenticated sessions can use the native gh backend; keep ISSUE_SPEC_TOKEN="$(gh auth token)" only as an older-version or forced-rest compatibility path.
3. Create or update PROCESS comments with owner agent, scope, dependencies, write ownership, and status.
4. Split non-trivial work into independent worker PROCESS nodes when file/module ownership does not overlap; execute independent workers in parallel when available.
5. Add dedicated review PROCESS nodes for non-trivial changes. Review PROCESS nodes should own review scopes such as CLI/API behavior, workflow docs, tests, compatibility, or security-sensitive surfaces.
6. Link each PROCESS to its TASK comments with issue-spec link.
7. Implement the code changes for one PROCESS scope at a time, or integrate completed worker outputs by dependency order.
8. Link every worker and review PROCESS to the PR with issue-spec pr link-process.
9. Add PR rationale comments on key changed lines with issue-spec pr rationale, each linked to a SPEC comment.
10. Mark PROCESS comments done only after implementation/review work and focused verification evidence exist.
## Coordinator DAG Execution
1. Build the ready set from PROCESS nodes whose dependencies are done.
2. Keep immediate blocking work local when the next step depends on it.
3. Spawn or assign independent worker agents only when their write ownership is disjoint.
4. Spawn or assign independent review agents only when their review scopes are disjoint.
5. Integrate completed outputs by dependency order and update PROCESS evidence before marking done.

View File

@@ -1,20 +0,0 @@
---
name: "Issue Spec: Archive"
description: "Create the post-merge durable spec archive PR for an issue-spec change."
category: "Workflow"
tags: ["workflow", "issue-spec"]
---
# Issue Spec Archive
Use when the user asks for /issue-spec:archive, issue-spec archive, or creating the post-merge durable spec PR.
## Steps
1. Confirm the implementation PR is merged.
2. Create the durable spec PR:
issue-spec archive durable-spec --repo higress-group/higress --proposal <issue> --capability <capability> --create-pr --branch issue-spec/durable-spec-<capability> --json
3. Review the durable spec PR for long-lived behavior only. Do not copy process records, review findings, or verification logs into durable specs.
4. After durable spec PR merge, keep proposal/design/implement issues as audit history unless the project policy says to close them.

View File

@@ -1,33 +0,0 @@
---
name: "Issue Spec: Propose"
description: "Create or continue proposal, SPEC, QUESTION, design, and TASK artifacts for an issue-spec change."
category: "Workflow"
tags: ["workflow", "issue-spec"]
---
# Issue Spec Propose
Use when the user asks for /issue-spec:propose, issue-spec propose, creating a change proposal, drafting SPEC comments, or preparing design/tasks after questions converge.
## Steps
1. Create the proposal issue:
issue-spec issue create proposal --repo higress-group/higress --change <change-name> --body-file <proposal.md>
2. If the proposal body needs revision after discussion, update it in place:
issue-spec issue update --repo higress-group/higress --issue <proposal-issue> --body-file <proposal.md> --summary "<what changed>"
3. Add SPEC comments with issue-spec comment upsert --type SPEC. SPEC comments must use MUST/SHALL and WHEN/THEN scenarios.
4. Add QUESTION comments for unresolved behavior with issue-spec question create and resolve blocking questions before design.
5. Create the design issue after SPEC/QUESTION convergence:
issue-spec issue create design --repo higress-group/higress --change <change-name> --proposal <proposal-issue-or-url> --body-file <design.md>
6. Add TASK comments with issue-spec comment upsert --type TASK and link every TASK to covered SPEC comments with issue-spec link.
7. Create the implement issue once tasks are ready:
issue-spec issue create implement --repo higress-group/higress --change <change-name> --proposal <proposal-issue-or-url> --design <design-issue-or-url> --body-file <implement.md>
8. Run issue-spec verify-links and fix missing backlinks before implementation.

View File

@@ -1,28 +0,0 @@
---
name: "Issue Spec: Review"
description: "Review an issue-spec implementation PR, create PR line findings, reply after fixes, and sync REVIEW comments."
category: "Workflow"
tags: ["workflow", "issue-spec"]
---
# Issue Spec Review
Use when the user asks for /issue-spec:review, issue-spec review, or a PR review gate for an issue-spec implementation.
## Steps
1. Run issue-spec review sync --repo higress-group/higress --pr <number> --implement <issue> --id REVIEW-<n> --json to capture current rationale comments, findings, and checks.
2. For non-trivial PRs, spawn or assign dedicated review agents as review PROCESS owners. Multiple review agents can run in parallel when their review scopes are independent.
3. Give each review agent a concrete scope and expected output: actionable findings only, severity, file/line, linked SPEC, owner PROCESS, and suggested fix.
4. Create actionable PR line findings with issue-spec review finding. Use P0/P1 for blockers and P2 for non-blocking follow-up.
5. Assign every finding to a PROCESS owner. If no findings are found, record that result in REVIEW or VERIFY evidence.
6. After the worker fixes a finding, reply to the original thread with issue-spec review reply --status resolved.
7. Re-run review sync. P0/P1 findings must be resolved before final verify/archive.
## Review DAG Policy
1. Every non-trivial PR should have at least one dedicated review PROCESS node before final verify.
2. Use multiple review agents in parallel when scopes are independent, for example CLI/API behavior, workflow docs, tests, compatibility, or security-sensitive surfaces.
3. A review agent reports findings only; the coordinator converts actionable line findings into issue-spec review finding comments.
4. P0/P1 findings block final verify until the owner PROCESS fixes them and issue-spec review reply records the resolution on the original thread.
5. If a review agent finds no issues, record that result in REVIEW or VERIFY evidence before marking the review PROCESS done.

View File

@@ -1,24 +0,0 @@
---
name: "Issue Spec: Verify"
description: "Run final issue-spec verification across traceability, questions, review findings, PR rationale, PR checks, and durable spec draft."
category: "Workflow"
tags: ["workflow", "issue-spec"]
---
# Issue Spec Verify
Use when the user asks for /issue-spec:verify, issue-spec verify, or final readiness evidence before merge/archive.
## Steps
1. Run focused project tests and record evidence in VERIFY comments.
2. Run issue-spec verify-links --repo higress-group/higress --proposal <issue> --design <issue> --implement <issue> --json.
3. Render a durable spec draft:
issue-spec archive durable-spec --repo higress-group/higress --proposal <issue> --capability <capability> --output /tmp/<capability>-spec.md --json
4. Run final verify:
issue-spec verify --repo higress-group/higress --proposal <issue> --design <issue> --implement <issue> --pr <pr> --durable-spec /tmp/<capability>-spec.md --json
5. Final verify must fail if blocking questions, missing links, missing PROCESS rationale, open P0/P1 findings, failed or pending PR checks, or durable spec omissions exist.

View File

@@ -1 +0,0 @@
../../.agents/skills/higress-update-envoy-gateway

View File

@@ -1,35 +0,0 @@
---
name: issue-spec-apply
description: Implement PROCESS comments for an issue-spec change and keep PR traceability synchronized.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Apply
Use when the user asks for /issue-spec:apply, issue-spec apply, or implementing PROCESS/TASK scopes from an issue-spec change.
## Steps
1. Read proposal/design/implement issue context and list typed comments with issue-spec comment list --json.
2. Confirm issue-spec auth status --json includes the expected GitHub backend. Local gh-authenticated sessions can use the native gh backend; keep ISSUE_SPEC_TOKEN="$(gh auth token)" only as an older-version or forced-rest compatibility path.
3. Create or update PROCESS comments with owner agent, scope, dependencies, write ownership, and status.
4. Split non-trivial work into independent worker PROCESS nodes when file/module ownership does not overlap; execute independent workers in parallel when available.
5. Add dedicated review PROCESS nodes for non-trivial changes. Review PROCESS nodes should own review scopes such as CLI/API behavior, workflow docs, tests, compatibility, or security-sensitive surfaces.
6. Link each PROCESS to its TASK comments with issue-spec link.
7. Implement the code changes for one PROCESS scope at a time, or integrate completed worker outputs by dependency order.
8. Link every worker and review PROCESS to the PR with issue-spec pr link-process.
9. Add PR rationale comments on key changed lines with issue-spec pr rationale, each linked to a SPEC comment.
10. Mark PROCESS comments done only after implementation/review work and focused verification evidence exist.
## Coordinator DAG Execution
1. Build the ready set from PROCESS nodes whose dependencies are done.
2. Keep immediate blocking work local when the next step depends on it.
3. Spawn or assign independent worker agents only when their write ownership is disjoint.
4. Spawn or assign independent review agents only when their review scopes are disjoint.
5. Integrate completed outputs by dependency order and update PROCESS evidence before marking done.

View File

@@ -1,24 +0,0 @@
---
name: issue-spec-archive
description: Create the post-merge durable spec archive PR for an issue-spec change.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Archive
Use when the user asks for /issue-spec:archive, issue-spec archive, or creating the post-merge durable spec PR.
## Steps
1. Confirm the implementation PR is merged.
2. Create the durable spec PR:
issue-spec archive durable-spec --repo higress-group/higress --proposal <issue> --capability <capability> --create-pr --branch issue-spec/durable-spec-<capability> --json
3. Review the durable spec PR for long-lived behavior only. Do not copy process records, review findings, or verification logs into durable specs.
4. After durable spec PR merge, keep proposal/design/implement issues as audit history unless the project policy says to close them.

View File

@@ -1,58 +0,0 @@
---
name: issue-spec-github
description: Use GitHub CLI for GitHub issues, pull requests, CI runs, and API queries that issue-spec does not wrap.
license: MIT
compatibility: Requires GitHub CLI (gh).
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# GitHub CLI
Use the `gh` CLI to interact with GitHub repositories, issues, pull requests, CI, and API endpoints.
## When To Use
- Checking PR status, reviews, mergeability, or CI checks.
- Creating, viewing, updating, closing, or commenting on GitHub issues.
- Listing or inspecting pull requests, workflow runs, releases, labels, or repository metadata.
- Calling GitHub API endpoints with `gh api` when issue-spec does not provide a dedicated command.
## When Not To Use
- Local git operations such as commit, branch, fetch, merge, or push. Use `git` directly.
- Non-GitHub repositories. Use the matching provider CLI instead.
- Complex code review across local diffs. Read the repository files directly and use issue-spec review commands for traceable findings.
## Setup
```bash
gh auth login
gh auth status
```
## Common Commands
```bash
gh issue list --repo owner/repo --state open
gh issue view 42 --repo owner/repo --json number,title,state,url,body
gh issue comment 42 --repo owner/repo --body "Comment body"
gh pr list --repo owner/repo
gh pr view 17 --repo owner/repo --json number,title,state,headRefName,baseRefName,url
gh pr checks 17 --repo owner/repo
gh run list --repo owner/repo --limit 10
gh run view <run-id> --repo owner/repo --log-failed
gh api repos/owner/repo/labels --jq '.[].name'
```
## Notes
- Always pass `--repo owner/repo` when the current directory is not definitely inside the target repository.
- Use GitHub URLs directly when convenient, for example `gh pr view https://github.com/owner/repo/pull/17`.
- Prefer structured output with `--json` and `--jq` when another command or agent step consumes the result.
- issue-spec owns the proposal, design, implement, typed comment, review, verify, and archive workflow state. Use `gh` for adjacent GitHub operations that are outside issue-spec's command surface.

View File

@@ -1,37 +0,0 @@
---
name: issue-spec-propose
description: Create or continue proposal, SPEC, QUESTION, design, and TASK artifacts for an issue-spec change.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Propose
Use when the user asks for /issue-spec:propose, issue-spec propose, creating a change proposal, drafting SPEC comments, or preparing design/tasks after questions converge.
## Steps
1. Create the proposal issue:
issue-spec issue create proposal --repo higress-group/higress --change <change-name> --body-file <proposal.md>
2. If the proposal body needs revision after discussion, update it in place:
issue-spec issue update --repo higress-group/higress --issue <proposal-issue> --body-file <proposal.md> --summary "<what changed>"
3. Add SPEC comments with issue-spec comment upsert --type SPEC. SPEC comments must use MUST/SHALL and WHEN/THEN scenarios.
4. Add QUESTION comments for unresolved behavior with issue-spec question create and resolve blocking questions before design.
5. Create the design issue after SPEC/QUESTION convergence:
issue-spec issue create design --repo higress-group/higress --change <change-name> --proposal <proposal-issue-or-url> --body-file <design.md>
6. Add TASK comments with issue-spec comment upsert --type TASK and link every TASK to covered SPEC comments with issue-spec link.
7. Create the implement issue once tasks are ready:
issue-spec issue create implement --repo higress-group/higress --change <change-name> --proposal <proposal-issue-or-url> --design <design-issue-or-url> --body-file <implement.md>
8. Run issue-spec verify-links and fix missing backlinks before implementation.

View File

@@ -1,32 +0,0 @@
---
name: issue-spec-review
description: Review an issue-spec implementation PR, create PR line findings, reply after fixes, and sync REVIEW comments.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Review
Use when the user asks for /issue-spec:review, issue-spec review, or a PR review gate for an issue-spec implementation.
## Steps
1. Run issue-spec review sync --repo higress-group/higress --pr <number> --implement <issue> --id REVIEW-<n> --json to capture current rationale comments, findings, and checks.
2. For non-trivial PRs, spawn or assign dedicated review agents as review PROCESS owners. Multiple review agents can run in parallel when their review scopes are independent.
3. Give each review agent a concrete scope and expected output: actionable findings only, severity, file/line, linked SPEC, owner PROCESS, and suggested fix.
4. Create actionable PR line findings with issue-spec review finding. Use P0/P1 for blockers and P2 for non-blocking follow-up.
5. Assign every finding to a PROCESS owner. If no findings are found, record that result in REVIEW or VERIFY evidence.
6. After the worker fixes a finding, reply to the original thread with issue-spec review reply --status resolved.
7. Re-run review sync. P0/P1 findings must be resolved before final verify/archive.
## Review DAG Policy
1. Every non-trivial PR should have at least one dedicated review PROCESS node before final verify.
2. Use multiple review agents in parallel when scopes are independent, for example CLI/API behavior, workflow docs, tests, compatibility, or security-sensitive surfaces.
3. A review agent reports findings only; the coordinator converts actionable line findings into issue-spec review finding comments.
4. P0/P1 findings block final verify until the owner PROCESS fixes them and issue-spec review reply records the resolution on the original thread.
5. If a review agent finds no issues, record that result in REVIEW or VERIFY evidence before marking the review PROCESS done.

View File

@@ -1,28 +0,0 @@
---
name: issue-spec-verify
description: Run final issue-spec verification across traceability, questions, review findings, PR rationale, PR checks, and durable spec draft.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Verify
Use when the user asks for /issue-spec:verify, issue-spec verify, or final readiness evidence before merge/archive.
## Steps
1. Run focused project tests and record evidence in VERIFY comments.
2. Run issue-spec verify-links --repo higress-group/higress --proposal <issue> --design <issue> --implement <issue> --json.
3. Render a durable spec draft:
issue-spec archive durable-spec --repo higress-group/higress --proposal <issue> --capability <capability> --output /tmp/<capability>-spec.md --json
4. Run final verify:
issue-spec verify --repo higress-group/higress --proposal <issue> --design <issue> --implement <issue> --pr <pr> --durable-spec /tmp/<capability>-spec.md --json
5. Final verify must fail if blocking questions, missing links, missing PROCESS rationale, open P0/P1 findings, failed or pending PR checks, or durable spec omissions exist.

View File

@@ -1,53 +0,0 @@
---
name: issue-spec-workflow
description: Use issue-spec to run an issue-native OpenSpec-style workflow with GitHub issues, typed comments, PR review comments, final verification, and durable spec archive PRs.
license: MIT
compatibility: Requires issue-spec CLI.
metadata:
author: issue-spec
version: "1.0"
generatedBy: "issue-spec"
---
# Issue Spec Workflow
Use this skill for issue-native OpenSpec work. Active change artifacts live in GitHub issues and issue comments; durable specs are repository files created after implementation merge.
## Start
1. Run issue-spec auth status --json and confirm the active auth source and GitHub backend.
2. Run issue-spec status --repo higress-group/higress --proposal <issue> --design <issue> --implement <issue> --json when issues already exist.
3. For new work, create proposal, design, and implement issues with issue-spec issue create and pass --body-file with concrete markdown content.
4. When an issue body changes, update it in place with issue-spec issue update --body-file and include --summary for the human-readable audit trail.
5. Store requirements, tasks, process ownership, review, and verify evidence as typed comments.
## GitHub Backend
- Local agents may rely on native GitHub CLI support: when no ISSUE_SPEC_TOKEN, GH_TOKEN, GITHUB_TOKEN, keyring token, or issue-spec config token is present and gh auth status --active succeeds for the target host, issue-spec auto-selects the gh backend.
- Explicit env or stored issue-spec tokens keep the rest backend under auto selection. Set ISSUE_SPEC_GITHUB_BACKEND=rest or ISSUE_SPEC_GITHUB_BACKEND=gh only when a workflow needs deterministic backend selection.
- The gh backend proxies GitHub API operations through gh api and uses gh --hostname for Enterprise hosts. It does not replace local git commands.
- ISSUE_SPEC_API_URL applies to the rest backend. Forced gh mode should be used only with hosts that gh can address.
- Use ISSUE_SPEC_TOKEN="$(gh auth token)" only for older issue-spec versions or when deliberately forcing rest while sourcing the token from gh.
## Rules
- Create SPEC comments before design; each SPEC must be testable and include WHEN/THEN scenarios.
- Do not leave active proposal/design/implement issue bodies as TBD placeholders.
- Resolve blocking QUESTION comments before design/tasks, or explicitly record accepted assumptions.
- Link SPEC <-> TASK and TASK <-> PROCESS with issue-spec link.
- Link every PROCESS to the implementation PR with issue-spec pr link-process.
- For non-trivial changes, include review PROCESS nodes in the DAG; review agents are scheduled like worker agents and can run in parallel when their review scopes are independent.
- Small changes may stay coordinator-only, but record the serial execution decision in the implement or VERIFY evidence.
- Before human review, add PR rationale comments with issue-spec pr rationale for every active PROCESS.
- Use issue-spec review finding for PR line findings and issue-spec review reply to close the original thread.
- Run issue-spec review sync and issue-spec verify before declaring ready.
- After the implementation PR merges, create the separate durable spec PR with issue-spec archive durable-spec --create-pr.
## Coordinator DAG Execution
1. Treat PROCESS comments as DAG nodes with explicit owner, dependencies, write or review scope, PR link, and evidence.
2. Select ready PROCESS nodes whose dependencies are done and whose scopes do not overlap.
3. Dispatch independent worker PROCESS nodes in parallel when their file/module ownership is disjoint.
4. Dispatch independent review PROCESS nodes in parallel for non-trivial PRs after PR rationale exists.
5. Integrate completed worker outputs by dependency order; route P0/P1 review findings back to the owner PROCESS.
6. Mark PROCESS nodes done only after their implementation or review evidence is recorded and blocking findings are resolved.

View File

@@ -115,8 +115,7 @@ jobs:
run: |
echo "Building WASM for ${{ matrix.plugin }}..."
# Run prepare.sh if it exists (e.g., download BPE vocabulary)
if [ -f prepare.sh ]; then sh ./prepare.sh; fi
# 检查是否存在main.go文件
export GOOS=wasip1
export GOARCH=wasm

View File

@@ -1,4 +0,0 @@
{
"hostname": "github.com",
"repo": "higress-group/higress"
}

View File

@@ -36,9 +36,7 @@ header:
- 'pkg/ingress/kube/gateway/istio/testdata'
- 'release-notes/**'
- '.cursor/**'
- '.claude/**'
- '.agents/**'
- '.issue-spec/**'
- '.claude/**'
comment: on-failure
dependency:

156
AGENTS.md
View File

@@ -1,156 +0,0 @@
# AGENTS.md
Guidance for AI agents working in this repository.
Higress is a cloud-native API gateway built on Istio and Envoy. The control
plane extends Istio/pilot (Go); the data plane is Envoy extended with WASM
plugins (Go/Rust/C++/AssemblyScript) and a Go-based `golang-filter`. It supports
Ingress/Gateway API and ships a rich plugin ecosystem (including AI gateway
plugins).
## Repository layout
Top-level directories (all paths relative to repo root):
- `cmd/higress/` — main entrypoint (`main.go`) for the Higress controller binary.
- `pkg/` — core Go control-plane packages: `bootstrap/`, `cert/`, `cmd/`,
`common/`, `config/`, `ingress/` (Ingress/Gateway config translation),
`kube/`.
- `api/` — protobuf/CRD API definitions; Higress CRDs live in
`api/extensions/v1alpha1` (e.g. the `WasmPlugin` type). Generated with
`make gen-api` / `make gen-client` (see `api/gen.sh`, `buf.*`).
- `client/` — generated Go clientset for Higress CRDs.
- `istio/` — git submodules of higress-group forks of Istio (`api`, `istio`,
`client-go`, `pkg`, `proxy`); see `.gitmodules`. Pulled via `make submodule`
(part of `prebuild`).
- `envoy/` — Envoy + `go-control-plane` submodules (higress-group forks).
- `external/` — vendored/external mirror dirs used during build (istio, envoy,
proxy, etc.).
- `plugins/` — all data-plane plugins (see "Plugins" below).
- `registry/` — service-discovery registry integrations (nacos, consul, eureka,
zookeeper, direct, mcp, ...).
- `hgctl/` — the `hgctl` CLI (separate Go module) for managing Higress.
- `helm/` — Helm charts: `helm/core` (the dev/install chart) and `helm/higress`.
- `test/``test/e2e/` (conformance/e2e, see "Build & test") and
`test/gateway/`.
- `tools/` — build/CI scripting: `tools/hack/` (build scripts), `tools/bin/`,
`tools/linter/`, `*.mk`.
- `samples/` — example manifests (gateway-api, hello-world, wasmplugin, ...).
- `docker/`, `docs/`, `release-notes/` — packaging, docs, and release notes.
- `Makefile` — istio common-files wrapper (supports `BUILD_WITH_CONTAINER`);
real targets live in `Makefile.core.mk` (+ `Makefile.overrides.mk`).
## Plugins
All plugins live under `plugins/`. See `plugins/README.md` for the contributor
overview. Prebuilt plugin images are published to
`higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins`.
### plugins/wasm-go/ (primary WASM plugin framework, Go)
- `extensions/<name>/` — one directory per plugin (~59 plugins, many `ai-*`).
Each plugin is its own Go module: `main.go`, `go.mod`/`go.sum`, `VERSION`,
`README.md`(+`README_EN.md`), often `config/`, `util/`, `main_test.go`.
Optional `.buildrc` sets `EXTRA_TAGS`; optional `prepare.sh`/`prepare.sh`.
`plugin.wasm` is a build artifact and is **not** committed.
- Shared SDK: plugins depend on external modules
`github.com/higress-group/wasm-go` and
`github.com/higress-group/proxy-wasm-go-sdk` (NOT an in-repo SDK dir).
In-repo, `plugins/wasm-go/pkg/mcp/` provides MCP helpers and
`plugins/wasm-go/mcp-servers/` holds MCP server plugins.
- `examples/` — minimal reference plugins (custom-log, custom-span-attribute,
test-foreign-function).
- Build: `plugins/wasm-go/Makefile`. `PLUGIN_NAME=<name> make build` builds a
wasm file (output to `extensions/<name>/plugin.wasm`) + image via
`Dockerfile`/`DockerfileBuilder` (uses a `wasm-go-builder` image, Go 1.24,
TinyGo optional). `make build-push` pushes the image; `make local-build`
builds locally with `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared`.
- Conventions: `VERSION` is the image tag; the CI/e2e batch builder
(`tools/hack/build-wasm-plugins.sh`) only compiles a wasm-go plugin whose
`VERSION` ends in `-alpha` (see the section at the bottom of this file).
### plugins/wasm-rust/ (Rust WASM plugins)
- Workspace-style: root `Cargo.toml`/`Cargo.lock`, shared `src/`,
`extensions/<name>/` per plugin (e.g. `ai-data-masking`, `ai-intent`,
`request-block`, `say-hello`, `demo-wasm`), `example/`.
- Build via `plugins/wasm-rust/Makefile` (`PLUGIN_NAME=<name> make build`, plus
`lint`/`test`); the batch builder runs it when `PLUGIN_TYPE=RUST`.
### plugins/wasm-cpp/ (C++ WASM plugins, Bazel)
- Bazel project: `WORKSPACE`, `BUILD`, `bazel/`, `common/`, `scripts/`,
`extensions/<name>/` (e.g. `basic_auth`, `jwt_auth`, `key_rate_limit`,
`model_router`, ...). Build via `plugins/wasm-cpp/Makefile`
(`PLUGIN_NAME=<name> make build`), invoked with `PLUGIN_TYPE=CPP`.
### plugins/wasm-assemblyscript/ (AssemblyScript WASM plugins)
- Node/AssemblyScript project: `asconfig.json`, `package.json`, `assembly/`,
`extensions/`.
### plugins/golang-filter/ (Envoy Go HTTP filter, NOT WASM)
- A native Envoy Golang HTTP filter (`main.go`, `mcp-server/`, `mcp-session/`);
compiled as a shared object (`.so`) independent of Envoy — no Envoy rebuild
needed. Requires Higress >= 2.1.0. Plugins register in `main.go`'s `init()`
via `RegisterHttpFilterFactoryAndConfigParser`. See
`plugins/golang-filter/README.md`.
- Build: `plugins/golang-filter/Makefile` (docker build, outputs
`golang-filter_<arch>.so`). Wired into the gateway image build via
`Makefile.core.mk` targets `build-golang-filter[-amd64|-arm64]`.
### How plugins are loaded
`WasmPlugin` CRDs (`extensions.higress.io/v1alpha1`) reference a plugin by
`url:` — either `oci://.../plugins/<name>:<version>` (image) or
`file:///opt/plugins/.../plugin.wasm` (local mount used in e2e). The dev install
`make install-dev-wasmplugin` sets Helm `global.volumeWasmPlugins=true` to mount
locally built wasm files into the gateway.
## Build & test
Run targets from the repo root; `Makefile` delegates to `Makefile.core.mk`.
Common ones:
- `make build` / `make build-linux` — build the Higress controller binary
(`prebuild` first fetches submodules).
- `make build-hgctl` — build the `hgctl` CLI.
- `make build-gateway` / `make build-istio` / `make build-envoy` — data-plane
and control-plane images (gateway pulls in the golang-filter).
- `make build-wasmplugins` — runs `tools/hack/build-wasm-plugins.sh` to batch
build WASM plugins (respects `PLUGIN_TYPE` / `PLUGIN_NAME`; Go plugins require
a `-alpha` VERSION).
- `make gen-api` / `make gen-client` — regenerate API/client code.
### Conformance / e2e tests (`test/e2e/`)
- Entrypoint `test/e2e/e2e_test.go`, run with build tag `conformance` and
`--test-area` / `--execute-tests` flags.
- Cases live in `test/e2e/conformance/tests/` as **paired `<name>.go` +
`<name>.yaml`** files (~68 cases; WASM cases are prefixed by language, e.g.
`go-wasm-*`, `cpp-wasm-*`). Support code: `conformance/base/`,
`conformance/utils/`, `conformance/embed.go`.
- Key Make targets (each spins up a kind cluster):
- `make higress-conformance-test` — Ingress/Gateway conformance.
- `make higress-wasmplugin-test` — WASM plugin e2e (uses
`install-dev-wasmplugin`, which builds plugins and mounts them).
- `*-prepare` / `*-skip-docker-build` / `*-clean` variants exist for
iterating; `run-higress-e2e-test[-wasmplugin]` runs `go test` against an
already-prepared cluster (filter with `TEST_SHORTNAME`).
- For the specifics of authoring a wasm-go e2e test, see the section below.
## Writing e2e conformance tests with wasm-go plugins
When adding an e2e conformance test that ships its own wasm-go plugin under
`plugins/wasm-go/extensions/<name>/`:
- The plugin's `VERSION` file **must end in `-alpha`** (e.g. `1.0.0-alpha`).
CI's `tools/hack/build-wasm-plugins.sh` only compiles a wasm-go plugin when
its version ends in `-alpha`; otherwise it silently skips it.
- `plugin.wasm` is a build artifact and is **not** committed. If the plugin
isn't built, the `file:///opt/plugins/.../plugin.wasm` URL in the test's
`WasmPlugin` manifest resolves to a missing file, envoy rejects the wasm
config and fails closed, and every request on that route returns HTTP 500.
Locally this can be masked because a previously built `plugin.wasm` still
exists on disk — so a test can pass locally yet 500 in CI.

View File

@@ -151,7 +151,7 @@ docker-buildx-push: clean-env docker.higress-buildx
export PARENT_GIT_TAG:=$(shell cat VERSION)
export PARENT_GIT_REVISION:=$(TAG)
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.2.3/envoy-symbol-ARCH.tar.gz
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.2.2/envoy-symbol-ARCH.tar.gz
build-envoy: prebuild
./tools/hack/build-envoy.sh
@@ -205,8 +205,8 @@ install: pre-install
helm install higress helm/higress -n higress-system --create-namespace --set 'global.local=true'
HIGRESS_LATEST_IMAGE_TAG ?= latest
ENVOY_LATEST_IMAGE_TAG ?= 91244c578aef498af93cacb2cf353f3878b92fc4
ISTIO_LATEST_IMAGE_TAG ?= de2c9628294f51b13c4a70b3a862b4372890797a
ENVOY_LATEST_IMAGE_TAG ?= 4219c3d8e99adb269e7db947011eca24717882af
ISTIO_LATEST_IMAGE_TAG ?= ce494feddfa817346404090dfc598badb02efa83
install-dev: pre-install
helm install higress helm/core -n higress-system --create-namespace --set 'controller.tag=$(TAG)' --set 'gateway.replicas=1' --set 'pilot.tag=$(ISTIO_LATEST_IMAGE_TAG)' --set 'gateway.tag=$(ENVOY_LATEST_IMAGE_TAG)' --set 'global.local=true'

View File

@@ -1 +1 @@
v2.2.3
v2.2.2

View File

@@ -6,7 +6,7 @@ ARG BASE_VERSION=latest
ARG HUB=higress-registry.cn-hangzhou.cr.aliyuncs.com/higress
ARG TARGETARCH
ARG TARGETARCH=amd64
# The following section is used as base image if BASE_DISTRIBUTION=debug
# This base image is provided by istio, see: https://github.com/istio/istio/blob/master/docker/Dockerfile.base

View File

@@ -1,5 +1,5 @@
apiVersion: v2
appVersion: 2.2.3
appVersion: 2.2.2
description: Helm chart for deploying higress gateways
icon: https://higress.io/img/higress_logo_small.png
home: http://higress.io/
@@ -15,4 +15,4 @@ dependencies:
repository: "file://../redis"
version: 0.0.1
type: application
version: 2.2.3
version: 2.2.2

View File

@@ -20,6 +20,11 @@ template:
{{- end }}
{{- include "gateway.selectorLabels" . | nindent 6 }}
spec:
{{- if .Values.gateway.imagePullPolicy }}
imagePullPolicy: {{ .Values.gateway.imagePullPolicy }}
{{- else if .Values.global.imagePullPolicy }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
{{- end }}
{{- with .Values.gateway.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 6 }}
@@ -40,11 +45,6 @@ template:
containers:
- name: higress-gateway
image: "{{ .Values.gateway.hub | default .Values.global.hub }}/higress/{{ .Values.gateway.image | default "gateway" }}:{{ .Values.gateway.tag | default .Chart.AppVersion }}"
{{- if .Values.gateway.imagePullPolicy }}
imagePullPolicy: {{ .Values.gateway.imagePullPolicy }}
{{- else if .Values.global.imagePullPolicy }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
{{- end }}
args:
- proxy
- router
@@ -211,9 +211,7 @@ template:
{{- $config := $o11y.promtail }}
- name: promtail
image: {{ $config.image.repository | default (printf "%s/higress/promtail" .Values.global.hub) }}:{{ $config.image.tag }}
{{- if .Values.global.imagePullPolicy }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
{{- end }}
imagePullPolicy: IfNotPresent
args:
- -config.file=/etc/promtail/promtail.yaml
env:

View File

@@ -39,11 +39,6 @@ spec:
securityContext:
{{- toYaml .Values.controller.securityContext | nindent 12 }}
image: "{{ .Values.controller.hub | default .Values.global.hub }}/higress/{{ .Values.controller.image | default "higress" }}:{{ .Values.controller.tag | default .Chart.AppVersion }}"
{{- if .Values.controller.imagePullPolicy }}
imagePullPolicy: {{ .Values.controller.imagePullPolicy }}
{{- else if .Values.global.imagePullPolicy }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
{{- end }}
args:
- "serve"
- --gatewaySelectorKey=higress
@@ -54,7 +49,6 @@ spec:
- --enableStatus={{ .Values.global.enableStatus }}
{{- end }}
- --ingressClass={{ .Values.global.ingressClass }}
- --gatewayClass={{ .Values.global.gatewayClass }}
{{- if .Values.global.watchNamespace }}
- --watchNamespace={{ .Values.global.watchNamespace }}
{{- end }}
@@ -83,7 +77,7 @@ spec:
- name: PILOT_ENABLE_GATEWAY_API
value: "{{ .Values.global.enableGatewayAPI }}"
- name: PILOT_ENABLE_ALPHA_GATEWAY_API
value: "{{ .Values.global.enableAlphaGatewayAPI }}"
value: "{{ .Values.global.enableGatewayAPI }}"
{{- if .Values.global.enableInferenceExtension }}
- name: ENABLE_GATEWAY_API_INFERENCE_EXTENSION
value: "true"

View File

@@ -1,8 +1,8 @@
{{- if and .Values.global.ingressClass .Values.global.createIngressClass }}
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: {{ .Values.global.ingressClass }}
spec:
controller: higress.io/higress-controller
{{- end }}
{{- if .Values.global.ingressClass }}
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: {{ .Values.global.ingressClass }}
spec:
controller: higress.io/higress-controller
{{- end }}

View File

@@ -23,7 +23,7 @@ spec:
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: {{ .Values.pluginServer.hub | default .Values.global.hub }}/higress/{{ .Values.pluginServer.image | default "plugin-server" }}:{{ .Values.pluginServer.tag | default .Chart.Version }}
image: {{ .Values.pluginServer.hub | default .Values.global.hub }}/higress/{{ .Values.pluginServer.image | default "plugin-server" }}:{{ .Values.pluginServer.tag | default "1.0.0" }}
{{- if .Values.pluginServer.imagePullPolicy }}
imagePullPolicy: {{ .Values.pluginServer.imagePullPolicy }}
{{- else if .Values.global.imagePullPolicy }}

View File

@@ -25,13 +25,6 @@ global:
# 2. When the ingress class is set empty, the higress controller will watch all ingress
# resources in the k8s cluster.
ingressClass: "higress"
# -- Whether to create the IngressClass resource for global.ingressClass.
# Set this to false when reusing an existing IngressClass, for example during Nginx Ingress migration.
createIngressClass: true
# -- GatewayClassName used by Higress to select Gateway API resources.
# The default value higress uses controllerName higress.io/gateway-controller.
# A custom value, for example higress-internal, uses controllerName higress.io/gateway-controller-higress-internal.
gatewayClass: "higress"
# -- If not empty, Higress Controller will only watch resources in the specified namespace.
# When isolating different business systems using K8s namespace,
# if each namespace requires a standalone gateway instance,
@@ -54,8 +47,6 @@ global:
enableIstioAPI: true
# -- If true, Higress Controller will monitor Gateway API resources as well
enableGatewayAPI: true
# -- If true, Higress Controller will monitor Gateway API resources that have not reached v1 yet
enableAlphaGatewayAPI: false
# -- If true, enable Gateway API Inference Extension support
enableInferenceExtension: false
# -- Used to locate istiod.

View File

@@ -1,9 +1,9 @@
dependencies:
- name: higress-core
repository: file://../core
version: 2.2.3
version: 2.2.2
- name: higress-console
repository: https://higress.io/helm-charts/
version: 2.2.3
digest: sha256:b2089e3b943edcf5cfbb52c42fbf00fad58171460a89e5a20c88cd7657f75419
generated: "2026-06-24T19:48:33.129399+08:00"
version: 2.2.2
digest: sha256:8bb8790bea259fc8039c5b49997de57bdfe1a948b8e7f3db51810c3fd825c487
generated: "2026-05-21T10:40:28.691491+08:00"

View File

@@ -1,5 +1,5 @@
apiVersion: v2
appVersion: 2.2.3
appVersion: 2.2.2
description: Helm chart for deploying Higress gateways
icon: https://higress.io/img/higress_logo_small.png
home: http://higress.io/
@@ -12,9 +12,9 @@ sources:
dependencies:
- name: higress-core
repository: "file://../core"
version: 2.2.3
version: 2.2.2
- name: higress-console
repository: "https://higress.io/helm-charts/"
version: 2.2.3
version: 2.2.2
type: application
version: 2.2.3
version: 2.2.2

View File

@@ -161,12 +161,10 @@ The command removes all the Kubernetes components associated with the chart and
| global.caAddress | string | `""` | The customized CA address to retrieve certificates for the pods in the cluster. CSR clients such as the Istio Agent and ingress gateways can use this to specify the CA endpoint. If not set explicitly, default to the Istio discovery address. |
| global.caName | string | `""` | The name of the CA for workload certificates. For example, when caName=GkeWorkloadCertificate, GKE workload certificates will be used as the certificates for workloads. The default value is "" and when caName="", the CA will be configured by other mechanisms (e.g., environmental variable CA_PROVIDER). |
| global.configCluster | bool | `false` | Configure a remote cluster as the config cluster for an external istiod. |
| global.createIngressClass | bool | `true` | Whether to create the IngressClass resource for global.ingressClass. Set this to false when reusing an existing IngressClass, for example during Nginx Ingress migration. |
| global.defaultPodDisruptionBudget | object | `{"enabled":false}` | enable pod disruption budget for the control plane, which is used to ensure Istio control plane components are gradually upgraded or recovered. |
| global.defaultResources | object | `{"requests":{"cpu":"10m"}}` | A minimal set of requested resources to applied to all deployments so that Horizontal Pod Autoscaler will be able to function (if set). Each component can overwrite these default values by adding its own resources block in the relevant section below and setting the desired resources values. |
| global.defaultUpstreamConcurrencyThreshold | int | `10000` | |
| global.disableAlpnH2 | bool | `false` | Whether to disable HTTP/2 in ALPN |
| global.enableAlphaGatewayAPI | bool | `false` | If true, Higress Controller will monitor Gateway API resources that have not reached v1 yet |
| global.enableDeltaXDS | bool | `true` | Whether to enable Istio delta xDS, default is false. |
| global.enableGatewayAPI | bool | `true` | If true, Higress Controller will monitor Gateway API resources as well |
| global.enableH3 | bool | `false` | |
@@ -181,7 +179,6 @@ The command removes all the Kubernetes components associated with the chart and
| global.enableSRDS | bool | `true` | |
| global.enableStatus | bool | `true` | If true, Higress Controller will update the status field of Ingress resources. When migrating from Nginx Ingress, in order to avoid status field of Ingress objects being overwritten, this parameter needs to be set to false, so Higress won't write the entry IP to the status field of the corresponding Ingress object. |
| global.externalIstiod | bool | `false` | Configure a remote cluster data plane controlled by an external istiod. When set to true, istiod is not deployed locally and only a subset of the other discovery charts are enabled. |
| global.gatewayClass | string | `"higress"` | GatewayClassName used by Higress to select Gateway API resources. The default value higress uses controllerName higress.io/gateway-controller. A custom value, for example higress-internal, uses controllerName higress.io/gateway-controller-higress-internal. |
| global.hostRDSMergeSubset | bool | `false` | |
| global.hub | string | `"higress-registry.cn-hangzhou.cr.aliyuncs.com"` | Default hub (registry) for Higress images. For Higress deployments, images are pulled from: {hub}/higress/{image} For built-in plugins, images are pulled from: {hub}/{pluginNamespace}/{plugin-name} Change this to use a mirror registry closer to your deployment region for faster image pulls. |
| global.imagePullPolicy | string | `""` | Specify image pull policy if default behavior isn't desired. Default behavior: latest images will be Always else IfNotPresent. |

View File

@@ -145,9 +145,7 @@ helm delete higress -n higress-system
| gateway.service.ports[1].protocol | string | `"TCP"` | 协议 |
| gateway.service.ports[1].targetPort | int | `443` | 靶向端口 |
| gateway.service.type | string | `"LoadBalancer"` | 服务类型 |
| global.createIngressClass | bool | `true` | 是否为 global.ingressClass 创建 IngressClass 资源。复用已有 IngressClass 时可设置为 false例如从 Nginx Ingress 迁移场景。 |
| global.disableAlpnH2 | bool | `false` | 设置是否禁用 ALPN 中的 http/2 |
| global.enableAlphaGatewayAPI | bool | `false` | 是否监听 alpha 阶段的 Gateway API 资源 |
| global.enableInferenceExtension | bool | `false` | 是否启用 Gateway API Inference Extension 支持 |
| ... | ... | ... | ... |

View File

@@ -110,7 +110,6 @@ type ServerArgs struct {
// 2. When the ingress class is set empty, the higress controller will watch all ingress
// resources in the k8s cluster.
IngressClass string
GatewayClass string
EnableStatus bool
WatchNamespace string
GrpcKeepAliveOptions *keepalive.Options
@@ -223,7 +222,6 @@ func (s *Server) initConfigController() error {
Enable: true,
ClusterId: s.RegistryOptions.KubeOptions.ClusterID,
IngressClass: s.IngressClass,
GatewayClass: s.GatewayClass,
WatchNamespace: s.WatchNamespace,
EnableStatus: s.EnableStatus,
SystemNamespace: higressconfig.PodNamespace,

View File

@@ -106,7 +106,6 @@ func getServerCommand() *cobra.Command {
serveCmd.PersistentFlags().StringVar(&serverArgs.GatewaySelectorValue, "gatewaySelectorValue", "higress-system-higress-gateway", "gateway resource selector label value")
serveCmd.PersistentFlags().BoolVar(&serverArgs.EnableStatus, "enableStatus", true, "enable the ingress status syncer which use to update the ip in ingress's status")
serveCmd.PersistentFlags().StringVar(&serverArgs.IngressClass, "ingressClass", innerconstants.DefaultIngressClass, "if not empty, only watch the ingresses have the specified class, otherwise watch all ingresses")
serveCmd.PersistentFlags().StringVar(&serverArgs.GatewayClass, "gatewayClass", innerconstants.DefaultGatewayClass, "if not empty, only process Gateway API resources that belong to the specified GatewayClass")
serveCmd.PersistentFlags().StringVar(&serverArgs.WatchNamespace, "watchNamespace", "", "if not empty, only wath the ingresses in the specified namespace, otherwise watch in all namespacees")
serveCmd.PersistentFlags().BoolVar(&serverArgs.Debug, "debug", serverArgs.Debug, "if true, enables more debug http api")
serveCmd.PersistentFlags().StringVar(&serverArgs.HttpAddress, "httpAddress", serverArgs.HttpAddress, "the http address")

View File

@@ -46,8 +46,6 @@ import (
"istio.io/istio/pkg/log"
"istio.io/istio/pkg/util/sets"
v1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
networkingv1beta1 "k8s.io/api/networking/v1beta1"
listersv1 "k8s.io/client-go/listers/core/v1"
"k8s.io/client-go/tools/cache"
@@ -440,7 +438,6 @@ func (m *IngressConfig) convertGateways(configs []common.WrapperConfig) []config
if err != nil {
IngressLog.Errorf("Get higress https configmap err %v", err)
}
m.preparePassthroughTLSHostOwners(&convertOptions, configs)
for idx := range configs {
cfg := configs[idx]
clusterId := common.GetClusterId(cfg.Config.Annotations)
@@ -507,8 +504,6 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) []
}
}
m.preparePassthroughTLSHostOwners(&convertOptions, configs)
// convert http route
for idx := range configs {
cfg := configs[idx]
@@ -575,8 +570,13 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) []
m.ingressRouteCache = convertOptions.IngressRouteCache.Extract()
m.mutex.Unlock()
out := make([]config.Config, 0, len(convertOptions.VirtualServices))
for host, wrapperVS := range convertOptions.VirtualServices {
// Convert http route to virtual service
out := make([]config.Config, 0, len(convertOptions.HTTPRoutes))
for host, routes := range convertOptions.HTTPRoutes {
if len(routes) == 0 {
continue
}
cleanHost := common.CleanHost(host)
// namespace/name, name format: (istio cluster id)-host
gateways := []string{
@@ -585,10 +585,13 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) []
common.CreateConvertedName(constants.IstioIngressGatewayName, cleanHost),
}
wrapperVS, exist := convertOptions.VirtualServices[host]
if !exist {
IngressLog.Warnf("virtual service for host %s does not exist.", host)
}
vs := wrapperVS.VirtualService
vs.Gateways = gateways
routes := convertOptions.HTTPRoutes[host]
// Sort, exact -> prefix -> regex
common.SortHTTPRoutes(routes)
@@ -596,18 +599,14 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) []
vs.Http = append(vs.Http, route.HTTPRoute)
}
if len(vs.Http) == 0 && len(vs.Tls) == 0 {
continue
}
vsName, clusterId := virtualServiceNameAndClusterID(cleanHost, wrapperVS, routes)
firstRoute := routes[0]
out = append(out, config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.VirtualService,
Name: vsName,
Name: common.CreateConvertedName(constants.IstioIngressGatewayName, firstRoute.WrapperConfig.Config.Namespace, firstRoute.WrapperConfig.Config.Name, cleanHost),
Namespace: m.namespace,
Annotations: map[string]string{
common.ClusterIdAnnotation: clusterId.String(),
common.ClusterIdAnnotation: firstRoute.ClusterId.String(),
},
},
Spec: vs,
@@ -626,129 +625,6 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) []
return out
}
func virtualServiceNameAndClusterID(cleanHost string, wrapperVS *common.WrapperVirtualService, routes []*common.WrapperHTTPRoute) (string, cluster.ID) {
if len(routes) > 0 {
firstRoute := routes[0]
return common.CreateConvertedName(constants.IstioIngressGatewayName, firstRoute.WrapperConfig.Config.Namespace, firstRoute.WrapperConfig.Config.Name, cleanHost), firstRoute.ClusterId
}
cfg := wrapperVS.WrapperConfig.Config
return common.CreateConvertedName(constants.IstioIngressGatewayName, cfg.Namespace, cfg.Name, cleanHost), common.GetClusterId(cfg.Annotations)
}
func (m *IngressConfig) preparePassthroughTLSHostOwners(convertOptions *common.ConvertOptions, configs []common.WrapperConfig) {
if convertOptions.PassthroughTLSHostOwners == nil {
convertOptions.PassthroughTLSHostOwners = map[string]*config.Config{}
}
// ingress-nginx enables SSL passthrough at host level when any ingress for the host has the
// annotation, then uses the first root path as the passthrough backend.
passthroughHosts := map[string]struct{}{}
firstRootPathHostOwners := map[string]*config.Config{}
for idx := range configs {
cfg := configs[idx]
if cfg.AnnotationsConfig.IsCanary() {
continue
}
if cfg.AnnotationsConfig.IsSSLPassthrough() {
for _, host := range ingressRuleHosts(cfg.Config.Spec) {
passthroughHosts[host] = struct{}{}
}
}
for _, host := range ingressRootPathHosts(cfg.Config.Spec) {
if _, exist := firstRootPathHostOwners[host]; exist {
continue
}
firstRootPathHostOwners[host] = cfg.Config
}
}
for host := range passthroughHosts {
if owner := firstRootPathHostOwners[host]; owner != nil {
convertOptions.PassthroughTLSHostOwners[host] = owner
}
}
}
func ingressRuleHosts(spec config.Spec) []string {
switch ingressSpec := spec.(type) {
case networkingv1.IngressSpec:
return ingressV1RuleHosts(ingressSpec.Rules)
case networkingv1beta1.IngressSpec:
return ingressV1Beta1RuleHosts(ingressSpec.Rules)
default:
return nil
}
}
func ingressRootPathHosts(spec config.Spec) []string {
switch ingressSpec := spec.(type) {
case networkingv1.IngressSpec:
return ingressV1RootPathHosts(ingressSpec.Rules)
case networkingv1beta1.IngressSpec:
return ingressV1Beta1RootPathHosts(ingressSpec.Rules)
default:
return nil
}
}
func ingressV1RuleHosts(rules []networkingv1.IngressRule) []string {
out := make([]string, 0, len(rules))
for _, rule := range rules {
out = append(out, rule.Host)
}
return out
}
func ingressV1Beta1RuleHosts(rules []networkingv1beta1.IngressRule) []string {
out := make([]string, 0, len(rules))
for _, rule := range rules {
out = append(out, rule.Host)
}
return out
}
func ingressV1RootPathHosts(rules []networkingv1.IngressRule) []string {
out := make([]string, 0, len(rules))
for _, rule := range rules {
if rule.HTTP == nil || !hasV1RootHTTPIngressPath(rule.HTTP.Paths) {
continue
}
out = append(out, rule.Host)
}
return out
}
func ingressV1Beta1RootPathHosts(rules []networkingv1beta1.IngressRule) []string {
out := make([]string, 0, len(rules))
for _, rule := range rules {
if rule.HTTP == nil || !hasV1Beta1RootHTTPIngressPath(rule.HTTP.Paths) {
continue
}
out = append(out, rule.Host)
}
return out
}
func hasV1RootHTTPIngressPath(paths []networkingv1.HTTPIngressPath) bool {
for _, path := range paths {
if path.Path == "" || path.Path == "/" {
return true
}
}
return false
}
func hasV1Beta1RootHTTPIngressPath(paths []networkingv1beta1.HTTPIngressPath) bool {
for _, path := range paths {
if path.Path == "" || path.Path == "/" {
return true
}
}
return false
}
func (m *IngressConfig) convertEnvoyFilter(convertOptions *common.ConvertOptions) {
var envoyFilters []config.Config
mappings := map[string]*common.Rule{}

View File

@@ -23,7 +23,6 @@ import (
networking "istio.io/api/networking/v1alpha3"
"istio.io/istio/pkg/cluster"
"istio.io/istio/pkg/config"
"istio.io/istio/pkg/config/constants"
"istio.io/istio/pkg/config/schema/gvk"
"istio.io/istio/pkg/config/xds"
ingress "k8s.io/api/networking/v1"
@@ -110,405 +109,6 @@ func TestNormalizeWeightedCluster(t *testing.T) {
}
}
func TestVirtualServiceNameAndClusterID(t *testing.T) {
cleanHost := common.CleanHost("example.com")
wrapperVS := &common.WrapperVirtualService{
WrapperConfig: &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "tls-ns",
Name: "tls-ingress",
Annotations: map[string]string{
common.ClusterIdAnnotation: "tls-cluster",
},
},
},
},
}
routes := []*common.WrapperHTTPRoute{
{
WrapperConfig: &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "http-ns",
Name: "http-ingress",
},
},
},
ClusterId: "http-cluster",
},
}
name, clusterID := virtualServiceNameAndClusterID(cleanHost, wrapperVS, routes)
if name != common.CreateConvertedName(constants.IstioIngressGatewayName, "http-ns", "http-ingress", cleanHost) {
t.Fatalf("http-backed virtual service name mismatch: %s", name)
}
if clusterID != "http-cluster" {
t.Fatalf("http-backed cluster id mismatch: %s", clusterID)
}
name, clusterID = virtualServiceNameAndClusterID(cleanHost, wrapperVS, nil)
if name != common.CreateConvertedName(constants.IstioIngressGatewayName, "tls-ns", "tls-ingress", cleanHost) {
t.Fatalf("tls-only virtual service name mismatch: %s", name)
}
if clusterID != "tls-cluster" {
t.Fatalf("tls-only cluster id mismatch: %s", clusterID)
}
}
func TestPreparePassthroughTLSHostOwnersRequiresPassthroughHost(t *testing.T) {
m := &IngressConfig{}
configs := []common.WrapperConfig{
{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "plain-root",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{Path: "/"},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{},
},
{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "plain-root-duplicate",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{Path: "/"},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{},
},
}
options := &common.ConvertOptions{}
m.preparePassthroughTLSHostOwners(options, configs)
if len(options.PassthroughTLSHostOwners) != 0 {
t.Fatalf("unexpected ssl passthrough owners: %+v", options.PassthroughTLSHostOwners)
}
}
func TestPreparePassthroughTLSHostOwnersUsesFirstRootPathOwner(t *testing.T) {
m := &IngressConfig{}
configs := []common.WrapperConfig{
{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "plain-root",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{Path: "/"},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{},
},
{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "passthrough-non-root",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{Path: "/api"},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
},
}
options := &common.ConvertOptions{}
m.preparePassthroughTLSHostOwners(options, configs)
if !common.IsPassthroughTLSHostOwner(options, configs[0].Config, "example.com") {
t.Fatal("first root ingress was not recorded as passthrough owner")
}
if !common.HasPassthroughTLSHostOwner(options, configs[0].Config) {
t.Fatal("first root ingress was not found as passthrough owner")
}
}
func TestPreparePassthroughTLSHostOwnersIgnoresHTTPOnlyIngressForHTTPSFallback(t *testing.T) {
m := &IngressConfig{}
configs := []common.WrapperConfig{
{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "http-only",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{Path: "/api"},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{},
},
{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-ingress",
},
Spec: ingress.IngressSpec{
TLS: []ingress.IngressTLS{
{
Hosts: []string{"example.com"},
SecretName: "example-com",
},
},
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{Path: "/app"},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{},
},
}
options := &common.ConvertOptions{}
m.preparePassthroughTLSHostOwners(options, configs)
if len(options.PassthroughTLSHostOwners) != 0 {
t.Fatalf("unexpected ssl passthrough owners: %+v", options.PassthroughTLSHostOwners)
}
}
func TestConvertGatewaysHonorsFirstRootPathSSLPassthroughOwner(t *testing.T) {
fake := kube.NewFakeClient()
options := common.Options{
Enable: true,
ClusterId: "ingress-v1",
RawClusterId: "ingress-v1__",
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
}
ingressController := controllerv1.NewController(fake, fake, options, nil)
m := NewIngressConfig(fake, nil, "wakanda", options)
m.remoteIngressControllers = map[cluster.ID]common.IngressController{
"ingress-v1": ingressController,
}
configs := []common.WrapperConfig{
{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-non-root",
Annotations: map[string]string{
common.ClusterIdAnnotation: "ingress-v1",
},
},
Spec: ingress.IngressSpec{
TLS: []ingress.IngressTLS{
{
Hosts: []string{"example.com"},
SecretName: "example-com",
},
},
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{Path: "/api"},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{},
},
{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "passthrough-root",
Annotations: map[string]string{
common.ClusterIdAnnotation: "ingress-v1",
},
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{Path: "/"},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
},
}
result := m.convertGateways(configs)
if len(result) != 1 {
t.Fatalf("gateway count mismatch, want 1, got %d", len(result))
}
gateway := result[0].Spec.(*networking.Gateway)
if len(gateway.Servers) != 2 {
t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers))
}
tlsServer := gateway.Servers[1]
if tlsServer.Port.Protocol != "TLS" {
t.Fatalf("tls server protocol mismatch, want TLS, got %s", tlsServer.Port.Protocol)
}
if tlsServer.Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH {
t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", tlsServer.Tls.GetMode())
}
}
func TestConvertGatewaysUsesFirstRootOwnerWhenLaterIngressEnablesSSLPassthrough(t *testing.T) {
fake := kube.NewFakeClient()
options := common.Options{
Enable: true,
ClusterId: "ingress-v1",
RawClusterId: "ingress-v1__",
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
}
ingressController := controllerv1.NewController(fake, fake, options, nil)
m := NewIngressConfig(fake, nil, "wakanda", options)
m.remoteIngressControllers = map[cluster.ID]common.IngressController{
"ingress-v1": ingressController,
}
configs := []common.WrapperConfig{
ingressV1Wrapper("root", "example.com", "/", false),
ingressV1Wrapper("passthrough", "example.com", "/passthrough", true),
}
result := m.convertGateways(configs)
if len(result) != 1 {
t.Fatalf("gateway count mismatch, want 1, got %d", len(result))
}
gateway := result[0].Spec.(*networking.Gateway)
if len(gateway.Servers) != 2 {
t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers))
}
tlsServer := gateway.Servers[1]
if tlsServer.Port.Protocol != "TLS" {
t.Fatalf("tls server protocol mismatch, want TLS, got %s", tlsServer.Port.Protocol)
}
if tlsServer.Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH {
t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", tlsServer.Tls.GetMode())
}
}
func TestConvertVirtualServiceUsesFirstRootOwnerWhenLaterIngressEnablesSSLPassthrough(t *testing.T) {
fake := kube.NewFakeClient()
options := common.Options{
Enable: true,
ClusterId: "ingress-v1",
RawClusterId: "ingress-v1__",
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
}
ingressController := controllerv1.NewController(fake, fake, options, nil)
m := NewIngressConfig(fake, nil, "wakanda", options)
m.remoteIngressControllers = map[cluster.ID]common.IngressController{
"ingress-v1": ingressController,
}
configs := []common.WrapperConfig{
ingressV1Wrapper("root", "example.com", "/", false),
ingressV1Wrapper("passthrough", "example.com", "/passthrough", true),
}
result := m.convertVirtualService(configs)
if len(result) != 1 {
t.Fatalf("virtual service count mismatch, want 1, got %d", len(result))
}
vs := result[0].Spec.(*networking.VirtualService)
if len(vs.Tls) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(vs.Tls))
}
if got := vs.Tls[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, want root.default.svc.cluster.local, got %s", got)
}
}
func TestConvertGatewaysForIngress(t *testing.T) {
fake := kube.NewFakeClient()
v1Beta1Options := common.Options{
@@ -1016,46 +616,3 @@ func TestConstructBasicAuthEnvoyFilter(t *testing.T) {
target := proto.Clone(pb).(*httppb.HttpFilter)
t.Log(target)
}
func ingressV1Wrapper(name, host, path string, sslPassthrough bool) common.WrapperConfig {
wrapper := common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: name,
Annotations: map[string]string{
common.ClusterIdAnnotation: "ingress-v1",
},
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: host,
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: path,
Backend: ingress.IngressBackend{
Service: &ingress.IngressServiceBackend{
Name: name,
Port: ingress.ServiceBackendPort{Number: 443},
},
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
Match: &annotations.MatchConfig{},
},
}
if sslPassthrough {
wrapper.AnnotationsConfig.SSLPassthrough = &annotations.SSLPassthroughConfig{Enabled: true}
}
return wrapper
}

View File

@@ -57,8 +57,6 @@ type Ingress struct {
DownstreamTLS *DownstreamTLSConfig
SSLPassthrough *SSLPassthroughConfig
Canary *CanaryConfig
IPAccessControl *IPAccessControlConfig
@@ -117,10 +115,6 @@ func (i *Ingress) IsCanary() bool {
return i.Canary.Enabled
}
func (i *Ingress) IsSSLPassthrough() bool {
return i.SSLPassthrough != nil && i.SSLPassthrough.Enabled
}
// CanaryKind return byHeader, byWeight
func (i *Ingress) CanaryKind() (bool, bool) {
if !i.IsCanary() {
@@ -163,7 +157,6 @@ func NewAnnotationHandlerManager() AnnotationHandler {
canary{},
cors{},
downstreamTLS{},
sslPassthrough{},
redirect{},
rewrite{},
upstreamTLS{},

View File

@@ -106,9 +106,6 @@ func (d downstreamTLS) ApplyGateway(gateway *networking.Gateway, config *Ingress
downstreamTLSConfig := config.DownstreamTLS
for _, server := range gateway.Servers {
if gatewaytool.IsTLSServer(server) {
if server.Tls != nil && server.Tls.Mode == networking.ServerTLSSettings_PASSTHROUGH {
continue
}
if downstreamTLSConfig.CASecretName.Name != "" {
serverCert := extraSecret(server.Tls.CredentialName)
if downstreamTLSConfig.CASecretName.Namespace != serverCert.Namespace ||

View File

@@ -269,40 +269,6 @@ func TestApplyGateway(t *testing.T) {
},
},
},
{
name: "skip passthrough server",
input: &networking.Gateway{
Servers: []*networking.Server{
{
Port: &networking.Port{
Protocol: "TLS",
},
Tls: &networking.ServerTLSSettings{
Mode: networking.ServerTLSSettings_PASSTHROUGH,
},
},
},
},
config: &Ingress{
DownstreamTLS: &DownstreamTLSConfig{
CipherSuites: []string{"ECDHE-RSA-AES256-GCM-SHA384"},
MinVersion: "TLSv1.2",
MaxVersion: "TLSv1.3",
},
},
expect: &networking.Gateway{
Servers: []*networking.Server{
{
Port: &networking.Port{
Protocol: "TLS",
},
Tls: &networking.ServerTLSSettings{
Mode: networking.ServerTLSSettings_PASSTHROUGH,
},
},
},
},
},
}
for _, tc := range testCases {

View File

@@ -1,34 +0,0 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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.
package annotations
const sslPassthroughAnnotation = "ssl-passthrough"
var _ Parser = &sslPassthrough{}
type SSLPassthroughConfig struct {
Enabled bool
}
type sslPassthrough struct{}
func (s sslPassthrough) Parse(annotations Annotations, config *Ingress, _ *GlobalContext) error {
enabled, err := annotations.ParseBoolASAP(sslPassthroughAnnotation)
if err != nil {
return nil
}
config.SSLPassthrough = &SSLPassthroughConfig{Enabled: enabled}
return nil
}

View File

@@ -1,112 +0,0 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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.
package annotations
import "testing"
func TestSSLPassthroughParse(t *testing.T) {
testCases := []struct {
name string
input Annotations
enabled bool
exists bool
}{
{
name: "missing",
input: Annotations{},
},
{
name: "enabled by nginx annotation",
input: Annotations{
buildNginxAnnotationKey(sslPassthroughAnnotation): "true",
},
enabled: true,
exists: true,
},
{
name: "enabled by higress annotation",
input: Annotations{
buildHigressAnnotationKey(sslPassthroughAnnotation): "true",
},
enabled: true,
exists: true,
},
{
name: "disabled by nginx annotation",
input: Annotations{
buildNginxAnnotationKey(sslPassthroughAnnotation): "false",
},
exists: true,
},
{
name: "disabled by higress annotation",
input: Annotations{
buildHigressAnnotationKey(sslPassthroughAnnotation): "false",
},
exists: true,
},
}
parser := sslPassthrough{}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config := &Ingress{}
if err := parser.Parse(tc.input, config, nil); err != nil {
t.Fatalf("Parse() error = %v", err)
}
if tc.exists && config.SSLPassthrough == nil {
t.Fatal("expected ssl passthrough config")
}
if !tc.exists && config.SSLPassthrough != nil {
t.Fatal("unexpected ssl passthrough config")
}
if tc.exists && config.SSLPassthrough.Enabled != tc.enabled {
t.Fatalf("enabled mismatch, want %v, got %v", tc.enabled, config.SSLPassthrough.Enabled)
}
})
}
}
func TestSSLPassthroughDoesNotSetUpstreamTLS(t *testing.T) {
parser := sslPassthrough{}
config := &Ingress{}
err := parser.Parse(Annotations{
buildNginxAnnotationKey(sslPassthroughAnnotation): "true",
}, config, nil)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if config.UpstreamTLS != nil {
t.Fatal("unexpected upstream tls config")
}
}
func TestSSLPassthroughKeepsExplicitBackendProtocol(t *testing.T) {
manager := NewAnnotationHandlerManager()
config := &Ingress{}
err := manager.Parse(Annotations{
buildNginxAnnotationKey(sslPassthroughAnnotation): "true",
buildNginxAnnotationKey(backendProtocol): "HTTPS",
}, config, nil)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if config.UpstreamTLS == nil {
t.Fatal("expected upstream tls config")
}
if config.UpstreamTLS.BackendProtocol != "HTTPS" {
t.Fatalf("backend protocol mismatch, want HTTPS, got %s", config.UpstreamTLS.BackendProtocol)
}
}

View File

@@ -170,6 +170,9 @@ func processMTLS(config *Ingress) *networking.ClientTLSSettings {
func processSimple(config *Ingress) *networking.ClientTLSSettings {
tls := &networking.ClientTLSSettings{
Mode: networking.ClientTLSSettings_SIMPLE,
InsecureSkipVerify: &wrappers.BoolValue{
Value: true,
},
}
if config.UpstreamTLS.EnableSNI && config.UpstreamTLS.SNI != "" {

View File

@@ -17,8 +17,10 @@ package annotations
import (
"testing"
"github.com/golang/protobuf/ptypes/wrappers"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"google.golang.org/protobuf/testing/protocmp"
networking "istio.io/api/networking/v1alpha3"
)
@@ -129,6 +131,9 @@ func TestApplyTrafficPolicy(t *testing.T) {
Tls: &networking.ClientTLSSettings{
Mode: networking.ClientTLSSettings_SIMPLE,
Sni: "SNI",
InsecureSkipVerify: &wrappers.BoolValue{
Value: true,
},
},
},
},
@@ -158,7 +163,9 @@ func TestApplyTrafficPolicy(t *testing.T) {
for _, testCase := range testCases {
t.Run("", func(t *testing.T) {
parser.ApplyTrafficPolicy(nil, testCase.input, testCase.config)
if diff := cmp.Diff(testCase.expect, testCase.input, cmpopts.IgnoreUnexported(unexportedIgnoredTypes...)); diff != "" {
if diff := cmp.Diff(testCase.expect, testCase.input, protocmp.Transform(),
cmpopts.IgnoreUnexported(unexportedIgnoredTypes...),
); diff != "" {
t.Fatalf("TestApplyTrafficPolicy() mismatch (-want +got): \n%s", diff)
}
})

View File

@@ -15,7 +15,6 @@
package common
import (
"strconv"
"strings"
"time"
@@ -24,7 +23,6 @@ import (
"istio.io/istio/pkg/cluster"
"istio.io/istio/pkg/config"
gatewaytool "istio.io/istio/pkg/config/gateway"
"istio.io/istio/pkg/config/protocol"
listerv1 "k8s.io/client-go/listers/core/v1"
"k8s.io/client-go/tools/cache"
@@ -80,20 +78,6 @@ func (w *WrapperGateway) IsHTTPS() bool {
return false
}
func CreateSSLPassthroughServer(host string, port uint32, clusterId cluster.ID) *networking.Server {
return &networking.Server{
Port: &networking.Port{
Number: port,
Protocol: string(protocol.TLS),
Name: CreateConvertedName("tls-"+strconv.FormatUint(uint64(port), 10)+"-ingress", clusterId.String()),
},
Hosts: []string{WildcardHost(host)},
Tls: &networking.ServerTLSSettings{
Mode: networking.ServerTLSSettings_PASSTHROUGH,
},
}
}
type WrapperHTTPRoute struct {
HTTPRoute *networking.HTTPRoute
WrapperConfig *WrapperConfig
@@ -127,50 +111,6 @@ type WrapperVirtualService struct {
AppRoot string
}
func (w *WrapperVirtualService) HasTLSRouteForHost(host string) bool {
if w == nil || w.VirtualService == nil {
return false
}
host = WildcardHost(host)
for _, route := range w.VirtualService.Tls {
for _, match := range route.Match {
for _, sniHost := range match.SniHosts {
if WildcardHost(sniHost) == host {
return true
}
}
}
}
return false
}
func NewWrapperVirtualService(host string, wrapper *WrapperConfig) *WrapperVirtualService {
return &WrapperVirtualService{
VirtualService: &networking.VirtualService{
Hosts: []string{WildcardHost(host)},
},
WrapperConfig: wrapper,
}
}
func CreateTLSRoute(host string, routeDestination []*networking.RouteDestination) *networking.TLSRoute {
return &networking.TLSRoute{
Match: []*networking.TLSMatchAttributes{
{
SniHosts: []string{WildcardHost(host)},
},
},
Route: routeDestination,
}
}
func WildcardHost(host string) string {
if host == "" {
return "*"
}
return host
}
type WrapperTrafficPolicy struct {
TrafficPolicy *networking.TrafficPolicy
PortTrafficPolicy *networking.TrafficPolicy_PortTrafficPolicy

View File

@@ -145,41 +145,6 @@ func (i *IngressDomainCache) Extract() model.IngressDomainCollection {
}
}
func SameConfig(left *config.Config, right *config.Config) bool {
if left == nil || right == nil {
return left == right
}
return GetClusterId(left.Annotations) == GetClusterId(right.Annotations) &&
left.Namespace == right.Namespace &&
left.Name == right.Name
}
func IsPassthroughTLSHostOwner(convertOptions *ConvertOptions, cfg *config.Config, host string) bool {
if convertOptions == nil || convertOptions.PassthroughTLSHostOwners == nil {
return true
}
return SameConfig(convertOptions.PassthroughTLSHostOwners[host], cfg)
}
func PassthroughTLSHostOwner(convertOptions *ConvertOptions, host string) *config.Config {
if convertOptions == nil || len(convertOptions.PassthroughTLSHostOwners) == 0 {
return nil
}
return convertOptions.PassthroughTLSHostOwners[host]
}
func HasPassthroughTLSHostOwner(convertOptions *ConvertOptions, cfg *config.Config) bool {
if convertOptions == nil || len(convertOptions.PassthroughTLSHostOwners) == 0 {
return false
}
for _, owner := range convertOptions.PassthroughTLSHostOwners {
if SameConfig(owner, cfg) {
return true
}
}
return false
}
type ConvertOptions struct {
HostWithRule2Ingress map[string]*config.Config
@@ -202,9 +167,6 @@ type ConvertOptions struct {
CanaryIngresses []*WrapperConfig
// Host to the first root-path ingress owner for hosts that have TLS passthrough enabled.
PassthroughTLSHostOwners map[string]*config.Config
Service2TrafficPolicy map[ServiceKey]*WrapperTrafficPolicy
ServiceWrappers map[string]*ServiceWrapper

View File

@@ -18,48 +18,10 @@ import (
"testing"
"github.com/stretchr/testify/assert"
networking "istio.io/api/networking/v1alpha3"
"istio.io/istio/pilot/pkg/model"
"istio.io/istio/pkg/config"
)
func TestWildcardHostForSSLPassthrough(t *testing.T) {
server := CreateSSLPassthroughServer("", 443, "")
assert.Equal(t, []string{"*"}, server.Hosts)
vs := NewWrapperVirtualService("", &WrapperConfig{})
assert.Equal(t, []string{"*"}, vs.VirtualService.Hosts)
route := CreateTLSRoute("", []*networking.RouteDestination{{Weight: 100}})
assert.Equal(t, []string{"*"}, route.Match[0].SniHosts)
vs.VirtualService.Tls = append(vs.VirtualService.Tls, route)
assert.True(t, vs.HasTLSRouteForHost(""))
}
func TestPassthroughTLSHostOwnerNilMapAllowsStandaloneConversion(t *testing.T) {
cfg := &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough",
},
}
// A nil owner map means the caller did not prepare ownership from the full ingress snapshot.
assert.True(t, IsPassthroughTLSHostOwner(&ConvertOptions{}, cfg, "example.com"))
assert.Nil(t, PassthroughTLSHostOwner(&ConvertOptions{}, "example.com"))
// A non-nil owner map means ownership has been prepared and missing hosts have no owner.
options := &ConvertOptions{
PassthroughTLSHostOwners: map[string]*config.Config{},
}
assert.False(t, IsPassthroughTLSHostOwner(options, cfg, "example.com"))
assert.Nil(t, PassthroughTLSHostOwner(options, "example.com"))
options.PassthroughTLSHostOwners["example.com"] = cfg
assert.True(t, IsPassthroughTLSHostOwner(options, cfg, "example.com"))
assert.Equal(t, cfg, PassthroughTLSHostOwner(options, "example.com"))
}
func TestIngressDomainCache(t *testing.T) {
cache := NewIngressDomainCache()
assert.NotNil(t, cache)

View File

@@ -364,8 +364,6 @@ func getLoadBalancerIp(svc *v1.Service) []string {
hostName := strings.TrimSuffix(ingress.Hostname, SvcHostNameSuffix)
if net.ParseIP(hostName) != nil {
out = append(out, hostName)
} else {
out = append(out, ingress.Hostname)
}
}
}
@@ -391,8 +389,7 @@ func getSvcIpList(svcList []*v1.Service) []string {
func SortLbIngressList(lbi []v1.LoadBalancerIngress) func(int, int) bool {
return func(i int, j int) bool {
return loadBalancerIngressAddress(lbi[i].IP, lbi[i].Hostname) <
loadBalancerIngressAddress(lbi[j].IP, lbi[j].Hostname)
return lbi[i].IP < lbi[j].IP
}
}
@@ -400,11 +397,7 @@ func GetLbStatusList(svcList []*v1.Service) []v1.LoadBalancerIngress {
svcIpList := getSvcIpList(svcList)
lbi := make([]v1.LoadBalancerIngress, 0, len(svcIpList))
for _, ep := range svcIpList {
if net.ParseIP(ep) != nil {
lbi = append(lbi, v1.LoadBalancerIngress{IP: ep})
} else {
lbi = append(lbi, v1.LoadBalancerIngress{Hostname: ep})
}
lbi = append(lbi, v1.LoadBalancerIngress{IP: ep})
}
sort.SliceStable(lbi, SortLbIngressList(lbi))
@@ -413,8 +406,7 @@ func GetLbStatusList(svcList []*v1.Service) []v1.LoadBalancerIngress {
func SortLbIngressListV1(lbi []networkingv1.IngressLoadBalancerIngress) func(int, int) bool {
return func(i int, j int) bool {
return loadBalancerIngressAddress(lbi[i].IP, lbi[i].Hostname) <
loadBalancerIngressAddress(lbi[j].IP, lbi[j].Hostname)
return lbi[i].IP < lbi[j].IP
}
}
@@ -422,11 +414,7 @@ func GetLbStatusListV1(svcList []*v1.Service) []networkingv1.IngressLoadBalancer
svcIpList := getSvcIpList(svcList)
lbi := make([]networkingv1.IngressLoadBalancerIngress, 0, len(svcIpList))
for _, ep := range svcIpList {
if net.ParseIP(ep) != nil {
lbi = append(lbi, networkingv1.IngressLoadBalancerIngress{IP: ep})
} else {
lbi = append(lbi, networkingv1.IngressLoadBalancerIngress{Hostname: ep})
}
lbi = append(lbi, networkingv1.IngressLoadBalancerIngress{IP: ep})
}
sort.SliceStable(lbi, SortLbIngressListV1(lbi))
@@ -435,8 +423,7 @@ func GetLbStatusListV1(svcList []*v1.Service) []networkingv1.IngressLoadBalancer
func SortLbIngressListV1Beta1(lbi []networkingv1beta1.IngressLoadBalancerIngress) func(int, int) bool {
return func(i int, j int) bool {
return loadBalancerIngressAddress(lbi[i].IP, lbi[i].Hostname) <
loadBalancerIngressAddress(lbi[j].IP, lbi[j].Hostname)
return lbi[i].IP < lbi[j].IP
}
}
@@ -444,20 +431,9 @@ func GetLbStatusListV1Beta1(svcList []*v1.Service) []networkingv1beta1.IngressLo
svcIpList := getSvcIpList(svcList)
lbi := make([]networkingv1beta1.IngressLoadBalancerIngress, 0, len(svcIpList))
for _, ep := range svcIpList {
if net.ParseIP(ep) != nil {
lbi = append(lbi, networkingv1beta1.IngressLoadBalancerIngress{IP: ep})
} else {
lbi = append(lbi, networkingv1beta1.IngressLoadBalancerIngress{Hostname: ep})
}
lbi = append(lbi, networkingv1beta1.IngressLoadBalancerIngress{IP: ep})
}
sort.SliceStable(lbi, SortLbIngressListV1Beta1(lbi))
return lbi
}
func loadBalancerIngressAddress(ip, hostname string) string {
if ip != "" {
return ip
}
return hostname
}

View File

@@ -21,8 +21,6 @@ import (
"istio.io/istio/pilot/pkg/model"
"istio.io/istio/pkg/config"
v1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
networkingv1beta1 "k8s.io/api/networking/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/alibaba/higress/v2/pkg/ingress/kube/annotations"
@@ -208,9 +206,6 @@ func TestGenerateUniqueRouteName(t *testing.T) {
func TestGetLbStatusList(t *testing.T) {
clusterPrefix = "gw-123-"
svcName := clusterPrefix
aliyunHostname := "higress.cn-hangzhou.alb.aliyuncs.com"
awsHostname := "k8s-kubeingr-higressg-1234567890.eu-north-1.elb.amazonaws.com"
tencentHostname := "lb-12345678.clb.ap-guangzhou.tencentclb.com"
svcList := []*v1.Service{
{
ObjectMeta: metav1.ObjectMeta{
@@ -229,57 +224,6 @@ func TestGetLbStatusList(t *testing.T) {
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: svcName,
},
Spec: v1.ServiceSpec{
Type: v1.ServiceTypeLoadBalancer,
},
Status: v1.ServiceStatus{
LoadBalancer: v1.LoadBalancerStatus{
Ingress: []v1.LoadBalancerIngress{
{
Hostname: awsHostname,
},
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: svcName,
},
Spec: v1.ServiceSpec{
Type: v1.ServiceTypeLoadBalancer,
},
Status: v1.ServiceStatus{
LoadBalancer: v1.LoadBalancerStatus{
Ingress: []v1.LoadBalancerIngress{
{
Hostname: aliyunHostname,
},
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: svcName,
},
Spec: v1.ServiceSpec{
Type: v1.ServiceTypeLoadBalancer,
},
Status: v1.ServiceStatus{
LoadBalancer: v1.LoadBalancerStatus{
Ingress: []v1.LoadBalancerIngress{
{
Hostname: tencentHostname,
},
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: svcName,
@@ -351,15 +295,17 @@ func TestGetLbStatusList(t *testing.T) {
}
lbiList := GetLbStatusList(svcList)
assert.Equal(t, []v1.LoadBalancerIngress{
{IP: "1.1.1.1"},
{IP: "2.2.2.2"},
{IP: "3.3.3.3"},
{IP: "4.4.4.4"},
{Hostname: aliyunHostname},
{Hostname: awsHostname},
{Hostname: tencentHostname},
}, lbiList)
if len(lbiList) != 4 {
t.Fatal("len should be 4")
}
if lbiList[0].IP != "1.1.1.1" {
t.Fatal("should be 1.1.1.1")
}
if lbiList[3].IP != "4.4.4.4" {
t.Fatal("should be 4.4.4.4")
}
}
func TestSortRoutes(t *testing.T) {
@@ -1066,9 +1012,6 @@ func TestPartMd5(t *testing.T) {
func TestGetLbStatusListV1AndV1Beta1(t *testing.T) {
clusterPrefix = "gw-123-"
svcName := clusterPrefix
aliyunHostname := "higress.cn-hangzhou.alb.aliyuncs.com"
awsHostname := "k8s-kubeingr-higressg-1234567890.eu-north-1.elb.amazonaws.com"
tencentHostname := "lb-12345678.clb.ap-guangzhou.tencentclb.com"
svcList := []*v1.Service{
{
ObjectMeta: metav1.ObjectMeta{
@@ -1104,82 +1047,23 @@ func TestGetLbStatusListV1AndV1Beta1(t *testing.T) {
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: svcName,
},
Spec: v1.ServiceSpec{
Type: v1.ServiceTypeLoadBalancer,
},
Status: v1.ServiceStatus{
LoadBalancer: v1.LoadBalancerStatus{
Ingress: []v1.LoadBalancerIngress{
{
Hostname: awsHostname,
},
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: svcName,
},
Spec: v1.ServiceSpec{
Type: v1.ServiceTypeLoadBalancer,
},
Status: v1.ServiceStatus{
LoadBalancer: v1.LoadBalancerStatus{
Ingress: []v1.LoadBalancerIngress{
{
Hostname: aliyunHostname,
},
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: svcName,
},
Spec: v1.ServiceSpec{
Type: v1.ServiceTypeLoadBalancer,
},
Status: v1.ServiceStatus{
LoadBalancer: v1.LoadBalancerStatus{
Ingress: []v1.LoadBalancerIngress{
{
Hostname: tencentHostname,
},
},
},
},
},
}
// Test the V1 version
t.Run("GetLbStatusListV1", func(t *testing.T) {
lbiList := GetLbStatusListV1(svcList)
assert.Equal(t, []networkingv1.IngressLoadBalancerIngress{
{IP: "1.1.1.1"},
{IP: "2.2.2.2"},
{Hostname: aliyunHostname},
{Hostname: awsHostname},
{Hostname: tencentHostname},
}, lbiList)
assert.Equal(t, 2, len(lbiList), "There should be 2 entry points")
assert.Equal(t, "1.1.1.1", lbiList[0].IP, "The first IP should be 1.1.1.1")
assert.Equal(t, "2.2.2.2", lbiList[1].IP, "The second IP should be 2.2.2.2")
})
// Test the V1Beta1 version
t.Run("GetLbStatusListV1Beta1", func(t *testing.T) {
lbiList := GetLbStatusListV1Beta1(svcList)
assert.Equal(t, []networkingv1beta1.IngressLoadBalancerIngress{
{IP: "1.1.1.1"},
{IP: "2.2.2.2"},
{Hostname: aliyunHostname},
{Hostname: awsHostname},
{Hostname: tencentHostname},
}, lbiList)
assert.Equal(t, 2, len(lbiList), "There should be 2 entry points")
assert.Equal(t, "1.1.1.1", lbiList[0].IP, "The first IP should be 1.1.1.1")
assert.Equal(t, "2.2.2.2", lbiList[1].IP, "The second IP should be 2.2.2.2")
})
}

View File

@@ -77,7 +77,6 @@ func NewController(client kube.Client, options common.Options, xdsUpdater model.
ClusterID: clusterId,
Revision: higressconfig.Revision,
}
istiogateway.SetGatewayClassName(options.GatewayClass)
istioController := istiogateway.NewController(client, client.CrdWatcher().WaitForCRD, opt, xdsUpdater)
if options.GatewaySelectorKey != "" {
istioController.DefaultGatewaySelector = map[string]string{options.GatewaySelectorKey: options.GatewaySelectorValue}

View File

@@ -27,6 +27,7 @@ import (
gw "sigs.k8s.io/gateway-api/apis/v1"
gatewayx "sigs.k8s.io/gateway-api/apisx/v1alpha1"
higressconstants "github.com/alibaba/higress/v2/pkg/config/constants"
networking "istio.io/api/networking/v1alpha3"
networkingclient "istio.io/client-go/pkg/apis/networking/v1"
kubesecrets "istio.io/istio/pilot/pkg/credentials/kube"
@@ -424,7 +425,7 @@ func BackendTLSPolicyCollection(
Kind: ptr.Of(gw.Kind(gvk.KubernetesGateway.Kind)),
Name: gw.ObjectName(g.Name),
}
ancestorStatus = append(ancestorStatus, setAncestorStatus(pr, status, i.Generation, conds, gw.GatewayController(managedGatewayController)))
ancestorStatus = append(ancestorStatus, setAncestorStatus(pr, status, i.Generation, conds, gw.GatewayController(higressconstants.ManagedGatewayController)))
}
status.Ancestors = mergeAncestors(status.Ancestors, ancestorStatus)
return status, res
@@ -634,16 +635,14 @@ func parentRefEqual(a, b gw.ParentReference) bool {
ptr.Equal(a.Port, b.Port)
}
func isOutController(controller gw.GatewayController) bool {
return controller == managedGatewayController || controller == constants.ManagedGatewayMeshController
}
var outControllers = sets.New(gw.GatewayController(higressconstants.ManagedGatewayController), constants.ManagedGatewayMeshController)
// mergeAncestors merges an existing ancestor with in incoming one. We preserve order, prune stale references set by our controller,
// and add any new references from our controller.
func mergeAncestors(existing []gw.PolicyAncestorStatus, incoming []gw.PolicyAncestorStatus) []gw.PolicyAncestorStatus {
n := 0
for _, x := range existing {
if !isOutController(x.ControllerName) {
if !outControllers.Contains(x.ControllerName) {
// Keep it as-is
existing[n] = x
n++

View File

@@ -21,6 +21,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8s "sigs.k8s.io/gateway-api/apis/v1"
higressconstants "github.com/alibaba/higress/v2/pkg/config/constants"
"istio.io/istio/pilot/pkg/features"
"istio.io/istio/pilot/pkg/model/kstatus"
"istio.io/istio/pkg/config/schema/gvk"
@@ -48,12 +49,11 @@ func createRouteStatus(
generation int64,
currentParents []k8s.RouteParentStatus,
) []k8s.RouteParentStatus {
controllerName := k8s.GatewayController(managedGatewayController)
parents := slices.Clone(currentParents)
parentIndexes := map[string]int{}
for idx, p := range parents {
// Only consider our own
if p.ControllerName != controllerName {
if p.ControllerName != k8s.GatewayController(higressconstants.ManagedGatewayController) {
continue
}
rs := parentRefString(p.ParentRef, objectNamespace)
@@ -186,14 +186,14 @@ func createRouteStatus(
var currentConditions []metav1.Condition
currentStatus := slices.FindFunc(currentParents, func(s k8s.RouteParentStatus) bool {
return parentRefString(s.ParentRef, objectNamespace) == myRef &&
s.ControllerName == controllerName
s.ControllerName == k8s.GatewayController(higressconstants.ManagedGatewayController)
})
if currentStatus != nil {
currentConditions = currentStatus.Conditions
}
ns := k8s.RouteParentStatus{
ParentRef: gw.OriginalReference,
ControllerName: controllerName,
ControllerName: k8s.GatewayController(higressconstants.ManagedGatewayController),
Conditions: setConditions(generation, currentConditions, conds),
}
// Parent ref already exists, insert in the same place

View File

@@ -27,7 +27,6 @@ import (
)
func TestCreateRouteStatus(t *testing.T) {
setGatewayClassNameForTest(t, "")
lastTransitionTime := metav1.Now()
parentRef := httpRouteSpec.ParentRefs[0]
parentStatus := []k8s.RouteParentStatus{
@@ -123,29 +122,3 @@ func TestCreateRouteStatus(t *testing.T) {
})
}
}
func TestCreateRouteStatusWithCustomController(t *testing.T) {
if runInGatewayClassSubprocess(t) {
return
}
setGatewayClassNameForTest(t, "higress-internal")
parentRef := httpRouteSpec.ParentRefs[0]
customController := k8s.GatewayController(managedGatewayController)
current := []k8s.RouteParentStatus{
{
ParentRef: parentRef,
ControllerName: k8s.GatewayController(higressconstants.ManagedGatewayController),
},
}
got := createRouteStatus([]RouteParentResult{{OriginalReference: parentRef}}, "default", 1, current)
if len(got) != 2 {
t.Fatalf("expected default and custom controller status entries, got %+v", got)
}
if got[0].ControllerName != k8s.GatewayController(higressconstants.ManagedGatewayController) {
t.Fatalf("expected existing default controller status to be preserved, got %+v", got)
}
if got[1].ControllerName != customController {
t.Fatalf("expected custom controller status %q, got %+v", customController, got)
}
}

View File

@@ -15,10 +15,6 @@
package istio
import (
"os"
"os/exec"
"regexp"
"strings"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -81,7 +77,6 @@ var AlwaysReady = func(class schema.GroupVersionResource, stop <-chan struct{})
}
func setupController(t *testing.T, objs ...runtime.Object) *Controller {
setGatewayClassNameForTest(t, "")
kc := kube.NewFakeClient(objs...)
setupClientCRDs(t, kc)
stop := test.NewStop(t)
@@ -99,57 +94,6 @@ func setupController(t *testing.T, objs ...runtime.Object) *Controller {
return controller
}
func setupControllerWithGatewayClass(t *testing.T, gatewayClass string, objs ...runtime.Object) *Controller {
setGatewayClassNameForTest(t, gatewayClass)
kc := kube.NewFakeClient(objs...)
setupClientCRDs(t, kc)
stop := test.NewStop(t)
controller := NewController(
kc,
AlwaysReady,
controller.Options{KrtDebugger: krt.GlobalDebugHandler},
nil)
kc.RunAndWait(stop)
go controller.Run(stop)
cg := core.NewConfigGenTest(t, core.TestOptions{})
controller.Reconcile(cg.PushContext())
kube.WaitForCacheSync("test", stop, controller.HasSynced)
return controller
}
func setGatewayClassNameForTest(t *testing.T, gatewayClass string) {
t.Helper()
if gatewayClass != "" {
SetGatewayClassName(gatewayClass)
}
}
func runInGatewayClassSubprocess(t *testing.T) bool {
t.Helper()
const env = "HIGRESS_TEST_GATEWAY_CLASS_SUBPROCESS"
if os.Getenv(env) == t.Name() {
return false
}
cmd := exec.Command(os.Args[0], "-test.run=^"+regexp.QuoteMeta(t.Name())+"$", "-test.count=1")
cmd.Env = append(testEnvWithoutCoverage(), env+"="+t.Name())
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gateway class subprocess failed: %v\n%s", err, out)
}
return true
}
func testEnvWithoutCoverage() []string {
var out []string
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, "GOCOVERDIR=") {
continue
}
out = append(out, kv)
}
return out
}
func TestListInvalidGroupVersionKind(t *testing.T) {
controller := setupController(t)
@@ -191,52 +135,3 @@ func TestListGatewayResourceType(t *testing.T) {
assert.Equal(t, c.Spec, any(expectedgw))
}
}
func TestListGatewayResourceTypeWithCustomGatewayClass(t *testing.T) {
if runInGatewayClassSubprocess(t) {
return
}
customGatewayClass := "higress-internal"
customControllerName := higressconstant.ManagedGatewayController + "-" + customGatewayClass
defaultGateway := gatewaySpec.DeepCopy()
defaultGateway.GatewayClassName = k8s.ObjectName(higressconstant.DefaultGatewayClass)
customGateway := gatewaySpec.DeepCopy()
customGateway.GatewayClassName = k8s.ObjectName(customGatewayClass)
controller := setupControllerWithGatewayClass(t, customGatewayClass,
&k8sbeta.GatewayClass{
ObjectMeta: metav1.ObjectMeta{
Name: higressconstant.DefaultGatewayClass,
},
Spec: *gatewayClassSpec,
},
&k8sbeta.GatewayClass{
ObjectMeta: metav1.ObjectMeta{
Name: customGatewayClass,
},
Spec: k8s.GatewayClassSpec{
ControllerName: k8s.GatewayController(customControllerName),
},
},
&k8sbeta.Gateway{
ObjectMeta: metav1.ObjectMeta{
Name: "default-gw",
Namespace: "ns1",
},
Spec: *defaultGateway,
},
&k8sbeta.Gateway{
ObjectMeta: metav1.ObjectMeta{
Name: "custom-gw",
Namespace: "ns1",
},
Spec: *customGateway,
})
dumpOnFailure(t, krt.GlobalDebugHandler)
cfg := controller.List(gvk.Gateway, "ns1")
assert.Equal(t, len(cfg), 1)
assert.Equal(t, cfg[0].Name, "custom-gw"+"-"+constants.KubernetesGatewayName+"-default")
assert.Equal(t, cfg[0].Namespace, "ns1")
assert.Equal(t, cfg[0].Spec, any(expectedgw))
}

View File

@@ -23,7 +23,6 @@ import (
"github.com/alibaba/higress/v2/pkg/ingress/kube/util"
"istio.io/istio/pilot/pkg/credentials"
"net"
"net/netip"
"path"
inferencev1 "sigs.k8s.io/gateway-api-inference-extension/api/v1"
"sort"
@@ -1736,7 +1735,9 @@ func reportGatewayStatus(
setProgrammedCondition(gatewayConditions, internal, gatewayServices, warnings, allUsable)
addressesToReport := external
addrType := k8s.IPAddressType
if len(addressesToReport) == 0 {
addrType = k8s.HostnameAddressType
for _, hostport := range internal {
svchost, _, _ := net.SplitHostPort(hostport)
if !slices.Contains(pending, svchost) && !slices.Contains(addressesToReport, svchost) {
@@ -1744,21 +1745,12 @@ func reportGatewayStatus(
}
}
}
// Do not report an address until we are ready. But once we are ready, never remove the address.
if len(addressesToReport) > 0 {
gs.Addresses = make([]k8s.GatewayStatusAddress, 0, len(addressesToReport))
for _, addr := range addressesToReport {
var addrType k8s.AddressType
if _, err := netip.ParseAddr(addr); err == nil {
addrType = k8s.IPAddressType
} else {
addrType = k8s.HostnameAddressType
}
gs.Addresses = append(gs.Addresses, k8s.GatewayStatusAddress{
Value: addr,
Type: &addrType,
})
}
gs.Addresses = make([]k8s.GatewayStatusAddress, 0, len(addressesToReport))
for _, addr := range addressesToReport {
gs.Addresses = append(gs.Addresses, k8s.GatewayStatusAddress{
Value: addr,
Type: &addrType,
})
}
// Prune listeners that have been removed
haveListeners := getListenerNames(&obj.Spec)

View File

@@ -30,7 +30,6 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
k8s "sigs.k8s.io/gateway-api/apis/v1"
k8sbeta "sigs.k8s.io/gateway-api/apis/v1beta1"
"sigs.k8s.io/gateway-api/pkg/consts"
"sigs.k8s.io/yaml"
@@ -131,9 +130,9 @@ var services = []*model.Service{
Attributes: model.ServiceAttributes{
Namespace: "default",
Labels: map[string]string{
InferencePoolExtensionRefSvc: "ext-proc-svc",
InferencePoolExtensionRefPort: "9002",
InferencePoolExtensionRefFailureMode: "FailClose",
"higress.io/inferencepool-extension-service": "ext-proc-svc",
"higress.io/inferencepool-extension-port": "9002",
"higress.io/inferencepool-extension-failure-mode": "FailClose",
},
},
Ports: ports,
@@ -146,9 +145,9 @@ var services = []*model.Service{
Attributes: model.ServiceAttributes{
Namespace: "default",
Labels: map[string]string{
InferencePoolExtensionRefSvc: "ext-proc-svc-2",
InferencePoolExtensionRefPort: "9002",
InferencePoolExtensionRefFailureMode: "FailClose",
"higress.io/inferencepool-extension-service": "ext-proc-svc-2",
"higress.io/inferencepool-extension-port": "9002",
"higress.io/inferencepool-extension-failure-mode": "FailClose",
},
},
Ports: ports,
@@ -157,36 +156,6 @@ var services = []*model.Service{
return name
}())),
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
Labels: map[string]string{
InferencePoolExtensionRefSvc: "model1-epp",
InferencePoolExtensionRefPort: "9002",
InferencePoolExtensionRefFailureMode: "FailClose",
},
},
Ports: ports,
Hostname: host.Name(fmt.Sprintf("%s.default.svc.domain.suffix", func() string {
name, _ := InferencePoolServiceName("infpool-model1")
return name
}())),
},
{
Attributes: model.ServiceAttributes{
Namespace: "default",
Labels: map[string]string{
InferencePoolExtensionRefSvc: "model2-epp",
InferencePoolExtensionRefPort: "9002",
InferencePoolExtensionRefFailureMode: "FailClose",
},
},
Ports: ports,
Hostname: host.Name(fmt.Sprintf("%s.default.svc.domain.suffix", func() string {
name, _ := InferencePoolServiceName("infpool-model2")
return name
}())),
},
{
Attributes: model.ServiceAttributes{
@@ -601,8 +570,9 @@ func init() {
features.EnableAlphaGatewayAPI = true
features.EnableAmbientWaypoints = true
features.EnableAmbientMultiNetwork = true
// Recompute with the desired feature flags.
SetGatewayClassName("")
// Recompute with ambient enabled
classInfos = getClassInfos()
builtinClasses = getBuiltinClasses()
}
type TestStatusQueue struct {
@@ -824,128 +794,6 @@ func TestConvertResources(t *testing.T) {
}
}
func TestReportGatewayStatusAddressType(t *testing.T) {
cases := []struct {
name string
ingresses []corev1.LoadBalancerIngress
want map[string]k8s.AddressType
}{
{
name: "load balancer IPv4",
ingresses: []corev1.LoadBalancerIngress{
{IP: "47.98.1.2"},
},
want: map[string]k8s.AddressType{
"47.98.1.2": k8s.IPAddressType,
},
},
{
name: "load balancer IPv6",
ingresses: []corev1.LoadBalancerIngress{
{IP: "2001:db8::1"},
},
want: map[string]k8s.AddressType{
"2001:db8::1": k8s.IPAddressType,
},
},
{
name: "load balancer hostname",
ingresses: []corev1.LoadBalancerIngress{
{Hostname: "k8s-higress-higressg-1234567890.us-west-2.elb.amazonaws.com"},
},
want: map[string]k8s.AddressType{
"k8s-higress-higressg-1234567890.us-west-2.elb.amazonaws.com": k8s.HostnameAddressType,
},
},
{
name: "mixed load balancer addresses",
ingresses: []corev1.LoadBalancerIngress{
{IP: "47.98.1.2"},
{Hostname: "higress.cn-hangzhou.alb.aliyuncs.com"},
},
want: map[string]k8s.AddressType{
"47.98.1.2": k8s.IPAddressType,
"higress.cn-hangzhou.alb.aliyuncs.com": k8s.HostnameAddressType,
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "higress-gateway",
Namespace: "higress-system",
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeLoadBalancer,
Ports: []corev1.ServicePort{
{
Name: "http",
Port: 80,
Protocol: corev1.ProtocolTCP,
},
},
},
Status: corev1.ServiceStatus{
LoadBalancer: corev1.LoadBalancerStatus{
Ingress: tt.ingresses,
},
},
}
stop := test.NewStop(t)
kc := kube.NewFakeClient(svc)
kc.RunAndWait(stop)
ctx := NewGatewayContext(nil, constants.DefaultClusterName, kc, "cluster.local")
gw := &k8sbeta.Gateway{
ObjectMeta: metav1.ObjectMeta{
Name: "higress-gateway",
Namespace: "higress-system",
Generation: 1,
},
Spec: k8sbeta.GatewaySpec{
GatewayClassName: "higress",
Listeners: []k8sbeta.Listener{
{
Name: "http",
Port: 80,
Protocol: k8s.HTTPProtocolType,
},
},
},
}
gs := &k8sbeta.GatewayStatus{}
servers := []*istio.Server{
{
Port: &istio.Port{
Name: "http",
Number: 80,
Protocol: "HTTP",
},
},
}
reportGatewayStatus(&ctx, gw, gs, []string{"higress-gateway.higress-system.svc.cluster.local"}, servers, 0, nil)
if len(gs.Addresses) != len(tt.want) {
t.Fatalf("expected %d addresses, got %d: %#v", len(tt.want), len(gs.Addresses), gs.Addresses)
}
for _, got := range gs.Addresses {
wantType, ok := tt.want[got.Value]
if !ok {
t.Fatalf("unexpected address value %q in %#v", got.Value, gs.Addresses)
}
if got.Type == nil {
t.Fatalf("expected address %q type %q, got nil", got.Value, wantType)
}
if *got.Type != wantType {
t.Fatalf("expected address %q type %q, got %q", got.Value, wantType, *got.Type)
}
}
})
}
}
func setupClientCRDs(t *testing.T, kc kube.CLIClient) {
for _, crd := range []schema.GroupVersionResource{
gvr.KubernetesGateway,

View File

@@ -48,33 +48,15 @@ type classInfo struct {
addressType gateway.AddressType
}
var (
gatewayClassName = gateway.ObjectName(higressconstants.DefaultGatewayClass)
managedGatewayController = gateway.GatewayController(higressconstants.ManagedGatewayController)
classInfos = getClassInfos()
builtinClasses = getBuiltinClasses()
)
var classInfos = getClassInfos()
// SetGatewayClassName configures the single GatewayClassName this process owns.
func SetGatewayClassName(gatewayClass string) {
if gatewayClass == "" {
gatewayClass = higressconstants.DefaultGatewayClass
}
gatewayClassName = gateway.ObjectName(gatewayClass)
if gatewayClass == higressconstants.DefaultGatewayClass {
managedGatewayController = gateway.GatewayController(higressconstants.ManagedGatewayController)
} else {
managedGatewayController = gateway.GatewayController(higressconstants.ManagedGatewayController + "-" + gatewayClass)
}
classInfos = getClassInfos()
builtinClasses = getBuiltinClasses()
}
var builtinClasses = getBuiltinClasses()
func getBuiltinClasses() map[gateway.ObjectName]gateway.GatewayController {
res := map[gateway.ObjectName]gateway.GatewayController{
// Start - Updated by Higress
//gateway.ObjectName(features.GatewayAPIDefaultGatewayClass): gateway.GatewayController(features.ManagedGatewayController),
gatewayClassName: managedGatewayController,
higressconstants.DefaultGatewayClass: higressconstants.ManagedGatewayController,
// End - Updated by Higress
}
// Start - Commented by Higress
@@ -98,8 +80,8 @@ func getBuiltinClasses() map[gateway.ObjectName]gateway.GatewayController {
func getClassInfos() map[gateway.GatewayController]classInfo {
// Start - Updated by Higress
m := map[gateway.GatewayController]classInfo{
managedGatewayController: {
controller: string(managedGatewayController),
gateway.GatewayController(higressconstants.ManagedGatewayController): {
controller: higressconstants.ManagedGatewayController,
description: "The default Higress GatewayClass",
templates: "kube-gateway",
defaultServiceType: corev1.ServiceTypeLoadBalancer,

View File

@@ -38,11 +38,8 @@ func GatewayClassesCollection(
krt.Collection[GatewayClass],
) {
return krt.NewStatusCollection(gatewayClasses, func(ctx krt.HandlerContext, obj *gateway.GatewayClass) (*gateway.GatewayClassStatus, *GatewayClass) {
if gatewayv1.ObjectName(obj.Name) != gatewayv1.ObjectName(gatewayClassName) ||
obj.Spec.ControllerName != managedGatewayController {
return nil, nil
}
if _, known := classInfos[obj.Spec.ControllerName]; !known {
_, known := classInfos[obj.Spec.ControllerName]
if !known {
return nil, nil
}
status := obj.Status.DeepCopy()

View File

@@ -16,13 +16,13 @@ package istio
import (
"fmt"
"github.com/alibaba/higress/v2/pkg/config/constants"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
gateway "sigs.k8s.io/gateway-api/apis/v1beta1"
"github.com/alibaba/higress/v2/pkg/config/constants"
"istio.io/istio/pkg/kube"
"istio.io/istio/pkg/kube/kclient/clienttest"
"istio.io/istio/pkg/test"
@@ -30,7 +30,6 @@ import (
)
func TestClassController(t *testing.T) {
setGatewayClassNameForTest(t, "")
client := kube.NewFakeClient()
cc := NewClassController(client)
classes := clienttest.Wrap(t, cc.classes)
@@ -92,70 +91,3 @@ func TestClassController(t *testing.T) {
deleteClass("something-else")
expectClass("something-else", "")
}
func TestClassControllerWithCustomGatewayClass(t *testing.T) {
if runInGatewayClassSubprocess(t) {
return
}
gatewayClass := "higress-internal"
setGatewayClassNameForTest(t, gatewayClass)
client := kube.NewFakeClient()
controllerName := string(gateway.GatewayController(constants.ManagedGatewayController + "-" + gatewayClass))
cc := NewClassController(client)
classes := clienttest.Wrap(t, cc.classes)
stop := test.NewStop(t)
client.RunAndWait(stop)
go cc.Run(stop)
expectClass := func(name, controller string) {
t.Helper()
retry.UntilSuccessOrFail(t, func() error {
gc := classes.Get(name, "")
if controller == "" {
if gc == nil {
return nil
}
return fmt.Errorf("expected no class, got %v", gc.Spec.ControllerName)
}
if gc == nil {
return fmt.Errorf("expected class %v, got none", controller)
}
if gateway.GatewayController(controller) != gc.Spec.ControllerName {
return fmt.Errorf("expected class %v, got %v", controller, gc.Spec.ControllerName)
}
return nil
}, retry.Timeout(time.Second*3))
}
expectClass(gatewayClass, controllerName)
expectClass(constants.DefaultGatewayClass, "")
}
func TestSetGatewayClassName(t *testing.T) {
if runInGatewayClassSubprocess(t) {
return
}
SetGatewayClassName("")
if gatewayClassName != gateway.ObjectName(constants.DefaultGatewayClass) {
t.Fatalf("expected default gateway class %q, got %q", constants.DefaultGatewayClass, gatewayClassName)
}
if managedGatewayController != gateway.GatewayController(constants.ManagedGatewayController) {
t.Fatalf("expected default controller %q, got %q", constants.ManagedGatewayController, managedGatewayController)
}
customClass := "higress-internal"
SetGatewayClassName(customClass)
customController := gateway.GatewayController(constants.ManagedGatewayController + "-" + customClass)
if gatewayClassName != gateway.ObjectName(customClass) {
t.Fatalf("expected custom gateway class %q, got %q", customClass, gatewayClassName)
}
if managedGatewayController != customController {
t.Fatalf("expected custom controller %q, got %q", customController, managedGatewayController)
}
if got := builtinClasses[gateway.ObjectName(customClass)]; got != customController {
t.Fatalf("expected builtin class controller %q, got %q", customController, got)
}
if _, exists := builtinClasses[gateway.ObjectName(constants.DefaultGatewayClass)]; exists {
t.Fatalf("custom config should not include default gateway class %q", constants.DefaultGatewayClass)
}
}

View File

@@ -52,6 +52,8 @@ const (
// ControllerName is the name of this controller for labeling resources it manages
const ControllerName = "inference-controller"
var supportedControllers = getSupportedControllers()
func getSupportedControllers() sets.Set[gatewayv1.GatewayController] {
ret := sets.New[gatewayv1.GatewayController]()
for _, controller := range builtinClasses {
@@ -239,7 +241,7 @@ func findGatewayParents(
for _, parentStatus := range route.Status.Parents {
// Only consider parents managed by our supported controllers (from supportedControllers variable)
// This filters out parents from other controllers we don't manage
if !getSupportedControllers().Contains(parentStatus.ControllerName) {
if !supportedControllers.Contains(parentStatus.ControllerName) {
continue
}
@@ -352,7 +354,7 @@ func calculateAcceptedStatus(
// Check if this route has our gateway as a parent and if it's accepted
for _, parentStatus := range route.Status.Parents {
// Only consider parents managed by supported controllers
if !getSupportedControllers().Contains(parentStatus.ControllerName) {
if !supportedControllers.Contains(parentStatus.ControllerName) {
continue
}

View File

@@ -766,63 +766,12 @@ func mergeHTTPRoutes(baseVirtualServices krt.Collection[RouteWithKey], opts ...k
sortRoutesByCreationTime(configs)
base := configs[0].DeepCopy()
baseVS := base.Spec.(*istio.VirtualService)
// Deep copy the InferencePool configs map to avoid race conditions
// The default DeepCopy() only does shallow copy of Extra field
if base.Extra != nil {
if ipConfigs, ok := base.Extra[constants.ConfigExtraPerRouteRuleInferencePoolConfigs].(map[string]kube.InferencePoolRouteRuleConfig); ok {
// Create a new map to avoid modifying the shared underlying map
newIPConfigs := make(map[string]kube.InferencePoolRouteRuleConfig, len(ipConfigs))
for k, v := range ipConfigs {
newIPConfigs[k] = v
}
base.Extra[constants.ConfigExtraPerRouteRuleInferencePoolConfigs] = newIPConfigs
}
}
for i, config := range configs[1:] {
for _, config := range configs[1:] {
thisVS := config.Spec.(*istio.VirtualService)
baseVS.Http = append(baseVS.Http, thisVS.Http...)
// append parents
base.Annotations[constants.InternalParentNames] = fmt.Sprintf("%s,%s",
base.Annotations[constants.InternalParentNames], config.Annotations[constants.InternalParentNames])
// Merge Extra field (especially for InferencePool configs)
if base.Extra == nil && config.Extra != nil {
base.Extra = make(map[string]any)
}
if config.Extra != nil {
for k, v := range config.Extra {
// For non-InferencePool configs, keep the first value for stability
if k != constants.ConfigExtraPerRouteRuleInferencePoolConfigs {
if _, exists := base.Extra[k]; !exists {
base.Extra[k] = v
}
continue
}
// For InferencePool configs, merge the maps
baseMap, baseOk := base.Extra[k].(map[string]kube.InferencePoolRouteRuleConfig)
configMap, configOk := v.(map[string]kube.InferencePoolRouteRuleConfig)
if baseOk && configOk {
log.Debugf("Merging InferencePool configs: adding %d route configs from VirtualService %d to base (namespace=%s)",
len(configMap), i+1, config.Namespace)
// Route names are composed of the HTTPRoute/VirtualService namespaced name so they can't possibly conflict
for routeName, routeConfig := range configMap {
baseMap[routeName] = routeConfig
}
} else if configOk {
if _, exists := base.Extra[k]; !exists {
log.Debugf("Creating new InferencePool config map from VirtualService %d (namespace=%s)", i+1, config.Namespace)
base.Extra[k] = v
}
} else if !configOk {
log.Debugf("Skipping InferencePool config from VirtualService %d due to unexpected type (namespace=%s)", i+1, config.Namespace)
}
}
}
}
// Log final merged InferencePool configs
if base.Extra != nil {
if ipConfigs, ok := base.Extra[constants.ConfigExtraPerRouteRuleInferencePoolConfigs].(map[string]kube.InferencePoolRouteRuleConfig); ok {
log.Debugf("Final merged VirtualService for key %s has %d InferencePool route configs", object.Key, len(ipConfigs))
}
}
sortHTTPRoutes(baseVS.Http)
base.Name = strings.ReplaceAll(object.Key, "/", "~")

View File

@@ -1,140 +0,0 @@
// Copyright Istio Authors
//
// 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.
package istio
import (
"strings"
"testing"
"time"
istio "istio.io/api/networking/v1alpha3"
"istio.io/istio/pkg/config"
"istio.io/istio/pkg/config/constants"
"istio.io/istio/pkg/config/gateway/kube"
"istio.io/istio/pkg/kube/krt"
"istio.io/istio/pkg/test"
)
func TestMergeHTTPRoutesMergesInferencePoolExtra(t *testing.T) {
stop := test.NewStop(t)
routeKey := "default/gateway/example.com"
baseRouteName := "default/local-ai-chat"
otherRouteName := "default/local-ai-chat-360m"
baseInferenceConfigs := map[string]kube.InferencePoolRouteRuleConfig{
baseRouteName: {
FQDN: "local-ai-chat-pool-epp.default.svc.cluster.local",
Port: "9002",
FailureModeAllow: true,
},
}
otherInferenceConfigs := map[string]kube.InferencePoolRouteRuleConfig{
otherRouteName: {
FQDN: "local-ai-chat-360m-pool-epp.default.svc.cluster.local",
Port: "9002",
},
}
baseCfg := &config.Config{
Meta: config.Meta{
Name: "local-ai-chat",
Namespace: "default",
CreationTimestamp: time.Unix(1, 0),
Annotations: map[string]string{
constants.InternalParentNames: "parent-a",
},
},
Spec: &istio.VirtualService{
Hosts: []string{"example.com"},
Gateways: []string{"default/gateway"},
Http: []*istio.HTTPRoute{{
Name: baseRouteName,
}},
},
Extra: map[string]any{
constants.ConfigExtraPerRouteRuleInferencePoolConfigs: baseInferenceConfigs,
"non-inference-extra": "kept-from-base",
},
}
otherCfg := &config.Config{
Meta: config.Meta{
Name: "local-ai-chat-360m",
Namespace: "default",
CreationTimestamp: time.Unix(2, 0),
Annotations: map[string]string{
constants.InternalParentNames: "parent-b",
},
},
Spec: &istio.VirtualService{
Hosts: []string{"example.com"},
Gateways: []string{"default/gateway"},
Http: []*istio.HTTPRoute{{
Name: otherRouteName,
}},
},
Extra: map[string]any{
constants.ConfigExtraPerRouteRuleInferencePoolConfigs: otherInferenceConfigs,
"non-inference-extra": "ignored-from-later-route",
"other-extra": "added-from-later-route",
},
}
baseVirtualServices := krt.NewStaticCollection[RouteWithKey](nil, []RouteWithKey{
{
Config: baseCfg,
Key: routeKey,
},
{
Config: otherCfg,
Key: routeKey,
},
}, krt.WithStop(stop), krt.WithName("base"))
merged := mergeHTTPRoutes(baseVirtualServices, krt.WithStop(stop), krt.WithName("merged"))
merged.WaitUntilSynced(stop)
gotList := merged.List()
if len(gotList) != 1 {
t.Fatalf("expected one merged VirtualService, got %d", len(gotList))
}
got := gotList[0]
if got.Name != strings.ReplaceAll(routeKey, "/", "~") {
t.Fatalf("expected merged VirtualService name %q, got %q", strings.ReplaceAll(routeKey, "/", "~"), got.Name)
}
gotVS := got.Spec.(*istio.VirtualService)
if len(gotVS.Http) != 2 {
t.Fatalf("expected merged VirtualService to contain 2 HTTP routes, got %d", len(gotVS.Http))
}
gotInferenceConfigs, ok := got.Extra[constants.ConfigExtraPerRouteRuleInferencePoolConfigs].(map[string]kube.InferencePoolRouteRuleConfig)
if !ok {
t.Fatalf("expected merged InferencePool configs, got %T", got.Extra[constants.ConfigExtraPerRouteRuleInferencePoolConfigs])
}
if len(gotInferenceConfigs) != 2 {
t.Fatalf("expected 2 merged InferencePool configs, got %d: %v", len(gotInferenceConfigs), gotInferenceConfigs)
}
if gotInferenceConfigs[baseRouteName].FQDN != baseInferenceConfigs[baseRouteName].FQDN {
t.Fatalf("expected base route InferencePool config to be preserved, got %v", gotInferenceConfigs[baseRouteName])
}
if gotInferenceConfigs[otherRouteName].FQDN != otherInferenceConfigs[otherRouteName].FQDN {
t.Fatalf("expected later route InferencePool config to be merged, got %v", gotInferenceConfigs[otherRouteName])
}
if got.Extra["non-inference-extra"] != "kept-from-base" {
t.Fatalf("expected non-InferencePool Extra to keep base value, got %v", got.Extra["non-inference-extra"])
}
if got.Extra["other-extra"] != "added-from-later-route" {
t.Fatalf("expected missing non-InferencePool Extra to be added, got %v", got.Extra["other-extra"])
}
if _, found := baseInferenceConfigs[otherRouteName]; found {
t.Fatalf("expected base InferencePool config map not to be mutated by merge")
}
}

View File

@@ -14,22 +14,6 @@ metadata:
spec: null
status: {}
---
apiVersion: inference.networking.k8s.io/v1
kind: InferencePool
metadata:
name: infpool-model1
namespace: default
spec: null
status: {}
---
apiVersion: inference.networking.k8s.io/v1
kind: InferencePool
metadata:
name: infpool-model2
namespace: default
spec: null
status: {}
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: GatewayClass
metadata:
@@ -65,7 +49,7 @@ status:
status: "True"
type: Programmed
listeners:
- attachedRoutes: 13
- attachedRoutes: 11
conditions:
- lastTransitionTime: fake
message: No errors found
@@ -288,54 +272,6 @@ status:
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: multi-route-infpool-1
namespace: default
spec: null
status:
parents:
- conditions:
- lastTransitionTime: fake
message: Route was valid
reason: Accepted
status: "True"
type: Accepted
- lastTransitionTime: fake
message: All references resolved
reason: ResolvedRefs
status: "True"
type: ResolvedRefs
controllerName: higress.io/gateway-controller
parentRef:
name: gateway
namespace: higress-system
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: multi-route-infpool-2
namespace: default
spec: null
status:
parents:
- conditions:
- lastTransitionTime: fake
message: Route was valid
reason: Accepted
status: "True"
type: Accepted
- lastTransitionTime: fake
message: All references resolved
reason: ResolvedRefs
status: "True"
type: ResolvedRefs
controllerName: higress.io/gateway-controller
parentRef:
name: gateway
namespace: higress-system
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: multiple-inferencepool-backend-refs
namespace: default

View File

@@ -421,80 +421,3 @@ spec:
name: vllm-llama3-8b-instruct-epp
port:
number: 9002
---
# Test case for multiple HTTPRoutes with InferencePools on same gateway.
# This verifies that InferencePool configs in Config.Extra are preserved when
# the routes are merged into a single VirtualService.
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: multi-route-infpool-1
namespace: default
spec:
parentRefs:
- name: gateway
namespace: higress-system
hostnames: ["multi-infpool.domain.example"]
rules:
- matches:
- path:
type: PathPrefix
value: /model1
backendRefs:
- name: infpool-model1
group: inference.networking.k8s.io
kind: InferencePool
port: 80
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: multi-route-infpool-2
namespace: default
spec:
parentRefs:
- name: gateway
namespace: higress-system
hostnames: ["multi-infpool.domain.example"]
rules:
- matches:
- path:
type: PathPrefix
value: /model2
backendRefs:
- name: infpool-model2
group: inference.networking.k8s.io
kind: InferencePool
port: 80
---
apiVersion: inference.networking.k8s.io/v1
kind: InferencePool
metadata:
name: infpool-model1
namespace: default
spec:
targetPorts:
- number: 8000
selector:
matchLabels:
app: model1-server
endpointPickerRef:
name: model1-epp
port:
number: 9002
---
apiVersion: inference.networking.k8s.io/v1
kind: InferencePool
metadata:
name: infpool-model2
namespace: default
spec:
targetPorts:
- number: 8000
selector:
matchLabels:
app: model2-server
endpointPickerRef:
name: model2-epp
port:
number: 9002

View File

@@ -242,35 +242,6 @@ spec:
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
annotations:
internal.istio.io/parents: HTTPRoute/multi-route-infpool-1.default,HTTPRoute/multi-route-infpool-2.default
internal.istio.io/route-semantics: gateway
name: higress-system~gateway-istio-autogenerated-k8s-gateway-default~multi-infpool.domain.example
namespace: default
spec:
gateways:
- higress-system/gateway-istio-autogenerated-k8s-gateway-default
hosts:
- multi-infpool.domain.example
http:
- match:
- uri:
prefix: /model1
name: default/multi-route-infpool-1
route:
- destination:
host: infpool-model1-ip-aaaaf2d6.default.svc.domain.suffix
- match:
- uri:
prefix: /model2
name: default/multi-route-infpool-2
route:
- destination:
host: infpool-model2-ip-f857bff9.default.svc.domain.suffix
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
annotations:
internal.istio.io/parents: HTTPRoute/redirect-prefix-replace.default

View File

@@ -409,7 +409,7 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
Protocol: string(protocol.HTTP),
Name: common.CreateConvertedName("http-80-ingress", c.options.ClusterId.String()),
},
Hosts: []string{common.WildcardHost(rule.Host)},
Hosts: []string{rule.Host},
})
// Add new gateway, builder
@@ -422,45 +422,6 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
}
}
passthroughOwner := common.PassthroughTLSHostOwner(convertOptions, rule.Host)
standaloneSSLPassthrough := convertOptions.PassthroughTLSHostOwners == nil && wrapper.AnnotationsConfig.IsSSLPassthrough()
if common.SameConfig(passthroughOwner, cfg) || standaloneSSLPassthrough {
if rule.HTTP == nil || len(rule.HTTP.Paths) == 0 {
continue
}
if _, ok := rootHTTPIngressPath(rule.HTTP.Paths); !ok {
continue
}
domainBuilder.Protocol = common.HTTPS
if wrapperGateway.IsHTTPS() {
if common.SameConfig(preDomainBuilder.Ingress, cfg) {
continue
}
domainBuilder.Event = common.DuplicatedTls
domainBuilder.PreIngress = preDomainBuilder.Ingress
convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid,
domainBuilder.Build())
continue
}
wrapperGateway.Gateway.Servers = append(wrapperGateway.Gateway.Servers,
common.CreateSSLPassthroughServer(rule.Host, c.options.GatewayHttpsPort, c.options.ClusterId))
convertOptions.IngressDomainCache.Valid[rule.Host] = domainBuilder
continue
}
if wrapper.AnnotationsConfig.IsSSLPassthrough() {
if rule.HTTP != nil {
if _, ok := rootHTTPIngressPath(rule.HTTP.Paths); ok && passthroughOwner != nil {
domainBuilder.Protocol = common.HTTPS
domainBuilder.Event = common.DuplicatedTls
domainBuilder.PreIngress = passthroughOwner
convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid,
domainBuilder.Build())
}
}
continue
}
// There are no tls settings, so just skip.
if len(ingressV1Beta.TLS) == 0 {
continue
@@ -509,14 +470,6 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
domainBuilder.SecretName = path.Join(c.options.ClusterId.String(), cfg.Namespace, secretName)
if passthroughOwner != nil {
domainBuilder.Event = common.DuplicatedTls
domainBuilder.PreIngress = passthroughOwner
convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid,
domainBuilder.Build())
continue
}
// There is a matching secret and the gateway has already a tls secret.
// We should report the duplicated tls secret event.
if wrapperGateway.IsHTTPS() {
@@ -534,7 +487,7 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
Protocol: string(protocol.HTTPS),
Name: common.CreateConvertedName("https-443-ingress", c.options.ClusterId.String()),
},
Hosts: []string{common.WildcardHost(rule.Host)},
Hosts: []string{rule.Host},
Tls: &networking.ServerTLSSettings{
Mode: networking.ServerTLSSettings_SIMPLE,
CredentialName: credentials.ToKubernetesIngressResource(c.options.RawClusterId, secretNamespace, secretName),
@@ -562,19 +515,6 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
return nil
}
if convertOptions.Route2Ingress == nil {
convertOptions.Route2Ingress = map[string]*common.WrapperConfigWithRuleKey{}
}
if convertOptions.IngressRouteCache == nil {
convertOptions.IngressRouteCache = common.NewIngressRouteCache()
}
if convertOptions.VirtualServices == nil {
convertOptions.VirtualServices = map[string]*common.WrapperVirtualService{}
}
if convertOptions.HTTPRoutes == nil {
convertOptions.HTTPRoutes = map[string][]*common.WrapperHTTPRoute{}
}
cfg := wrapper.Config
ingressV1, ok := cfg.Spec.(ingress.IngressSpec)
if !ok {
@@ -606,7 +546,12 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
wrapperVS, exist := convertOptions.VirtualServices[rule.Host]
if !exist {
wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper)
wrapperVS = &common.WrapperVirtualService{
VirtualService: &networking.VirtualService{
Hosts: []string{rule.Host},
},
WrapperConfig: wrapper,
}
convertOptions.VirtualServices[rule.Host] = wrapperVS
}
@@ -634,11 +579,7 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
pathType = common.PrefixRegex
}
} else {
ingressPathType := defaultPathType
if httpPath.PathType != nil {
ingressPathType = *httpPath.PathType
}
switch ingressPathType {
switch *httpPath.PathType {
case ingress.PathTypeExact:
pathType = common.Exact
case ingress.PathTypePrefix:
@@ -720,84 +661,9 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
common.SortHTTPRoutes(routes)
}
if common.HasPassthroughTLSHostOwner(convertOptions, cfg) ||
(convertOptions.PassthroughTLSHostOwners == nil && wrapper.AnnotationsConfig.IsSSLPassthrough()) {
return c.ConvertTLSRoute(convertOptions, wrapper)
}
return nil
}
func (c *controller) ConvertTLSRoute(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig) error {
if convertOptions == nil {
return fmt.Errorf("convertOptions is nil")
}
if wrapper == nil {
return fmt.Errorf("wrapperConfig is nil")
}
if convertOptions.VirtualServices == nil {
convertOptions.VirtualServices = map[string]*common.WrapperVirtualService{}
}
cfg := wrapper.Config
ingressV1Beta, ok := cfg.Spec.(ingress.IngressSpec)
if !ok {
common.IncrementInvalidIngress(c.options.ClusterId, common.Unknown)
return fmt.Errorf("convert type is invalid in cluster %s", c.options.ClusterId)
}
if len(ingressV1Beta.Rules) == 0 {
common.IncrementInvalidIngress(c.options.ClusterId, common.EmptyRule)
return fmt.Errorf("invalid ingress rule %s:%s in cluster %s, `rules` must be specified", cfg.Namespace, cfg.Name, c.options.ClusterId)
}
for _, rule := range ingressV1Beta.Rules {
if !common.IsPassthroughTLSHostOwner(convertOptions, cfg, rule.Host) {
IngressLog.Warnf("ignore duplicated ssl passthrough ingress rule %s:%s for host %q in cluster %s", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId)
continue
}
if rule.HTTP == nil || len(rule.HTTP.Paths) == 0 {
IngressLog.Warnf("invalid ssl passthrough ingress rule %s:%s for host %q in cluster %s, no paths defined", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId)
continue
}
httpPath, ok := rootHTTPIngressPath(rule.HTTP.Paths)
if !ok {
IngressLog.Warnf("ignore ssl passthrough ingress rule %s:%s for host %q in cluster %s, root path is not defined", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId)
continue
}
wrapperVS, exist := convertOptions.VirtualServices[rule.Host]
if !exist {
wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper)
convertOptions.VirtualServices[rule.Host] = wrapperVS
} else if wrapperVS.HasTLSRouteForHost(rule.Host) {
continue
}
routeDestination, event := c.backendToTLSRouteDestination(&httpPath.Backend, cfg.Namespace, wrapper.AnnotationsConfig.Destination)
if event != common.Normal {
common.IncrementInvalidIngress(c.options.ClusterId, event)
continue
}
wrapperVS.VirtualService.Tls = append(wrapperVS.VirtualService.Tls,
common.CreateTLSRoute(rule.Host, routeDestination))
}
return nil
}
func rootHTTPIngressPath(paths []ingress.HTTPIngressPath) (*ingress.HTTPIngressPath, bool) {
for idx := range paths {
if paths[idx].Path == "" || paths[idx].Path == "/" {
return &paths[idx], true
}
}
return nil, false
}
func (c *controller) ApplyDefaultBackend(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig) error {
if convertOptions == nil {
return fmt.Errorf("convertOptions is nil")
@@ -825,7 +691,12 @@ func (c *controller) ApplyDefaultBackend(convertOptions *common.ConvertOptions,
wirecardVS, exist := convertOptions.VirtualServices[host]
if !exist || !wirecardVS.ConfiguredDefaultBackend {
if !exist {
wirecardVS = common.NewWrapperVirtualService(host, wrapper)
wirecardVS = &common.WrapperVirtualService{
VirtualService: &networking.VirtualService{
Hosts: []string{host},
},
WrapperConfig: wrapper,
}
}
specDefaultBackend := c.createDefaultRoute(wrapper, ingressV1Beta1.Backend, "*")
@@ -919,11 +790,7 @@ func (c *controller) ApplyCanaryIngress(convertOptions *common.ConvertOptions, w
pathType = common.PrefixRegex
}
} else {
ingressPathType := defaultPathType
if httpPath.PathType != nil {
ingressPathType = *httpPath.PathType
}
switch ingressPathType {
switch *httpPath.PathType {
case ingress.PathTypeExact:
pathType = common.Exact
case ingress.PathTypePrefix:
@@ -1225,53 +1092,6 @@ func (c *controller) backendToRouteDestination(backend *ingress.IngressBackend,
}, common.Normal
}
func (c *controller) backendToTLSRouteDestination(backend *ingress.IngressBackend, namespace string,
config *annotations.DestinationConfig,
) ([]*networking.RouteDestination, common.Event) {
if backend == nil {
return nil, common.InvalidBackendService
}
if backend.ServiceName == "" {
if config != nil && len(config.McpDestination) > 0 {
return httpRouteDestinationToRouteDestination(config.McpDestination), common.Normal
}
return nil, common.InvalidBackendService
}
port := &networking.PortSelector{}
if backend.ServicePort.Type == intstr.Int {
port.Number = uint32(backend.ServicePort.IntVal)
} else {
resolvedPort, err := resolveNamedPort(backend, namespace, c.serviceLister)
if err != nil {
return nil, common.PortNameResolveError
}
port.Number = uint32(resolvedPort)
}
return []*networking.RouteDestination{
{
Destination: &networking.Destination{
Host: util.CreateServiceFQDN(namespace, backend.ServiceName),
Port: port,
},
Weight: 100,
},
}, common.Normal
}
func httpRouteDestinationToRouteDestination(destinations []*networking.HTTPRouteDestination) []*networking.RouteDestination {
out := make([]*networking.RouteDestination, 0, len(destinations))
for _, destination := range destinations {
out = append(out, &networking.RouteDestination{
Destination: destination.Destination,
Weight: destination.Weight,
})
}
return out
}
func resolveNamedPort(backend *ingress.IngressBackend, namespace string, serviceLister listerv1.ServiceLister) (int32, error) {
if backend == nil {
return 0, fmt.Errorf("ingressBackend is nil")

View File

@@ -16,11 +16,9 @@ package ingress
import (
"context"
"strings"
"testing"
"time"
"github.com/alibaba/higress/v2/pkg/cert"
"github.com/google/go-cmp/cmp"
"istio.io/api/networking/v1alpha3"
"istio.io/istio/pilot/pkg/model"
@@ -70,962 +68,6 @@ func TestIngressControllerApplies(t *testing.T) {
}
}
func TestSSLPassthroughConvertGatewayAndTLSRoute(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "app",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
gatewayOptions := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(gatewayOptions, wrapper, nil); err != nil {
t.Fatalf("ConvertGateway() error = %v", err)
}
gateway := gatewayOptions.Gateways["example.com"].Gateway
if len(gateway.Servers) != 2 {
t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers))
}
tlsServer := gateway.Servers[1]
if tlsServer.Port.Protocol != "TLS" {
t.Fatalf("protocol mismatch, want TLS, got %s", tlsServer.Port.Protocol)
}
if tlsServer.Port.Number != 443 {
t.Fatalf("port mismatch, want 443, got %d", tlsServer.Port.Number)
}
if tlsServer.Tls.GetMode() != v1alpha3.ServerTLSSettings_PASSTHROUGH {
t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", tlsServer.Tls.GetMode())
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
httpRoutes := routeOptions.HTTPRoutes["example.com"]
if len(httpRoutes) != 1 {
t.Fatalf("http route count mismatch, want 1, got %d", len(httpRoutes))
}
if got := httpRoutes[0].HTTPRoute.Route[0].Destination.Host; got != "app.default.svc.cluster.local" {
t.Fatalf("http destination host mismatch, got %s", got)
}
routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
route := routes[0]
if got := route.Match[0].SniHosts[0]; got != "example.com" {
t.Fatalf("sni host mismatch, want example.com, got %s", got)
}
if got := route.Route[0].Destination.Host; got != "app.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
if got := route.Route[0].Destination.Port.Number; got != 443 {
t.Fatalf("destination port mismatch, got %d", got)
}
}
func TestSSLPassthroughConvertTLSRouteRejectsNilInputs(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{},
AnnotationsConfig: &annotations.Ingress{},
}
if err := c.ConvertTLSRoute(nil, wrapper); err == nil {
t.Fatal("ConvertTLSRoute() with nil convertOptions returned nil error")
}
if err := c.ConvertTLSRoute(&common.ConvertOptions{}, nil); err == nil {
t.Fatal("ConvertTLSRoute() with nil wrapper returned nil error")
}
}
func TestSSLPassthroughUsesConfiguredHTTPSPort(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 8443,
},
}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "app",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
gatewayOptions := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(gatewayOptions, wrapper, nil); err != nil {
t.Fatalf("ConvertGateway() error = %v", err)
}
tlsServer := gatewayOptions.Gateways["example.com"].Gateway.Servers[1]
if tlsServer.Port.Number != 8443 {
t.Fatalf("port mismatch, want 8443, got %d", tlsServer.Port.Number)
}
}
func TestSSLPassthroughCanaryIngressKeepsCanaryHandling(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-canary",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "app-canary",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
Canary: &annotations.CanaryConfig{Enabled: true},
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
if len(routeOptions.CanaryIngresses) != 1 {
t.Fatalf("canary ingress count mismatch, want 1, got %d", len(routeOptions.CanaryIngresses))
}
if len(routeOptions.VirtualServices) != 0 {
t.Fatalf("unexpected virtual services: %+v", routeOptions.VirtualServices)
}
}
func TestSSLPassthroughSkipsDuplicatedTLSHost(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
primary := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-primary",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "primary",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
duplicate := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-duplicate",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "duplicate",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
PassthroughTLSHostOwners: map[string]*config.Config{"example.com": primary.Config},
}
if err := c.ConvertGateway(options, primary, nil); err != nil {
t.Fatalf("ConvertGateway(primary) error = %v", err)
}
if err := c.ConvertGateway(options, duplicate, nil); err != nil {
t.Fatalf("ConvertGateway(duplicate) error = %v", err)
}
options.VirtualServices = map[string]*common.WrapperVirtualService{}
if err := c.ConvertTLSRoute(options, duplicate); err != nil {
t.Fatalf("ConvertTLSRoute() error = %v", err)
}
if len(options.VirtualServices) != 0 {
t.Fatalf("unexpected virtual services: %+v", options.VirtualServices)
}
}
func TestSSLPassthroughDuplicateTLSHostUsesExistingGatewayOwner(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
primary := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-primary",
},
Spec: ingress.IngressSpec{
TLS: []ingress.IngressTLS{
{Hosts: []string{"example.com"}},
},
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "primary",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{},
}
duplicate := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-duplicate",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "duplicate",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
httpsCredentialConfig := &cert.Config{
CredentialConfig: []cert.CredentialEntry{
{
Domains: []string{"example.com"},
TLSSecret: "default/example-tls",
},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(options, primary, httpsCredentialConfig); err != nil {
t.Fatalf("ConvertGateway(primary) error = %v", err)
}
if err := c.ConvertGateway(options, duplicate, httpsCredentialConfig); err != nil {
t.Fatalf("ConvertGateway(duplicate) error = %v", err)
}
if len(options.IngressDomainCache.Invalid) != 1 {
t.Fatalf("invalid domain count mismatch, want 1, got %d", len(options.IngressDomainCache.Invalid))
}
invalid := options.IngressDomainCache.Invalid[0]
if !strings.Contains(invalid.Error, "tls-primary") {
t.Fatalf("invalid domain error does not reference existing gateway owner: %s", invalid.Error)
}
}
func TestBackendToTLSRouteDestinationRejectsEmptyMCPDestination(t *testing.T) {
c := controller{}
backend := &ingress.IngressBackend{}
config := &annotations.DestinationConfig{}
destinations, event := c.backendToTLSRouteDestination(backend, "default", config)
if event != common.InvalidBackendService {
t.Fatalf("event mismatch, want InvalidBackendService, got %s", event)
}
if len(destinations) != 0 {
t.Fatalf("destination count mismatch, want 0, got %d", len(destinations))
}
}
func TestSSLPassthroughUsesFirstRootOwnerWhenLaterIngressEnablesPassthrough(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
root := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "root",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
ingressV1Beta1Rule("example.com", ingressV1Beta1Path("/", "root", 443)),
},
},
},
AnnotationsConfig: &annotations.Ingress{},
}
passthrough := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "passthrough",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
ingressV1Beta1Rule("example.com", ingressV1Beta1Path("/passthrough", "passthrough", 443)),
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
PassthroughTLSHostOwners: map[string]*config.Config{"example.com": root.Config},
}
if err := c.ConvertGateway(options, root, nil); err != nil {
t.Fatalf("ConvertGateway(root) error = %v", err)
}
if err := c.ConvertGateway(options, passthrough, nil); err != nil {
t.Fatalf("ConvertGateway(passthrough) error = %v", err)
}
gateway := options.Gateways["example.com"].Gateway
if len(gateway.Servers) != 2 {
t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers))
}
if gateway.Servers[1].Tls.GetMode() != v1alpha3.ServerTLSSettings_PASSTHROUGH {
t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", gateway.Servers[1].Tls.GetMode())
}
routeOptions := &common.ConvertOptions{
PassthroughTLSHostOwners: map[string]*config.Config{"example.com": root.Config},
}
if err := c.ConvertHTTPRoute(routeOptions, root); err != nil {
t.Fatalf("ConvertHTTPRoute(root) error = %v", err)
}
if err := c.ConvertHTTPRoute(routeOptions, passthrough); err != nil {
t.Fatalf("ConvertHTTPRoute(passthrough) error = %v", err)
}
routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, want root.default.svc.cluster.local, got %s", got)
}
}
func TestSSLPassthroughNonRootIngressDoesNotBlockLaterRootIngress(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
nonRoot := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-non-root",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
ingressV1Beta1Rule("example.com", ingressV1Beta1Path("/api", "api", 8443)),
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
root := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-root",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
ingressV1Beta1Rule("example.com", ingressV1Beta1Path("/", "root", 443)),
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(options, nonRoot, nil); err != nil {
t.Fatalf("ConvertGateway(nonRoot) error = %v", err)
}
if len(options.Gateways["example.com"].Gateway.Servers) != 1 {
t.Fatalf("non-root ingress server count mismatch, want 1, got %d", len(options.Gateways["example.com"].Gateway.Servers))
}
if err := c.ConvertGateway(options, root, nil); err != nil {
t.Fatalf("ConvertGateway(root) error = %v", err)
}
if options.Gateways["example.com"].Gateway.Servers[1].Tls.GetMode() != v1alpha3.ServerTLSSettings_PASSTHROUGH {
t.Fatal("root ingress did not create a TLS passthrough server")
}
options.VirtualServices = map[string]*common.WrapperVirtualService{}
if err := c.ConvertTLSRoute(options, root); err != nil {
t.Fatalf("ConvertTLSRoute(root) error = %v", err)
}
routes := options.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
}
func TestSSLPassthroughPreservesRepeatedHostInSameIngress(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-repeated-host",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/health",
Backend: ingress.IngressBackend{
ServiceName: "health",
ServicePort: intstr.FromInt(8443),
},
},
},
},
},
},
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "root",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(options, wrapper, nil); err != nil {
t.Fatalf("ConvertGateway() error = %v", err)
}
options.VirtualServices = map[string]*common.WrapperVirtualService{}
if err := c.ConvertTLSRoute(options, wrapper); err != nil {
t.Fatalf("ConvertTLSRoute() error = %v", err)
}
routes := options.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
}
func TestSSLPassthroughUsesFirstRootBackendForRepeatedHost(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-repeated-root",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "first",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "second",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "first.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
}
func TestSSLPassthroughHandlesMultipleHosts(t *testing.T) {
c := controller{}
testcases := []struct {
name string
rules []ingress.IngressRule
wantHosts []string
wantRoutes map[string]string
}{
{
name: "root path first",
rules: []ingress.IngressRule{
ingressV1Beta1Rule("first.example.com", ingressV1Beta1Path("/", "first", 443)),
ingressV1Beta1Rule("middle.example.com", ingressV1Beta1Path("/health", "middle", 8443)),
ingressV1Beta1Rule("last.example.com", ingressV1Beta1Path("/health", "last", 8443)),
},
wantHosts: []string{"first.example.com"},
wantRoutes: map[string]string{
"first.example.com": "first.default.svc.cluster.local",
},
},
{
name: "root path middle",
rules: []ingress.IngressRule{
ingressV1Beta1Rule("first.example.com", ingressV1Beta1Path("/health", "first", 8443)),
ingressV1Beta1Rule("middle.example.com", ingressV1Beta1Path("/", "middle", 443)),
ingressV1Beta1Rule("last.example.com", ingressV1Beta1Path("/health", "last", 8443)),
},
wantHosts: []string{"middle.example.com"},
wantRoutes: map[string]string{
"middle.example.com": "middle.default.svc.cluster.local",
},
},
{
name: "root path last",
rules: []ingress.IngressRule{
ingressV1Beta1Rule("first.example.com", ingressV1Beta1Path("/health", "first", 8443)),
ingressV1Beta1Rule("middle.example.com", ingressV1Beta1Path("/health", "middle", 8443)),
ingressV1Beta1Rule("last.example.com", ingressV1Beta1Path("/", "last", 443)),
},
wantHosts: []string{"last.example.com"},
wantRoutes: map[string]string{
"last.example.com": "last.default.svc.cluster.local",
},
},
{
name: "multiple root hosts",
rules: []ingress.IngressRule{
ingressV1Beta1Rule("first.example.com", ingressV1Beta1Path("/", "first", 443)),
ingressV1Beta1Rule("middle.example.com", ingressV1Beta1Path("/", "middle", 443)),
ingressV1Beta1Rule("last.example.com", ingressV1Beta1Path("/", "last", 443)),
},
wantHosts: []string{"first.example.com", "middle.example.com", "last.example.com"},
wantRoutes: map[string]string{
"first.example.com": "first.default.svc.cluster.local",
"middle.example.com": "middle.default.svc.cluster.local",
"last.example.com": "last.default.svc.cluster.local",
},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-multi-host",
},
Spec: ingress.IngressSpec{
Rules: tc.rules,
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
for _, host := range tc.wantHosts {
routes := routeOptions.VirtualServices[host].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch for host %s, want 1, got %d", host, len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != tc.wantRoutes[host] {
t.Fatalf("destination host mismatch for host %s, want %s, got %s", host, tc.wantRoutes[host], got)
}
}
})
}
}
func ingressV1Beta1Path(path, service string, port int32) ingress.HTTPIngressPath {
return ingress.HTTPIngressPath{
Path: path,
Backend: ingress.IngressBackend{
ServiceName: service,
ServicePort: intstr.FromInt(int(port)),
},
}
}
func ingressV1Beta1Rule(host string, paths ...ingress.HTTPIngressPath) ingress.IngressRule {
return ingress.IngressRule{
Host: host,
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: paths,
},
},
}
}
func TestSSLPassthroughUsesRootPathBackend(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-root",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/api",
Backend: ingress.IngressBackend{
ServiceName: "api",
ServicePort: intstr.FromInt(8443),
},
},
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "root",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
}
func TestSSLPassthroughWildcardHostKeepsVirtualServiceConsistent(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-wildcard",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/",
Backend: ingress.IngressBackend{
ServiceName: "root",
ServicePort: intstr.FromInt(443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
if err := c.ConvertTLSRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertTLSRoute() error = %v", err)
}
vs := routeOptions.VirtualServices[""].VirtualService
if got := vs.Hosts; len(got) != 1 || got[0] != "*" {
t.Fatalf("virtual service hosts mismatch, got %+v", got)
}
if len(vs.Tls) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(vs.Tls))
}
if got := vs.Tls[0].Match[0].SniHosts; len(got) != 1 || got[0] != "*" {
t.Fatalf("sni hosts mismatch, got %+v", got)
}
}
func TestSSLPassthroughIgnoresNonRootPath(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-non-root",
},
Spec: ingress.IngressSpec{
Rules: []ingress.IngressRule{
{
Host: "example.com",
IngressRuleValue: ingress.IngressRuleValue{
HTTP: &ingress.HTTPIngressRuleValue{
Paths: []ingress.HTTPIngressPath{
{
Path: "/api",
Backend: ingress.IngressBackend{
ServiceName: "api",
ServicePort: intstr.FromInt(8443),
},
},
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
if len(routeOptions.HTTPRoutes["example.com"]) != 1 {
t.Fatalf("http route count mismatch, want 1, got %d", len(routeOptions.HTTPRoutes["example.com"]))
}
if routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls; len(routes) != 0 {
t.Fatalf("unexpected tls routes: %+v", routes)
}
}
func testApplyCanaryIngress(t *testing.T, c common.IngressController) {
testcases := []struct {
description string

View File

@@ -337,13 +337,6 @@ func extractTLSSecretName(host string, tls []ingress.IngressTLS) string {
}
func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig, httpsCredentialConfig *cert.Config) error {
if convertOptions == nil {
return fmt.Errorf("convertOptions is nil")
}
if wrapper == nil {
return fmt.Errorf("wrapperConfig is nil")
}
// Ignore canary config.
if wrapper.AnnotationsConfig.IsCanary() {
return nil
@@ -389,7 +382,7 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
Protocol: string(protocol.HTTP),
Name: common.CreateConvertedName("http-"+strconv.FormatUint(uint64(c.options.GatewayHttpPort), 10)+"-ingress", string(c.options.ClusterId)),
},
Hosts: []string{common.WildcardHost(rule.Host)},
Hosts: []string{rule.Host},
})
// Add new gateway, builder
@@ -402,45 +395,6 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
}
}
passthroughOwner := common.PassthroughTLSHostOwner(convertOptions, rule.Host)
standaloneSSLPassthrough := convertOptions.PassthroughTLSHostOwners == nil && wrapper.AnnotationsConfig.IsSSLPassthrough()
if common.SameConfig(passthroughOwner, cfg) || standaloneSSLPassthrough {
if rule.HTTP == nil || len(rule.HTTP.Paths) == 0 {
continue
}
if _, ok := rootHTTPIngressPath(rule.HTTP.Paths); !ok {
continue
}
domainBuilder.Protocol = common.HTTPS
if wrapperGateway.IsHTTPS() {
if common.SameConfig(preDomainBuilder.Ingress, cfg) {
continue
}
domainBuilder.Event = common.DuplicatedTls
domainBuilder.PreIngress = preDomainBuilder.Ingress
convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid,
domainBuilder.Build())
continue
}
wrapperGateway.Gateway.Servers = append(wrapperGateway.Gateway.Servers,
common.CreateSSLPassthroughServer(rule.Host, c.options.GatewayHttpsPort, c.options.ClusterId))
convertOptions.IngressDomainCache.Valid[rule.Host] = domainBuilder
continue
}
if wrapper.AnnotationsConfig.IsSSLPassthrough() {
if rule.HTTP != nil {
if _, ok := rootHTTPIngressPath(rule.HTTP.Paths); ok && passthroughOwner != nil {
domainBuilder.Protocol = common.HTTPS
domainBuilder.Event = common.DuplicatedTls
domainBuilder.PreIngress = passthroughOwner
convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid,
domainBuilder.Build())
}
}
continue
}
// There are no tls settings, so just skip.
if len(ingressV1.TLS) == 0 {
continue
@@ -489,14 +443,6 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
domainBuilder.Protocol = common.HTTPS
domainBuilder.SecretName = path.Join(c.options.ClusterId.String(), cfg.Namespace, secretName)
if passthroughOwner != nil {
domainBuilder.Event = common.DuplicatedTls
domainBuilder.PreIngress = passthroughOwner
convertOptions.IngressDomainCache.Invalid = append(convertOptions.IngressDomainCache.Invalid,
domainBuilder.Build())
continue
}
// There is a matching secret and the gateway has already a tls secret.
// We should report the duplicated tls secret event.
if wrapperGateway.IsHTTPS() {
@@ -514,7 +460,7 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
Protocol: string(protocol.HTTPS),
Name: common.CreateConvertedName("https-"+strconv.FormatUint(uint64(c.options.GatewayHttpsPort), 10)+"-ingress", string(c.options.ClusterId)),
},
Hosts: []string{common.WildcardHost(rule.Host)},
Hosts: []string{rule.Host},
Tls: &networking.ServerTLSSettings{
Mode: networking.ServerTLSSettings_SIMPLE,
CredentialName: credentials.ToKubernetesIngressResource(c.options.RawClusterId, secretNamespace, secretName),
@@ -529,32 +475,12 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
}
func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig) error {
if convertOptions == nil {
return fmt.Errorf("convertOptions is nil")
}
if wrapper == nil {
return fmt.Errorf("wrapperConfig is nil")
}
// Canary ingress will be processed in the end.
if wrapper.AnnotationsConfig.IsCanary() {
convertOptions.CanaryIngresses = append(convertOptions.CanaryIngresses, wrapper)
return nil
}
if convertOptions.Route2Ingress == nil {
convertOptions.Route2Ingress = map[string]*common.WrapperConfigWithRuleKey{}
}
if convertOptions.IngressRouteCache == nil {
convertOptions.IngressRouteCache = common.NewIngressRouteCache()
}
if convertOptions.VirtualServices == nil {
convertOptions.VirtualServices = map[string]*common.WrapperVirtualService{}
}
if convertOptions.HTTPRoutes == nil {
convertOptions.HTTPRoutes = map[string][]*common.WrapperHTTPRoute{}
}
cfg := wrapper.Config
ingressV1, ok := cfg.Spec.(ingress.IngressSpec)
if !ok {
@@ -589,7 +515,12 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
wrapperVS, exist := convertOptions.VirtualServices[rule.Host]
if !exist {
wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper)
wrapperVS = &common.WrapperVirtualService{
VirtualService: &networking.VirtualService{
Hosts: []string{rule.Host},
},
WrapperConfig: wrapper,
}
convertOptions.VirtualServices[rule.Host] = wrapperVS
}
@@ -618,11 +549,7 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
pathType = common.PrefixRegex
}
} else {
ingressPathType := defaultPathType
if httpPath.PathType != nil {
ingressPathType = *httpPath.PathType
}
switch ingressPathType {
switch *httpPath.PathType {
case ingress.PathTypeExact:
pathType = common.Exact
case ingress.PathTypePrefix:
@@ -699,84 +626,9 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
}
}
if common.HasPassthroughTLSHostOwner(convertOptions, cfg) ||
(convertOptions.PassthroughTLSHostOwners == nil && wrapper.AnnotationsConfig.IsSSLPassthrough()) {
return c.ConvertTLSRoute(convertOptions, wrapper)
}
return nil
}
func (c *controller) ConvertTLSRoute(convertOptions *common.ConvertOptions, wrapper *common.WrapperConfig) error {
if convertOptions == nil {
return fmt.Errorf("convertOptions is nil")
}
if wrapper == nil {
return fmt.Errorf("wrapperConfig is nil")
}
if convertOptions.VirtualServices == nil {
convertOptions.VirtualServices = map[string]*common.WrapperVirtualService{}
}
cfg := wrapper.Config
ingressV1, ok := cfg.Spec.(ingress.IngressSpec)
if !ok {
common.IncrementInvalidIngress(c.options.ClusterId, common.Unknown)
return fmt.Errorf("convert type is invalid in cluster %s", c.options.ClusterId)
}
if len(ingressV1.Rules) == 0 {
common.IncrementInvalidIngress(c.options.ClusterId, common.EmptyRule)
return fmt.Errorf("invalid ingress rule %s:%s in cluster %s, `rules` must be specified", cfg.Namespace, cfg.Name, c.options.ClusterId)
}
for _, rule := range ingressV1.Rules {
if !common.IsPassthroughTLSHostOwner(convertOptions, cfg, rule.Host) {
IngressLog.Warnf("ignore duplicated ssl passthrough ingress rule %s:%s for host %q in cluster %s", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId)
continue
}
if rule.HTTP == nil || len(rule.HTTP.Paths) == 0 {
IngressLog.Warnf("invalid ssl passthrough ingress rule %s:%s for host %q in cluster %s, no paths defined", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId)
continue
}
httpPath, ok := rootHTTPIngressPath(rule.HTTP.Paths)
if !ok {
IngressLog.Warnf("ignore ssl passthrough ingress rule %s:%s for host %q in cluster %s, root path is not defined", cfg.Namespace, cfg.Name, rule.Host, c.options.ClusterId)
continue
}
wrapperVS, exist := convertOptions.VirtualServices[rule.Host]
if !exist {
wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper)
convertOptions.VirtualServices[rule.Host] = wrapperVS
} else if wrapperVS.HasTLSRouteForHost(rule.Host) {
continue
}
routeDestination, event := c.backendToTLSRouteDestination(&httpPath.Backend, cfg.Namespace, wrapper.AnnotationsConfig.Destination)
if event != common.Normal {
common.IncrementInvalidIngress(c.options.ClusterId, event)
continue
}
wrapperVS.VirtualService.Tls = append(wrapperVS.VirtualService.Tls,
common.CreateTLSRoute(rule.Host, routeDestination))
}
return nil
}
func rootHTTPIngressPath(paths []ingress.HTTPIngressPath) (*ingress.HTTPIngressPath, bool) {
for idx := range paths {
if paths[idx].Path == "" || paths[idx].Path == "/" {
return &paths[idx], true
}
}
return nil, false
}
func (c *controller) generateHttpMatches(pathType common.PathType, path string, wrapperVS *common.WrapperVirtualService) []*networking.HTTPMatchRequest {
var httpMatches []*networking.HTTPMatchRequest
@@ -837,7 +689,12 @@ func (c *controller) ApplyDefaultBackend(convertOptions *common.ConvertOptions,
wirecardVS, exist := convertOptions.VirtualServices[host]
if !exist || !wirecardVS.ConfiguredDefaultBackend {
if !exist {
wirecardVS = common.NewWrapperVirtualService(host, wrapper)
wirecardVS = &common.WrapperVirtualService{
VirtualService: &networking.VirtualService{
Hosts: []string{host},
},
WrapperConfig: wrapper,
}
convertOptions.VirtualServices[host] = wirecardVS
}
@@ -925,11 +782,7 @@ func (c *controller) ApplyCanaryIngress(convertOptions *common.ConvertOptions, w
pathType = common.PrefixRegex
}
} else {
ingressPathType := defaultPathType
if httpPath.PathType != nil {
ingressPathType = *httpPath.PathType
}
switch ingressPathType {
switch *httpPath.PathType {
case ingress.PathTypeExact:
pathType = common.Exact
case ingress.PathTypePrefix:
@@ -1221,54 +1074,6 @@ func (c *controller) backendToRouteDestination(backend *ingress.IngressBackend,
}, common.Normal
}
func (c *controller) backendToTLSRouteDestination(backend *ingress.IngressBackend, namespace string,
config *annotations.DestinationConfig,
) ([]*networking.RouteDestination, common.Event) {
if backend == nil {
return nil, common.InvalidBackendService
}
if backend.Service == nil {
if config != nil && len(config.McpDestination) > 0 {
return httpRouteDestinationToRouteDestination(config.McpDestination), common.Normal
}
return nil, common.InvalidBackendService
}
service := backend.Service
port := &networking.PortSelector{}
if service.Port.Number > 0 {
port.Number = uint32(service.Port.Number)
} else {
resolvedPort, err := resolveNamedPort(service, namespace, c.serviceLister)
if err != nil {
return nil, common.PortNameResolveError
}
port.Number = uint32(resolvedPort)
}
return []*networking.RouteDestination{
{
Destination: &networking.Destination{
Host: util.CreateServiceFQDN(namespace, service.Name),
Port: port,
},
Weight: 100,
},
}, common.Normal
}
func httpRouteDestinationToRouteDestination(destinations []*networking.HTTPRouteDestination) []*networking.RouteDestination {
out := make([]*networking.RouteDestination, 0, len(destinations))
for _, destination := range destinations {
out = append(out, &networking.RouteDestination{
Destination: destination.Destination,
Weight: destination.Weight,
})
}
return out
}
func resolveNamedPort(service *ingress.IngressServiceBackend, namespace string, serviceLister listerv1.ServiceLister) (int32, error) {
svc, err := serviceLister.Services(namespace).Get(service.Name)
if err != nil {

View File

@@ -15,17 +15,12 @@
package ingressv1
import (
"strings"
"testing"
"github.com/alibaba/higress/v2/pkg/cert"
"github.com/alibaba/higress/v2/pkg/ingress/kube/annotations"
"github.com/alibaba/higress/v2/pkg/ingress/kube/common"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
networking "istio.io/api/networking/v1alpha3"
"istio.io/istio/pkg/config"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -123,904 +118,3 @@ func TestGenerateHttpMatches(t *testing.T) {
}
}
}
func TestSSLPassthroughConvertGatewayAndTLSRoute(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough",
},
Spec: ingressSpecWithSSLPassthroughBackend("example.com", "app", 443),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
gatewayOptions := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(gatewayOptions, wrapper, nil); err != nil {
t.Fatalf("ConvertGateway() error = %v", err)
}
gateway := gatewayOptions.Gateways["example.com"].Gateway
if len(gateway.Servers) != 2 {
t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers))
}
tlsServer := gateway.Servers[1]
if tlsServer.Port.Protocol != "TLS" {
t.Fatalf("protocol mismatch, want TLS, got %s", tlsServer.Port.Protocol)
}
if tlsServer.Port.Number != 443 {
t.Fatalf("port mismatch, want 443, got %d", tlsServer.Port.Number)
}
if tlsServer.Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH {
t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", tlsServer.Tls.GetMode())
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
httpRoutes := routeOptions.HTTPRoutes["example.com"]
if len(httpRoutes) != 1 {
t.Fatalf("http route count mismatch, want 1, got %d", len(httpRoutes))
}
if got := httpRoutes[0].HTTPRoute.Route[0].Destination.Host; got != "app.default.svc.cluster.local" {
t.Fatalf("http destination host mismatch, got %s", got)
}
routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
route := routes[0]
if got := route.Match[0].SniHosts[0]; got != "example.com" {
t.Fatalf("sni host mismatch, want example.com, got %s", got)
}
if got := route.Route[0].Destination.Host; got != "app.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
if got := route.Route[0].Destination.Port.Number; got != 443 {
t.Fatalf("destination port mismatch, got %d", got)
}
}
func TestSSLPassthroughConvertGatewayRejectsNilInputs(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{},
AnnotationsConfig: &annotations.Ingress{},
}
if err := c.ConvertGateway(nil, wrapper, nil); err == nil {
t.Fatal("ConvertGateway() with nil convertOptions returned nil error")
}
if err := c.ConvertGateway(&common.ConvertOptions{}, nil, nil); err == nil {
t.Fatal("ConvertGateway() with nil wrapper returned nil error")
}
}
func TestSSLPassthroughConvertTLSRouteRejectsNilInputs(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{},
AnnotationsConfig: &annotations.Ingress{},
}
if err := c.ConvertTLSRoute(nil, wrapper); err == nil {
t.Fatal("ConvertTLSRoute() with nil convertOptions returned nil error")
}
if err := c.ConvertTLSRoute(&common.ConvertOptions{}, nil); err == nil {
t.Fatal("ConvertTLSRoute() with nil wrapper returned nil error")
}
}
func TestSSLPassthroughUsesConfiguredHTTPSPort(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 8443,
},
}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough",
},
Spec: ingressSpecWithSSLPassthroughBackend("example.com", "app", 443),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
gatewayOptions := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(gatewayOptions, wrapper, nil); err != nil {
t.Fatalf("ConvertGateway() error = %v", err)
}
tlsServer := gatewayOptions.Gateways["example.com"].Gateway.Servers[1]
if tlsServer.Port.Number != 8443 {
t.Fatalf("port mismatch, want 8443, got %d", tlsServer.Port.Number)
}
}
func TestSSLPassthroughCanaryIngressKeepsCanaryHandling(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-canary",
},
Spec: ingressSpecWithSSLPassthroughBackend("example.com", "app-canary", 443),
},
AnnotationsConfig: &annotations.Ingress{
Canary: &annotations.CanaryConfig{Enabled: true},
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
if len(routeOptions.CanaryIngresses) != 1 {
t.Fatalf("canary ingress count mismatch, want 1, got %d", len(routeOptions.CanaryIngresses))
}
if len(routeOptions.VirtualServices) != 0 {
t.Fatalf("unexpected virtual services: %+v", routeOptions.VirtualServices)
}
}
func TestSSLPassthroughSkipsDuplicatedTLSHost(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
primary := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-primary",
},
Spec: ingressSpecWithSSLPassthroughBackend("example.com", "primary", 443),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
duplicate := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-duplicate",
},
Spec: ingressSpecWithSSLPassthroughBackend("example.com", "duplicate", 443),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
PassthroughTLSHostOwners: map[string]*config.Config{"example.com": primary.Config},
}
if err := c.ConvertGateway(options, primary, nil); err != nil {
t.Fatalf("ConvertGateway(primary) error = %v", err)
}
if err := c.ConvertGateway(options, duplicate, nil); err != nil {
t.Fatalf("ConvertGateway(duplicate) error = %v", err)
}
options.VirtualServices = map[string]*common.WrapperVirtualService{}
if err := c.ConvertTLSRoute(options, duplicate); err != nil {
t.Fatalf("ConvertTLSRoute() error = %v", err)
}
if len(options.VirtualServices) != 0 {
t.Fatalf("unexpected virtual services: %+v", options.VirtualServices)
}
}
func TestSSLPassthroughDuplicateTLSHostRecordsInvalidDomain(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
primary := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-primary",
},
Spec: ingressSpecWithSSLPassthroughBackend("example.com", "primary", 443),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
duplicate := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-duplicate",
},
Spec: ingressSpecWithSSLPassthroughBackend("example.com", "duplicate", 443),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
PassthroughTLSHostOwners: map[string]*config.Config{"example.com": primary.Config},
}
if err := c.ConvertGateway(options, primary, nil); err != nil {
t.Fatalf("ConvertGateway(primary) error = %v", err)
}
if err := c.ConvertGateway(options, duplicate, nil); err != nil {
t.Fatalf("ConvertGateway(duplicate) error = %v", err)
}
if len(options.IngressDomainCache.Invalid) != 1 {
t.Fatalf("invalid domain count mismatch, want 1, got %d", len(options.IngressDomainCache.Invalid))
}
invalid := options.IngressDomainCache.Invalid[0]
if invalid.Error == "" {
t.Fatal("duplicated tls invalid domain error is empty")
}
if !strings.Contains(invalid.Error, "tls-passthrough-primary") {
t.Fatalf("invalid domain error does not reference previous ingress: %s", invalid.Error)
}
}
func TestSSLPassthroughDuplicateTLSHostUsesExistingGatewayOwner(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
primary := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-primary",
},
Spec: v1.IngressSpec{
TLS: []v1.IngressTLS{
{Hosts: []string{"example.com"}},
},
Rules: []v1.IngressRule{
ingressRule("example.com", ingressPath("/", "primary", 443)),
},
},
},
AnnotationsConfig: &annotations.Ingress{},
}
duplicate := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-duplicate",
},
Spec: ingressSpecWithSSLPassthroughBackend("example.com", "duplicate", 443),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
httpsCredentialConfig := &cert.Config{
CredentialConfig: []cert.CredentialEntry{
{
Domains: []string{"example.com"},
TLSSecret: "default/example-tls",
},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(options, primary, httpsCredentialConfig); err != nil {
t.Fatalf("ConvertGateway(primary) error = %v", err)
}
if err := c.ConvertGateway(options, duplicate, httpsCredentialConfig); err != nil {
t.Fatalf("ConvertGateway(duplicate) error = %v", err)
}
if len(options.IngressDomainCache.Invalid) != 1 {
t.Fatalf("invalid domain count mismatch, want 1, got %d", len(options.IngressDomainCache.Invalid))
}
invalid := options.IngressDomainCache.Invalid[0]
if !strings.Contains(invalid.Error, "tls-primary") {
t.Fatalf("invalid domain error does not reference existing gateway owner: %s", invalid.Error)
}
}
func TestSSLPassthroughUsesFirstRootOwnerWhenLaterIngressEnablesPassthrough(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
root := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "root",
},
Spec: v1.IngressSpec{
Rules: []v1.IngressRule{
ingressRule("example.com", ingressPath("/", "root", 443)),
},
},
},
AnnotationsConfig: &annotations.Ingress{},
}
passthrough := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "passthrough",
},
Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{
ingressPath("/passthrough", "passthrough", 443),
}),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
PassthroughTLSHostOwners: map[string]*config.Config{"example.com": root.Config},
}
if err := c.ConvertGateway(options, root, nil); err != nil {
t.Fatalf("ConvertGateway(root) error = %v", err)
}
if err := c.ConvertGateway(options, passthrough, nil); err != nil {
t.Fatalf("ConvertGateway(passthrough) error = %v", err)
}
gateway := options.Gateways["example.com"].Gateway
if len(gateway.Servers) != 2 {
t.Fatalf("server count mismatch, want 2, got %d", len(gateway.Servers))
}
if gateway.Servers[1].Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH {
t.Fatalf("tls mode mismatch, want PASSTHROUGH, got %s", gateway.Servers[1].Tls.GetMode())
}
routeOptions := &common.ConvertOptions{
PassthroughTLSHostOwners: map[string]*config.Config{"example.com": root.Config},
}
if err := c.ConvertHTTPRoute(routeOptions, root); err != nil {
t.Fatalf("ConvertHTTPRoute(root) error = %v", err)
}
if err := c.ConvertHTTPRoute(routeOptions, passthrough); err != nil {
t.Fatalf("ConvertHTTPRoute(passthrough) error = %v", err)
}
routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, want root.default.svc.cluster.local, got %s", got)
}
}
func TestSSLPassthroughNonRootIngressDoesNotBlockLaterRootIngress(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
nonRoot := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-non-root",
},
Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{
ingressPath("/api", "api", 8443),
}),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
root := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-root",
},
Spec: ingressSpecWithSSLPassthroughBackend("example.com", "root", 443),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(options, nonRoot, nil); err != nil {
t.Fatalf("ConvertGateway(nonRoot) error = %v", err)
}
if len(options.Gateways["example.com"].Gateway.Servers) != 1 {
t.Fatalf("non-root ingress server count mismatch, want 1, got %d", len(options.Gateways["example.com"].Gateway.Servers))
}
if err := c.ConvertGateway(options, root, nil); err != nil {
t.Fatalf("ConvertGateway(root) error = %v", err)
}
if options.Gateways["example.com"].Gateway.Servers[1].Tls.GetMode() != networking.ServerTLSSettings_PASSTHROUGH {
t.Fatal("root ingress did not create a TLS passthrough server")
}
options.VirtualServices = map[string]*common.WrapperVirtualService{}
if err := c.ConvertTLSRoute(options, root); err != nil {
t.Fatalf("ConvertTLSRoute(root) error = %v", err)
}
routes := options.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
}
func TestSSLPassthroughPreservesRepeatedHostInSameIngress(t *testing.T) {
c := controller{
options: common.Options{
GatewayHttpPort: 80,
GatewayHttpsPort: 443,
},
}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-repeated-host",
},
Spec: v1.IngressSpec{
Rules: []v1.IngressRule{
{
Host: "example.com",
IngressRuleValue: v1.IngressRuleValue{
HTTP: &v1.HTTPIngressRuleValue{
Paths: []v1.HTTPIngressPath{
ingressPath("/health", "health", 8443),
},
},
},
},
{
Host: "example.com",
IngressRuleValue: v1.IngressRuleValue{
HTTP: &v1.HTTPIngressRuleValue{
Paths: []v1.HTTPIngressPath{
ingressPath("/", "root", 443),
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
options := &common.ConvertOptions{
Gateways: map[string]*common.WrapperGateway{},
IngressDomainCache: common.NewIngressDomainCache(),
}
if err := c.ConvertGateway(options, wrapper, nil); err != nil {
t.Fatalf("ConvertGateway() error = %v", err)
}
options.VirtualServices = map[string]*common.WrapperVirtualService{}
if err := c.ConvertTLSRoute(options, wrapper); err != nil {
t.Fatalf("ConvertTLSRoute() error = %v", err)
}
routes := options.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
}
func TestSSLPassthroughUsesFirstRootBackendForRepeatedHost(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-repeated-root",
},
Spec: v1.IngressSpec{
Rules: []v1.IngressRule{
{
Host: "example.com",
IngressRuleValue: v1.IngressRuleValue{
HTTP: &v1.HTTPIngressRuleValue{
Paths: []v1.HTTPIngressPath{
ingressPath("/", "first", 443),
},
},
},
},
{
Host: "example.com",
IngressRuleValue: v1.IngressRuleValue{
HTTP: &v1.HTTPIngressRuleValue{
Paths: []v1.HTTPIngressPath{
ingressPath("/", "second", 443),
},
},
},
},
},
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "first.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
}
func TestSSLPassthroughHandlesMultipleHosts(t *testing.T) {
c := controller{}
testcases := []struct {
name string
rules []v1.IngressRule
wantHosts []string
wantRoutes map[string]string
}{
{
name: "root path first",
rules: []v1.IngressRule{
ingressRule("first.example.com", ingressPath("/", "first", 443)),
ingressRule("middle.example.com", ingressPath("/health", "middle", 8443)),
ingressRule("last.example.com", ingressPath("/health", "last", 8443)),
},
wantHosts: []string{"first.example.com"},
wantRoutes: map[string]string{
"first.example.com": "first.default.svc.cluster.local",
},
},
{
name: "root path middle",
rules: []v1.IngressRule{
ingressRule("first.example.com", ingressPath("/health", "first", 8443)),
ingressRule("middle.example.com", ingressPath("/", "middle", 443)),
ingressRule("last.example.com", ingressPath("/health", "last", 8443)),
},
wantHosts: []string{"middle.example.com"},
wantRoutes: map[string]string{
"middle.example.com": "middle.default.svc.cluster.local",
},
},
{
name: "root path last",
rules: []v1.IngressRule{
ingressRule("first.example.com", ingressPath("/health", "first", 8443)),
ingressRule("middle.example.com", ingressPath("/health", "middle", 8443)),
ingressRule("last.example.com", ingressPath("/", "last", 443)),
},
wantHosts: []string{"last.example.com"},
wantRoutes: map[string]string{
"last.example.com": "last.default.svc.cluster.local",
},
},
{
name: "multiple root hosts",
rules: []v1.IngressRule{
ingressRule("first.example.com", ingressPath("/", "first", 443)),
ingressRule("middle.example.com", ingressPath("/", "middle", 443)),
ingressRule("last.example.com", ingressPath("/", "last", 443)),
},
wantHosts: []string{"first.example.com", "middle.example.com", "last.example.com"},
wantRoutes: map[string]string{
"first.example.com": "first.default.svc.cluster.local",
"middle.example.com": "middle.default.svc.cluster.local",
"last.example.com": "last.default.svc.cluster.local",
},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-multi-host",
},
Spec: v1.IngressSpec{
Rules: tc.rules,
},
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
for _, host := range tc.wantHosts {
routes := routeOptions.VirtualServices[host].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch for host %s, want 1, got %d", host, len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != tc.wantRoutes[host] {
t.Fatalf("destination host mismatch for host %s, want %s, got %s", host, tc.wantRoutes[host], got)
}
}
})
}
}
func TestSSLPassthroughUsesRootPathBackend(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-root",
},
Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{
ingressPath("/api", "api", 8443),
ingressPath("/", "root", 443),
}),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "root.default.svc.cluster.local" {
t.Fatalf("destination host mismatch, got %s", got)
}
}
func TestSSLPassthroughWildcardHostKeepsVirtualServiceConsistent(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-wildcard",
},
Spec: ingressSpecWithSSLPassthroughBackend("", "root", 443),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
if err := c.ConvertTLSRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertTLSRoute() error = %v", err)
}
vs := routeOptions.VirtualServices[""].VirtualService
if got := vs.Hosts; len(got) != 1 || got[0] != "*" {
t.Fatalf("virtual service hosts mismatch, got %+v", got)
}
if len(vs.Tls) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(vs.Tls))
}
if got := vs.Tls[0].Match[0].SniHosts; len(got) != 1 || got[0] != "*" {
t.Fatalf("sni hosts mismatch, got %+v", got)
}
}
func TestSSLPassthroughIgnoresNonRootPath(t *testing.T) {
c := controller{}
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-non-root",
},
Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{
ingressPath("/api", "api", 8443),
}),
},
AnnotationsConfig: &annotations.Ingress{
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
if len(routeOptions.HTTPRoutes["example.com"]) != 1 {
t.Fatalf("http route count mismatch, want 1, got %d", len(routeOptions.HTTPRoutes["example.com"]))
}
if routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls; len(routes) != 0 {
t.Fatalf("unexpected tls routes: %+v", routes)
}
}
func TestSSLPassthroughKeepsMCPResourceBackend(t *testing.T) {
c := controller{}
apiGroup := "networking.higress.io"
wrapper := &common.WrapperConfig{
Config: &config.Config{
Meta: config.Meta{
Namespace: "default",
Name: "tls-passthrough-mcp",
},
Spec: ingressSpecWithSSLPassthroughPaths("example.com", []v1.HTTPIngressPath{
{
Path: "/",
PathType: pathTypePtr(v1.PathTypePrefix),
Backend: v1.IngressBackend{
Resource: &corev1.TypedLocalObjectReference{
APIGroup: &apiGroup,
Kind: "McpBridge",
Name: "default",
},
},
},
}),
},
AnnotationsConfig: &annotations.Ingress{
Destination: &annotations.DestinationConfig{
McpDestination: []*networking.HTTPRouteDestination{
{
Destination: &networking.Destination{
Host: "mcp.example.internal",
Port: &networking.PortSelector{Number: 443},
},
Weight: 100,
},
},
WeightSum: 100,
},
SSLPassthrough: &annotations.SSLPassthroughConfig{Enabled: true},
},
}
routeOptions := &common.ConvertOptions{}
if err := c.ConvertHTTPRoute(routeOptions, wrapper); err != nil {
t.Fatalf("ConvertHTTPRoute() error = %v", err)
}
routes := routeOptions.VirtualServices["example.com"].VirtualService.Tls
if len(routes) != 1 {
t.Fatalf("tls route count mismatch, want 1, got %d", len(routes))
}
if got := routes[0].Route[0].Destination.Host; got != "mcp.example.internal" {
t.Fatalf("destination host mismatch, got %s", got)
}
}
func TestBackendToTLSRouteDestinationRejectsEmptyMCPDestination(t *testing.T) {
c := controller{}
apiGroup := "networking.higress.io"
backend := &v1.IngressBackend{
Resource: &corev1.TypedLocalObjectReference{
APIGroup: &apiGroup,
Kind: "McpBridge",
Name: "default",
},
}
config := &annotations.DestinationConfig{}
destinations, event := c.backendToTLSRouteDestination(backend, "default", config)
if event != common.InvalidBackendService {
t.Fatalf("event mismatch, want InvalidBackendService, got %s", event)
}
if len(destinations) != 0 {
t.Fatalf("destination count mismatch, want 0, got %d", len(destinations))
}
}
func ingressSpecWithSSLPassthroughBackend(host, service string, port int32) v1.IngressSpec {
return ingressSpecWithSSLPassthroughPaths(host, []v1.HTTPIngressPath{
ingressPath("/", service, port),
})
}
func ingressSpecWithSSLPassthroughPaths(host string, paths []v1.HTTPIngressPath) v1.IngressSpec {
return v1.IngressSpec{
Rules: []v1.IngressRule{
{
Host: host,
IngressRuleValue: v1.IngressRuleValue{
HTTP: &v1.HTTPIngressRuleValue{
Paths: paths,
},
},
},
},
}
}
func ingressPath(path, service string, port int32) v1.HTTPIngressPath {
return v1.HTTPIngressPath{
Path: path,
PathType: pathTypePtr(v1.PathTypePrefix),
Backend: v1.IngressBackend{
Service: &v1.IngressServiceBackend{
Name: service,
Port: v1.ServiceBackendPort{Number: port},
},
},
}
}
func pathTypePtr(pathType v1.PathType) *v1.PathType {
return &pathType
}
func ingressRule(host string, paths ...v1.HTTPIngressPath) v1.IngressRule {
return v1.IngressRule{
Host: host,
IngressRuleValue: v1.IngressRuleValue{
HTTP: &v1.HTTPIngressRuleValue{
Paths: paths,
},
},
}
}

View File

@@ -33,35 +33,17 @@ description: JWT 认证插件配置参考
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| ----------------------- | ----------------- | -------- | ------------------------------------------------- | ------------------------ |
| `name` | string | 必填 | - | 配置该consumer的名称 |
| `jwks` | string | wasm-cpp 必填wasm-go 未配置 `remote_jwks` 时必填 | - | https://www.rfc-editor.org/rfc/rfc7517 指定的json格式字符串是由验证JWT中签名的公钥或对称密钥组成的Json Web Key Set |
| `remote_jwks` | object | wasm-go 实现选填 | - | 远程 JWKS 服务引用。引用的服务需要已由 Higress 配置或发现,例如通过 McpBridge |
| `jwks_cache_duration` | number | wasm-go 实现选填 | 600 | 远程 JWKS 缓存时间,单位为秒,最大值为 604800 |
| `jwks_fetch_timeout` | number | wasm-go 实现选填 | 1500 | 远程 JWKS 拉取超时时间,单位为毫秒,最大值为 10000 |
| `jwks` | string | 必填 | - | https://www.rfc-editor.org/rfc/rfc7517 指定的json格式字符串是由验证JWT中签名的公钥或对称密钥组成的Json Web Key Set |
| `issuer` | string | 必填 | - | JWT的签发者需要和payload中的iss字段保持一致 |
| `claims_to_headers` | array of object | 选填 | - | 抽取JWT的payload中指定字段设置到指定的请求头中转发给后端 |
| `from_headers` | array of object | 选填 | {"name":"Authorization","value_prefix":"Bearer "} | 从指定的请求头中抽取JWT |
| `from_params` | array of string | 选填 | access_token | 从指定的URL参数中抽取JWT |
| `from_cookies` | array of string | 选填 | - | 从指定的cookie中抽取JWT |
| `clock_skew_seconds` | number | 选填 | 60 | 校验JWT的exp和iat字段时允许的时钟偏移量单位为秒 |
| `keep_token` | bool | 选填 | true | 转发给后端时是否保留JWT |
| `keep_token` | bool | 选填 | ture | 转发给后端时是否保留JWT |
**注意:**
- 只有当`from_headers`,`from_params`,`from_cookies`均未配置时,才会使用默认值
- `remote_jwks``jwks_cache_duration``jwks_fetch_timeout` 仅适用于 wasm-go 实现wasm-cpp 实现仍需要配置 `jwks`
- 在 wasm-go 实现中,`jwks``remote_jwks` 只能配置其中一个。
- 使用 `remote_jwks` 时,远程 JWKS 拉取失败、返回非法 JWKS、刷新被限流或在多 key 集合中找不到匹配 `kid` 时会拒绝请求。无 `kid` 的 token 仅在拉取到的 JWKS 恰好只有一个 key 时被接受。
- 远程 JWKS 的失败重试按远程服务引用限流 30 秒;冷启动首次拉取未完成期间,以及拉取失败后的限流窗口内,请求会被本地拒绝。过期缓存仅在刷新请求已发起且尚未完成时继续使用。
- 超过 64 KiB 的远程 JWKS 响应会被拒绝。
- `jwks_cache_duration` 必须至少为 30 秒,避免缓存过期时间短于远程 JWKS 失败重试窗口。
- 在 wasm-go 实现中,空的内联 `jwks` key 集合会在配置解析阶段被拒绝。`keep_token``false` 时,从 `from_params` 提取的 token 会从转发请求路径中移除。
`remote_jwks` 的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| -------------- | -------- | -------- | ------ | ---- |
| `service_name` | string | 必填 | - | 用于构造出站集群的 Higress 服务名称 |
| `service_host` | string | 必填 | - | JWKS 请求使用的 Host 或 `:authority` 请求头,不包含端口;端口通过 `service_port` 配置 |
| `service_port` | number | 选填 | 443 | JWKS 请求使用的服务端口 |
| `path` | string | 必填 | - | JWKS 请求路径,例如 `/.well-known/jwks.json` |
`from_headers` 中每一项的配置字段说明如下:
@@ -174,26 +156,6 @@ curl http://xxx.hello.com/test
curl 'http://xxx.example.com/test' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMyJ9.eyJpc3MiOiJhYmNkIiwic3ViIjoidGVzdCIsImlhdCI6MTY2NTY2MDUyNywiZXhwIjoxODY1NjczODE5fQ.-vBSV0bKeDwQcuS6eeSZN9dLTUnSnZVk8eVCXdooCQ4'
```
### 使用远程 JWKS仅 wasm-go 实现)
如果签名公钥由身份提供方维护wasm-go 实现可以通过已配置的服务引用拉取和缓存远程 JWKS。该服务需要已由 Higress 配置或发现。
```yaml
global_auth: false
consumers:
- name: remote-consumer
issuer: https://issuer.example.com
remote_jwks:
service_name: issuer.example.com.dns
service_host: issuer.example.com
service_port: 443
path: /.well-known/jwks.json
jwks_cache_duration: 600
jwks_fetch_timeout: 1500
```
配置远程 JWKS 时需要确保网关数据面能够访问引用的服务。远程 JWKS 响应体超过 64 KiB 会被视为非法响应。
#### 网关实例级别开启
以下配置将对网关实例级别开启 JWT Auth 认证,所有请求均需要经过认证后才能访问。

View File

@@ -28,10 +28,7 @@ The configuration fields for each item in `consumers` are as follows:
| Name | Data Type | Requirements | Default Value | Description |
| ----------------------- | ------------------ | ------------ | -------------------------------------------------- | ------------------------------- |
| `name` | string | Required | - | The name of the consumer |
| `jwks` | string | Required for wasm-cpp; required for wasm-go unless `remote_jwks` is set | - | JSON format string specified by https://www.rfc-editor.org/rfc/rfc7517, consisting of the public key (or symmetric key) used to verify the JWT signature. |
| `remote_jwks` | object | Optional in the wasm-go implementation | - | Remote JWKS service reference. The referenced service must already be configured or discovered by Higress, for example via McpBridge. |
| `jwks_cache_duration` | number | Optional in the wasm-go implementation | 600 | Remote JWKS cache duration, in seconds. Maximum: 604800. |
| `jwks_fetch_timeout` | number | Optional in the wasm-go implementation | 1500 | Remote JWKS fetch timeout, in milliseconds. Maximum: 10000. |
| `jwks` | string | Required | - | JSON format string specified by https://www.rfc-editor.org/rfc/rfc7517, consisting of the public key (or symmetric key) used to verify the JWT signature. |
| `issuer` | string | Required | - | The issuer of the JWT, must match the `iss` field in the payload. |
| `claims_to_headers` | array of object | Optional | - | Extract the specified fields from the JWT payload and set them in the specified request headers to forward to the backend. |
| `from_headers` | array of object | Optional | {"name":"Authorization","value_prefix":"Bearer "} | Extract JWT from the specified request headers. |
@@ -42,22 +39,6 @@ The configuration fields for each item in `consumers` are as follows:
**Note:**
- The default values will only be used when `from_headers`, `from_params`, and `from_cookies` are not all configured.
- `remote_jwks`, `jwks_cache_duration`, and `jwks_fetch_timeout` apply only to the wasm-go implementation. The wasm-cpp implementation still requires `jwks`.
- In the wasm-go implementation, only one of `jwks` and `remote_jwks` can be configured.
- When `remote_jwks` is used, requests fail closed if the remote JWKS fetch fails, the response is invalid, refresh is throttled, or no matching `kid` can be found in a multi-key set. Tokens without `kid` are accepted only when the fetched JWKS contains exactly one key.
- Failed remote JWKS fetches are throttled per remote service reference for 30 seconds. Concurrent requests during the initial cold fetch, and requests during the post-failure throttle window, are denied locally. Expired cached JWKS are served only while a refresh is already in flight.
- Remote JWKS responses larger than 64 KiB are rejected.
- `jwks_cache_duration` must be at least 30 seconds so cache expiry does not undercut the remote JWKS failure backoff window.
- In the wasm-go implementation, empty inline `jwks` key sets are rejected during configuration parsing. When `keep_token` is `false`, tokens extracted from `from_params` are removed from the forwarded request path.
The configuration fields for `remote_jwks` are as follows:
| Name | Data Type | Requirements | Default Value | Description |
| -------------- | --------- | ------------ | ------------- | ----------- |
| `service_name` | string | Required | - | The Higress service name used to build the outbound cluster. |
| `service_host` | string | Required | - | Host or `:authority` header for the JWKS request, without a port. Use `service_port` for the port. |
| `service_port` | number | Optional | 443 | Service port for the JWKS request. |
| `path` | string | Required | - | JWKS request path, for example `/.well-known/jwks.json`. |
The configuration fields for each item in `from_headers` are as follows:
| Name | Data Type | Requirements | Default Value | Description |
| --------------- | ---------------- | ------------ | ------------- | ----------------------------------------------- |
@@ -158,26 +139,6 @@ curl http://xxx.hello.com/test
curl 'http://xxx.example.com/test' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMyJ9.eyJpc3MiOiJhYmNkIiwic3ViIjoidGVzdCIsImlhdCI6MTY2NTY2MDUyNywiZXhwIjoxODY1NjczODE5fQ.-vBSV0bKeDwQcuS6eeSZN9dLTUnSnZVk8eVCXdooCQ4'
```
### Using Remote JWKS (wasm-go implementation only)
If the signing keys are maintained by an identity provider, the wasm-go implementation can fetch and cache the remote JWKS from a configured service reference. The service must already be configured or discovered by Higress.
```yaml
global_auth: false
consumers:
- name: remote-consumer
issuer: https://issuer.example.com
remote_jwks:
service_name: issuer.example.com.dns
service_host: issuer.example.com
service_port: 443
path: /.well-known/jwks.json
jwks_cache_duration: 600
jwks_fetch_timeout: 1500
```
When remote JWKS is configured, make sure the referenced service is reachable from the gateway data plane. Remote JWKS responses larger than 64 KiB are treated as invalid responses.
#### Enable at Gateway Instance Level
The following configuration will enable JWT Auth authentication at the instance level, requiring all requests to be authenticated before accessing.
```yaml

View File

@@ -168,7 +168,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration,
LOG_WARN("failed to parse configuration for block_bodys.");
return false;
}
// compatible
// compatiable
if (!JsonArrayIterate(
configuration, "block_bodies", [&](const json& item) -> bool {
auto body = JsonValueAs<std::string>(item);

View File

@@ -14,7 +14,6 @@ COPY . .
WORKDIR /workspace/extensions/$PLUGIN_NAME
RUN go mod tidy
RUN if [ -f prepare.sh ]; then sh ./prepare.sh; fi
RUN \
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o /main.wasm .

View File

@@ -60,9 +60,7 @@ builder:
@echo "image: ${BUILDER}"
local-build:
cd extensions/${PLUGIN_NAME}; \
if [ -f prepare.sh ]; then sh ./prepare.sh; fi; \
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ./main.wasm .
cd extensions/${PLUGIN_NAME};GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ./main.wasm .
@echo ""
@echo "wasm: extensions/${PLUGIN_NAME}/main.wasm"

View File

@@ -38,6 +38,7 @@ func init() {
wrapper.ProcessRequestBodyBy(onHttpRequestBody),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
wrapper.ProcessStreamingResponseBodyBy(onHttpResponseBody),
wrapper.WithRebuildAfterRequests[config.PluginConfig](1000),
)
}

View File

@@ -890,175 +890,6 @@ data: [DONE]`
require.Equal(t, expectedStreamResponseBody, actualStreamResponseBody)
})
// 测试流式响应体处理:首 chunk 只带 role 无 content混元大模型场景#3953
// 验证:
// 1) processSSEMessage 不会因为首 chunk 没有 content 而报错,
// 避免后续 chunk 被 ERROR_PARTIAL_MESSAGE_KEY 短路。
// 2) 全部 content chunk 累积完成后data: [DONE] 触发的 processStreamLastChunk
// 必须把累积内容写入 Redis —— 而不是像修复前那样返回空字符串导致 cacheResponse
// 直接吞掉。
t.Run("stream response body with role-only first chunk", func(t *testing.T) {
host, status := test.NewTestHost(basicRedisConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/chat"},
{":method", "POST"},
{"content-type", "application/json"},
})
// 设置流式请求体
requestBody := `{
"model": "hy-mt2-pro",
"messages": [
{
"role": "user",
"content": "介绍天津"
}
],
"stream": true
}`
host.CallOnHttpRequestBody([]byte(requestBody))
// 模拟 Redis 缓存未命中CallOnRedisCall 会消费 callout 列表中的第 0 项,
// 即上一步 CheckCacheForKey 产生的 GET。响应后该条目被移除
// 流式响应便会走完整的 cacheResponse 写缓存路径。
host.CallOnRedisCall(0, test.CreateRedisRespNull())
// 设置流式响应头
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "text/event-stream"},
})
// 首 chunk只有 delta.role没有 delta.content混元/hy-mt2-pro 行为)
roleOnlyChunk := `data: {"id":"0b8ed3c9-3710-920b-abd9-581a0a77fd28","object":"chat.completion.chunk","created":1781169426,"model":"hy-mt2-pro","choices":[{"index":0,"delta":{"role":"assistant"}}]}
`
// 中间 chunk包含实际 content
contentChunk := `data: {"id":"0b8ed3c9-3710-920b-abd9-581a0a77fd28","object":"chat.completion.chunk","created":1781169426,"model":"hy-mt2-pro","choices":[{"index":0,"delta":{"content":"天津"}}]}
data: {"id":"0b8ed3c9-3710-920b-abd9-581a0a77fd28","object":"chat.completion.chunk","created":1781169426,"model":"hy-mt2-pro","choices":[{"index":0,"delta":{"content":"是历史文化名城"}}]}
`
// 末 chunk流结束标记
doneChunk := `data: [DONE]
`
// 三次调用模拟上游分片推送:首 chunk 不带 content后续 chunk 带 content
// 修复前:第一次调用会因首 chunk 无 content 报错ERROR_PARTIAL_MESSAGE_KEY 被设置,
// 后续所有 chunk 在 onHttpResponseBody 入口被短路,缓存永远写不进去。
// 修复后:首 chunk 静默跳过content chunk 正常累积并写入缓存。
action := host.CallOnHttpStreamingResponseBody([]byte(roleOnlyChunk), false)
require.Equal(t, types.ActionContinue, action)
action = host.CallOnHttpStreamingResponseBody([]byte(contentChunk), false)
require.Equal(t, types.ActionContinue, action)
action = host.CallOnHttpStreamingResponseBody([]byte(doneChunk), true)
require.Equal(t, types.ActionContinue, action)
// 在响应 SET 之前先快照 callout 属性 —— 框架在 CallOnRedisCallResponse
// 时会同步把条目从 map 里清掉,所以必须在消费前抓取。
redisCalls := host.GetRedisCalloutAttributes()
require.GreaterOrEqual(t, len(redisCalls), 1,
"expected a SET redis callout after [DONE], got %d", len(redisCalls))
// 模拟 Redis SET 响应。
host.CallOnRedisCall(0, test.CreateRedisRespString("OK"))
// 断言上游流式响应走到末尾后ai-cache 必须真的发出一次 SET
// 且写入的值是前面 content chunk 累积出的完整文本。
setCall := redisCalls[len(redisCalls)-1]
setQuery := string(setCall.Query)
require.Contains(t, setQuery, "higress-ai-cache:介绍天津",
"SET command should target the cache key derived from the request, got query: %q", setQuery)
require.Contains(t, setQuery, "天津是历史文化名城",
"SET command should contain the accumulated value, got query: %q", setQuery)
})
// 测试流式响应体处理:最后一个 buffer 同时包含最后一段 content 与 data: [DONE]
// 验证:
// 1) 同一 buffer 内的最后一段 content 不会被 [DONE] 早 return 丢掉;
// 2) cacheResponse SET 写入的值是先前 chunk 与本次合并后的完整累积文本。
// 回归保护PR #3962 review 指出当 content 与 [DONE] 在同一 lastChunk 到达时,
// processSSEMessage 会在 [DONE] 处直接 return ctx 旧值,导致最后一段 content 丢失。
t.Run("stream response body with final content and [DONE] in same chunk", func(t *testing.T) {
host, status := test.NewTestHost(basicRedisConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/chat"},
{":method", "POST"},
{"content-type", "application/json"},
})
// 设置流式请求体
requestBody := `{
"model": "hy-mt2-pro",
"messages": [
{
"role": "user",
"content": "介绍天津"
}
],
"stream": true
}`
host.CallOnHttpRequestBody([]byte(requestBody))
// 模拟 Redis 缓存未命中(消费 CheckCacheForKey 的 GET
host.CallOnRedisCall(0, test.CreateRedisRespNull())
// 设置流式响应头
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "text/event-stream"},
})
// 非末次 chunk先累积 "天津"
firstChunk := `data: {"id":"0b8ed3c9-3710-920b-abd9-581a0a77fd28","object":"chat.completion.chunk","created":1781169426,"model":"hy-mt2-pro","choices":[{"index":0,"delta":{"content":"天津"}}]}
`
// 末次 chunk最后一段 content + [DONE] 同一个 buffer 到达
finalChunk := `data: {"id":"0b8ed3c9-3710-920b-abd9-581a0a77fd28","object":"chat.completion.chunk","created":1781169426,"model":"hy-mt2-pro","choices":[{"index":0,"delta":{"content":"是历史文化名城"}}]}
data: [DONE]
`
action := host.CallOnHttpStreamingResponseBody([]byte(firstChunk), false)
require.Equal(t, types.ActionContinue, action)
action = host.CallOnHttpStreamingResponseBody([]byte(finalChunk), true)
require.Equal(t, types.ActionContinue, action)
// 在消费前快照 callout —— 框架在 CallOnRedisCall 同步把条目从 map 里清掉。
redisCalls := host.GetRedisCalloutAttributes()
require.GreaterOrEqual(t, len(redisCalls), 1,
"expected a SET redis callout after [DONE], got %d", len(redisCalls))
// 模拟 Redis SET 响应
host.CallOnRedisCall(0, test.CreateRedisRespString("OK"))
// 断言 SET value 必须是完整累积文本,而非被 [DONE] 截断后的 "天津"
setCall := redisCalls[len(redisCalls)-1]
setQuery := string(setCall.Query)
require.Contains(t, setQuery, "higress-ai-cache:介绍天津",
"SET command should target the cache key derived from the request, got query: %q", setQuery)
require.Contains(t, setQuery, "天津是历史文化名城",
"SET command should contain the full accumulated value (final chunk content must not be dropped by [DONE]), got query: %q", setQuery)
})
// 测试无缓存键的响应体处理
t.Run("response body without cache key", func(t *testing.T) {
host, status := test.NewTestHost(basicRedisConfig)

View File

@@ -88,13 +88,6 @@ func processStreamLastChunk(ctx wrapper.HttpContext, c config.PluginConfig, chun
if err != nil {
return "", fmt.Errorf("[processStreamLastChunk] processSSEMessage failed, error: %v", err)
}
// 兜底:[DONE] 或其它尾部 chunk 无 content 时processSSEMessage 返回空,
// 此时从 ctx 取已累积的缓存内容,避免缓存写空。
if value == "" {
if tempContentI := ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY); tempContentI != nil {
value = tempContentI.(string)
}
}
return value, nil
}
tempContentI := ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY)
@@ -106,11 +99,6 @@ func processStreamLastChunk(ctx wrapper.HttpContext, c config.PluginConfig, chun
func processSSEMessage(ctx wrapper.HttpContext, c config.PluginConfig, sseMessage string, log log.Log) (string, error) {
content := ""
// done 标记本次 sseMessage 是否遇到 [DONE]。当最后一段 content 与 [DONE]
// 处于同一 buffer 时,必须跳出循环后由循环外的 merge 逻辑统一合并到
// CACHE_CONTENT_CONTEXT_KEY否则本次解析的最后一段 content 会被 [DONE]
// 早 return 丢弃PR #3962 review
done := false
for _, chunk := range strings.Split(sseMessage, "\n\n") {
log.Debugf("single sse message: %s", chunk)
subMessages := strings.Split(chunk, "\n")
@@ -129,9 +117,7 @@ func processSSEMessage(ctx wrapper.HttpContext, c config.PluginConfig, sseMessag
bodyJson := message[5:]
if strings.TrimSpace(bodyJson) == "[DONE]" {
// 跳出循环,把已解析的局部 content 留到循环外统一合并。
done = true
break
return content, nil
}
// Extract values from JSON fields
@@ -144,29 +130,22 @@ func processSSEMessage(ctx wrapper.HttpContext, c config.PluginConfig, sseMessag
}
// Check if the ResponseBody field exists
if responseBody.Exists() {
if !responseBody.Exists() {
if ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY) != nil {
log.Debugf("[processSSEMessage] unable to extract content from message; cache content is not nil: %s", message)
return content, nil
}
return content, fmt.Errorf("[processSSEMessage] unable to extract content from message; cache content is nil: %s", message)
} else {
content += responseBody.String()
}
}
// 本次 sseMessage 既没解析到 content 也没遇到 [DONE]:保持 ctx 不变,直接返回。
if content == "" && !done {
log.Debugf("[processSSEMessage] no content extracted; skipping cache update: %s", sseMessage)
return "", nil
tempContentI := ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY)
// If there is no content in the cache, initialize and set the content
if tempContentI == nil {
ctx.SetContext(CACHE_CONTENT_CONTEXT_KEY, content)
} else {
ctx.SetContext(CACHE_CONTENT_CONTEXT_KEY, tempContentI.(string)+content)
}
if content != "" {
if v := ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY); v == nil {
ctx.SetContext(CACHE_CONTENT_CONTEXT_KEY, content)
} else {
ctx.SetContext(CACHE_CONTENT_CONTEXT_KEY, v.(string)+content)
}
}
// handleStreamChunk 不使用返回值processStreamLastChunk 把它作为 cacheResponse 的
// SET value必须是完整累积值避免最后一段 content 因 [DONE] 早 return 被丢)。
if v := ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY); v != nil {
return v.(string), nil
}
return "", nil
return content, nil
}

View File

@@ -1,6 +0,0 @@
plugin.wasm
*.wasm
.tmp/
.gocache/
PR.md
bpe/o200k_base.tiktoken

View File

@@ -1,13 +0,0 @@
.PHONY: prepare build build-go clean
prepare:
@sh ./prepare.sh
build: build-go
build-go: prepare
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm .
clean:
rm -f main.wasm
rm -f bpe/o200k_base.tiktoken

View File

@@ -1,68 +0,0 @@
---
title: AI 上下文窗口限制
keywords: [ AI网关, 上下文窗口, Token ]
description: AI 上下文窗口限制插件配置参考
---
## 功能说明
`ai-context-limit` 用于在请求转发到上游大模型前,对 OpenAI Chat Completions、Anthropic Messages 等协议兼容请求中的文本输入进行 token 估算。当估算结果超过配置的上下文窗口大小时,插件会直接返回错误响应,避免超长上下文继续进入后端模型服务。
该插件适用于按路由、服务、域名或 MCP Server 控制请求上下文规模的场景,可用于为不同业务、模型或调用入口设置独立的上下文窗口上限。
## 运行属性
插件执行阶段:`默认阶段`
插件执行优先级:`1000`
## 构建
插件依赖内嵌的 BPE 词表文件,首次构建前需要下载:
```bash
make build
```
或分步执行:
```bash
make prepare # 下载词表到 bpe/o200k_base.tiktoken
make build-go # 编译 WASM
```
## 配置字段
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|------|---------|---------|--------|------|
| `max_context_tokens` | int | 必填 | - | 最大上下文 token 数。输入估算结果超过该值时,请求会被拦截。设为 0 表示禁用拦截。 |
| `buffer_ratio` | float | 非必填 | 1.10 | 安全缓冲系数(取值范围 0~10。插件会将估算 token 数乘以该系数后再与阈值比较。 |
| `error_status_code` | int | 非必填 | 400 | 请求超出上下文窗口限制时返回的 HTTP 状态码(取值范围 400~599。 |
## 配置示例
```yaml
max_context_tokens: 128000
buffer_ratio: 1.10
error_status_code: 400
```
## 返回示例
当请求输入超过配置限制时,插件会返回如下格式的错误响应:
```json
{
"error": {
"message": "This model's maximum context length is 128000 tokens. Your request had approximately 140000 tokens.",
"type": "invalid_request_error",
"code": "context_length_exceeded"
}
}
```
## 注意事项
- 当前版本会统计文本承载字段,包括 text、tool schema、tool arguments、thinking、text document、search_result 等图片、音频、base64/url/file document 等非文本内容会跳过 token 统计,整个请求直接放行。
- 非 JSON 请求或非兼容协议的请求不会触发上下文限制。
- 插件最多读取 8MB 请求体用于文本估算,超出部分不会被处理。

View File

@@ -1,68 +0,0 @@
---
title: AI Context Limit
keywords: [ AI Gateway, Context Window, Token ]
description: AI Context Limit plugin configuration reference
---
## Functional Description
`ai-context-limit` estimates the input token count of OpenAI Chat Completions, Anthropic Messages and other compatible requests before forwarding them to the upstream model service. When the estimated input size exceeds the configured context window limit, the plugin returns an error response directly.
This plugin can be used to control context window size by route, service, domain, or MCP Server. It is suitable for setting independent context limits for different applications, models, or traffic entry points.
## Runtime Properties
Plugin execution phase: `Default Phase`
Plugin execution priority: `1000`
## Build
The plugin requires an embedded BPE vocabulary file. Download it before the first build:
```bash
make build
```
Or step by step:
```bash
make prepare # Download vocabulary to bpe/o200k_base.tiktoken
make build-go # Compile WASM
```
## Configuration Fields
| Name | Data Type | Requirement | Default Value | Description |
|------|-----------|-------------|---------------|-------------|
| `max_context_tokens` | int | Required | - | Maximum context token limit. Requests whose estimated input size exceeds this value will be blocked. Set to 0 to disable. |
| `buffer_ratio` | float | Optional | 1.10 | Safety buffer ratio (valid range: 010). The estimated token count is multiplied by this ratio before comparison. |
| `error_status_code` | int | Optional | 400 | HTTP status code returned when the request exceeds the context limit (valid range: 400599). |
## Configuration Example
```yaml
max_context_tokens: 128000
buffer_ratio: 1.10
error_status_code: 400
```
## Response Example
When a request exceeds the configured limit, the plugin returns an error response in the following format:
```json
{
"error": {
"message": "This model's maximum context length is 128000 tokens. Your request had approximately 140000 tokens.",
"type": "invalid_request_error",
"code": "context_length_exceeded"
}
}
```
## Notes
- The plugin counts text-bearing fields including text, tool schema, tool arguments, thinking, text document, and search_result. Non-text content such as images, audio, and base64/url/file documents will skip token counting and the entire request is passed through.
- Non-JSON requests and requests that are not in a compatible protocol format will not trigger the context limit.
- The plugin reads up to 8MB of the request body for text estimation; content beyond this limit will not be processed.

View File

@@ -1 +0,0 @@
1.0.0

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Alibaba Group Holding Ltd.
//
// 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.
package main
import (
"encoding/json"
"fmt"
"github.com/tidwall/gjson"
)
// Config 上下文限制插件配置
type Config struct {
// MaxContextTokens 必填,输入侧 token 上限(用户阈值)
MaxContextTokens int `json:"max_context_tokens"`
// ErrorStatusCode 超限响应码,默认 400
ErrorStatusCode int `json:"error_status_code"`
// BufferRatio token 预估值放大系数,默认 1.10
BufferRatio float64 `json:"buffer_ratio"`
}
const (
defaultErrorStatusCode = 400
defaultBufferRatio = 1.10
// MaxRequestBodyBytes 强制调大的 envoy 请求体 buffer 上限
// 上下文限制仅需要读取请求体中的文本输入8MB 可覆盖常见长上下文请求。
MaxRequestBodyBytes uint32 = 8 * 1024 * 1024
)
// parseConfig 解析 WasmPlugin defaultConfig 字段
func parseConfig(jsonConfig gjson.Result, cfg *Config) error {
if err := json.Unmarshal([]byte(jsonConfig.Raw), cfg); err != nil {
return fmt.Errorf("parse config failed: %w", err)
}
if cfg.MaxContextTokens < 0 {
return fmt.Errorf("max_context_tokens must be non-negative, got %d", cfg.MaxContextTokens)
}
if cfg.MaxContextTokens == 0 {
// 阈值为 0 视为未启用,不拦截请求(防止误配置导致全量 5xx
return nil
}
if cfg.ErrorStatusCode == 0 {
cfg.ErrorStatusCode = defaultErrorStatusCode
} else if cfg.ErrorStatusCode < 400 || cfg.ErrorStatusCode > 599 {
return fmt.Errorf("error_status_code must be between 400 and 599, got %d", cfg.ErrorStatusCode)
}
if cfg.BufferRatio < 0 {
return fmt.Errorf("buffer_ratio must be non-negative, got %f", cfg.BufferRatio)
}
if cfg.BufferRatio == 0 {
cfg.BufferRatio = defaultBufferRatio
} else if cfg.BufferRatio > 10 {
return fmt.Errorf("buffer_ratio must not exceed 10, got %f", cfg.BufferRatio)
}
return nil
}
// IsEnabled 判断当前配置是否需要执行拦截
func (c *Config) IsEnabled() bool {
return c.MaxContextTokens > 0
}

View File

@@ -1,201 +0,0 @@
# ai-context-limit 设计文档
## 背景
大模型网关经常需要在请求到达上游模型之前拦截超长 prompt。现有 token 相关插件更多依赖模型响应中的 usage 字段进行事后统计,适合做计量和配额,但无法阻止超限请求进入模型。
`ai-context-limit` 提供请求侧上下文窗口保护能力,面向 OpenAI Chat Completions、Anthropic Messages 等协议兼容请求,在转发前估算输入 token 数,并在估算结果超过配置阈值时直接返回错误响应。
## 目标
- 在请求发送到上游模型前拦截超限文本输入。
- 支持通过 Higress WasmPlugin 在路由、服务、域名和 MCP Server 等粒度配置。
- 首个版本保持数据面自包含,运行时不依赖网络下载分词资源。
- 返回 OpenAI 兼容错误响应,方便常见 SDK 识别异常。
## 非目标
- 为每个模型系列精确适配专属分词器。
- 根据模型名自动选择分词器。
- 统计多模态内容的 token。
- 做响应侧 token 统计或配额管理。
如果后续特定模型族对精度有更高要求,可以在该插件基础上继续扩展。
## 配置
```yaml
max_context_tokens: 128000
buffer_ratio: 1.10
error_status_code: 400
```
| 字段 | 类型 | 默认值 | 校验规则 | 含义 |
|---|---|---|---|---|
| `max_context_tokens` | int | - | `>= 0` | 最大输入 token 估算阈值。设为 `0` 表示禁用拦截。 |
| `buffer_ratio` | float | `1.10` | `0 <= value <= 10` | 安全缓冲系数,估算 token 数会先乘以该系数再与阈值比较。设为 `0` 使用默认值。 |
| `error_status_code` | int | `400` | `400 <= value <= 599` | 请求被拦截时返回的 HTTP 状态码。 |
## 请求处理流程
1. 请求头阶段,非 JSON 请求和未启用配置直接放行,不读取请求体。
2. JSON 请求会把请求体 buffer 上限调到 8MB并等待请求体阶段处理。
3. 插件会自动识别请求协议OpenAI 或 Anthropic并抽取对应字段的文本
- **OpenAI Chat Completions**
- `messages[].content` 字符串或 text parts 数组;
- `messages[].role``messages[].name`
- `messages[].tool_calls[].function.name``arguments`
- `tools[].function.name``description``parameters`
- `response_format.json_schema.name``description``schema`
- 顶层 `system` 字段。
- **Anthropic Messages**(通过 `tools[].input_schema` / `tool_use` / `tool_result` / `thinking` / `redacted_thinking` / `document` / `search_result` 等特有字段识别):
- `messages[].content` 字符串或 content block 数组;
- `messages[].role`
- `text` block 的 `text` 字段;
- `tool_use` block 的 `name``input`raw JSON
- `tool_result` block 的 `content`(字符串或 content block 数组,递归处理);
- `thinking` block 的 `thinking` 字段;
- `redacted_thinking` block 的 `data` 字段(保守计入);
- `document` block`source.type=text` 时计入 `title` + `source.data`,其他视为多模态;
- `search_result` block 的 `title` + `source` + `content[]` 中的 text blocks
- `tools[].name``description``type``input_schema`raw JSON
- 顶层 `system`(字符串或 text block 数组)。
4. 如果检测到非文本二进制内容如图片、音频、base64/url/file document整个请求直接放行。插件会统计所有文本承载字段未知非文本 block 视为多模态并放行。
5. 插件对抽取文本进行 token 估算,并将结果乘以 `buffer_ratio` 后与 `max_context_tokens` 比较。
6.`estimated_tokens > max_context_tokens` 时,插件返回 OpenAI 兼容的 `context_length_exceeded` 错误。
## Token 估算策略
首个版本使用单一内嵌 BPE 词表,并在插件启动时初始化 tokenizer。这样可以避免 WASM 沙箱运行时下载资源,使请求处理完全在本地完成。
默认 `buffer_ratio``1.10`。基于长中文文档、混合 RAG、代码、多轮对话等文本的验证结果该缓冲系数可以覆盖已观察到的低估场景同时保持实现简单、确定。
## 错误响应
```json
{
"error": {
"message": "This model's maximum context length is 128000 tokens. Your request had approximately 140000 tokens.",
"type": "invalid_request_error",
"code": "context_length_exceeded"
}
}
```
## 验证
实现已包含单元测试,覆盖:
- 配置默认值和非法值;
- 从 messages、tools、顶层 system 字段抽取文本;
- 多模态检测与放行;
- token 计数基础行为;
- 严格阈值比较逻辑。
插件已通过以下验证:
- `go test ./...`
- `go vet ./...`
- `make local-build PLUGIN_NAME=ai-context-limit`
---
# ai-context-limit Design
## Background
Large language model gateways often need to reject over-sized prompts before they reach the upstream model. Existing token-related plugins mainly rely on response-side usage fields, which is useful for accounting but cannot prevent an over-limit request from reaching the model.
`ai-context-limit` provides request-side context window protection for OpenAI Chat Completions, Anthropic Messages and other compatible traffic. It estimates input tokens before forwarding and returns an error response when the estimated input size exceeds the configured limit.
## Goals
- Block over-limit text requests before they are sent to the upstream model.
- Support route, service, domain, and MCP Server level configuration through Higress WasmPlugin.
- Keep the first version self-contained in the data plane, without runtime network access for tokenizer resources.
- Provide OpenAI-compatible error responses so common SDKs can parse the failure.
## Non-goals
- Exact tokenizer matching for every model family.
- Model-name based tokenizer selection.
- Multimodal token counting.
- Response-side token accounting or quota management.
These can be added later if there is a stronger precision requirement for specific model families.
## Configuration
```yaml
max_context_tokens: 128000
buffer_ratio: 1.10
error_status_code: 400
```
| Field | Type | Default | Validation | Meaning |
|---|---|---|---|---|
| `max_context_tokens` | int | - | `>= 0` | Maximum estimated input tokens. `0` disables blocking. |
| `buffer_ratio` | float | `1.10` | `0 <= value <= 10` | Safety multiplier applied to estimated tokens before comparison. `0` uses the default. |
| `error_status_code` | int | `400` | `400 <= value <= 599` | HTTP status code for blocked requests. |
## Request Processing
1. In the request header phase, non-JSON requests and disabled configs are passed through without reading the body.
2. For JSON requests, the plugin raises the request body buffer limit to 8MB and waits for the body phase.
3. The plugin auto-detects the request protocol (OpenAI or Anthropic) and extracts text from the corresponding fields:
- **OpenAI Chat Completions**:
- `messages[].content` string or text parts array;
- `messages[].role` and `messages[].name`;
- `messages[].tool_calls[].function.name` and `arguments`;
- `tools[].function.name`, `description`, and `parameters`;
- `response_format.json_schema.name`, `description`, and `schema`;
- top-level `system`.
- **Anthropic Messages** (detected via `tools[].input_schema` / `tool_use` / `tool_result` / `thinking` / `redacted_thinking` / `document` / `search_result`):
- `messages[].content` string or content block array;
- `messages[].role`;
- `text` block `text` field;
- `tool_use` block `name` and `input` (raw JSON);
- `tool_result` block `content` (string or content block array, recursively processed);
- `thinking` block `thinking` field;
- `redacted_thinking` block `data` field (conservatively counted);
- `document` block: `source.type=text` counts `title` + `source.data`, others treated as multimodal;
- `search_result` block `title` + `source` + `content[]` text blocks;
- `tools[].name`, `description`, `type`, and `input_schema` (raw JSON);
- top-level `system` (string or text block array).
4. If non-text binary content is detected (e.g., images, audio, base64/url/file documents), the request is passed through. The plugin counts all text-bearing fields; unknown non-text blocks are treated as multimodal and bypassed.
5. The extracted text is tokenized, multiplied by `buffer_ratio`, and compared with `max_context_tokens`.
6. If `estimated_tokens > max_context_tokens`, the plugin returns an OpenAI-compatible `context_length_exceeded` response.
## Token Estimation Strategy
The first version uses a single embedded BPE vocabulary and initializes the tokenizer once during plugin startup. This avoids runtime downloads in the WASM sandbox and keeps request processing fully local.
The default `buffer_ratio` is `1.10`. Internal validation on long Chinese documents, mixed RAG content, code, and multi-turn conversations showed that the buffer covers observed under-estimation cases while keeping the implementation simple and deterministic.
## Error Response
```json
{
"error": {
"message": "This model's maximum context length is 128000 tokens. Your request had approximately 140000 tokens.",
"type": "invalid_request_error",
"code": "context_length_exceeded"
}
}
```
## Validation
The implementation includes unit tests for:
- configuration defaults and invalid values;
- text extraction from messages, tools, and top-level system fields;
- multimodal detection and pass-through behavior;
- token counting basics;
- strict threshold comparison.
The plugin has also been verified with:
- `go test ./...`
- `go vet ./...`
- `make local-build PLUGIN_NAME=ai-context-limit`

View File

@@ -1,384 +0,0 @@
// Copyright (c) 2026 Alibaba Group Holding Ltd.
//
// 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.
package main
import (
"strings"
"github.com/tidwall/gjson"
)
// extractResult 文本抽取结果
type extractResult struct {
// Text 拼接后的所有可计 token 文本
Text string
// HasMultimodal 是否检测到非 text 类型 partimage_url/audio/...),命中即放行
HasMultimodal bool
}
// extractPromptText 从请求体抽取所有需要计入 input tokens 的文本。
//
// 协议识别策略:通过检测 Anthropic 特有字段tools[].input_schema、
// content type=tool_use/tool_result/thinking/redacted_thinking/document/search_result
// 来判断是否为 Anthropic 协议请求。
// 普通纯文本请求即使是 Anthropic 格式,走 OpenAI 路径也能正确统计。
//
// 多模态降级非文本二进制内容image/audio/base64 document视为多模态
// 整个请求放行。
func extractPromptText(body []byte) extractResult {
if hasAnthropicSpecificFields(body) {
return extractAnthropicText(body)
}
return extractOpenAIText(body)
}
// hasAnthropicSpecificFields 保守识别 Anthropic 协议特有字段。
//
// 强信号(任一命中即判定为 Anthropic
// - tools[].input_schema 存在OpenAI 用 tools[].function.parameters
// - tools[] 中存在无 function 包装但有 name 的条目Anthropic server tools
// - messages[].content[] 中含 type=tool_use/tool_result/thinking/redacted_thinking/document/search_result
//
// 不以 content array + type=text 判断OpenAI 多模态也有此结构)。
func hasAnthropicSpecificFields(body []byte) bool {
// 检查 tools[]
tools := gjson.GetBytes(body, "tools").Array()
for _, tool := range tools {
if tool.Get("input_schema").Exists() {
return true
}
if tool.Get("name").Exists() && !tool.Get("function").Exists() {
return true
}
}
// 检查 messages[].content[] 中的 Anthropic 特有 block types
messages := gjson.GetBytes(body, "messages").Array()
for _, msg := range messages {
content := msg.Get("content")
if !content.IsArray() {
continue
}
for _, part := range content.Array() {
switch part.Get("type").String() {
case "tool_use", "tool_result", "thinking", "redacted_thinking", "document", "search_result":
return true
}
}
}
return false
}
// ---------------------------------------------------------------------------
// OpenAI Chat Completions extractor
// ---------------------------------------------------------------------------
// extractOpenAIText 从 OpenAI Chat Completions 请求体抽取文本。
//
// 协议参考https://platform.openai.com/docs/api-reference/chat/create
//
// 抽取范围:
// - messages[].role / name / contentstring 或 text parts array
// - messages[].tool_calls[].function.{name, arguments}
// - tools[].function.{name, description, parameters}
// - response_format.json_schema.{name, description, schema}
// - 顶层 system 字段(兼容将 system prompt 放在顶层的协议)
func extractOpenAIText(body []byte) extractResult {
var sb strings.Builder
result := extractResult{}
// 1. messages[]
messages := gjson.GetBytes(body, "messages").Array()
for _, msg := range messages {
if name := msg.Get("name").String(); name != "" {
sb.WriteString(name)
sb.WriteByte('\n')
}
if role := msg.Get("role").String(); role != "" {
sb.WriteString(role)
sb.WriteByte('\n')
}
content := msg.Get("content")
switch {
case content.Type == gjson.String:
sb.WriteString(content.String())
sb.WriteByte('\n')
case content.IsArray():
for _, part := range content.Array() {
partType := part.Get("type").String()
if partType == "text" {
sb.WriteString(part.Get("text").String())
sb.WriteByte('\n')
continue
}
// 任意非 text part → 多模态,立即返回触发放行
result.HasMultimodal = true
return result
}
}
// messages[].tool_calls[](多轮对话中 assistant 的工具调用参数)
toolCalls := msg.Get("tool_calls").Array()
for _, tc := range toolCalls {
fn := tc.Get("function")
if !fn.Exists() {
continue
}
if name := fn.Get("name").String(); name != "" {
sb.WriteString(name)
sb.WriteByte('\n')
}
if args := fn.Get("arguments").String(); args != "" {
sb.WriteString(args)
sb.WriteByte('\n')
}
}
}
// 2. tools[]
tools := gjson.GetBytes(body, "tools").Array()
for _, tool := range tools {
fn := tool.Get("function")
if !fn.Exists() {
continue
}
if name := fn.Get("name").String(); name != "" {
sb.WriteString(name)
sb.WriteByte('\n')
}
if desc := fn.Get("description").String(); desc != "" {
sb.WriteString(desc)
sb.WriteByte('\n')
}
if params := fn.Get("parameters"); params.Exists() {
sb.WriteString(params.Raw)
sb.WriteByte('\n')
}
}
// 3. response_format.json_schema结构化输出 schema 计入 input tokens
jsonSchema := gjson.GetBytes(body, "response_format.json_schema")
if jsonSchema.Exists() {
if name := jsonSchema.Get("name").String(); name != "" {
sb.WriteString(name)
sb.WriteByte('\n')
}
if desc := jsonSchema.Get("description").String(); desc != "" {
sb.WriteString(desc)
sb.WriteByte('\n')
}
if schema := jsonSchema.Get("schema"); schema.Exists() {
sb.WriteString(schema.Raw)
sb.WriteByte('\n')
}
}
// 4. 顶层 system 字段
extractTopLevelSystem(body, &sb)
result.Text = sb.String()
return result
}
// ---------------------------------------------------------------------------
// Anthropic Messages extractor
// ---------------------------------------------------------------------------
// extractAnthropicText 从 Anthropic Messages 请求体抽取文本。
//
// 协议参考https://docs.anthropic.com/en/api/messages
//
// 抽取范围:
// - systemstring 或 text block array
// - messages[].role
// - messages[].contentstring 或 content block array
// - type=text → text 字段
// - type=tool_use → name + inputraw JSON
// - type=tool_result → contentstring 或 content block array
// - type=thinking → thinking 字段
// - type=redacted_thinking → data 字段
// - type=document → source.type=text 时计入,其他视为多模态
// - type=search_result → title + source + content text blocks
// - tools[].name / description / type / input_schemaraw JSON
func extractAnthropicText(body []byte) extractResult {
var sb strings.Builder
result := extractResult{}
// 1. systemstring 或 text block array
extractTopLevelSystem(body, &sb)
// 2. messages[]
messages := gjson.GetBytes(body, "messages").Array()
for _, msg := range messages {
if role := msg.Get("role").String(); role != "" {
sb.WriteString(role)
sb.WriteByte('\n')
}
content := msg.Get("content")
switch {
case content.Type == gjson.String:
sb.WriteString(content.String())
sb.WriteByte('\n')
case content.IsArray():
for _, part := range content.Array() {
if extractAnthropicContentBlock(part, &sb) {
result.HasMultimodal = true
return result
}
}
}
}
// 3. tools[]
tools := gjson.GetBytes(body, "tools").Array()
for _, tool := range tools {
if tp := tool.Get("type").String(); tp != "" {
sb.WriteString(tp)
sb.WriteByte('\n')
}
if name := tool.Get("name").String(); name != "" {
sb.WriteString(name)
sb.WriteByte('\n')
}
if desc := tool.Get("description").String(); desc != "" {
sb.WriteString(desc)
sb.WriteByte('\n')
}
if schema := tool.Get("input_schema"); schema.Exists() {
sb.WriteString(schema.Raw)
sb.WriteByte('\n')
}
}
result.Text = sb.String()
return result
}
// extractAnthropicContentBlock 统一处理单个 Anthropic content block。
// 顶层 messages[].content[] 和 tool_result.content[] 均复用此函数。
// 返回 true 表示发现多模态内容(需放行)。
func extractAnthropicContentBlock(part gjson.Result, sb *strings.Builder) bool {
t := part.Get("type").String()
switch t {
case "text":
sb.WriteString(part.Get("text").String())
sb.WriteByte('\n')
case "tool_use":
if name := part.Get("name").String(); name != "" {
sb.WriteString(name)
sb.WriteByte('\n')
}
if input := part.Get("input"); input.Exists() {
sb.WriteString(input.Raw)
sb.WriteByte('\n')
}
case "tool_result":
content := part.Get("content")
switch {
case content.Type == gjson.String:
sb.WriteString(content.String())
sb.WriteByte('\n')
case content.IsArray():
for _, block := range content.Array() {
if extractAnthropicContentBlock(block, sb) {
return true
}
}
}
case "thinking":
if thinking := part.Get("thinking").String(); thinking != "" {
sb.WriteString(thinking)
sb.WriteByte('\n')
}
case "redacted_thinking":
if data := part.Get("data").String(); data != "" {
sb.WriteString(data)
sb.WriteByte('\n')
}
case "document":
return extractAnthropicDocument(part, sb)
case "search_result":
extractAnthropicSearchResult(part, sb)
default:
// 真正的非文本 blockimage/audio/等)视为多模态
return true
}
return false
}
// extractAnthropicDocument 处理 Anthropic document content block。
// source.type=="text" 时抽取文本内容其他类型base64/url/file视为多模态。
// 返回 true 表示多模态(需放行)。
func extractAnthropicDocument(part gjson.Result, sb *strings.Builder) bool {
sourceType := part.Get("source.type").String()
if sourceType == "text" {
// 纯文本文档,计入 token
if title := part.Get("title").String(); title != "" {
sb.WriteString(title)
sb.WriteByte('\n')
}
if data := part.Get("source.data").String(); data != "" {
sb.WriteString(data)
sb.WriteByte('\n')
}
return false
}
// base64/url/file 等非文本源 → 多模态
return true
}
// extractAnthropicSearchResult 处理 Anthropic search_result content block。
// 抽取 title、source 和 content[] 中的 text blocks。
func extractAnthropicSearchResult(part gjson.Result, sb *strings.Builder) {
if title := part.Get("title").String(); title != "" {
sb.WriteString(title)
sb.WriteByte('\n')
}
if source := part.Get("source").String(); source != "" {
sb.WriteString(source)
sb.WriteByte('\n')
}
// content 是 text blocks 数组
contentArr := part.Get("content").Array()
for _, block := range contentArr {
if block.Get("type").String() == "text" {
sb.WriteString(block.Get("text").String())
sb.WriteByte('\n')
}
}
}
// ---------------------------------------------------------------------------
// 共用辅助函数
// ---------------------------------------------------------------------------
// extractTopLevelSystem 抽取顶层 system 字段string 或 text block array
// OpenAI 和 Anthropic 均可能使用顶层 system。
func extractTopLevelSystem(body []byte, sb *strings.Builder) {
sys := gjson.GetBytes(body, "system")
if !sys.Exists() {
return
}
switch {
case sys.Type == gjson.String:
sb.WriteString(sys.String())
sb.WriteByte('\n')
case sys.IsArray():
for _, part := range sys.Array() {
if part.Get("type").String() == "text" {
sb.WriteString(part.Get("text").String())
sb.WriteByte('\n')
}
}
}
}

View File

@@ -1,25 +0,0 @@
module ai-context-limit
go 1.24.1
toolchain go1.24.4
require (
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20251103120604-77e9cce339d2
github.com/higress-group/wasm-go v1.0.7-0.20251209122854-7e766df5675c
github.com/pkoukk/tiktoken-go v0.1.7
github.com/stretchr/testify v1.9.0
github.com/tidwall/gjson v1.18.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/resp v0.1.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,32 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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/higress-group/proxy-wasm-go-sdk v0.0.0-20251103120604-77e9cce339d2 h1:NY33OrWCJJ+DFiLc+lsBY4Ywor2Ik61ssk6qkGF8Ypo=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20251103120604-77e9cce339d2/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA=
github.com/higress-group/wasm-go v1.0.7-0.20251209122854-7e766df5675c h1:DdVPyaMHSYBqO5jwB9Wl3PqsBGIf4u29BHMI0uIVB1Y=
github.com/higress-group/wasm-go v1.0.7-0.20251209122854-7e766df5675c/go.mod h1:uKVYICbRaxTlKqdm8E0dpjbysxM8uCPb9LV26hF3Km8=
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,153 +0,0 @@
// Copyright (c) 2026 Alibaba Group Holding Ltd.
//
// 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.
// Package main 实现 ai-context-limit Higress WASM 插件。
//
// 插件会在 OpenAI / Anthropic 等协议兼容请求到达上游模型之前估算输入 token 数,
// 并对超过配置阈值的请求提前返回错误响应。
package main
import (
"fmt"
"strings"
"time"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/wasm-go/pkg/wrapper"
)
func main() {}
func init() {
// 在插件加载时一次性初始化 token 编码器。
if err := initEncoder(); err != nil {
// 初始化失败为致命错误:缺少编码器后续无法计算 token
// 但 wasm 运行期不能 panic记录后所有请求走"未启用"兜底路径
// 实际触发概率极低embed 词表打包失败才会出现)
// log 包在 init() 中尚不可用,使用 fmt.Println 兜底
fmt.Println("[ai-context-limit] init encoder failed:", err)
}
wrapper.SetCtx(
"ai-context-limit",
wrapper.ParseConfig(parseConfig),
wrapper.ProcessRequestHeaders(onHttpRequestHeaders),
wrapper.ProcessRequestBody(onHttpRequestBody),
)
}
// onHttpRequestHeaders 处理请求头阶段
//
// 关键约束:
// - envoy 默认 http filter buffer 仅 14.3KB,必须在此阶段调 SetRequestBodyBufferLimit
// - 必须返回 HeaderStopIteration否则 envoy 不会等待 body 阶段
// - 非 JSON 请求直接放行,不读 body
func onHttpRequestHeaders(ctx wrapper.HttpContext, cfg Config) types.Action {
ctx.DisableReroute()
if !cfg.IsEnabled() {
// 配置缺失时降级为不拦截,允许用户通过配置开关此插件
log.Warnf("max_context_tokens not configured, plugin disabled for this request")
ctx.DontReadRequestBody()
return types.ActionContinue
}
contentType, _ := proxywasm.GetHttpRequestHeader("content-type")
if !strings.Contains(strings.ToLower(contentType), "application/json") {
log.Debugf("non-json content-type=%q, skip body inspection", contentType)
ctx.DontReadRequestBody()
return types.ActionContinue
}
if !ctx.HasRequestBody() {
log.Debugf("no request body, skip")
ctx.DontReadRequestBody()
return types.ActionContinue
}
// 强制调大 envoy http downstream decoder buffer
// 写入 envoy property: set_decoder_buffer_limit
ctx.SetRequestBodyBufferLimit(MaxRequestBodyBytes)
// 移除 content-lengthbody 处理后由 envoy 重新计算
_ = proxywasm.RemoveHttpRequestHeader("content-length")
// 暂停 header 流转,等待 onHttpRequestBody 处理完
return types.HeaderStopIteration
}
// onHttpRequestBody 处理请求体阶段
//
// 流程:
// 1. 抽取请求体中所有需计 token 的文本(兼容 OpenAI / Anthropic 等协议)
// 2. 命中多模态image_url/audio→ 直接放行
// 3. token 计数 → ×buffer_ratio → 与阈值比较
// 4. 超阈值 → 发送 local responseOpenAI 风格错误体
//
// 各阶段统一 info 级耗时日志([aicl])方便 grep 与基准对照。
func onHttpRequestBody(ctx wrapper.HttpContext, cfg Config, body []byte) types.Action {
if !cfg.IsEnabled() {
return types.ActionContinue
}
bodyBytes := len(body)
log.Infof("[aicl] body_received bytes=%d", bodyBytes)
if encoder == nil {
log.Errorf("[aicl] token encoder not initialized, skip token counting")
return types.ActionContinue
}
t0 := time.Now()
result := extractPromptText(body)
extractMs := time.Since(t0).Milliseconds()
log.Infof("[aicl] extract_done bytes=%d text_bytes=%d multimodal=%v elapsed_ms=%d",
bodyBytes, len(result.Text), result.HasMultimodal, extractMs)
if result.HasMultimodal {
log.Debugf("[aicl] multimodal request detected, bypass token counting")
return types.ActionContinue
}
t1 := time.Now()
rawTokens := CountTokens(result.Text)
encodeMs := time.Since(t1).Milliseconds()
estimatedTokens := int(float64(rawTokens) * cfg.BufferRatio)
log.Infof("[aicl] encode_done bytes=%d text_bytes=%d raw_tokens=%d estimated=%d "+
"threshold=%d extract_ms=%d encode_ms=%d total_ms=%d",
bodyBytes, len(result.Text), rawTokens, estimatedTokens,
cfg.MaxContextTokens, extractMs, encodeMs, extractMs+encodeMs)
if estimatedTokens > cfg.MaxContextTokens {
return blockOverLimit(cfg, estimatedTokens)
}
return types.ActionContinue
}
// blockOverLimit 发送 OpenAI 风格的超限错误响应
//
// 响应体复刻 OpenAI 官方 context_length_exceeded 格式,
// 使客户端 SDKopenai-python / openai-node可解析为 BadRequestError
func blockOverLimit(cfg Config, estimatedTokens int) types.Action {
body := fmt.Sprintf(
`{"error":{"message":"This model's maximum context length is %d tokens. `+
`Your request had approximately %d tokens.",`+
`"type":"invalid_request_error","code":"context_length_exceeded"}}`,
cfg.MaxContextTokens, estimatedTokens,
)
headers := [][2]string{{"content-type", "application/json"}}
if err := proxywasm.SendHttpResponse(uint32(cfg.ErrorStatusCode), headers, []byte(body), -1); err != nil {
log.Errorf("send local response failed: %v", err)
return types.ActionContinue
}
log.Infof("blocked: estimated %d > limit %d", estimatedTokens, cfg.MaxContextTokens)
return types.ActionContinue
}

View File

@@ -1,730 +0,0 @@
// Copyright (c) 2026 Alibaba Group Holding Ltd.
//
// 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.
package main
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
)
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
wantMax int
wantCode int
wantRatio float64
wantOk bool
wantErr bool
}{
{
name: "完整配置",
input: `{"max_context_tokens":128000,"error_status_code":413,"buffer_ratio":1.2}`,
wantMax: 128000,
wantCode: 413,
wantRatio: 1.2,
wantOk: true,
},
{
name: "仅必填字段,其余取默认值",
input: `{"max_context_tokens":32000}`,
wantMax: 32000,
wantCode: defaultErrorStatusCode,
wantRatio: defaultBufferRatio,
wantOk: true,
},
{
name: "缺失阈值不抛错IsEnabled=false",
input: `{}`,
wantMax: 0,
wantCode: 0,
wantRatio: 0,
wantOk: false,
},
{
name: "阈值为 0 视为未启用",
input: `{"max_context_tokens":0}`,
wantMax: 0,
wantCode: 0,
wantRatio: 0,
wantOk: false,
},
{
name: "max_context_tokens 负数拒绝",
input: `{"max_context_tokens":-1}`,
wantErr: true,
},
{
name: "buffer_ratio 负数拒绝",
input: `{"max_context_tokens":1000,"buffer_ratio":-1}`,
wantErr: true,
},
{
name: "error_status_code=200 拒绝",
input: `{"max_context_tokens":1000,"error_status_code":200}`,
wantErr: true,
},
{
name: "error_status_code=600 拒绝",
input: `{"max_context_tokens":1000,"error_status_code":600}`,
wantErr: true,
},
{
name: "buffer_ratio=11 拒绝",
input: `{"max_context_tokens":1000,"buffer_ratio":11}`,
wantErr: true,
},
{
name: "buffer_ratio=10 边界允许",
input: `{"max_context_tokens":1000,"buffer_ratio":10}`,
wantMax: 1000,
wantCode: defaultErrorStatusCode,
wantRatio: 10,
wantOk: true,
},
{
name: "error_status_code=599 边界允许",
input: `{"max_context_tokens":1000,"error_status_code":599}`,
wantMax: 1000,
wantCode: 599,
wantRatio: defaultBufferRatio,
wantOk: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var cfg Config
err := parseConfig(gjson.Parse(tc.input), &cfg)
if tc.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tc.wantMax, cfg.MaxContextTokens)
assert.Equal(t, tc.wantCode, cfg.ErrorStatusCode)
assert.InDelta(t, tc.wantRatio, cfg.BufferRatio, 1e-9)
assert.Equal(t, tc.wantOk, cfg.IsEnabled())
})
}
}
// TestLightweightE2E 轻量端到端验证:
// 用低阈值跑完 extract + CountTokens + 判定,确认新增字段真实影响拦截/放行决策。
func TestLightweightE2E(t *testing.T) {
require := assert.New(t)
require.NoError(initEncoder())
cases := []struct {
name string
body []byte
maxTokens int
wantBlock bool
}{
{
name: "OpenAI tool_calls.arguments 超阈值 → 400",
body: []byte(`{
"messages": [
{"role": "user", "content": "go"},
{"role": "assistant", "tool_calls": [{"id": "c1", "type": "function", "function": {
"name": "big_query",
"arguments": "{\"sql\":\"SELECT a]very long query that should push tokens over the low threshold we set for this test, including multiple columns like id, name, email, phone, address, city, state, zip, country, created_at, updated_at, deleted_at FROM users WHERE status = active AND region IN (us-east-1, us-west-2, eu-west-1, ap-southeast-1) ORDER BY created_at DESC LIMIT 1000\"}"
}}]}
]
}`),
maxTokens: 5,
wantBlock: true,
},
{
name: "OpenAI response_format.json_schema 超阈值 → 400",
body: []byte(`{
"messages": [{"role": "user", "content": "x"}],
"response_format": {"type": "json_schema", "json_schema": {
"name": "big_schema",
"description": "A very detailed schema for structured extraction of complex nested data",
"schema": {"type": "object", "properties": {"a": {"type": "string"}, "b": {"type": "integer"}, "c": {"type": "array", "items": {"type": "object", "properties": {"d": {"type": "string"}}}}}}
}}
}`),
maxTokens: 5,
wantBlock: true,
},
{
name: "Anthropic tools.input_schema 超阈值 → 400",
body: []byte(`{
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"name": "search", "description": "Search the database with complex filters", "input_schema": {"type": "object", "properties": {"query": {"type": "string"}, "filters": {"type": "array", "items": {"type": "object", "properties": {"field": {"type": "string"}, "op": {"type": "string"}, "value": {"type": "string"}}}}}}}]
}`),
maxTokens: 5,
wantBlock: true,
},
{
name: "Anthropic 短文本 → 放行",
body: []byte(`{
"system": "ok",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"name": "t", "input_schema": {"type": "object"}}]
}`),
maxTokens: 100,
wantBlock: false,
},
{
name: "Anthropic image block → 放行",
body: []byte(`{
"messages": [{"role": "user", "content": [
{"type": "text", "text": "describe"},
{"type": "image", "source": {"type": "base64", "data": "..."}}
]}],
"tools": [{"name": "x", "input_schema": {}}]
}`),
maxTokens: 1,
wantBlock: false, // 多模态放行,不管阈值多低
},
{
name: "Anthropic thinking block 超阈值 → 400无 tools",
body: []byte(`{
"messages": [
{"role": "user", "content": "solve this"},
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "Let me reason through this carefully. First, I need to analyze the problem from multiple angles. The key insight is that we need to consider all boundary conditions and edge cases before arriving at a solution. This requires systematic decomposition of the constraints."},
{"type": "text", "text": "The answer is 42."}
]}
]
}`),
maxTokens: 5,
wantBlock: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := extractPromptText(tc.body)
tokens := CountTokens(r.Text)
estimated := int(float64(tokens) * 1.10)
blocked := !r.HasMultimodal && estimated > tc.maxTokens
t.Logf("multimodal=%v tokens=%d estimated=%d threshold=%d blocked=%v",
r.HasMultimodal, tokens, estimated, tc.maxTokens, blocked)
assert.Equal(t, tc.wantBlock, blocked)
})
}
}
// ---------------------------------------------------------------------------
// Anthropic 协议场景测试
// ---------------------------------------------------------------------------
func TestAnthropicDetection(t *testing.T) {
// OpenAI 请求不应触发 Anthropic 路径
openaiBody := []byte(`{
"messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
"tools": [{"type": "function", "function": {"name": "foo", "parameters": {}}}]
}`)
assert.False(t, hasAnthropicSpecificFields(openaiBody), "OpenAI content array + type=text 不应误判为 Anthropic")
// Anthropic tools[].input_schema
anthropicTools := []byte(`{
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"name": "get_weather", "input_schema": {"type": "object"}}]
}`)
assert.True(t, hasAnthropicSpecificFields(anthropicTools), "tools[].input_schema 必须识别为 Anthropic")
// Anthropic tool_use content block
toolUseBody := []byte(`{
"messages": [{"role": "assistant", "content": [
{"type": "tool_use", "id": "tu_1", "name": "calc", "input": {"expr": "1+1"}}
]}]
}`)
assert.True(t, hasAnthropicSpecificFields(toolUseBody), "content type=tool_use 必须识别为 Anthropic")
// Anthropic tool_result content block
toolResultBody := []byte(`{
"messages": [{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_1", "content": "2"}
]}]
}`)
assert.True(t, hasAnthropicSpecificFields(toolResultBody), "content type=tool_result 必须识别为 Anthropic")
// 仅含 thinking block无 tools也应识别为 Anthropic
thinkingOnly := []byte(`{
"messages": [{"role": "assistant", "content": [
{"type": "thinking", "thinking": "reasoning..."}
]}]
}`)
assert.True(t, hasAnthropicSpecificFields(thinkingOnly), "thinking block 必须识别为 Anthropic")
// 仅含 redacted_thinking block
redactedOnly := []byte(`{
"messages": [{"role": "assistant", "content": [
{"type": "redacted_thinking", "data": "xxx"}
]}]
}`)
assert.True(t, hasAnthropicSpecificFields(redactedOnly), "redacted_thinking block 必须识别为 Anthropic")
// 仅含 document block
docOnly := []byte(`{
"messages": [{"role": "user", "content": [
{"type": "document", "source": {"type": "text", "data": "..."}}
]}]
}`)
assert.True(t, hasAnthropicSpecificFields(docOnly), "document block 必须识别为 Anthropic")
// 仅含 search_result block
searchOnly := []byte(`{
"messages": [{"role": "user", "content": [
{"type": "search_result", "title": "t", "content": []}
]}]
}`)
assert.True(t, hasAnthropicSpecificFields(searchOnly), "search_result block 必须识别为 Anthropic")
}
func TestExtractAnthropicText_ToolUseAndResult(t *testing.T) {
body := []byte(`{
"system": "You are a helpful assistant",
"messages": [
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": [
{"type": "text", "text": "Let me calculate that."},
{"type": "tool_use", "id": "tu_1", "name": "calculator", "input": {"expression": "2+2"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_1", "content": "4"}
]},
{"role": "assistant", "content": "The answer is 4."}
],
"tools": [
{"name": "calculator", "description": "Evaluates math expressions", "input_schema": {"type": "object", "properties": {"expression": {"type": "string"}}}}
]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal)
// system
assert.Contains(t, r.Text, "You are a helpful assistant")
// messages content string
assert.Contains(t, r.Text, "What is 2+2?")
assert.Contains(t, r.Text, "The answer is 4.")
// tool_use: name + input
assert.Contains(t, r.Text, "calculator")
assert.Contains(t, r.Text, "expression")
assert.Contains(t, r.Text, "2+2")
// tool_result: content string
assert.Contains(t, r.Text, "4")
// tools[].input_schema
assert.Contains(t, r.Text, "Evaluates math expressions")
// text block in assistant
assert.Contains(t, r.Text, "Let me calculate that.")
}
func TestExtractAnthropicText_ToolResultContentArray(t *testing.T) {
body := []byte(`{
"messages": [
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_1", "content": [
{"type": "text", "text": "Result line 1"},
{"type": "text", "text": "Result line 2"}
]}
]}
],
"tools": [{"name": "dummy", "input_schema": {"type": "object"}}]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal)
assert.Contains(t, r.Text, "Result line 1")
assert.Contains(t, r.Text, "Result line 2")
}
func TestExtractAnthropicText_ImageMultimodal(t *testing.T) {
body := []byte(`{
"messages": [{"role": "user", "content": [
{"type": "text", "text": "describe this"},
{"type": "image", "source": {"type": "base64", "data": "..."}}
]}],
"tools": [{"name": "x", "input_schema": {}}]
}`)
r := extractPromptText(body)
assert.True(t, r.HasMultimodal, "Anthropic image block 必须触发多模态放行")
}
func TestExtractAnthropicText_UnknownBlock(t *testing.T) {
// 未知非文本 block如 audio、unknown_binary应触发多模态放行
body := []byte(`{
"messages": [{"role": "user", "content": [
{"type": "text", "text": "listen to this"},
{"type": "audio", "source": {"type": "base64", "data": "..."}}
]}],
"tools": [{"name": "x", "input_schema": {}}]
}`)
r := extractPromptText(body)
assert.True(t, r.HasMultimodal, "未知非文本 block 必须触发多模态放行")
}
func TestExtractAnthropicText_ToolResultWithImage(t *testing.T) {
// tool_result.content array 中包含非 text block 应触发多模态放行
body := []byte(`{
"messages": [
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_1", "content": [
{"type": "text", "text": "here is the result"},
{"type": "image", "source": {"type": "base64", "data": "..."}}
]}
]}
],
"tools": [{"name": "screenshot", "input_schema": {"type": "object"}}]
}`)
r := extractPromptText(body)
assert.True(t, r.HasMultimodal, "tool_result 含非 text block 必须触发多模态放行")
}
func TestExtractAnthropicText_StringContent(t *testing.T) {
// Anthropic 也支持 content 为纯字符串
body := []byte(`{
"system": [{"type": "text", "text": "system prompt"}],
"messages": [{"role": "user", "content": "hello world"}],
"tools": [{"name": "t1", "input_schema": {"type": "object"}}]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal)
assert.Contains(t, r.Text, "system prompt")
assert.Contains(t, r.Text, "hello world")
}
func TestExtractAnthropicText_ThinkingBlock(t *testing.T) {
// Extended thinking block 应被计入,不触发多模态
body := []byte(`{
"messages": [
{"role": "user", "content": "solve this"},
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "Let me think about this step by step. First I need to consider the constraints and then work through the logic carefully."},
{"type": "text", "text": "The answer is 42."}
]}
],
"tools": [{"name": "x", "input_schema": {}}]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal, "thinking block 不应触发多模态")
assert.Contains(t, r.Text, "step by step")
assert.Contains(t, r.Text, "The answer is 42.")
}
func TestExtractAnthropicText_RedactedThinking(t *testing.T) {
// Redacted thinking block 的 data 应被保守计入
body := []byte(`{
"messages": [
{"role": "assistant", "content": [
{"type": "redacted_thinking", "data": "abc123encrypteddatahere456"}
]}
],
"tools": [{"name": "x", "input_schema": {}}]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal, "redacted_thinking 不应触发多模态")
assert.Contains(t, r.Text, "abc123encrypteddatahere456")
}
func TestExtractAnthropicText_DocumentText(t *testing.T) {
// document source.type=text 应被计入
body := []byte(`{
"messages": [{"role": "user", "content": [
{"type": "document", "title": "report.txt", "source": {"type": "text", "data": "This is a very long document content that should be counted as input tokens."}}
]}],
"tools": [{"name": "x", "input_schema": {}}]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal, "text document 不应触发多模态")
assert.Contains(t, r.Text, "report.txt")
assert.Contains(t, r.Text, "very long document content")
}
func TestExtractAnthropicText_DocumentBase64(t *testing.T) {
// document source.type=base64 应触发多模态放行
body := []byte(`{
"messages": [{"role": "user", "content": [
{"type": "document", "title": "file.pdf", "source": {"type": "base64", "media_type": "application/pdf", "data": "..."}}
]}],
"tools": [{"name": "x", "input_schema": {}}]
}`)
r := extractPromptText(body)
assert.True(t, r.HasMultimodal, "base64 document 应触发多模态放行")
}
func TestExtractAnthropicText_SearchResult(t *testing.T) {
// search_result 的 title/source/content text blocks 应被计入
body := []byte(`{
"messages": [{"role": "user", "content": [
{"type": "search_result", "title": "Higress Documentation", "source": "https://higress.io/docs", "content": [
{"type": "text", "text": "Higress is a cloud-native API gateway."},
{"type": "text", "text": "It supports WASM plugins for extensibility."}
]}
]}],
"tools": [{"name": "x", "input_schema": {}}]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal, "search_result 不应触发多模态")
assert.Contains(t, r.Text, "Higress Documentation")
assert.Contains(t, r.Text, "https://higress.io/docs")
assert.Contains(t, r.Text, "cloud-native API gateway")
assert.Contains(t, r.Text, "WASM plugins")
}
// TestVerifyToolCallsAndResponseFormat 端到端验证:
// 真实场景请求体中的 tool_calls.arguments 和 response_format.json_schema
// 确实被纳入 token 统计,不会被漏算。
func TestVerifyToolCallsAndResponseFormat(t *testing.T) {
require := assert.New(t)
require.NoError(initEncoder())
// 构造包含大量 tool_calls arguments 的多轮对话
bodyWithToolCalls := []byte(`{
"messages": [
{"role": "user", "content": "help"},
{"role": "assistant", "tool_calls": [{
"id": "call_1", "type": "function",
"function": {
"name": "search_database",
"arguments": "{\"query\":\"SELECT id, name, email, phone, address, created_at, updated_at FROM users WHERE status = active AND region IN (us-east, us-west, eu-west) ORDER BY created_at DESC LIMIT 100\"}"
}
}]},
{"role": "tool", "content": "found 100 rows", "tool_call_id": "call_1"}
]
}`)
// 同样的请求但不带 tool_calls模拟修复前的漏算场景
bodyWithoutToolCalls := []byte(`{
"messages": [
{"role": "user", "content": "help"},
{"role": "assistant"},
{"role": "tool", "content": "found 100 rows", "tool_call_id": "call_1"}
]
}`)
rWith := extractPromptText(bodyWithToolCalls)
rWithout := extractPromptText(bodyWithoutToolCalls)
tokensWithToolCalls := CountTokens(rWith.Text)
tokensWithoutToolCalls := CountTokens(rWithout.Text)
t.Logf("含 tool_calls: text_bytes=%d, tokens=%d", len(rWith.Text), tokensWithToolCalls)
t.Logf("不含 tool_calls: text_bytes=%d, tokens=%d", len(rWithout.Text), tokensWithoutToolCalls)
t.Logf("tool_calls 贡献的额外 tokens: %d", tokensWithToolCalls-tokensWithoutToolCalls)
// tool_calls.arguments 包含大段 SQL必须贡献显著的额外 token
require.Greater(tokensWithToolCalls, tokensWithoutToolCalls+10,
"tool_calls.arguments 必须被计入 token 统计")
// 验证 response_format.json_schema 被统计
bodyWithSchema := []byte(`{
"messages": [{"role": "user", "content": "extract"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "extraction_result",
"description": "A comprehensive schema for extracting structured order information including customer details and line items",
"schema": {"type": "object", "properties": {"customer_name": {"type": "string"}, "order_id": {"type": "string"}, "items": {"type": "array", "items": {"type": "object", "properties": {"sku": {"type": "string"}, "qty": {"type": "integer"}, "price": {"type": "number"}}}}}}
}
}
}`)
bodyWithoutSchema := []byte(`{
"messages": [{"role": "user", "content": "extract"}]
}`)
rSchema := extractPromptText(bodyWithSchema)
rNoSchema := extractPromptText(bodyWithoutSchema)
tokensWithSchema := CountTokens(rSchema.Text)
tokensNoSchema := CountTokens(rNoSchema.Text)
t.Logf("含 json_schema: text_bytes=%d, tokens=%d", len(rSchema.Text), tokensWithSchema)
t.Logf("不含 json_schema: text_bytes=%d, tokens=%d", len(rNoSchema.Text), tokensNoSchema)
t.Logf("json_schema 贡献的额外 tokens: %d", tokensWithSchema-tokensNoSchema)
// json_schema 包含大段 schema 定义,必须贡献显著的额外 token
require.Greater(tokensWithSchema, tokensNoSchema+20,
"response_format.json_schema 必须被计入 token 统计")
}
func TestExtractPromptText_StringContent(t *testing.T) {
body := []byte(`{
"model": "gpt-4o",
"messages": [
{"role": "system", "content": "你是一个助手"},
{"role": "user", "content": "Hello world"}
]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal)
assert.Contains(t, r.Text, "你是一个助手")
assert.Contains(t, r.Text, "Hello world")
assert.Contains(t, r.Text, "system")
assert.Contains(t, r.Text, "user")
}
func TestExtractPromptText_ArrayContent(t *testing.T) {
body := []byte(`{
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "describe this"},
{"type": "text", "text": "in detail"}
]}
]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal)
assert.Contains(t, r.Text, "describe this")
assert.Contains(t, r.Text, "in detail")
}
func TestExtractPromptText_Multimodal(t *testing.T) {
body := []byte(`{
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "what is in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.jpg"}}
]}
]
}`)
r := extractPromptText(body)
assert.True(t, r.HasMultimodal, "image_url 必须触发多模态放行")
}
func TestExtractPromptText_Tools(t *testing.T) {
body := []byte(`{
"messages": [{"role": "user", "content": "查询天气"}],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
}
}
]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal)
assert.Contains(t, r.Text, "查询天气")
assert.Contains(t, r.Text, "get_weather")
assert.Contains(t, r.Text, "获取指定城市的天气信息")
// parameters 整体序列化进入计数
assert.Contains(t, r.Text, "city")
}
func TestExtractPromptText_TopLevelSystem(t *testing.T) {
body := []byte(`{
"system": "你是有帮助的助手",
"messages": [{"role": "user", "content": "hi"}]
}`)
r := extractPromptText(body)
assert.Contains(t, r.Text, "你是有帮助的助手")
assert.Contains(t, r.Text, "hi")
}
func TestExtractPromptText_Empty(t *testing.T) {
r := extractPromptText([]byte(`{}`))
assert.False(t, r.HasMultimodal)
assert.Equal(t, "", r.Text)
}
func TestExtractPromptText_ToolCalls(t *testing.T) {
body := []byte(`{
"messages": [
{"role": "user", "content": "查询订单"},
{"role": "assistant", "tool_calls": [
{"id": "call_1", "type": "function", "function": {
"name": "lookup_order",
"arguments": "{\"order_id\":\"12345\",\"detail\":true}"
}}
]},
{"role": "tool", "content": "订单已发货", "tool_call_id": "call_1"}
]
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal)
assert.Contains(t, r.Text, "lookup_order")
assert.Contains(t, r.Text, "order_id")
assert.Contains(t, r.Text, "12345")
assert.Contains(t, r.Text, "订单已发货")
}
func TestExtractPromptText_ResponseFormat(t *testing.T) {
body := []byte(`{
"messages": [{"role": "user", "content": "extract info"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "order_schema",
"description": "Schema for order extraction",
"schema": {"type": "object", "properties": {"id": {"type": "string"}}}
}
}
}`)
r := extractPromptText(body)
assert.False(t, r.HasMultimodal)
assert.Contains(t, r.Text, "order_schema")
assert.Contains(t, r.Text, "Schema for order extraction")
assert.Contains(t, r.Text, "properties")
}
// TestCountTokens 只做基本可用性断言,避免在单测中绑定具体词表细节。
func TestCountTokens(t *testing.T) {
require := assert.New(t)
require.NoError(initEncoder())
require.Equal(0, CountTokens(""), "空字符串返回 0")
require.Greater(CountTokens("Hello world"), 0)
require.Greater(CountTokens("中文测试"), 0)
// 重复文本 token 数应近似线性
once := CountTokens("hello")
thrice := CountTokens("hello hello hello")
require.Greater(thrice, once)
}
// TestBlockDecision 拦截判定逻辑×buffer_ratio 与阈值比较)
// 直接用真实编码器,构造 prompt 控制估算值的相对位置
func TestBlockDecision(t *testing.T) {
require := assert.New(t)
require.NoError(initEncoder())
// 构造一段已知 token 数的文本
prompt := "Hello world. This is a test prompt for token counting."
rawTokens := CountTokens(prompt)
require.Greater(rawTokens, 0)
cases := []struct {
name string
bufferRatio float64
threshold int
shouldBlock bool
}{
{"远低于阈值 → 放行", 1.10, 100000, false},
{"略低于阈值 → 放行", 1.10, rawTokens * 2, false},
{"恰好等于阈值 → 放行(>不>=", 1.0, rawTokens, false},
{"略超阈值 → 拦截", 1.10, 1, true},
{"buffer_ratio 抬高致超阈值", 10.0, rawTokens + 1, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
estimated := int(float64(rawTokens) * tc.bufferRatio)
got := estimated > tc.threshold
assert.Equal(t, tc.shouldBlock, got,
"raw=%d ratio=%.2f estimated=%d threshold=%d",
rawTokens, tc.bufferRatio, estimated, tc.threshold)
})
}
}

View File

@@ -1,27 +0,0 @@
#!/bin/sh
# prepare.sh — Download BPE vocabulary for ai-context-limit plugin.
# Called by Makefile, root Dockerfile, and root Makefile local-build.
set -e
BPE_DIR="bpe"
BPE_FILE="${BPE_DIR}/o200k_base.tiktoken"
BPE_URL="https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken"
if [ -f "$BPE_FILE" ]; then
exit 0
fi
mkdir -p "$BPE_DIR"
echo "Downloading o200k_base.tiktoken..."
if command -v curl >/dev/null 2>&1; then
curl -sSfL -o "$BPE_FILE" "$BPE_URL"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "$BPE_FILE" "$BPE_URL"
else
echo "Error: curl or wget is required to download BPE vocabulary" >&2
exit 1
fi
echo "Downloaded ${BPE_FILE} ($(wc -c < "$BPE_FILE" | tr -d ' ') bytes)"

View File

@@ -1,83 +0,0 @@
// Copyright (c) 2026 Alibaba Group Holding Ltd.
//
// 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.
package main
import (
_ "embed"
"encoding/base64"
"fmt"
"strconv"
"strings"
"github.com/pkoukk/tiktoken-go"
)
// 内嵌 o200k_base 词表(约 3.4MB
//
//go:embed bpe/o200k_base.tiktoken
var o200kBaseRaw []byte
// embedBpeLoader 实现 tiktoken-go 的 BpeLoader 接口
// 离线加载内嵌词表避免运行时下载WASM 环境无外网访问)
type embedBpeLoader struct{}
// LoadTiktokenBpe 解析 .tiktoken 格式(每行 "<base64-token> <rank>")。
// 内嵌词表为静态资源,任何解析异常均视为打包错误,直接返回 error。
func (l *embedBpeLoader) LoadTiktokenBpe(_ string) (map[string]int, error) {
bpeRanks := make(map[string]int, 200000)
for i, line := range strings.Split(string(o200kBaseRaw), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("bpe line %d: expected \"<base64> <rank>\", got %q", i+1, line)
}
token, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("bpe line %d: base64 decode failed: %w", i+1, err)
}
rank, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("bpe line %d: invalid rank %q: %w", i+1, parts[1], err)
}
bpeRanks[string(token)] = rank
}
return bpeRanks, nil
}
// encoder 全局编码器实例init 阶段加载,零拷贝复用)
var encoder *tiktoken.Tiktoken
// initEncoder 初始化 o200k_base 编码器
// 必须在插件 parseConfig 阶段或更早被调用
func initEncoder() error {
tiktoken.SetBpeLoader(&embedBpeLoader{})
enc, err := tiktoken.GetEncoding("o200k_base")
if err != nil {
return err
}
encoder = enc
return nil
}
// CountTokens 用 o200k_base 编码计算文本 token 数
// 输入空字符串返回 0
func CountTokens(text string) int {
if text == "" || encoder == nil {
return 0
}
return len(encoder.Encode(text, nil, nil))
}

View File

@@ -27,7 +27,7 @@ description: 针对LLM服务的负载均衡策略
`lb_type``cluster` 时支持的负载均衡策略包括:
- `cluster_metrics`: 基于网关统计的不同service的指标进行服务之间的负载均衡
- `cluster_hash`: 读取指定请求头做 FNV-1a 一致性 hash将相同 key 的请求始终路由到同一 cluster支持按权重分配流量
# 全局最小请求数
## 功能说明
@@ -217,7 +217,7 @@ lb_config:
| `mode` | string | 必填 | | 如何使用服务级指标做负载均衡,当前支持`[LeastBusy, LeastTotalLatency, LeastFirstTokenLatency ]` |
| `service_list` | []string | 必填 | | 路由后端服务列表 |
| `rate_limit` | string | 选填 | 1 | 单个服务处理请求比例上限取值范围0~1 |
| `cluster_header` | string | 选填 | `x-higress-target-cluster` | 通过取该header的值得知需要路由到哪个后端服务 |
| `cluster_header` | string | 选填 | `x-envoy-target-cluster` | 通过取该header的值得知需要路由到哪个后端服务 |
| `queue_size` | int | 选填 | 100 | 根据最近的多少个请求进行观测指标的计算 |
`mode` 各取值含义如下:
@@ -237,46 +237,4 @@ lb_config:
service_list:
- outbound|80||test-1.dns
- outbound|80||test-2.static
```
# Cluster Hash一致性 Hash 路由)
## 功能说明
读取指定请求头的值,使用 FNV-1a 一致性 hash 算法将请求路由到固定的上游集群,确保相同 hash key 的请求始终落到同一个 cluster同时支持按百分比权重控制各 cluster 的流量分配。
需要配合 EnvoyFilter 的 `cluster_header` 机制一起使用。
## 配置说明
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|------|----------|----------|--------|------|
| `clusters` | []ClusterEntry | 必填 | - | cluster 列表,所有 `weight` 之和必须为 100 |
| `hash_header` | string | 选填 | `x-mse-consumer` | 读取 hash key 的请求头名称 |
| `cluster_header` | string | 选填 | `x-higress-target-cluster` | 写入目标 cluster 的请求头名称 |
### ClusterEntry 字段
| 名称 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `cluster` | string | 是 | 上游集群名称,如 `outbound|443||llm-xxx.internal.static` |
| `weight` | int | 是 | 百分比权重,所有 cluster 的 weight 之和必须为 100 |
## 配置示例
```yaml
lb_type: cluster
lb_policy: cluster_hash
lb_config:
clusters:
- cluster: "outbound|80||llm-test1.internal.static"
weight: 69
- cluster: "outbound|443||llm-test2.internal.dns"
weight: 30
- cluster: "outbound|443||llm-test3.internal.dns"
weight: 1
hash_header: x-mse-consumer
cluster_header: x-higress-target-cluster
```
若请求缺少 hash header插件直接返回 **403**
```

Some files were not shown because too many files have changed in this diff Show More