mirror of
https://github.com/alibaba/higress.git
synced 2026-07-24 21:36:28 +08:00
Compare commits
51 Commits
v2.2.2
...
add-deep-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e426a5e99 | ||
|
|
d5f2b624fb | ||
|
|
6c98ea74e0 | ||
|
|
39ec41aab6 | ||
|
|
910c26ec14 | ||
|
|
582af464c0 | ||
|
|
04389fd0c6 | ||
|
|
10498a2c86 | ||
|
|
e958290283 | ||
|
|
6d1c55c1fb | ||
|
|
e57f18e581 | ||
|
|
1694f48fd9 | ||
|
|
7c94b5a822 | ||
|
|
ab0085362c | ||
|
|
9c13b6418c | ||
|
|
f060c9f51d | ||
|
|
bd6039f53b | ||
|
|
36bd830029 | ||
|
|
4db4633560 | ||
|
|
efaef2e3d0 | ||
|
|
e758504d72 | ||
|
|
3c3a067f07 | ||
|
|
7e11e2f320 | ||
|
|
7abf27a2a3 | ||
|
|
d1f95f6e54 | ||
|
|
83c12d571d | ||
|
|
45fc5a31bc | ||
|
|
3065d4e071 | ||
|
|
c65d4cd938 | ||
|
|
547b7bf45a | ||
|
|
bf0b1e96c5 | ||
|
|
c69526b30e | ||
|
|
73e7510eca | ||
|
|
2897c1e83a | ||
|
|
b69b5c7c10 | ||
|
|
57db9ccc46 | ||
|
|
137d76332e | ||
|
|
d888ab23de | ||
|
|
29b5a533e6 | ||
|
|
071086904d | ||
|
|
58ffdae6ba | ||
|
|
a45b1dde6c | ||
|
|
52c99eb27d | ||
|
|
c21a38e783 | ||
|
|
385f8d8b4e | ||
|
|
a86aaadaa4 | ||
|
|
e6fc09b14f | ||
|
|
b8ff815100 | ||
|
|
632c6ca655 | ||
|
|
8a0f8a8208 | ||
|
|
b99e0c5027 |
35
.agents/skills/issue-spec-apply/SKILL.md
Normal file
35
.agents/skills/issue-spec-apply/SKILL.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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.
|
||||
24
.agents/skills/issue-spec-archive/SKILL.md
Normal file
24
.agents/skills/issue-spec-archive/SKILL.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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.
|
||||
58
.agents/skills/issue-spec-github/SKILL.md
Normal file
58
.agents/skills/issue-spec-github/SKILL.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
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.
|
||||
37
.agents/skills/issue-spec-propose/SKILL.md
Normal file
37
.agents/skills/issue-spec-propose/SKILL.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
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.
|
||||
32
.agents/skills/issue-spec-review/SKILL.md
Normal file
32
.agents/skills/issue-spec-review/SKILL.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
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.
|
||||
28
.agents/skills/issue-spec-verify/SKILL.md
Normal file
28
.agents/skills/issue-spec-verify/SKILL.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
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.
|
||||
53
.agents/skills/issue-spec-workflow/SKILL.md
Normal file
53
.agents/skills/issue-spec-workflow/SKILL.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
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.
|
||||
31
.claude/commands/issue-spec/apply.md
Normal file
31
.claude/commands/issue-spec/apply.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
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.
|
||||
20
.claude/commands/issue-spec/archive.md
Normal file
20
.claude/commands/issue-spec/archive.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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.
|
||||
33
.claude/commands/issue-spec/propose.md
Normal file
33
.claude/commands/issue-spec/propose.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
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.
|
||||
28
.claude/commands/issue-spec/review.md
Normal file
28
.claude/commands/issue-spec/review.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
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.
|
||||
24
.claude/commands/issue-spec/verify.md
Normal file
24
.claude/commands/issue-spec/verify.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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.
|
||||
61
.claude/skills/higress-deep-troubleshooting/SKILL.md
Normal file
61
.claude/skills/higress-deep-troubleshooting/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: higress-deep-troubleshooting
|
||||
description: Deep-dive troubleshooting methodology for Higress gateway bugs that require code-trace evidence across the higress-group/envoy and higress-group/proxy-wasm-cpp-host forks — CPU spins, infinite loops, Wasm VM reentrancy, filter-chain stage-mismatch, callback-ordering issues, SaveRestoreContext/current_context_ bugs. Use when investigating a complex Higress/Wasm/proxy-wasm incident that needs root-cause analysis with file:line citations, when ramping up on a reported CPU-spin or crash, or when verifying whether a proposed fix covers related bug classes. Accumulates case-study patterns under patterns/ — add a new pattern after each major investigation.
|
||||
---
|
||||
|
||||
# Higress Deep Troubleshooting
|
||||
|
||||
A methodology plus an accumulating pattern library for deep-diving into Higress gateway bugs that require code-level root cause analysis. The codebase spans two Higress-maintained forks — [`higress-group/envoy`](https://github.com/higress-group/envoy) (filter chain, Wasm `Context` integration) and [`higress-group/proxy-wasm-cpp-host`](https://github.com/higress-group/proxy-wasm-cpp-host) (Wasm VM host runtime, `WasmBase`, `ContextBase`) — and bugs often cross the boundary between them. This skill exists so that the next investigation doesn't start from scratch.
|
||||
|
||||
## When to use
|
||||
|
||||
Reach for this skill when ANY of these apply:
|
||||
|
||||
- A Higress issue reports a CPU spin, infinite loop, crash, or stage-mismatched behavior.
|
||||
- The bug involves the Wasm plugin lifecycle, `addAfterVmCallAction` / `doAfterVmCallActions`, `SaveRestoreContext`, `current_context_`, or the filter chain reentry paths.
|
||||
- You need to verify whether a one-repo fix also covers an upstream bug class (e.g., `proxy-wasm/proxy-wasm-cpp-host#326`).
|
||||
- You're ramping up on an incident and need a structured way to collect evidence before proposing a fix.
|
||||
- The maintainer is pushing back on a conclusion and you need to re-verify with code.
|
||||
|
||||
Do NOT reach for this skill for routine plugin development (`higress-wasm-go-plugin` covers that), config issues, or anything solvable by reading a single file without cross-repo context.
|
||||
|
||||
## Investigation methodology (brief)
|
||||
|
||||
1. **Symptom → code surface.** From perf data / repro steps / stack trace, identify which functions and files are involved. Note which fork each file lives in.
|
||||
2. **Parallel investigation.** Dispatch focused worker agents for independent aspects (envoy side, proxy-wasm-cpp-host side, upstream references). Each worker returns file:line evidence — never narration.
|
||||
3. **Code-trace verification.** Every claim must cite `file:line`. No "I think it does X" — only "see envoy context.cc:2085 where it calls ... synchronously".
|
||||
4. **Synthesize root cause.** Combine worker findings into a numbered call-chain trace, showing program state (queue contents, `current_context_` value, etc.) at each hop.
|
||||
5. **Fix design + side effects.** Propose a primitive, analyze what else it touches, check if it covers related bug classes upstream.
|
||||
6. **Verify when challenged.** If the maintainer pushes back, re-verify with code — don't restate the conclusion. The earlier analysis is often wrong in some detail that only a fresh code read catches.
|
||||
|
||||
Full process: [`references/investigation-methodology.md`](references/investigation-methodology.md). Verification patterns: [`references/code-trace-verification.md`](references/code-trace-verification.md).
|
||||
|
||||
## Accumulated patterns
|
||||
|
||||
Each major investigation that uses this skill should add a pattern under `patterns/`. Patterns are the "living" part of this skill — they capture both the technical root cause AND the meta-lessons (what tripped up the analysis, what the maintainer caught, what the fix primitive generalizes to).
|
||||
|
||||
Current patterns:
|
||||
|
||||
- [`patterns/2026-07-wasm-doaftervmcallactions-reentry.md`](patterns/2026-07-wasm-doaftervmcallactions-reentry.md) — #4034 CPU 100% from `onRedisCallFailure` + `injectEncodedDataToFilterChain` nested reentry. Root cause: unguarded `while(!empty())` drain in `WasmBase::doAfterVmCallActions`. Fix: drain-to-local via `std::deque::swap`. Meta-lesson: the same primitive covers the upstream #326 stage-mismatch class, which the first analysis round missed.
|
||||
|
||||
To add a new pattern after an investigation, see [`patterns/README.md`](patterns/README.md).
|
||||
|
||||
## Templates
|
||||
|
||||
- [`templates/investigation-report.md`](templates/investigation-report.md) — structure for a new investigation's findings.
|
||||
- [`templates/code-trace-table.md`](templates/code-trace-table.md) — step-by-step state-trace table format (very effective for communicating queue/state transitions to maintainers).
|
||||
|
||||
## Key code maps (quick orientation)
|
||||
|
||||
When investigating a Wasm-related Higress bug, the usual suspects:
|
||||
|
||||
| Concern | Repo | File |
|
||||
| --- | --- | --- |
|
||||
| Plugin callback entry points (`onRequestHeaders`, `onResponseBody`, `onHttpCallResponse`, etc.) | `higress-group/proxy-wasm-cpp-host` | `src/context.cc` |
|
||||
| Deferred-action queue (`addAfterVmCallAction`, `doAfterVmCallActions`, `DeferAfterCallActions`) | `higress-group/proxy-wasm-cpp-host` | `include/proxy-wasm/wasm.h`, `src/context.cc` |
|
||||
| `SaveRestoreContext` / `current_context_` | `higress-group/proxy-wasm-cpp-host` | `include/proxy-wasm/wasm_vm.h`, `src/exports.cc` |
|
||||
| Higress envoy `Context` (filter chain integration, foreign functions) | `higress-group/envoy` | `source/extensions/common/wasm/context.cc` |
|
||||
| Foreign-function registration (`proxy_inject_encoded_data_to_filter_chain`, etc.) | `higress-group/envoy` | `source/extensions/common/wasm/foreign.cc` |
|
||||
| envoy fork's pinned proxy-wasm-cpp-host commit | `higress-group/envoy` | `bazel/repository_locations.bzl` |
|
||||
|
||||
The Higress envoy fork pins a specific `proxy-wasm-cpp-host` commit via bazel — always check which fork commit is pinned before reasoning about `WasmBase` behavior, because the fork diverges from upstream (RedisCall, rebuild/recover, weak_handle additions).
|
||||
@@ -0,0 +1,116 @@
|
||||
# Wasm `doAfterVmCallActions` reentry — CPU-spin and stage-mismatch, fixed by drain-to-local
|
||||
|
||||
> Source: [#4034](https://github.com/higress-group/higress/issues/4034) — Wasm 插件死循环:onRedisCallFailure + injectEncodedDataToFilterChain 嵌套导致 Envoy CPU 100% 跑满
|
||||
> Upstream relative: [proxy-wasm/proxy-wasm-cpp-host#326](https://github.com/proxy-wasm/proxy-wasm-cpp-host/issues/326)
|
||||
> Date: 2026-07
|
||||
> Status: fix designed (SPEC-001), implementation pending
|
||||
|
||||
## Symptom
|
||||
|
||||
`release-2.2.2` Envoy pods CPU 100% on all 6 pods within ~3 minutes under concurrent load. Trigger: Redis unreachable + plugin calls `InjectEncodedDataToFilterChain` from an HTTP/Redis callback. perf:
|
||||
|
||||
| Overhead | Function |
|
||||
| --- | --- |
|
||||
| 39.21% | `Envoy::Extensions::Common::Wasm::Context::onRedisCallFailure` |
|
||||
| 25.41% | `proxy_wasm::WasmBase::doAfterVmCallActions` |
|
||||
| **合计** | **64.62%** |
|
||||
|
||||
## Root cause
|
||||
|
||||
`WasmBase::doAfterVmCallActions` (`higress-group/proxy-wasm-cpp-host` `include/proxy-wasm/wasm.h:158-169`) drains `after_vm_call_actions_` in an unguarded `while(!empty())` loop:
|
||||
|
||||
```cpp
|
||||
while (!self->after_vm_call_actions_.empty()) {
|
||||
auto f = std::move(self->after_vm_call_actions_.front());
|
||||
self->after_vm_call_actions_.pop_front();
|
||||
f(); // if f() re-adds an action, loop never terminates
|
||||
}
|
||||
```
|
||||
|
||||
Several Envoy-side callbacks re-defer themselves when `current_context_ != nullptr` (meaning: "VM is still on the stack, defer until it unwinds"):
|
||||
|
||||
- `Context::onRedisCallFailure` (envoy `context.cc:1273-1277`)
|
||||
- `Context::onRedisCallSuccess` (envoy `context.cc:1243-1249`)
|
||||
- `Context::onHttpCallSuccess` (envoy `context.cc:2352-2358`)
|
||||
- `Context::onHttpCallFailure` (envoy `context.cc:2382-2386`)
|
||||
|
||||
Under a synchronous reentry path — `injectEncodedDataToFilterChain` (envoy `context.cc:2085-2091`) is called from inside an HTTP/Redis callback — the outer `SaveRestoreContext` keeps `current_context_` non-null even after the inner `~SaveRestoreContext` unwinds. So the drain loop pops a self-reenqueueing callback, fires it, it sees `current_context_ != nullptr`, re-adds itself, loop iterates again → infinite → CPU 100%.
|
||||
|
||||
Full call chain (10 hops, each with file:line) is in [the #4034 proposal issue](https://github.com/higress-group/higress/issues/4064) body and SPEC-001.
|
||||
|
||||
## Fix
|
||||
|
||||
Swap the member queue to a local deque before iterating, so any action re-added during the drain lands in the (now-empty) member queue and is picked up by the next-outer `DeferAfterCallActions` frame — not re-fired within the same drain.
|
||||
|
||||
```cpp
|
||||
void doAfterVmCallActions() {
|
||||
if (!after_vm_call_actions_.empty()) {
|
||||
auto self = shared_from_this();
|
||||
std::deque<std::function<void()>> local;
|
||||
local.swap(self->after_vm_call_actions_);
|
||||
for (auto& f : local) f();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`std::deque::swap` is O(1) whole-content exchange. After swap: `local` has the pre-existing queue, `self->after_vm_call_actions_` is empty. The for-loop iterates `local`; any `addAfterVmCallAction` call during iteration appends to the empty member, invisible to this drain.
|
||||
|
||||
## Coverage
|
||||
|
||||
The same primitive covers BOTH:
|
||||
|
||||
- **#4034 (CPU-spin)**: re-added actions go to the empty member queue → picked up by next outer frame → loop terminates.
|
||||
- **#326 (stage-mismatch)**: pre-existing actions stay in `local` → inner drain (from a nested `~DeferAfterCallActions`) sees empty member → does nothing → pre-existing actions fire in the outer drain's correct phase.
|
||||
|
||||
The coverage of #326 is non-obvious — see Meta-lessons below.
|
||||
|
||||
## Evidence
|
||||
|
||||
| Claim | Citation |
|
||||
| --- | --- |
|
||||
| Unguarded drain loop | `higress-group/proxy-wasm-cpp-host` `include/proxy-wasm/wasm.h:158-169` |
|
||||
| Self-reenqueue guard `if (current_context_ != nullptr)` | `higress-group/envoy` `source/extensions/common/wasm/context.cc:1273-1277` (onRedisCallFailure); also lines 1243, 2352, 2382 |
|
||||
| Sync `injectEncodedDataToFilterChain` (the trigger) | `higress-group/envoy` `source/extensions/common/wasm/context.cc:2085-2091` |
|
||||
| Async sibling `injectEncodedDataToFilterChainOnHeader` (the contrast) | `higress-group/envoy` `source/extensions/common/wasm/context.cc:2093-2103` |
|
||||
| `sendLocalReply` is deferred via `addAfterVmCallAction` | `higress-group/envoy` `source/extensions/common/wasm/context.cc:1638-1657` |
|
||||
| `continueDecoding` is deferred via `addAfterVmCallAction` | `higress-group/envoy` `source/extensions/common/wasm/context.cc:1508-1514` |
|
||||
| `~DeferAfterCallActions` drains unconditionally | `higress-group/proxy-wasm-cpp-host` `src/context.cc` (top, `~DeferAfterCallActions` calls `wasm_->doAfterVmCallActions()`) |
|
||||
| `SaveRestoreContext` save/restore semantics | `higress-group/proxy-wasm-cpp-host` `include/proxy-wasm/wasm_vm.h:392-405` |
|
||||
| `current_context_` is thread_local | `higress-group/proxy-wasm-cpp-host` `src/exports.cc:26` |
|
||||
|
||||
## Meta-lessons
|
||||
|
||||
This investigation had three rounds of analysis. The meta-lessons are the most valuable part for future investigators.
|
||||
|
||||
### Lesson 1: a single primitive can cover two bug classes — check coverage explicitly
|
||||
|
||||
The first analysis round concluded "Layer A fixes #4034 but NOT #326" and proposed them as separate work items. That was wrong. The `swap-to-local` primitive covers BOTH because:
|
||||
|
||||
- For #4034: re-added actions go to the empty member.
|
||||
- For #326: pre-existing actions stay in `local`, invisible to the nested drain.
|
||||
|
||||
**Generalization**: whenever a fix changes how a queue/collection is iterated (swap-to-local, snapshot-then-iterate, copy-on-iterate), check whether it ALSO changes cross-frame or cross-stage visibility. These primitives often cover more than the primary bug.
|
||||
|
||||
### Lesson 2: model the PATCHED code, not the unpatched code
|
||||
|
||||
When analyzing whether a fix covers a related bug, it's easy to accidentally reason about the current (unpatched) behavior instead of the patched behavior. The first analysis round said "the inner drain sees `[continueDecoding]`" — which is true for the CURRENT code but FALSE for the patched code, because the patch's swap empties the member before the inner drain runs.
|
||||
|
||||
**Generalization**: when someone proposes a fix and asks "does this also cover X?", re-read the PATCHED code, not just the diff. The patch changes program state at moments you might not be tracking.
|
||||
|
||||
### Lesson 3: "X is deferred" vs "X is direct" is a load-bearing distinction
|
||||
|
||||
Several rounds of analysis hinged on whether `sendLocalReply` and `continueDecoding` are called directly or deferred via `addAfterVmCallAction`. Both are deferred — and that determines the queue order that produces the bug.
|
||||
|
||||
**Generalization**: when a function's name suggests "does X immediately" (like `sendLocalReply`), don't trust the name. Read the function body and check whether it actually calls X directly or wraps it in `addAfterVmCallAction` / `dispatcher().post(...)` / similar defer primitives. The defer-vs-direct distinction changes everything about reentrancy analysis.
|
||||
|
||||
### Lesson 4: maintainer pushback is a signal to re-verify, not restate
|
||||
|
||||
When the maintainer challenged the "Layer A doesn't fix #326" conclusion, the right move was to re-read the code with their specific objection in mind — not to restate the conclusion with different words. The re-read found that the swap-to-local empties the member queue, which the first analysis had missed.
|
||||
|
||||
**Generalization**: maintainer pushback is high-signal information. They know the codebase. If they say "I think X", dispatch a fresh worker to verify X with code evidence. Don't confirm your own previous answer; that's confirmation bias.
|
||||
|
||||
### Lesson 5: the #326 reproducer's plugin call order matters
|
||||
|
||||
The #326 reproducer calls `SendHttpResponse` THEN `ResumeHttpRequest`. If the order were reversed, the queue would be `[continueDecoding, sendLocalReply]`, FIFO drain would pop `continueDecoding` first (in the correct phase), then `sendLocalReply` (triggering encoder reentry with empty queue) — and there would be no stage mismatch even WITHOUT Layer A.
|
||||
|
||||
**Generalization**: when analyzing a reproducer, note the exact order of plugin calls. Stage-mismatch bugs often depend on call order, and a "harmless" reorder can mask the bug.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Patterns — How to Add a New Investigation Case-Study
|
||||
|
||||
This directory accumulates case-studies from past deep investigations. Each pattern captures both the technical root cause AND the meta-lessons (what tripped up the analysis, what the maintainer caught, what the fix primitive generalizes to). The library grows by one entry per major investigation.
|
||||
|
||||
## When to add a pattern
|
||||
|
||||
Add a pattern after an investigation that:
|
||||
|
||||
- Required cross-repo code-trace evidence (envoy + proxy-wasm-cpp-host, or similar).
|
||||
- Resulted in a non-trivial fix (not just a one-line config change).
|
||||
- Has a meta-lesson worth carrying forward (a gotcha, a verification failure, a primitive that generalizes).
|
||||
|
||||
If the investigation was routine (single-file bug, obvious fix, no pushback), skip the pattern — the diff IS the documentation.
|
||||
|
||||
## File naming
|
||||
|
||||
`YYYY-MM-short-slug.md` where the date is when the investigation concluded. Examples:
|
||||
|
||||
- `2026-07-wasm-doaftervmcallactions-reentry.md`
|
||||
- `2026-09-filter-chain-headers-double-dispatch.md`
|
||||
- `2026-11-redis-pool-exhaustion-under-failover.md`
|
||||
|
||||
## Pattern structure
|
||||
|
||||
Use this template. Sections in `[brackets]` are required; sections in `<angle brackets>` are optional but encouraged.
|
||||
|
||||
```markdown
|
||||
# <Bug class one-line summary>
|
||||
|
||||
> Source: #ISSUE_NUMBER — <issue title>
|
||||
> Date: YYYY-MM
|
||||
> Status: <fixed / investigating / deferred>
|
||||
|
||||
## Symptom
|
||||
|
||||
<What observable behavior was wrong? Include perf data / repro steps if relevant.>
|
||||
|
||||
## Root cause
|
||||
|
||||
<One-paragraph explanation. Cite file:line for key claims.>
|
||||
|
||||
<Numbered call-chain trace, one hop per line, with file:line.>
|
||||
|
||||
## Fix
|
||||
|
||||
<The primitive. Code snippet if non-trivial.>
|
||||
|
||||
<Why it fixes the bug — the causal chain.>
|
||||
|
||||
## Coverage
|
||||
|
||||
<What bug classes does this fix cover? What does it NOT cover? Why?>
|
||||
|
||||
## Evidence
|
||||
|
||||
<File:line citations grouped by claim.>
|
||||
|
||||
## Meta-lessons
|
||||
|
||||
<What tripped up the first analysis round? What did the maintainer catch? What generalizes to other bugs in this area?>
|
||||
|
||||
<If the analysis was wrong initially and then corrected, document BOTH the wrong reasoning AND the correction — this is the most valuable part for future investigators.>
|
||||
```
|
||||
|
||||
## Worked example
|
||||
|
||||
See [`2026-07-wasm-doaftervmcallactions-reentry.md`](2026-07-wasm-doaftervmcallactions-reentry.md) for a complete pattern that follows this structure, including a meta-lesson section documenting an initial wrong conclusion that was corrected after maintainer pushback.
|
||||
|
||||
## Linking from new investigations
|
||||
|
||||
When a new investigation reuses a primitive or hits a similar gotcha to a past pattern, link to the pattern in the new investigation's report. Patterns are most valuable when they're cross-referenced — the second time you see a `std::deque::swap`-style fix, you should be able to find the #4034 pattern and reason by analogy.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Code-Trace Verification
|
||||
|
||||
The format and rules for verifying claims with code evidence during a deep investigation. Maintainers trust analysis they can audit; file:line citations are the audit trail.
|
||||
|
||||
## Core rule
|
||||
|
||||
**Every claim must cite `file:line`.** If you can't cite it, you haven't read it; if you haven't read it, you don't know.
|
||||
|
||||
## Acceptable evidence
|
||||
|
||||
| Claim type | What to cite |
|
||||
| --- | --- |
|
||||
| "Function X does Y" | `repo/path/file.cc:NN` + ≤5-line excerpt |
|
||||
| "Function X is synchronous" | The call site that lacks `dispatcher().post(...)` (or equivalent) + the sibling function that DOES use it, as contrast |
|
||||
| "The queue state is `[a, b]` at this point" | The sequence of `push_back` / `pop_front` calls leading to that state, each with file:line |
|
||||
| "The maintainer's claim is wrong" | Re-state their claim, then cite the specific code that contradicts it |
|
||||
| "This fix covers bug class Y too" | The mechanism by which it covers Y, with the call chain showing why |
|
||||
|
||||
## Unacceptable evidence
|
||||
|
||||
- "I think" / "probably" / "should be" — read the code.
|
||||
- "Similar to upstream" — check the fork commit pin in `bazel/repository_locations.bzl`.
|
||||
- "The function name implies X" — names lie. `injectEncodedDataToFilterChain` sounds innocuous; it's the trigger for a CPU-spin bug.
|
||||
- Reasoning from the bug report without code verification — the report describes symptoms.
|
||||
|
||||
## State-trace table format
|
||||
|
||||
The most effective format for communicating queue/state transitions. Use it whenever the bug involves a sequence of state changes (queue operations, `current_context_` saves/restores, reference-counting transitions).
|
||||
|
||||
Template:
|
||||
|
||||
| Step | Action | `<state variable 1>` | `<state variable 2>` |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Enter context | `[]` | `nullptr` |
|
||||
| 2 | Operation X | `[x]` | `nullptr` |
|
||||
| 3 | Operation Y | `[x, y]` | `ctx` |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
Rules:
|
||||
|
||||
- One row per discrete state change.
|
||||
- State columns should be the variables that matter for the bug (queue contents, `current_context_`, depth counter, etc.).
|
||||
- Mark the critical transition with ★ and explain it below the table.
|
||||
- If a state is "unknown" or "depends on plugin behavior", say so — don't fake precision.
|
||||
|
||||
## Worked example (from #4034)
|
||||
|
||||
The #4034 investigation hinged on the queue state at the moment of a nested drain. The state-trace table that settled the disagreement:
|
||||
|
||||
| Step | Action | `self->after_vm_call_actions_` | outer drain's `local` |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Enter `onHttpCallResponse` | `[]` | — |
|
||||
| 2 | Plugin calls `SendHttpResponse` | `[sendLocalReply]` | — |
|
||||
| 3 | Plugin calls `ResumeHttpRequest` | `[sendLocalReply, continueDecoding]` | — |
|
||||
| 4 | Outer `~DeferAfterCallActions` fires | same | — |
|
||||
| 5 | **Layer A: `local.swap(after_vm_call_actions_)`** ★ | `[]` | `[sendLocalReply, continueDecoding]` |
|
||||
| 6 | iter 1: fire `sendLocalReply` → `encodeHeaders` → `onResponseHeaders` | `[]` | same |
|
||||
| 7 | Inner `~DeferAfterCallActions` fires | `[]` (empty → no-op) | same |
|
||||
| 8 | iter 2: fire `continueDecoding` in outer context | `[]` | same |
|
||||
|
||||
The ★ at step 5 is the key insight: `std::deque::swap` empties the member, so the inner drain at step 7 sees an empty queue and does nothing. The earlier (wrong) analysis confused "queue before swap" with "queue at inner-drain time".
|
||||
|
||||
## Re-verification protocol (when the maintainer pushes back)
|
||||
|
||||
When a maintainer disagrees with your conclusion:
|
||||
|
||||
1. **Don't restate the conclusion.** Restating without new evidence wastes their time.
|
||||
2. **Identify the specific point of disagreement.** What exactly do they claim?
|
||||
3. **Dispatch a fresh worker** with the specific objection. The worker brief should say: "Maintainer claims X. Verify whether X is true. Cite file:line."
|
||||
4. **If the worker confirms the maintainer**: acknowledge the error publicly, explain what you missed, update artifacts.
|
||||
5. **If the worker refutes the maintainer**: respond with the specific code evidence, walk through the state-trace table, be respectful.
|
||||
|
||||
The third verification (yours) doesn't count if it just restates the second (also yours). You need to actually re-read the code with the maintainer's objection in mind, not just re-derive your previous answer.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Investigation Methodology
|
||||
|
||||
The step-by-step process for deep-diving a Higress bug. This is the canonical workflow; adapt it to the bug at hand but don't skip the evidence-collection steps.
|
||||
|
||||
## 0. Before you start: scope the investigation
|
||||
|
||||
Confirm the bug is real and worth a deep dive. Signs it's worth it:
|
||||
|
||||
- Reproducer exists (even if intermittent).
|
||||
- perf data or stack trace points at specific functions.
|
||||
- The fix likely requires code changes (not config/docs).
|
||||
- The blast radius is significant (production-affecting, multiple users).
|
||||
|
||||
If the bug is a one-off without a reproducer, or clearly a user-config issue, don't burn a deep investigation on it.
|
||||
|
||||
## 1. Symptom → code surface
|
||||
|
||||
Read the bug report and extract:
|
||||
|
||||
- **Symptom**: what observable behavior is wrong? (CPU spin, crash, wrong response, hang.)
|
||||
- **Trigger conditions**: what's the minimal repro path?
|
||||
- **perf / stack data**: which functions dominate?
|
||||
|
||||
Then map symptoms to code surfaces. For Wasm-related bugs, the surface is almost always split across:
|
||||
|
||||
- `higress-group/envoy` `source/extensions/common/wasm/context.cc` — Higress-specific `Context` subclass, foreign functions like `injectEncodedDataToFilterChain`.
|
||||
- `higress-group/proxy-wasm-cpp-host` `include/proxy-wasm/wasm.h` + `src/context.cc` — VM host runtime, `WasmBase`, `ContextBase`, deferred-action queue.
|
||||
|
||||
**Always check the envoy fork's `bazel/repository_locations.bzl` for the pinned `proxy-wasm-cpp-host` commit.** The fork diverges from upstream; reasoning about upstream code without checking the pin leads to wrong conclusions.
|
||||
|
||||
## 2. Parallel investigation (worker dispatch)
|
||||
|
||||
For non-trivial bugs, dispatch focused worker agents IN PARALLEL for independent aspects. Each worker gets a self-contained brief (it has no shared context with you). Example dispatch for a Wasm reentrancy bug:
|
||||
|
||||
- **Worker A (envoy side)**: verify the trigger path in `higress-group/envoy`. Find the synchronous call, the self-reenqueueing callbacks, the foreign-function registration. Return file:line evidence.
|
||||
- **Worker B (proxy-wasm-cpp-host side)**: verify the engine. Find the queue, the drain loop, the `SaveRestoreContext` semantics. Return file:line evidence.
|
||||
- **Worker C (upstream references)**: fetch any linked upstream issues/PRs, determine if they're related/resolved/portable.
|
||||
|
||||
Worker rules:
|
||||
|
||||
- Each worker is **research-only** unless you explicitly authorize code changes. Don't let workers edit code on their own initiative.
|
||||
- Each worker must **cite file:line for every claim**. Reject worker reports that say "the function does X" without a citation.
|
||||
- Cap report length (e.g., 600-800 words) to keep synthesis tractable.
|
||||
- Use `general-purpose` agent type for investigations that need to clone repos and reason across files; use `Explore` for narrow single-repo lookups.
|
||||
|
||||
## 3. Code-trace verification
|
||||
|
||||
Every claim in the final analysis must reduce to a file:line citation. The output format that works best for maintainer communication is the **step-by-step state-trace table**:
|
||||
|
||||
| Step | Action | Member queue state | Local queue state |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Enter callback | `[]` | — |
|
||||
| 2 | Plugin calls X | `[x]` | — |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
See [`code-trace-verification.md`](code-trace-verification.md) for the full format and a worked example.
|
||||
|
||||
Anti-patterns to avoid:
|
||||
|
||||
- **"I think the function does X"** — unacceptable. Read the code and cite the line.
|
||||
- **"This is probably similar to upstream"** — check the fork commit pin.
|
||||
- **"The drain loop terminates because of course it does"** — verify the actual loop condition.
|
||||
- **Reasoning from the bug report without reading the code** — the report describes symptoms, not causes.
|
||||
|
||||
## 4. Synthesize root cause
|
||||
|
||||
Combine worker findings into a numbered call-chain trace. For each hop:
|
||||
|
||||
- The function name.
|
||||
- The file:line.
|
||||
- What state it reads/writes (e.g., `current_context_` value, queue contents).
|
||||
|
||||
End with a one-sentence root cause: "X happens because Y, which is unguarded because Z."
|
||||
|
||||
## 5. Fix design + side effects
|
||||
|
||||
Propose a fix primitive. For each candidate:
|
||||
|
||||
- **What it changes**: the specific code transformation.
|
||||
- **Why it fixes the bug**: the causal chain.
|
||||
- **Side effects**: what else it touches. Be honest — even a 5-line fix can have ABI or lifecycle implications.
|
||||
- **Coverage**: does it also cover related bug classes? (E.g., drain-to-local covers both CPU-spin and stage-mismatch.)
|
||||
|
||||
If a fix has unacceptable side effects, document it as "deferred" and explain why. Don't sneak a behavior-change through as a "bug fix".
|
||||
|
||||
## 6. Cross-check related bug classes
|
||||
|
||||
Before declaring done, check: does the fix also cover bug classes that share the same machinery?
|
||||
|
||||
- Look at upstream issues in `proxy-wasm/proxy-wasm-cpp-host` that reference the same functions.
|
||||
- Look at the Higress fork's divergence from upstream — are there Higress-specific code paths that have the same bug?
|
||||
- Look at the `addAfterVmCallAction` callsites in envoy — are there other self-reenqueueing or cross-stage patterns?
|
||||
|
||||
Document the coverage explicitly: "Fixes X. Does NOT fix Y because Z. Y tracked separately as ..."
|
||||
|
||||
## 7. Verify when challenged
|
||||
|
||||
If the maintainer pushes back on a conclusion, **do not just restate the conclusion**. Re-read the code with their specific objection in mind.
|
||||
|
||||
Common failure modes in the first analysis round:
|
||||
|
||||
- **Confusing "state before operation" with "state after operation"**. (E.g., "the queue is `[X]`" — but is that before or after the swap?)
|
||||
- **Assuming a function is sync/async without checking**. (E.g., assuming `sendLocalReply` is direct when it's actually deferred via `addAfterVmCallAction`.)
|
||||
- **Reasoning about the patched code as if it were the unpatched code**. (E.g., analyzing Layer A's `doAfterVmCallActions` as if it still iterates the member queue in place, when actually it swaps to a local first.)
|
||||
- **Assuming plugin call order**. (E.g., assuming `ResumeHttpRequest` is called before `SendHttpResponse` — verify from the reproducer.)
|
||||
|
||||
When you re-verify, dispatch a fresh worker with the specific objection. The worker should be told: "the maintainer claims X; verify whether X is true with code evidence." This avoids confirmation bias.
|
||||
|
||||
## 8. Document the pattern
|
||||
|
||||
After the investigation concludes, add a pattern file to `patterns/` capturing:
|
||||
|
||||
- The bug class (one-line summary).
|
||||
- The root cause (with file:line evidence).
|
||||
- The fix primitive (with the code snippet).
|
||||
- The meta-lesson (what tripped up the analysis, what the maintainer caught, what generalizes).
|
||||
|
||||
This is what makes the skill accumulate value over time. See [`patterns/README.md`](../patterns/README.md) for the format.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Code-Trace Table Template
|
||||
|
||||
Use this template for the state-trace table that communicates queue/state transitions to maintainers. This format was developed during the #4034 investigation and proved effective for settling disagreements about program state at specific moments.
|
||||
|
||||
## When to use
|
||||
|
||||
- The bug involves a sequence of state changes (queue operations, `current_context_` saves/restores, reference-count transitions).
|
||||
- You need to communicate "what the program state is at moment X" to someone who wasn't in your head.
|
||||
- A maintainer is challenging a conclusion about state at a specific point in the call chain.
|
||||
|
||||
## Template
|
||||
|
||||
```markdown
|
||||
| Step | Action | `<state variable 1>` | `<state variable 2>` | ... |
|
||||
| --- | --- | --- | --- | ... |
|
||||
| 1 | <entry point> | `<initial value>` | `<initial value>` | ... |
|
||||
| 2 | <operation> | `<value after>` | `<value after>` | ... |
|
||||
| 3 | <operation> | `<value after>` | `<value after>` | ... |
|
||||
| ... | ... | ... | ... | ... |
|
||||
| N ★ | <critical transition> | `<value>` | `<value>` | ... |
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **One row per discrete state change.** Don't combine multiple operations into one row.
|
||||
2. **State columns are the variables that matter for the bug.** Common ones for Wasm bugs:
|
||||
- `self->after_vm_call_actions_` (the deferred-action queue)
|
||||
- `current_context_` (the thread_local ContextBase*)
|
||||
- `SaveRestoreContext` depth (how many nested VM frames)
|
||||
- `local` (the outer drain's swap target, post-Layer-A)
|
||||
3. **Mark the critical transition with ★** and explain it below the table. This is the moment where the bug happens (or doesn't).
|
||||
4. **Show the queue contents explicitly** — `[sendLocalReply, continueDecoding]`, not "non-empty". Order matters for FIFO drains.
|
||||
5. **If state is unknown / depends on plugin behavior, say so.** Don't fake precision. Better: "depends on whether the plugin called X before Y; both branches shown below".
|
||||
6. **Pair the table with a prose explanation of the ★ transition.** The table shows WHAT; the prose explains WHY.
|
||||
|
||||
## Worked example
|
||||
|
||||
From #4034 — the table that settled whether Layer A's swap-to-local covers #326:
|
||||
|
||||
```markdown
|
||||
| Step | Action | `self->after_vm_call_actions_` | outer drain's `local` |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Enter `onHttpCallResponse` | `[]` | — |
|
||||
| 2 | Plugin calls `SendHttpResponse` | `[sendLocalReply]` | — |
|
||||
| 3 | Plugin calls `ResumeHttpRequest` | `[sendLocalReply, continueDecoding]` | — |
|
||||
| 4 | Outer `~DeferAfterCallActions` fires | `[sendLocalReply, continueDecoding]` | — |
|
||||
| 5 ★ | **Layer A: `local.swap(after_vm_call_actions_)`** | `[]` | `[sendLocalReply, continueDecoding]` |
|
||||
| 6 | iter 1: fire `sendLocalReply` → `encodeHeaders` → `onResponseHeaders` | `[]` | `[sendLocalReply, continueDecoding]` |
|
||||
| 7 | Inner `~DeferAfterCallActions` fires | `[]` (empty → no-op) | `[sendLocalReply, continueDecoding]` |
|
||||
| 8 | iter 2: fire `continueDecoding` in outer context | `[]` | (iterating) |
|
||||
```
|
||||
|
||||
**Why the ★ matters**: `std::deque::swap` is O(1) whole-content exchange. After step 5, the member queue is empty — it's NOT `[continueDecoding]` anymore. So when step 7's inner drain checks `if (!after_vm_call_actions_.empty())`, it sees `false` and returns. `continueDecoding` stays in the outer drain's `local` and fires in step 8, in the outer drain's context (correct phase).
|
||||
|
||||
The earlier (wrong) analysis had assumed the member queue still contained `continueDecoding` at step 7 — confusing "queue before swap" with "queue after swap".
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
Don't do these:
|
||||
|
||||
- **Combining steps**: "steps 5-8: drain happens, continueDecoding fires" — too coarse; the reader can't see the state at each moment.
|
||||
- **Hiding the critical transition**: burying the ★ in a paragraph of prose. The table IS the argument; make it sharp.
|
||||
- **Vague state**: "queue has some items" — which items? In what order? FIFO drains care about order.
|
||||
- **No prose explanation**: the table alone isn't enough; pair it with 2-3 sentences explaining the ★.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Investigation Report Template
|
||||
|
||||
Use this structure when starting a new deep investigation. Fill in the brackets; remove sections that don't apply. The goal is a report that a maintainer can audit end-to-end: every claim has a citation, every fix has a side-effect analysis.
|
||||
|
||||
---
|
||||
|
||||
# Investigation: <one-line bug summary>
|
||||
|
||||
> Source: #ISSUE_NUMBER
|
||||
> Investigator: <name / agent session>
|
||||
> Date: YYYY-MM-DD
|
||||
> Status: <in-progress / root-caused / fix-designed / fixed>
|
||||
|
||||
## Symptom
|
||||
|
||||
<What observable behavior is wrong? Include:>
|
||||
|
||||
- **Observable**: <what the user sees — CPU spin, crash, wrong response, hang>
|
||||
- **Trigger conditions**: <minimal repro path>
|
||||
- **perf / stack data**: <paste perf sampling, stack trace, or relevant telemetry>
|
||||
- **Blast radius**: <which versions, which components, how many users>
|
||||
|
||||
## Code surfaces
|
||||
|
||||
<Which repos and files are involved? Use a table:>
|
||||
|
||||
| Concern | Repo | File | Why it matters |
|
||||
| --- | --- | --- | --- |
|
||||
| <concern> | <repo URL> | <path:line> | <one-line role> |
|
||||
|
||||
<For Wasm bugs, always check the envoy fork's `bazel/repository_locations.bzl` for the pinned proxy-wasm-cpp-host commit. Note it here.>
|
||||
|
||||
## Worker dispatch
|
||||
|
||||
<If you dispatched parallel workers, list them with their brief and key finding:>
|
||||
|
||||
- **Worker A (<repo>) — <one-line brief>**: <VERDICT — one-line finding>. Key evidence: <file:line>.
|
||||
- **Worker B (<repo>) — <one-line brief>**: ...
|
||||
|
||||
<If the investigation was single-threaded (no workers), say so and explain why.>
|
||||
|
||||
## Root cause
|
||||
|
||||
<One-paragraph explanation. Be specific about the mechanism.>
|
||||
|
||||
<Numbered call-chain trace, one hop per line:>
|
||||
|
||||
1. <Function> (`repo file.cc:NN`) — <what it does>
|
||||
2. <Function> (`repo file.cc:NN`) — <what it does>
|
||||
3. ...
|
||||
|
||||
<For queue/state bugs, include a state-trace table here. See `code-trace-verification.md` for the format.>
|
||||
|
||||
**Root cause (one sentence)**: <X happens because Y, which is unguarded because Z>.
|
||||
|
||||
## Fix
|
||||
|
||||
<The primitive. Code snippet if non-trivial.>
|
||||
|
||||
```cpp
|
||||
// Before
|
||||
<current code>
|
||||
|
||||
// After
|
||||
<fixed code>
|
||||
```
|
||||
|
||||
**Why it fixes the bug**: <causal chain, 2-3 sentences.>
|
||||
|
||||
**Side effects**: <what else the change touches. Be honest — even a 5-line fix can have ABI or lifecycle implications. If there are none, say "none identified" and explain the verification.>
|
||||
|
||||
## Coverage analysis
|
||||
|
||||
<Does the fix cover related bug classes? Check upstream issues, related callbacks, similar patterns.>
|
||||
|
||||
| Bug class | Covered? | Why |
|
||||
| --- | --- | --- |
|
||||
| <primary bug> | yes | <mechanism> |
|
||||
| <related class 1> | yes/no | <mechanism or reason> |
|
||||
| <related class 2> | yes/no | ... |
|
||||
|
||||
<For each "no", note where it's tracked (separate issue, deferred SPEC, etc.).>
|
||||
|
||||
## Evidence index
|
||||
|
||||
<All file:line citations grouped by claim. This is the audit trail.>
|
||||
|
||||
| Claim | Citation |
|
||||
| --- | --- |
|
||||
| <claim> | `repo/path/file.cc:NN` |
|
||||
| ... | ... |
|
||||
|
||||
## Open questions
|
||||
|
||||
<Any unresolved questions for the maintainer. Each should be specific and actionable.>
|
||||
|
||||
- **Q1**: <question>
|
||||
- **Q2**: <question>
|
||||
|
||||
## Next steps
|
||||
|
||||
<What happens next. Be concrete:>
|
||||
|
||||
- [ ] <action 1>
|
||||
- [ ] <action 2>
|
||||
|
||||
---
|
||||
|
||||
<After the investigation concludes, distill the meta-lessons and add a pattern file to `patterns/`. See `patterns/README.md` for the format.>
|
||||
35
.claude/skills/issue-spec-apply/SKILL.md
Normal file
35
.claude/skills/issue-spec-apply/SKILL.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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.
|
||||
24
.claude/skills/issue-spec-archive/SKILL.md
Normal file
24
.claude/skills/issue-spec-archive/SKILL.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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.
|
||||
58
.claude/skills/issue-spec-github/SKILL.md
Normal file
58
.claude/skills/issue-spec-github/SKILL.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
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.
|
||||
37
.claude/skills/issue-spec-propose/SKILL.md
Normal file
37
.claude/skills/issue-spec-propose/SKILL.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
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.
|
||||
32
.claude/skills/issue-spec-review/SKILL.md
Normal file
32
.claude/skills/issue-spec-review/SKILL.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
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.
|
||||
28
.claude/skills/issue-spec-verify/SKILL.md
Normal file
28
.claude/skills/issue-spec-verify/SKILL.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
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.
|
||||
53
.claude/skills/issue-spec-workflow/SKILL.md
Normal file
53
.claude/skills/issue-spec-workflow/SKILL.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
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.
|
||||
3
.github/workflows/wasm-plugin-unit-test.yml
vendored
3
.github/workflows/wasm-plugin-unit-test.yml
vendored
@@ -115,7 +115,8 @@ jobs:
|
||||
run: |
|
||||
echo "Building WASM for ${{ matrix.plugin }}..."
|
||||
|
||||
# 检查是否存在main.go文件
|
||||
# Run prepare.sh if it exists (e.g., download BPE vocabulary)
|
||||
if [ -f prepare.sh ]; then sh ./prepare.sh; fi
|
||||
|
||||
export GOOS=wasip1
|
||||
export GOARCH=wasm
|
||||
|
||||
4
.issue-spec/config.json
Normal file
4
.issue-spec/config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"hostname": "github.com",
|
||||
"repo": "higress-group/higress"
|
||||
}
|
||||
@@ -36,7 +36,9 @@ header:
|
||||
- 'pkg/ingress/kube/gateway/istio/testdata'
|
||||
- 'release-notes/**'
|
||||
- '.cursor/**'
|
||||
- '.claude/**'
|
||||
- '.claude/**'
|
||||
- '.agents/**'
|
||||
- '.issue-spec/**'
|
||||
|
||||
comment: on-failure
|
||||
dependency:
|
||||
|
||||
@@ -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.2/envoy-symbol-ARCH.tar.gz
|
||||
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.2.3/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 ?= 4219c3d8e99adb269e7db947011eca24717882af
|
||||
ISTIO_LATEST_IMAGE_TAG ?= ce494feddfa817346404090dfc598badb02efa83
|
||||
ENVOY_LATEST_IMAGE_TAG ?= fcc50202f47e27f6b8391a4bd9bbc0a9127d89d7
|
||||
ISTIO_LATEST_IMAGE_TAG ?= de2c9628294f51b13c4a70b3a862b4372890797a
|
||||
|
||||
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'
|
||||
|
||||
Submodule envoy/envoy updated: 0cfacca06f...f49d591e67
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
appVersion: 2.2.2
|
||||
appVersion: 2.2.3
|
||||
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.2
|
||||
version: 2.2.3
|
||||
|
||||
@@ -20,11 +20,6 @@ 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 }}
|
||||
@@ -45,6 +40,11 @@ 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,7 +211,9 @@ template:
|
||||
{{- $config := $o11y.promtail }}
|
||||
- name: promtail
|
||||
image: {{ $config.image.repository | default (printf "%s/higress/promtail" .Values.global.hub) }}:{{ $config.image.tag }}
|
||||
imagePullPolicy: IfNotPresent
|
||||
{{- if .Values.global.imagePullPolicy }}
|
||||
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
|
||||
{{- end }}
|
||||
args:
|
||||
- -config.file=/etc/promtail/promtail.yaml
|
||||
env:
|
||||
|
||||
@@ -39,6 +39,11 @@ 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
|
||||
@@ -49,6 +54,7 @@ spec:
|
||||
- --enableStatus={{ .Values.global.enableStatus }}
|
||||
{{- end }}
|
||||
- --ingressClass={{ .Values.global.ingressClass }}
|
||||
- --gatewayClass={{ .Values.global.gatewayClass }}
|
||||
{{- if .Values.global.watchNamespace }}
|
||||
- --watchNamespace={{ .Values.global.watchNamespace }}
|
||||
{{- end }}
|
||||
@@ -77,7 +83,7 @@ spec:
|
||||
- name: PILOT_ENABLE_GATEWAY_API
|
||||
value: "{{ .Values.global.enableGatewayAPI }}"
|
||||
- name: PILOT_ENABLE_ALPHA_GATEWAY_API
|
||||
value: "{{ .Values.global.enableGatewayAPI }}"
|
||||
value: "{{ .Values.global.enableAlphaGatewayAPI }}"
|
||||
{{- if .Values.global.enableInferenceExtension }}
|
||||
- name: ENABLE_GATEWAY_API_INFERENCE_EXTENSION
|
||||
value: "true"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{{- if .Values.global.ingressClass }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: IngressClass
|
||||
metadata:
|
||||
name: {{ .Values.global.ingressClass }}
|
||||
spec:
|
||||
controller: higress.io/higress-controller
|
||||
{{- end }}
|
||||
{{- 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 }}
|
||||
|
||||
@@ -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 "1.0.0" }}
|
||||
image: {{ .Values.pluginServer.hub | default .Values.global.hub }}/higress/{{ .Values.pluginServer.image | default "plugin-server" }}:{{ .Values.pluginServer.tag | default .Chart.Version }}
|
||||
{{- if .Values.pluginServer.imagePullPolicy }}
|
||||
imagePullPolicy: {{ .Values.pluginServer.imagePullPolicy }}
|
||||
{{- else if .Values.global.imagePullPolicy }}
|
||||
|
||||
@@ -25,6 +25,13 @@ 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,
|
||||
@@ -47,6 +54,8 @@ 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.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: higress-core
|
||||
repository: file://../core
|
||||
version: 2.2.2
|
||||
version: 2.2.3
|
||||
- name: higress-console
|
||||
repository: https://higress.io/helm-charts/
|
||||
version: 2.2.2
|
||||
digest: sha256:8bb8790bea259fc8039c5b49997de57bdfe1a948b8e7f3db51810c3fd825c487
|
||||
generated: "2026-05-21T10:40:28.691491+08:00"
|
||||
version: 2.2.3
|
||||
digest: sha256:b2089e3b943edcf5cfbb52c42fbf00fad58171460a89e5a20c88cd7657f75419
|
||||
generated: "2026-06-24T19:48:33.129399+08:00"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
appVersion: 2.2.2
|
||||
appVersion: 2.2.3
|
||||
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.2
|
||||
version: 2.2.3
|
||||
- name: higress-console
|
||||
repository: "https://higress.io/helm-charts/"
|
||||
version: 2.2.2
|
||||
version: 2.2.3
|
||||
type: application
|
||||
version: 2.2.2
|
||||
version: 2.2.3
|
||||
|
||||
@@ -161,10 +161,12 @@ 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` | |
|
||||
@@ -179,6 +181,7 @@ 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. |
|
||||
|
||||
@@ -145,7 +145,9 @@ 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 支持 |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
|
||||
Submodule istio/istio updated: c8fbadb17c...de2c962829
@@ -110,6 +110,7 @@ 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
|
||||
@@ -222,6 +223,7 @@ 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,
|
||||
|
||||
@@ -106,6 +106,7 @@ 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")
|
||||
|
||||
@@ -46,6 +46,8 @@ 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"
|
||||
|
||||
@@ -438,6 +440,7 @@ 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)
|
||||
@@ -504,6 +507,8 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) []
|
||||
}
|
||||
}
|
||||
|
||||
m.preparePassthroughTLSHostOwners(&convertOptions, configs)
|
||||
|
||||
// convert http route
|
||||
for idx := range configs {
|
||||
cfg := configs[idx]
|
||||
@@ -570,13 +575,8 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) []
|
||||
m.ingressRouteCache = convertOptions.IngressRouteCache.Extract()
|
||||
m.mutex.Unlock()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
out := make([]config.Config, 0, len(convertOptions.VirtualServices))
|
||||
for host, wrapperVS := range convertOptions.VirtualServices {
|
||||
cleanHost := common.CleanHost(host)
|
||||
// namespace/name, name format: (istio cluster id)-host
|
||||
gateways := []string{
|
||||
@@ -585,13 +585,10 @@ 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)
|
||||
|
||||
@@ -599,14 +596,18 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) []
|
||||
vs.Http = append(vs.Http, route.HTTPRoute)
|
||||
}
|
||||
|
||||
firstRoute := routes[0]
|
||||
if len(vs.Http) == 0 && len(vs.Tls) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
vsName, clusterId := virtualServiceNameAndClusterID(cleanHost, wrapperVS, routes)
|
||||
out = append(out, config.Config{
|
||||
Meta: config.Meta{
|
||||
GroupVersionKind: gvk.VirtualService,
|
||||
Name: common.CreateConvertedName(constants.IstioIngressGatewayName, firstRoute.WrapperConfig.Config.Namespace, firstRoute.WrapperConfig.Config.Name, cleanHost),
|
||||
Name: vsName,
|
||||
Namespace: m.namespace,
|
||||
Annotations: map[string]string{
|
||||
common.ClusterIdAnnotation: firstRoute.ClusterId.String(),
|
||||
common.ClusterIdAnnotation: clusterId.String(),
|
||||
},
|
||||
},
|
||||
Spec: vs,
|
||||
@@ -625,6 +626,129 @@ 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{}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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"
|
||||
@@ -109,6 +110,405 @@ 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{
|
||||
@@ -616,3 +1016,46 @@ 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
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ type Ingress struct {
|
||||
|
||||
DownstreamTLS *DownstreamTLSConfig
|
||||
|
||||
SSLPassthrough *SSLPassthroughConfig
|
||||
|
||||
Canary *CanaryConfig
|
||||
|
||||
IPAccessControl *IPAccessControlConfig
|
||||
@@ -115,6 +117,10 @@ 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() {
|
||||
@@ -157,6 +163,7 @@ func NewAnnotationHandlerManager() AnnotationHandler {
|
||||
canary{},
|
||||
cors{},
|
||||
downstreamTLS{},
|
||||
sslPassthrough{},
|
||||
redirect{},
|
||||
rewrite{},
|
||||
upstreamTLS{},
|
||||
|
||||
@@ -106,6 +106,9 @@ 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 ||
|
||||
|
||||
@@ -269,6 +269,40 @@ 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 {
|
||||
|
||||
34
pkg/ingress/kube/annotations/ssl_passthrough.go
Normal file
34
pkg/ingress/kube/annotations/ssl_passthrough.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// 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
|
||||
}
|
||||
112
pkg/ingress/kube/annotations/ssl_passthrough_test.go
Normal file
112
pkg/ingress/kube/annotations/ssl_passthrough_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -170,9 +170,6 @@ 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 != "" {
|
||||
|
||||
@@ -17,10 +17,8 @@ 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"
|
||||
)
|
||||
|
||||
@@ -131,9 +129,6 @@ func TestApplyTrafficPolicy(t *testing.T) {
|
||||
Tls: &networking.ClientTLSSettings{
|
||||
Mode: networking.ClientTLSSettings_SIMPLE,
|
||||
Sni: "SNI",
|
||||
InsecureSkipVerify: &wrappers.BoolValue{
|
||||
Value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -163,9 +158,7 @@ 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, protocmp.Transform(),
|
||||
cmpopts.IgnoreUnexported(unexportedIgnoredTypes...),
|
||||
); diff != "" {
|
||||
if diff := cmp.Diff(testCase.expect, testCase.input, cmpopts.IgnoreUnexported(unexportedIgnoredTypes...)); diff != "" {
|
||||
t.Fatalf("TestApplyTrafficPolicy() mismatch (-want +got): \n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +24,7 @@ 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"
|
||||
|
||||
@@ -78,6 +80,20 @@ 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
|
||||
@@ -111,6 +127,50 @@ 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
|
||||
|
||||
@@ -145,6 +145,41 @@ 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
|
||||
|
||||
@@ -167,6 +202,9 @@ 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
|
||||
|
||||
@@ -18,10 +18,48 @@ 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)
|
||||
|
||||
@@ -364,6 +364,8 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,7 +391,8 @@ func getSvcIpList(svcList []*v1.Service) []string {
|
||||
|
||||
func SortLbIngressList(lbi []v1.LoadBalancerIngress) func(int, int) bool {
|
||||
return func(i int, j int) bool {
|
||||
return lbi[i].IP < lbi[j].IP
|
||||
return loadBalancerIngressAddress(lbi[i].IP, lbi[i].Hostname) <
|
||||
loadBalancerIngressAddress(lbi[j].IP, lbi[j].Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +400,11 @@ func GetLbStatusList(svcList []*v1.Service) []v1.LoadBalancerIngress {
|
||||
svcIpList := getSvcIpList(svcList)
|
||||
lbi := make([]v1.LoadBalancerIngress, 0, len(svcIpList))
|
||||
for _, ep := range svcIpList {
|
||||
lbi = append(lbi, v1.LoadBalancerIngress{IP: ep})
|
||||
if net.ParseIP(ep) != nil {
|
||||
lbi = append(lbi, v1.LoadBalancerIngress{IP: ep})
|
||||
} else {
|
||||
lbi = append(lbi, v1.LoadBalancerIngress{Hostname: ep})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(lbi, SortLbIngressList(lbi))
|
||||
@@ -406,7 +413,8 @@ func GetLbStatusList(svcList []*v1.Service) []v1.LoadBalancerIngress {
|
||||
|
||||
func SortLbIngressListV1(lbi []networkingv1.IngressLoadBalancerIngress) func(int, int) bool {
|
||||
return func(i int, j int) bool {
|
||||
return lbi[i].IP < lbi[j].IP
|
||||
return loadBalancerIngressAddress(lbi[i].IP, lbi[i].Hostname) <
|
||||
loadBalancerIngressAddress(lbi[j].IP, lbi[j].Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +422,11 @@ func GetLbStatusListV1(svcList []*v1.Service) []networkingv1.IngressLoadBalancer
|
||||
svcIpList := getSvcIpList(svcList)
|
||||
lbi := make([]networkingv1.IngressLoadBalancerIngress, 0, len(svcIpList))
|
||||
for _, ep := range svcIpList {
|
||||
lbi = append(lbi, networkingv1.IngressLoadBalancerIngress{IP: ep})
|
||||
if net.ParseIP(ep) != nil {
|
||||
lbi = append(lbi, networkingv1.IngressLoadBalancerIngress{IP: ep})
|
||||
} else {
|
||||
lbi = append(lbi, networkingv1.IngressLoadBalancerIngress{Hostname: ep})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(lbi, SortLbIngressListV1(lbi))
|
||||
@@ -423,7 +435,8 @@ func GetLbStatusListV1(svcList []*v1.Service) []networkingv1.IngressLoadBalancer
|
||||
|
||||
func SortLbIngressListV1Beta1(lbi []networkingv1beta1.IngressLoadBalancerIngress) func(int, int) bool {
|
||||
return func(i int, j int) bool {
|
||||
return lbi[i].IP < lbi[j].IP
|
||||
return loadBalancerIngressAddress(lbi[i].IP, lbi[i].Hostname) <
|
||||
loadBalancerIngressAddress(lbi[j].IP, lbi[j].Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,9 +444,20 @@ func GetLbStatusListV1Beta1(svcList []*v1.Service) []networkingv1beta1.IngressLo
|
||||
svcIpList := getSvcIpList(svcList)
|
||||
lbi := make([]networkingv1beta1.IngressLoadBalancerIngress, 0, len(svcIpList))
|
||||
for _, ep := range svcIpList {
|
||||
lbi = append(lbi, networkingv1beta1.IngressLoadBalancerIngress{IP: ep})
|
||||
if net.ParseIP(ep) != nil {
|
||||
lbi = append(lbi, networkingv1beta1.IngressLoadBalancerIngress{IP: ep})
|
||||
} else {
|
||||
lbi = append(lbi, networkingv1beta1.IngressLoadBalancerIngress{Hostname: ep})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(lbi, SortLbIngressListV1Beta1(lbi))
|
||||
return lbi
|
||||
}
|
||||
|
||||
func loadBalancerIngressAddress(ip, hostname string) string {
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ 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"
|
||||
@@ -206,6 +208,9 @@ 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{
|
||||
@@ -224,6 +229,57 @@ 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,
|
||||
@@ -295,17 +351,15 @@ func TestGetLbStatusList(t *testing.T) {
|
||||
}
|
||||
|
||||
lbiList := GetLbStatusList(svcList)
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func TestSortRoutes(t *testing.T) {
|
||||
@@ -1012,6 +1066,9 @@ 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{
|
||||
@@ -1047,23 +1104,82 @@ 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, 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")
|
||||
assert.Equal(t, []networkingv1.IngressLoadBalancerIngress{
|
||||
{IP: "1.1.1.1"},
|
||||
{IP: "2.2.2.2"},
|
||||
{Hostname: aliyunHostname},
|
||||
{Hostname: awsHostname},
|
||||
{Hostname: tencentHostname},
|
||||
}, lbiList)
|
||||
})
|
||||
|
||||
// Test the V1Beta1 version
|
||||
t.Run("GetLbStatusListV1Beta1", func(t *testing.T) {
|
||||
lbiList := GetLbStatusListV1Beta1(svcList)
|
||||
|
||||
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")
|
||||
assert.Equal(t, []networkingv1beta1.IngressLoadBalancerIngress{
|
||||
{IP: "1.1.1.1"},
|
||||
{IP: "2.2.2.2"},
|
||||
{Hostname: aliyunHostname},
|
||||
{Hostname: awsHostname},
|
||||
{Hostname: tencentHostname},
|
||||
}, lbiList)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ 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}
|
||||
|
||||
@@ -27,7 +27,6 @@ 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"
|
||||
@@ -425,7 +424,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(higressconstants.ManagedGatewayController)))
|
||||
ancestorStatus = append(ancestorStatus, setAncestorStatus(pr, status, i.Generation, conds, gw.GatewayController(managedGatewayController)))
|
||||
}
|
||||
status.Ancestors = mergeAncestors(status.Ancestors, ancestorStatus)
|
||||
return status, res
|
||||
@@ -635,14 +634,16 @@ func parentRefEqual(a, b gw.ParentReference) bool {
|
||||
ptr.Equal(a.Port, b.Port)
|
||||
}
|
||||
|
||||
var outControllers = sets.New(gw.GatewayController(higressconstants.ManagedGatewayController), constants.ManagedGatewayMeshController)
|
||||
func isOutController(controller gw.GatewayController) bool {
|
||||
return controller == managedGatewayController || controller == 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 !outControllers.Contains(x.ControllerName) {
|
||||
if !isOutController(x.ControllerName) {
|
||||
// Keep it as-is
|
||||
existing[n] = x
|
||||
n++
|
||||
|
||||
@@ -21,7 +21,6 @@ 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"
|
||||
@@ -49,11 +48,12 @@ 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 != k8s.GatewayController(higressconstants.ManagedGatewayController) {
|
||||
if p.ControllerName != controllerName {
|
||||
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 == k8s.GatewayController(higressconstants.ManagedGatewayController)
|
||||
s.ControllerName == controllerName
|
||||
})
|
||||
if currentStatus != nil {
|
||||
currentConditions = currentStatus.Conditions
|
||||
}
|
||||
ns := k8s.RouteParentStatus{
|
||||
ParentRef: gw.OriginalReference,
|
||||
ControllerName: k8s.GatewayController(higressconstants.ManagedGatewayController),
|
||||
ControllerName: controllerName,
|
||||
Conditions: setConditions(generation, currentConditions, conds),
|
||||
}
|
||||
// Parent ref already exists, insert in the same place
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCreateRouteStatus(t *testing.T) {
|
||||
setGatewayClassNameForTest(t, "")
|
||||
lastTransitionTime := metav1.Now()
|
||||
parentRef := httpRouteSpec.ParentRefs[0]
|
||||
parentStatus := []k8s.RouteParentStatus{
|
||||
@@ -122,3 +123,29 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
package istio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -77,6 +81,7 @@ 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)
|
||||
@@ -94,6 +99,57 @@ 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)
|
||||
|
||||
@@ -135,3 +191,52 @@ 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))
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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"
|
||||
@@ -1735,9 +1736,7 @@ 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) {
|
||||
@@ -1745,12 +1744,21 @@ func reportGatewayStatus(
|
||||
}
|
||||
}
|
||||
}
|
||||
gs.Addresses = make([]k8s.GatewayStatusAddress, 0, len(addressesToReport))
|
||||
for _, addr := range addressesToReport {
|
||||
gs.Addresses = append(gs.Addresses, k8s.GatewayStatusAddress{
|
||||
Value: addr,
|
||||
Type: &addrType,
|
||||
})
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Prune listeners that have been removed
|
||||
haveListeners := getListenerNames(&obj.Spec)
|
||||
|
||||
@@ -30,6 +30,7 @@ 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"
|
||||
|
||||
@@ -130,9 +131,9 @@ var services = []*model.Service{
|
||||
Attributes: model.ServiceAttributes{
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{
|
||||
"higress.io/inferencepool-extension-service": "ext-proc-svc",
|
||||
"higress.io/inferencepool-extension-port": "9002",
|
||||
"higress.io/inferencepool-extension-failure-mode": "FailClose",
|
||||
InferencePoolExtensionRefSvc: "ext-proc-svc",
|
||||
InferencePoolExtensionRefPort: "9002",
|
||||
InferencePoolExtensionRefFailureMode: "FailClose",
|
||||
},
|
||||
},
|
||||
Ports: ports,
|
||||
@@ -145,9 +146,9 @@ var services = []*model.Service{
|
||||
Attributes: model.ServiceAttributes{
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{
|
||||
"higress.io/inferencepool-extension-service": "ext-proc-svc-2",
|
||||
"higress.io/inferencepool-extension-port": "9002",
|
||||
"higress.io/inferencepool-extension-failure-mode": "FailClose",
|
||||
InferencePoolExtensionRefSvc: "ext-proc-svc-2",
|
||||
InferencePoolExtensionRefPort: "9002",
|
||||
InferencePoolExtensionRefFailureMode: "FailClose",
|
||||
},
|
||||
},
|
||||
Ports: ports,
|
||||
@@ -156,6 +157,36 @@ 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{
|
||||
@@ -570,9 +601,8 @@ func init() {
|
||||
features.EnableAlphaGatewayAPI = true
|
||||
features.EnableAmbientWaypoints = true
|
||||
features.EnableAmbientMultiNetwork = true
|
||||
// Recompute with ambient enabled
|
||||
classInfos = getClassInfos()
|
||||
builtinClasses = getBuiltinClasses()
|
||||
// Recompute with the desired feature flags.
|
||||
SetGatewayClassName("")
|
||||
}
|
||||
|
||||
type TestStatusQueue struct {
|
||||
@@ -794,6 +824,128 @@ 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,
|
||||
|
||||
@@ -48,15 +48,33 @@ type classInfo struct {
|
||||
addressType gateway.AddressType
|
||||
}
|
||||
|
||||
var classInfos = getClassInfos()
|
||||
var (
|
||||
gatewayClassName = gateway.ObjectName(higressconstants.DefaultGatewayClass)
|
||||
managedGatewayController = gateway.GatewayController(higressconstants.ManagedGatewayController)
|
||||
classInfos = getClassInfos()
|
||||
builtinClasses = getBuiltinClasses()
|
||||
)
|
||||
|
||||
var builtinClasses = getBuiltinClasses()
|
||||
// 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()
|
||||
}
|
||||
|
||||
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),
|
||||
higressconstants.DefaultGatewayClass: higressconstants.ManagedGatewayController,
|
||||
gatewayClassName: managedGatewayController,
|
||||
// End - Updated by Higress
|
||||
}
|
||||
// Start - Commented by Higress
|
||||
@@ -80,8 +98,8 @@ func getBuiltinClasses() map[gateway.ObjectName]gateway.GatewayController {
|
||||
func getClassInfos() map[gateway.GatewayController]classInfo {
|
||||
// Start - Updated by Higress
|
||||
m := map[gateway.GatewayController]classInfo{
|
||||
gateway.GatewayController(higressconstants.ManagedGatewayController): {
|
||||
controller: higressconstants.ManagedGatewayController,
|
||||
managedGatewayController: {
|
||||
controller: string(managedGatewayController),
|
||||
description: "The default Higress GatewayClass",
|
||||
templates: "kube-gateway",
|
||||
defaultServiceType: corev1.ServiceTypeLoadBalancer,
|
||||
|
||||
@@ -38,8 +38,11 @@ func GatewayClassesCollection(
|
||||
krt.Collection[GatewayClass],
|
||||
) {
|
||||
return krt.NewStatusCollection(gatewayClasses, func(ctx krt.HandlerContext, obj *gateway.GatewayClass) (*gateway.GatewayClassStatus, *GatewayClass) {
|
||||
_, known := classInfos[obj.Spec.ControllerName]
|
||||
if !known {
|
||||
if gatewayv1.ObjectName(obj.Name) != gatewayv1.ObjectName(gatewayClassName) ||
|
||||
obj.Spec.ControllerName != managedGatewayController {
|
||||
return nil, nil
|
||||
}
|
||||
if _, known := classInfos[obj.Spec.ControllerName]; !known {
|
||||
return nil, nil
|
||||
}
|
||||
status := obj.Status.DeepCopy()
|
||||
|
||||
@@ -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,6 +30,7 @@ import (
|
||||
)
|
||||
|
||||
func TestClassController(t *testing.T) {
|
||||
setGatewayClassNameForTest(t, "")
|
||||
client := kube.NewFakeClient()
|
||||
cc := NewClassController(client)
|
||||
classes := clienttest.Wrap(t, cc.classes)
|
||||
@@ -91,3 +92,70 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ 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 {
|
||||
@@ -241,7 +239,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 !supportedControllers.Contains(parentStatus.ControllerName) {
|
||||
if !getSupportedControllers().Contains(parentStatus.ControllerName) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -354,7 +352,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 !supportedControllers.Contains(parentStatus.ControllerName) {
|
||||
if !getSupportedControllers().Contains(parentStatus.ControllerName) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -766,12 +766,63 @@ func mergeHTTPRoutes(baseVirtualServices krt.Collection[RouteWithKey], opts ...k
|
||||
sortRoutesByCreationTime(configs)
|
||||
base := configs[0].DeepCopy()
|
||||
baseVS := base.Spec.(*istio.VirtualService)
|
||||
for _, config := range configs[1:] {
|
||||
// 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:] {
|
||||
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, "/", "~")
|
||||
|
||||
140
pkg/ingress/kube/gateway/istio/route_collections_test.go
Normal file
140
pkg/ingress/kube/gateway/istio/route_collections_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,22 @@ 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:
|
||||
@@ -49,7 +65,7 @@ status:
|
||||
status: "True"
|
||||
type: Programmed
|
||||
listeners:
|
||||
- attachedRoutes: 11
|
||||
- attachedRoutes: 13
|
||||
conditions:
|
||||
- lastTransitionTime: fake
|
||||
message: No errors found
|
||||
@@ -272,6 +288,54 @@ 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
|
||||
|
||||
@@ -421,3 +421,80 @@ 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
|
||||
|
||||
@@ -242,6 +242,35 @@ 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
|
||||
|
||||
@@ -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{rule.Host},
|
||||
Hosts: []string{common.WildcardHost(rule.Host)},
|
||||
})
|
||||
|
||||
// Add new gateway, builder
|
||||
@@ -422,6 +422,45 @@ 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
|
||||
@@ -470,6 +509,14 @@ 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() {
|
||||
@@ -487,7 +534,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{rule.Host},
|
||||
Hosts: []string{common.WildcardHost(rule.Host)},
|
||||
Tls: &networking.ServerTLSSettings{
|
||||
Mode: networking.ServerTLSSettings_SIMPLE,
|
||||
CredentialName: credentials.ToKubernetesIngressResource(c.options.RawClusterId, secretNamespace, secretName),
|
||||
@@ -515,6 +562,19 @@ 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 {
|
||||
@@ -546,12 +606,7 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
|
||||
|
||||
wrapperVS, exist := convertOptions.VirtualServices[rule.Host]
|
||||
if !exist {
|
||||
wrapperVS = &common.WrapperVirtualService{
|
||||
VirtualService: &networking.VirtualService{
|
||||
Hosts: []string{rule.Host},
|
||||
},
|
||||
WrapperConfig: wrapper,
|
||||
}
|
||||
wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper)
|
||||
convertOptions.VirtualServices[rule.Host] = wrapperVS
|
||||
}
|
||||
|
||||
@@ -579,7 +634,11 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
|
||||
pathType = common.PrefixRegex
|
||||
}
|
||||
} else {
|
||||
switch *httpPath.PathType {
|
||||
ingressPathType := defaultPathType
|
||||
if httpPath.PathType != nil {
|
||||
ingressPathType = *httpPath.PathType
|
||||
}
|
||||
switch ingressPathType {
|
||||
case ingress.PathTypeExact:
|
||||
pathType = common.Exact
|
||||
case ingress.PathTypePrefix:
|
||||
@@ -661,9 +720,84 @@ 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")
|
||||
@@ -691,12 +825,7 @@ func (c *controller) ApplyDefaultBackend(convertOptions *common.ConvertOptions,
|
||||
wirecardVS, exist := convertOptions.VirtualServices[host]
|
||||
if !exist || !wirecardVS.ConfiguredDefaultBackend {
|
||||
if !exist {
|
||||
wirecardVS = &common.WrapperVirtualService{
|
||||
VirtualService: &networking.VirtualService{
|
||||
Hosts: []string{host},
|
||||
},
|
||||
WrapperConfig: wrapper,
|
||||
}
|
||||
wirecardVS = common.NewWrapperVirtualService(host, wrapper)
|
||||
}
|
||||
|
||||
specDefaultBackend := c.createDefaultRoute(wrapper, ingressV1Beta1.Backend, "*")
|
||||
@@ -790,7 +919,11 @@ func (c *controller) ApplyCanaryIngress(convertOptions *common.ConvertOptions, w
|
||||
pathType = common.PrefixRegex
|
||||
}
|
||||
} else {
|
||||
switch *httpPath.PathType {
|
||||
ingressPathType := defaultPathType
|
||||
if httpPath.PathType != nil {
|
||||
ingressPathType = *httpPath.PathType
|
||||
}
|
||||
switch ingressPathType {
|
||||
case ingress.PathTypeExact:
|
||||
pathType = common.Exact
|
||||
case ingress.PathTypePrefix:
|
||||
@@ -1092,6 +1225,53 @@ 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")
|
||||
|
||||
@@ -16,9 +16,11 @@ 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"
|
||||
@@ -68,6 +70,962 @@ 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
|
||||
|
||||
@@ -337,6 +337,13 @@ 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
|
||||
@@ -382,7 +389,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{rule.Host},
|
||||
Hosts: []string{common.WildcardHost(rule.Host)},
|
||||
})
|
||||
|
||||
// Add new gateway, builder
|
||||
@@ -395,6 +402,45 @@ 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
|
||||
@@ -443,6 +489,14 @@ 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() {
|
||||
@@ -460,7 +514,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{rule.Host},
|
||||
Hosts: []string{common.WildcardHost(rule.Host)},
|
||||
Tls: &networking.ServerTLSSettings{
|
||||
Mode: networking.ServerTLSSettings_SIMPLE,
|
||||
CredentialName: credentials.ToKubernetesIngressResource(c.options.RawClusterId, secretNamespace, secretName),
|
||||
@@ -475,12 +529,32 @@ 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 {
|
||||
@@ -515,12 +589,7 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
|
||||
|
||||
wrapperVS, exist := convertOptions.VirtualServices[rule.Host]
|
||||
if !exist {
|
||||
wrapperVS = &common.WrapperVirtualService{
|
||||
VirtualService: &networking.VirtualService{
|
||||
Hosts: []string{rule.Host},
|
||||
},
|
||||
WrapperConfig: wrapper,
|
||||
}
|
||||
wrapperVS = common.NewWrapperVirtualService(rule.Host, wrapper)
|
||||
convertOptions.VirtualServices[rule.Host] = wrapperVS
|
||||
}
|
||||
|
||||
@@ -549,7 +618,11 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
|
||||
pathType = common.PrefixRegex
|
||||
}
|
||||
} else {
|
||||
switch *httpPath.PathType {
|
||||
ingressPathType := defaultPathType
|
||||
if httpPath.PathType != nil {
|
||||
ingressPathType = *httpPath.PathType
|
||||
}
|
||||
switch ingressPathType {
|
||||
case ingress.PathTypeExact:
|
||||
pathType = common.Exact
|
||||
case ingress.PathTypePrefix:
|
||||
@@ -626,9 +699,84 @@ 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
|
||||
|
||||
@@ -689,12 +837,7 @@ func (c *controller) ApplyDefaultBackend(convertOptions *common.ConvertOptions,
|
||||
wirecardVS, exist := convertOptions.VirtualServices[host]
|
||||
if !exist || !wirecardVS.ConfiguredDefaultBackend {
|
||||
if !exist {
|
||||
wirecardVS = &common.WrapperVirtualService{
|
||||
VirtualService: &networking.VirtualService{
|
||||
Hosts: []string{host},
|
||||
},
|
||||
WrapperConfig: wrapper,
|
||||
}
|
||||
wirecardVS = common.NewWrapperVirtualService(host, wrapper)
|
||||
convertOptions.VirtualServices[host] = wirecardVS
|
||||
}
|
||||
|
||||
@@ -782,7 +925,11 @@ func (c *controller) ApplyCanaryIngress(convertOptions *common.ConvertOptions, w
|
||||
pathType = common.PrefixRegex
|
||||
}
|
||||
} else {
|
||||
switch *httpPath.PathType {
|
||||
ingressPathType := defaultPathType
|
||||
if httpPath.PathType != nil {
|
||||
ingressPathType = *httpPath.PathType
|
||||
}
|
||||
switch ingressPathType {
|
||||
case ingress.PathTypeExact:
|
||||
pathType = common.Exact
|
||||
case ingress.PathTypePrefix:
|
||||
@@ -1074,6 +1221,54 @@ 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 {
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
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"
|
||||
)
|
||||
@@ -118,3 +123,904 @@ 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,17 +33,35 @@ description: JWT 认证插件配置参考
|
||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|
||||
| ----------------------- | ----------------- | -------- | ------------------------------------------------- | ------------------------ |
|
||||
| `name` | string | 必填 | - | 配置该consumer的名称 |
|
||||
| `jwks` | string | 必填 | - | https://www.rfc-editor.org/rfc/rfc7517 指定的json格式字符串,是由验证JWT中签名的公钥(或对称密钥)组成的Json Web Key Set |
|
||||
| `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 |
|
||||
| `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 | 选填 | ture | 转发给后端时是否保留JWT |
|
||||
| `keep_token` | bool | 选填 | true | 转发给后端时是否保留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` 中每一项的配置字段说明如下:
|
||||
|
||||
@@ -156,6 +174,26 @@ 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 认证,所有请求均需要经过认证后才能访问。
|
||||
|
||||
@@ -28,7 +28,10 @@ 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 | - | 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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -39,6 +42,22 @@ 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 |
|
||||
| --------------- | ---------------- | ------------ | ------------- | ----------------------------------------------- |
|
||||
@@ -139,6 +158,26 @@ 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
|
||||
|
||||
@@ -168,7 +168,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration,
|
||||
LOG_WARN("failed to parse configuration for block_bodys.");
|
||||
return false;
|
||||
}
|
||||
// compatiable
|
||||
// compatible
|
||||
if (!JsonArrayIterate(
|
||||
configuration, "block_bodies", [&](const json& item) -> bool {
|
||||
auto body = JsonValueAs<std::string>(item);
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 .
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ builder:
|
||||
@echo "image: ${BUILDER}"
|
||||
|
||||
local-build:
|
||||
cd extensions/${PLUGIN_NAME};GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ./main.wasm .
|
||||
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 .
|
||||
|
||||
@echo ""
|
||||
@echo "wasm: extensions/${PLUGIN_NAME}/main.wasm"
|
||||
|
||||
@@ -38,7 +38,6 @@ func init() {
|
||||
wrapper.ProcessRequestBodyBy(onHttpRequestBody),
|
||||
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
|
||||
wrapper.ProcessStreamingResponseBodyBy(onHttpResponseBody),
|
||||
wrapper.WithRebuildAfterRequests[config.PluginConfig](1000),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -890,6 +890,175 @@ 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)
|
||||
|
||||
@@ -88,6 +88,13 @@ 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)
|
||||
@@ -99,6 +106,11 @@ 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")
|
||||
@@ -117,7 +129,9 @@ func processSSEMessage(ctx wrapper.HttpContext, c config.PluginConfig, sseMessag
|
||||
bodyJson := message[5:]
|
||||
|
||||
if strings.TrimSpace(bodyJson) == "[DONE]" {
|
||||
return content, nil
|
||||
// 跳出循环,把已解析的局部 content 留到循环外统一合并。
|
||||
done = true
|
||||
break
|
||||
}
|
||||
|
||||
// Extract values from JSON fields
|
||||
@@ -130,22 +144,29 @@ func processSSEMessage(ctx wrapper.HttpContext, c config.PluginConfig, sseMessag
|
||||
}
|
||||
|
||||
// Check if the ResponseBody field 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 {
|
||||
if responseBody.Exists() {
|
||||
content += responseBody.String()
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
// 本次 sseMessage 既没解析到 content 也没遇到 [DONE]:保持 ctx 不变,直接返回。
|
||||
if content == "" && !done {
|
||||
log.Debugf("[processSSEMessage] no content extracted; skipping cache update: %s", sseMessage)
|
||||
return "", nil
|
||||
}
|
||||
return content, nil
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
6
plugins/wasm-go/extensions/ai-context-limit/.gitignore
vendored
Normal file
6
plugins/wasm-go/extensions/ai-context-limit/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
plugin.wasm
|
||||
*.wasm
|
||||
.tmp/
|
||||
.gocache/
|
||||
PR.md
|
||||
bpe/o200k_base.tiktoken
|
||||
13
plugins/wasm-go/extensions/ai-context-limit/Makefile
Normal file
13
plugins/wasm-go/extensions/ai-context-limit/Makefile
Normal file
@@ -0,0 +1,13 @@
|
||||
.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
|
||||
68
plugins/wasm-go/extensions/ai-context-limit/README.md
Normal file
68
plugins/wasm-go/extensions/ai-context-limit/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
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 请求体用于文本估算,超出部分不会被处理。
|
||||
68
plugins/wasm-go/extensions/ai-context-limit/README_EN.md
Normal file
68
plugins/wasm-go/extensions/ai-context-limit/README_EN.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
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: 0–10). 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: 400–599). |
|
||||
|
||||
## 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.
|
||||
1
plugins/wasm-go/extensions/ai-context-limit/VERSION
Normal file
1
plugins/wasm-go/extensions/ai-context-limit/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
73
plugins/wasm-go/extensions/ai-context-limit/config.go
Normal file
73
plugins/wasm-go/extensions/ai-context-limit/config.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
# 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`
|
||||
384
plugins/wasm-go/extensions/ai-context-limit/extract.go
Normal file
384
plugins/wasm-go/extensions/ai-context-limit/extract.go
Normal file
@@ -0,0 +1,384 @@
|
||||
// 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 类型 part(image_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 / content(string 或 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
|
||||
//
|
||||
// 抽取范围:
|
||||
// - system:string 或 text block array
|
||||
// - messages[].role
|
||||
// - messages[].content:string 或 content block array
|
||||
// - type=text → text 字段
|
||||
// - type=tool_use → name + input(raw JSON)
|
||||
// - type=tool_result → content(string 或 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_schema(raw JSON)
|
||||
func extractAnthropicText(body []byte) extractResult {
|
||||
var sb strings.Builder
|
||||
result := extractResult{}
|
||||
|
||||
// 1. system(string 或 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:
|
||||
// 真正的非文本 block(image/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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
plugins/wasm-go/extensions/ai-context-limit/go.mod
Normal file
25
plugins/wasm-go/extensions/ai-context-limit/go.mod
Normal file
@@ -0,0 +1,25 @@
|
||||
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
|
||||
)
|
||||
32
plugins/wasm-go/extensions/ai-context-limit/go.sum
Normal file
32
plugins/wasm-go/extensions/ai-context-limit/go.sum
Normal file
@@ -0,0 +1,32 @@
|
||||
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=
|
||||
153
plugins/wasm-go/extensions/ai-context-limit/main.go
Normal file
153
plugins/wasm-go/extensions/ai-context-limit/main.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// 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-length,body 处理后由 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 response,OpenAI 风格错误体
|
||||
//
|
||||
// 各阶段统一 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 格式,
|
||||
// 使客户端 SDK(openai-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
|
||||
}
|
||||
730
plugins/wasm-go/extensions/ai-context-limit/main_test.go
Normal file
730
plugins/wasm-go/extensions/ai-context-limit/main_test.go
Normal file
@@ -0,0 +1,730 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
27
plugins/wasm-go/extensions/ai-context-limit/prepare.sh
Executable file
27
plugins/wasm-go/extensions/ai-context-limit/prepare.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/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)"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user