mirror of
https://github.com/alibaba/higress.git
synced 2026-07-24 13:20:41 +08:00
Compare commits
97 Commits
v2.2.1
...
fix/bump-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f1f935bc9 | ||
|
|
4db97b6a36 | ||
|
|
bf30043807 | ||
|
|
0a8aea12d6 | ||
|
|
91244c578a | ||
|
|
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 | ||
|
|
34040d1b99 | ||
|
|
870ed27435 | ||
|
|
1ccafab24b | ||
|
|
37b038f797 | ||
|
|
739d47ba9c | ||
|
|
e7651f3d3e | ||
|
|
e1e631263c | ||
|
|
f1dfc8f3d2 | ||
|
|
73c93a18b0 | ||
|
|
ba774da55e | ||
|
|
e497d8017a | ||
|
|
a93847e07f | ||
|
|
b032f344e6 | ||
|
|
c7eed0c0c1 | ||
|
|
f8d81a7eb4 | ||
|
|
29da03c371 | ||
|
|
1c4fe1c9f9 | ||
|
|
678ea5660e | ||
|
|
148ef6905e | ||
|
|
6d50e2da42 | ||
|
|
b77a074831 | ||
|
|
6199fe414d | ||
|
|
4aba4a9860 | ||
|
|
4e0d691432 | ||
|
|
74c68180c8 | ||
|
|
9b9f6b900a | ||
|
|
5173b4b2b8 | ||
|
|
1d33067372 | ||
|
|
fb8e1ef33f | ||
|
|
a93f77d838 | ||
|
|
90ccfc7ec5 | ||
|
|
d72363d8d1 | ||
|
|
784cffa5e2 | ||
|
|
8b8a710305 | ||
|
|
65405965b6 | ||
|
|
9128cbf729 | ||
|
|
94b40aab9a | ||
|
|
26e17c48eb | ||
|
|
b1187cc14d | ||
|
|
e2beb6cd45 | ||
|
|
899633ea5e | ||
|
|
bf96860a78 |
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.
|
||||
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.
|
||||
41
.github/workflows/sync.yml
vendored
Normal file
41
.github/workflows/sync.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Sync Upstream
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 每天 UTC 时间 00:00 运行 (北京时间 08:00)
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch: # 允许手动触发
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # 获取所有历史记录,防止报错
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
|
||||
- name: Add Upstream Remote
|
||||
# 将 <原始仓库URL> 替换为你要同步的原始项目地址
|
||||
run: git remote add upstream https://github.com/alibaba/higress.git
|
||||
|
||||
- name: Fetch Upstream
|
||||
run: git fetch upstream
|
||||
|
||||
- name: Merge Upstream Changes
|
||||
# 将 upstream/main 替换为 upstream/master 或其他分支名
|
||||
run: |
|
||||
git checkout main
|
||||
git merge upstream/main --no-edit
|
||||
# 如果合并冲突,可以使用 git merge --abort 中止,或者手动处理
|
||||
|
||||
- name: Push to Origin
|
||||
# 使用 secrets.GITHUB_TOKEN 自动推送,无需配置 PAT
|
||||
run: git push origin main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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:
|
||||
|
||||
156
AGENTS.md
Normal file
156
AGENTS.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI agents working in this repository.
|
||||
|
||||
Higress is a cloud-native API gateway built on Istio and Envoy. The control
|
||||
plane extends Istio/pilot (Go); the data plane is Envoy extended with WASM
|
||||
plugins (Go/Rust/C++/AssemblyScript) and a Go-based `golang-filter`. It supports
|
||||
Ingress/Gateway API and ships a rich plugin ecosystem (including AI gateway
|
||||
plugins).
|
||||
|
||||
## Repository layout
|
||||
|
||||
Top-level directories (all paths relative to repo root):
|
||||
|
||||
- `cmd/higress/` — main entrypoint (`main.go`) for the Higress controller binary.
|
||||
- `pkg/` — core Go control-plane packages: `bootstrap/`, `cert/`, `cmd/`,
|
||||
`common/`, `config/`, `ingress/` (Ingress/Gateway config translation),
|
||||
`kube/`.
|
||||
- `api/` — protobuf/CRD API definitions; Higress CRDs live in
|
||||
`api/extensions/v1alpha1` (e.g. the `WasmPlugin` type). Generated with
|
||||
`make gen-api` / `make gen-client` (see `api/gen.sh`, `buf.*`).
|
||||
- `client/` — generated Go clientset for Higress CRDs.
|
||||
- `istio/` — git submodules of higress-group forks of Istio (`api`, `istio`,
|
||||
`client-go`, `pkg`, `proxy`); see `.gitmodules`. Pulled via `make submodule`
|
||||
(part of `prebuild`).
|
||||
- `envoy/` — Envoy + `go-control-plane` submodules (higress-group forks).
|
||||
- `external/` — vendored/external mirror dirs used during build (istio, envoy,
|
||||
proxy, etc.).
|
||||
- `plugins/` — all data-plane plugins (see "Plugins" below).
|
||||
- `registry/` — service-discovery registry integrations (nacos, consul, eureka,
|
||||
zookeeper, direct, mcp, ...).
|
||||
- `hgctl/` — the `hgctl` CLI (separate Go module) for managing Higress.
|
||||
- `helm/` — Helm charts: `helm/core` (the dev/install chart) and `helm/higress`.
|
||||
- `test/` — `test/e2e/` (conformance/e2e, see "Build & test") and
|
||||
`test/gateway/`.
|
||||
- `tools/` — build/CI scripting: `tools/hack/` (build scripts), `tools/bin/`,
|
||||
`tools/linter/`, `*.mk`.
|
||||
- `samples/` — example manifests (gateway-api, hello-world, wasmplugin, ...).
|
||||
- `docker/`, `docs/`, `release-notes/` — packaging, docs, and release notes.
|
||||
- `Makefile` — istio common-files wrapper (supports `BUILD_WITH_CONTAINER`);
|
||||
real targets live in `Makefile.core.mk` (+ `Makefile.overrides.mk`).
|
||||
|
||||
## Plugins
|
||||
|
||||
All plugins live under `plugins/`. See `plugins/README.md` for the contributor
|
||||
overview. Prebuilt plugin images are published to
|
||||
`higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins`.
|
||||
|
||||
### plugins/wasm-go/ (primary WASM plugin framework, Go)
|
||||
|
||||
- `extensions/<name>/` — one directory per plugin (~59 plugins, many `ai-*`).
|
||||
Each plugin is its own Go module: `main.go`, `go.mod`/`go.sum`, `VERSION`,
|
||||
`README.md`(+`README_EN.md`), often `config/`, `util/`, `main_test.go`.
|
||||
Optional `.buildrc` sets `EXTRA_TAGS`; optional `prepare.sh`/`prepare.sh`.
|
||||
`plugin.wasm` is a build artifact and is **not** committed.
|
||||
- Shared SDK: plugins depend on external modules
|
||||
`github.com/higress-group/wasm-go` and
|
||||
`github.com/higress-group/proxy-wasm-go-sdk` (NOT an in-repo SDK dir).
|
||||
In-repo, `plugins/wasm-go/pkg/mcp/` provides MCP helpers and
|
||||
`plugins/wasm-go/mcp-servers/` holds MCP server plugins.
|
||||
- `examples/` — minimal reference plugins (custom-log, custom-span-attribute,
|
||||
test-foreign-function).
|
||||
- Build: `plugins/wasm-go/Makefile`. `PLUGIN_NAME=<name> make build` builds a
|
||||
wasm file (output to `extensions/<name>/plugin.wasm`) + image via
|
||||
`Dockerfile`/`DockerfileBuilder` (uses a `wasm-go-builder` image, Go 1.24,
|
||||
TinyGo optional). `make build-push` pushes the image; `make local-build`
|
||||
builds locally with `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared`.
|
||||
- Conventions: `VERSION` is the image tag; the CI/e2e batch builder
|
||||
(`tools/hack/build-wasm-plugins.sh`) only compiles a wasm-go plugin whose
|
||||
`VERSION` ends in `-alpha` (see the section at the bottom of this file).
|
||||
|
||||
### plugins/wasm-rust/ (Rust WASM plugins)
|
||||
|
||||
- Workspace-style: root `Cargo.toml`/`Cargo.lock`, shared `src/`,
|
||||
`extensions/<name>/` per plugin (e.g. `ai-data-masking`, `ai-intent`,
|
||||
`request-block`, `say-hello`, `demo-wasm`), `example/`.
|
||||
- Build via `plugins/wasm-rust/Makefile` (`PLUGIN_NAME=<name> make build`, plus
|
||||
`lint`/`test`); the batch builder runs it when `PLUGIN_TYPE=RUST`.
|
||||
|
||||
### plugins/wasm-cpp/ (C++ WASM plugins, Bazel)
|
||||
|
||||
- Bazel project: `WORKSPACE`, `BUILD`, `bazel/`, `common/`, `scripts/`,
|
||||
`extensions/<name>/` (e.g. `basic_auth`, `jwt_auth`, `key_rate_limit`,
|
||||
`model_router`, ...). Build via `plugins/wasm-cpp/Makefile`
|
||||
(`PLUGIN_NAME=<name> make build`), invoked with `PLUGIN_TYPE=CPP`.
|
||||
|
||||
### plugins/wasm-assemblyscript/ (AssemblyScript WASM plugins)
|
||||
|
||||
- Node/AssemblyScript project: `asconfig.json`, `package.json`, `assembly/`,
|
||||
`extensions/`.
|
||||
|
||||
### plugins/golang-filter/ (Envoy Go HTTP filter, NOT WASM)
|
||||
|
||||
- A native Envoy Golang HTTP filter (`main.go`, `mcp-server/`, `mcp-session/`);
|
||||
compiled as a shared object (`.so`) independent of Envoy — no Envoy rebuild
|
||||
needed. Requires Higress >= 2.1.0. Plugins register in `main.go`'s `init()`
|
||||
via `RegisterHttpFilterFactoryAndConfigParser`. See
|
||||
`plugins/golang-filter/README.md`.
|
||||
- Build: `plugins/golang-filter/Makefile` (docker build, outputs
|
||||
`golang-filter_<arch>.so`). Wired into the gateway image build via
|
||||
`Makefile.core.mk` targets `build-golang-filter[-amd64|-arm64]`.
|
||||
|
||||
### How plugins are loaded
|
||||
|
||||
`WasmPlugin` CRDs (`extensions.higress.io/v1alpha1`) reference a plugin by
|
||||
`url:` — either `oci://.../plugins/<name>:<version>` (image) or
|
||||
`file:///opt/plugins/.../plugin.wasm` (local mount used in e2e). The dev install
|
||||
`make install-dev-wasmplugin` sets Helm `global.volumeWasmPlugins=true` to mount
|
||||
locally built wasm files into the gateway.
|
||||
|
||||
## Build & test
|
||||
|
||||
Run targets from the repo root; `Makefile` delegates to `Makefile.core.mk`.
|
||||
Common ones:
|
||||
|
||||
- `make build` / `make build-linux` — build the Higress controller binary
|
||||
(`prebuild` first fetches submodules).
|
||||
- `make build-hgctl` — build the `hgctl` CLI.
|
||||
- `make build-gateway` / `make build-istio` / `make build-envoy` — data-plane
|
||||
and control-plane images (gateway pulls in the golang-filter).
|
||||
- `make build-wasmplugins` — runs `tools/hack/build-wasm-plugins.sh` to batch
|
||||
build WASM plugins (respects `PLUGIN_TYPE` / `PLUGIN_NAME`; Go plugins require
|
||||
a `-alpha` VERSION).
|
||||
- `make gen-api` / `make gen-client` — regenerate API/client code.
|
||||
|
||||
### Conformance / e2e tests (`test/e2e/`)
|
||||
|
||||
- Entrypoint `test/e2e/e2e_test.go`, run with build tag `conformance` and
|
||||
`--test-area` / `--execute-tests` flags.
|
||||
- Cases live in `test/e2e/conformance/tests/` as **paired `<name>.go` +
|
||||
`<name>.yaml`** files (~68 cases; WASM cases are prefixed by language, e.g.
|
||||
`go-wasm-*`, `cpp-wasm-*`). Support code: `conformance/base/`,
|
||||
`conformance/utils/`, `conformance/embed.go`.
|
||||
- Key Make targets (each spins up a kind cluster):
|
||||
- `make higress-conformance-test` — Ingress/Gateway conformance.
|
||||
- `make higress-wasmplugin-test` — WASM plugin e2e (uses
|
||||
`install-dev-wasmplugin`, which builds plugins and mounts them).
|
||||
- `*-prepare` / `*-skip-docker-build` / `*-clean` variants exist for
|
||||
iterating; `run-higress-e2e-test[-wasmplugin]` runs `go test` against an
|
||||
already-prepared cluster (filter with `TEST_SHORTNAME`).
|
||||
- For the specifics of authoring a wasm-go e2e test, see the section below.
|
||||
|
||||
## Writing e2e conformance tests with wasm-go plugins
|
||||
|
||||
When adding an e2e conformance test that ships its own wasm-go plugin under
|
||||
`plugins/wasm-go/extensions/<name>/`:
|
||||
|
||||
- The plugin's `VERSION` file **must end in `-alpha`** (e.g. `1.0.0-alpha`).
|
||||
CI's `tools/hack/build-wasm-plugins.sh` only compiles a wasm-go plugin when
|
||||
its version ends in `-alpha`; otherwise it silently skips it.
|
||||
- `plugin.wasm` is a build artifact and is **not** committed. If the plugin
|
||||
isn't built, the `file:///opt/plugins/.../plugin.wasm` URL in the test's
|
||||
`WasmPlugin` manifest resolves to a missing file, envoy rejects the wasm
|
||||
config and fails closed, and every request on that route returns HTTP 500.
|
||||
Locally this can be masked because a previously built `plugin.wasm` still
|
||||
exists on disk — so a test can pass locally yet 500 in CI.
|
||||
@@ -1,4 +1,24 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
# Higress Code of Conduct
|
||||
|
||||
Higress is a [Cloud Native Computing Foundation](https://www.cncf.io/) sandbox
|
||||
project. As a CNCF project, the Higress community follows the
|
||||
[**CNCF Code of Conduct**](https://github.com/cncf/foundation/blob/main/code-of-conduct.md).
|
||||
|
||||
The text below is the project's adopted Code of Conduct, based on the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org/), and is
|
||||
substantively aligned with the CNCF Code of Conduct. Where any conflict exists,
|
||||
the CNCF Code of Conduct prevails.
|
||||
|
||||
Instances of unacceptable behavior may be reported to the CNCF Code of
|
||||
Conduct Committee at [conduct@cncf.io](mailto:conduct@cncf.io). For more
|
||||
detailed instructions on how to submit a report, including how to submit a
|
||||
report anonymously, please see the CNCF
|
||||
[Incident Resolution Procedures](https://github.com/cncf/foundation/blob/main/code-of-conduct/coc-incident-resolution-procedures.md).
|
||||
You can expect a response within three business days.
|
||||
|
||||
---
|
||||
|
||||
## Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
@@ -55,7 +75,8 @@ further defined and clarified by project maintainers.
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at higress@googlegroups.com. All
|
||||
reported by contacting the CNCF Code of Conduct Committee at
|
||||
[conduct@cncf.io](mailto:conduct@cncf.io). All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
## 报告安全问题
|
||||
|
||||
安全问题总是得到认真对待。作为我们通常的原则,我们不鼓励任何人传播安全问题。如果您发现Higress的安全问题,请不要公开讨论,甚至不要公开问题。相反,我们鼓励您向 [higress@googlegroups.com](mailto:higress@googlegroups.com) 发送私人电子邮件 以报告此情况。
|
||||
安全问题总是得到认真对待。作为我们通常的原则,我们不鼓励任何人传播安全问题。如果您发现 Higress 的安全问题,请不要公开讨论,甚至不要公开 issue。请按照 [`SECURITY.md`](./SECURITY.md) 中描述的流程私密报告漏洞。
|
||||
|
||||
## 报告一般问题
|
||||
|
||||
@@ -204,10 +204,18 @@ make prebuild && go mod tidy
|
||||
|
||||
任何测试用例都会受到欢迎。目前,Higress 功能测试用例是高优先级的。
|
||||
|
||||
* 对于单元测试,您需要在同一模块的 test 目录中创建一个名为 xxxTest.go 的测试文件。
|
||||
### 新功能的测试要求
|
||||
|
||||
* 对于集成测试,您可以将集成测试放在 test 目录。
|
||||
//TBD
|
||||
- **新 Wasm 插件**:必须包含单元测试,代码覆盖率不低于 30%(CI 强制检查)。
|
||||
- **新核心功能**:应包含单元测试,适用时还应添加 E2E 一致性测试用例。
|
||||
- **Bug 修复**:应包含能复现该 Bug 的回归测试。
|
||||
- **Patch 覆盖率**:新增或修改的代码必须达到 50% 的覆盖率(由 Codecov 通过 `codecov.yml` 强制检查)。
|
||||
|
||||
### 如何编写测试
|
||||
|
||||
* 对于单元测试,在同一模块的 test 目录中创建一个名为 `xxxTest.go` 的测试文件。
|
||||
* 对于集成测试,将集成测试放在 test 目录。
|
||||
* 对于 Wasm 插件 E2E 测试,在 `test/e2e/conformance/tests/` 中添加测试用例。详见 [test/README.md](./test/README.md)。
|
||||
|
||||
## 参与帮助任何事情
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Your interest in contributing to Higress is warmly welcomed. First, we encourage
|
||||
|
||||
## Reporting security issues
|
||||
|
||||
Security issues are always treated seriously. As our usual principle, we discourage anyone to spread security issues. If you find a security issue of Higress, please do not discuss it in public and even do not open a public issue. Instead we encourage you to send us a private email to [higress@googlegroups.com](mailto:higress@googlegroups.com) to report this.
|
||||
Security issues are always treated seriously. As our usual principle, we discourage anyone to spread security issues. If you find a security issue of Higress, please do not discuss it in public and even do not open a public issue. Instead please follow the process described in [`SECURITY.md`](./SECURITY.md) to report vulnerabilities privately.
|
||||
|
||||
## Reporting general issues
|
||||
|
||||
@@ -204,9 +204,18 @@ make prebuild && go mod tidy
|
||||
|
||||
Any test case would be welcomed. Currently, Higress function test cases are high priority.
|
||||
|
||||
* For unit test, you need to create a test file named `xxxTest.go` in the test directory of the same module.
|
||||
* For integration test, you can put the integration test in the test directory.
|
||||
//TBD
|
||||
### Test requirements for new functionality
|
||||
|
||||
- **New Wasm plugins**: MUST include unit tests with at least 30% code coverage (enforced by CI).
|
||||
- **New core features**: SHOULD include unit tests and, where applicable, E2E conformance test cases.
|
||||
- **Bug fixes**: SHOULD include a regression test that reproduces the bug.
|
||||
- **Patch coverage**: New or changed code must meet a 50% coverage target for the patch (enforced by Codecov via `codecov.yml`).
|
||||
|
||||
### How to write tests
|
||||
|
||||
* For unit tests, create a test file named `xxxTest.go` in the test directory of the same module.
|
||||
* For integration tests, you can put the integration test in the test directory.
|
||||
* For Wasm plugin E2E tests, add test cases in `test/e2e/conformance/tests/`. See [test/README.md](./test/README.md) for details.
|
||||
## Engage to help anything
|
||||
|
||||
We choose GitHub as the primary place for Higress to collaborate. So the latest updates of Higress are always here. Although contributions via PR is an explicit way to help, we still call for any other ways.
|
||||
|
||||
@@ -23,7 +23,7 @@ Higress のハッキングに興味がある場合は、温かく歓迎します
|
||||
|
||||
## セキュリティ問題の報告
|
||||
|
||||
セキュリティ問題は常に真剣に扱われます。通常の原則として、セキュリティ問題を広めることは推奨しません。Higress のセキュリティ問題を発見した場合は、公開で議論せず、公開の問題を開かないでください。代わりに、[higress@googlegroups.com](mailto:higress@googlegroups.com) にプライベートなメールを送信して報告することをお勧めします。
|
||||
セキュリティ問題は常に真剣に扱われます。通常の原則として、セキュリティ問題を広めることは推奨しません。Higress のセキュリティ問題を発見した場合は、公開で議論せず、公開の issue を開かないでください。[`SECURITY.md`](./SECURITY.md) に記載されたプロセスに従って、脆弱性を非公開で報告してください。
|
||||
|
||||
## 一般的な問題の報告
|
||||
|
||||
@@ -199,9 +199,18 @@ make prebuild && go mod tidy
|
||||
|
||||
テストケースは歓迎されます。現在、Higress の機能テストケースが高優先度です。
|
||||
|
||||
* 単体テストの場合、同じモジュールの test ディレクトリに xxxTest.go という名前のテストファイルを作成する必要があります。
|
||||
* 統合テストの場合、統合テストを test ディレクトリに配置できます。
|
||||
//TBD
|
||||
### 新機能のテスト要件
|
||||
|
||||
- **新しい Wasm プラグイン**:30% 以上のコードカバレッジを持つユニットテストを含める必要があります(CI で強制チェック)。
|
||||
- **新しいコア機能**:ユニットテストを含めるべきであり、該当する場合は E2E コンフォーマンステストケースも追加すべきです。
|
||||
- **バグ修正**:バグを再現する回帰テストを含めるべきです。
|
||||
- **パッチカバレッジ**:新規または変更されたコードは 50% のカバレッジ目標を満たす必要があります(Codecov が `codecov.yml` を通じて強制チェック)。
|
||||
|
||||
### テストの書き方
|
||||
|
||||
* ユニットテストの場合、同じモジュールの test ディレクトリに `xxxTest.go` という名前のテストファイルを作成します。
|
||||
* 統合テストの場合、統合テストを test ディレクトリに配置します。
|
||||
* Wasm プラグイン E2E テストの場合、`test/e2e/conformance/tests/` にテストケースを追加します。詳細は [test/README.md](./test/README.md) を参照してください。
|
||||
|
||||
## 何かを手伝うための参加
|
||||
|
||||
|
||||
50
GOVERNANCE.md
Normal file
50
GOVERNANCE.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Higress Governance
|
||||
|
||||
Higress is a [Cloud Native Computing Foundation (CNCF)](https://www.cncf.io/)
|
||||
sandbox project. This document describes the project's open governance model.
|
||||
|
||||
All community members must follow the
|
||||
[**CNCF Code of Conduct**](https://github.com/cncf/foundation/blob/main/code-of-conduct.md)
|
||||
and the project's adopted policy in [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md).
|
||||
|
||||
## Values
|
||||
|
||||
Higress governance is guided by the following values:
|
||||
|
||||
- **Openness**: Communication and decision making happen in public channels and repositories whenever possible.
|
||||
- **Fairness**: Contributions are evaluated on technical merit rather than company affiliation.
|
||||
- **Community First**: Long-term community health has priority over short-term product goals.
|
||||
- **Inclusivity**: We welcome contributors from different regions and backgrounds.
|
||||
- **Participation**: Project responsibilities are earned through sustained contribution.
|
||||
|
||||
## Roles
|
||||
|
||||
Higress role definitions are documented in:
|
||||
|
||||
- [`MAINTAINERS.md`](./MAINTAINERS.md)
|
||||
- [`CODEOWNERS`](./CODEOWNERS)
|
||||
- [`CONTRIBUTING_EN.md`](./CONTRIBUTING_EN.md)
|
||||
|
||||
## Decision Making
|
||||
|
||||
Higress uses **lazy consensus** by default.
|
||||
|
||||
When consensus cannot be reached, maintainers may start a vote on a public
|
||||
issue or pull request. A simple majority of votes cast decides the outcome.
|
||||
|
||||
For governance or project direction changes, maintainers should allow adequate
|
||||
time for public discussion before finalizing decisions.
|
||||
|
||||
## Governance Updates
|
||||
|
||||
Changes to this document are made through pull requests and approved by
|
||||
maintainers.
|
||||
|
||||
## Security
|
||||
|
||||
Security reporting and response follow [`SECURITY.md`](./SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
Higress is a [Cloud Native Computing Foundation](https://www.cncf.io/) sandbox
|
||||
project.
|
||||
38
MAINTAINERS.md
Normal file
38
MAINTAINERS.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Higress Maintainers
|
||||
|
||||
This file lists the current maintainers of the Higress project.
|
||||
|
||||
A maintainer is a contributor who is actively responsible for guiding the
|
||||
direction of the project, reviewing and merging contributions, and
|
||||
participating in project governance.
|
||||
|
||||
For the day-to-day code-review ownership of individual subdirectories,
|
||||
see [`CODEOWNERS`](./CODEOWNERS).
|
||||
|
||||
## Maintainers
|
||||
|
||||
| Name | GitHub | Affiliation |
|
||||
| -------------- | ------------------------------------------------------- | ----------------- |
|
||||
| Yiquan Dong | [@CH3CHO](https://github.com/CH3CHO) | Trip.com |
|
||||
| Yuanxiao Zhao | [@EndlessSeeker](https://github.com/EndlessSeeker) | Alibaba Cloud |
|
||||
| Leilei Geng | [@gengleilei](https://github.com/gengleilei) | Alibaba Cloud |
|
||||
| Xiantao Han | [@hanxiantao](https://github.com/hanxiantao) | XinYe Technology |
|
||||
| Zhiwei Cheng | [@cr7258](https://github.com/cr7258) | NVIDIA |
|
||||
| Tianyi Zhang | [@johnlanni](https://github.com/johnlanni) | Alibaba Cloud |
|
||||
| Jingfeng Xu | [@lexburner](https://github.com/lexburner) | Alibaba Cloud |
|
||||
|
||||
## Becoming a maintainer
|
||||
|
||||
Higress follows the contribution and graduation paths described in
|
||||
[`CONTRIBUTING_EN.md`](./CONTRIBUTING_EN.md). Active and sustained
|
||||
contributors who consistently demonstrate good technical judgement and
|
||||
community stewardship can be nominated as maintainers by an existing
|
||||
maintainer; nominations are accepted by lazy consensus among the current
|
||||
maintainers.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
* For security issues, please follow [`SECURITY.md`](./SECURITY.md).
|
||||
* For all other questions, please use
|
||||
[GitHub Issues](https://github.com/higress-group/higress/issues) or our
|
||||
community channels listed in the [README](./README.md).
|
||||
@@ -23,6 +23,11 @@ export GOPROXY ?= https://proxy.golang.org,direct
|
||||
|
||||
TARGET_ARCH ?= amd64
|
||||
|
||||
VALID_ARCHS := amd64 arm64
|
||||
ifeq ($(filter $(TARGET_ARCH),$(VALID_ARCHS)),)
|
||||
$(error "TARGET_ARCH must be one of: $(VALID_ARCHS)")
|
||||
endif
|
||||
|
||||
GOARCH_LOCAL := $(TARGET_ARCH)
|
||||
GOOS_LOCAL := $(TARGET_OS)
|
||||
RELEASE_LDFLAGS='$(GO_LDFLAGS) -extldflags -static -s -w'
|
||||
@@ -146,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.1/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
|
||||
@@ -166,7 +171,7 @@ build-gateway: prebuild buildx-prepare build-golang-filter
|
||||
USE_REAL_USER=1 TARGET_ARCH=arm64 DOCKER_TARGETS="docker.proxyv2" ./tools/hack/build-istio-image.sh init
|
||||
DOCKER_TARGETS="docker.proxyv2" IMG_URL="${IMG_URL}" ./tools/hack/build-istio-image.sh docker.buildx
|
||||
|
||||
build-gateway-local: prebuild build-golang-filter-amd64
|
||||
build-gateway-local: prebuild $(if $(filter amd64,$(TARGET_ARCH)),build-golang-filter-amd64,build-golang-filter-arm64)
|
||||
TARGET_ARCH=${TARGET_ARCH} DOCKER_TARGETS="docker.proxyv2" ./tools/hack/build-istio-image.sh docker
|
||||
|
||||
build-golang-filter-amd64:
|
||||
@@ -200,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 ?= 36c1d07376bf11295edc40357d74a5ecb50122b1
|
||||
ISTIO_LATEST_IMAGE_TAG ?= 36c1d07376bf11295edc40357d74a5ecb50122b1
|
||||
ENVOY_LATEST_IMAGE_TAG ?= 91244c578aef498af93cacb2cf353f3878b92fc4
|
||||
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'
|
||||
|
||||
72
README.md
72
README.md
@@ -7,12 +7,14 @@
|
||||
<h4 align="center"> AI Native API Gateway </h4>
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
[](https://github.com/alibaba/higress/actions)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://discord.gg/tSbww9VDaM)
|
||||
[](https://www.cncf.io/projects/)
|
||||
[](https://www.bestpractices.dev/projects/12667)
|
||||
|
||||
<a href="https://trendshift.io/repositories/10918" target="_blank"><img src="https://trendshift.io/api/badge/repositories/10918" alt="alibaba%2Fhigress | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://www.producthunt.com/posts/higress?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-higress" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=951287&theme=light&t=1745492822283" alt="Higress - Global APIs as MCP powered by AI Gateway | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<a href="https://trendshift.io/repositories/26458" target="_blank"><img src="https://trendshift.io/api/badge/repositories/26458" alt="higress-group%2Fhigress | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://www.producthunt.com/posts/higress?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-higress" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=951287&theme=light&t=1745492822283" alt="Higress - Global APIs as MCP powered by AI Gateway | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -33,7 +35,7 @@ Higress is a cloud-native API gateway based on Istio and Envoy, which can be ext
|
||||
|
||||
### Core Use Cases
|
||||
|
||||
Higress's AI gateway capabilities support all [mainstream model providers](https://github.com/alibaba/higress/tree/main/plugins/wasm-go/extensions/ai-proxy/provider) both domestic and international. It also supports hosting MCP (Model Context Protocol) Servers through its plugin mechanism, enabling AI Agents to easily call various tools and services. With the [openapi-to-mcp tool](https://github.com/higress-group/openapi-to-mcpserver), you can quickly convert OpenAPI specifications into remote MCP servers for hosting. Higress provides unified management for both LLM API and MCP API.
|
||||
Higress's AI gateway capabilities support all [mainstream model providers](https://github.com/alibaba/higress/tree/main/plugins/wasm-go/extensions/ai-proxy/provider) both domestic and international. It also supports hosting MCP (Model Context Protocol) Servers through its plugin mechanism, enabling AI Agents to easily call various tools and services. With the [openapi-to-mcp tool](https://github.com/higress-group/openapi-to-mcpserver), you can quickly convert OpenAPI specifications into remote MCP servers for hosting. Higress provides unified management for both LLM API and MCP API.
|
||||
|
||||
**🌟 Try it now at [https://mcp.higress.ai/](https://mcp.higress.ai/)** to experience Higress-hosted Remote MCP Servers firsthand:
|
||||
|
||||
@@ -48,11 +50,9 @@ You can click the button below to install the enterprise version of Higress:
|
||||
[](https://www.aliyun.com/product/api-gateway?spm=higress-github.topbar.0.0.0)
|
||||
|
||||
|
||||
If you use open-source Higress and wish to obtain enterprise-level support, you can contact the project maintainer johnlanni's email: **zty98751@alibaba-inc.com** or social media accounts (WeChat ID: **nomadao**, DingTalk ID: **chengtanzty**). Please note **Higress** when adding as a friend :)
|
||||
|
||||
## Summary
|
||||
|
||||
- [**Quick Start**](#quick-start)
|
||||
- [**Quick Start**](#quick-start)
|
||||
- [**Feature Showcase**](#feature-showcase)
|
||||
- [**Use Cases**](#use-cases)
|
||||
- [**Core Advantages**](#core-advantages)
|
||||
@@ -79,20 +79,20 @@ Port descriptions:
|
||||
|
||||
> All Higress Docker images use Higress's own image repository and are not affected by Docker Hub rate limits.
|
||||
> In addition, the submission and updates of the images are protected by a security scanning mechanism (powered by Alibaba Cloud ACR), making them very secure for use in production environments.
|
||||
>
|
||||
>
|
||||
> If you experience a timeout when pulling image from `higress-registry.cn-hangzhou.cr.aliyuncs.com`, you can try replacing it with the following docker registry mirror source:
|
||||
>
|
||||
>
|
||||
> **North America**: `higress-registry.us-west-1.cr.aliyuncs.com`
|
||||
>
|
||||
>
|
||||
> **Southeast Asia**: `higress-registry.ap-southeast-7.cr.aliyuncs.com`
|
||||
|
||||
> **For Kubernetes deployments**, you can configure the `global.hub` parameter in Helm values to use a mirror registry closer to your region. This applies to both Higress component images and built-in Wasm plugin images:
|
||||
>
|
||||
>
|
||||
> ```bash
|
||||
> # Example: Using North America mirror
|
||||
> helm install higress -n higress-system higress.io/higress --set global.hub=higress-registry.us-west-1.cr.aliyuncs.com --create-namespace
|
||||
> ```
|
||||
>
|
||||
>
|
||||
> Available mirror registries:
|
||||
> - **China (Hangzhou)**: `higress-registry.cn-hangzhou.cr.aliyuncs.com` (default)
|
||||
> - **North America**: `higress-registry.us-west-1.cr.aliyuncs.com`
|
||||
@@ -130,7 +130,7 @@ If you are deploying on the cloud, it is recommended to use the [Enterprise Edit
|
||||
- **Kubernetes ingress controller**:
|
||||
|
||||
Higress can function as a feature-rich ingress controller, which is compatible with many annotations of K8s' nginx ingress controller.
|
||||
|
||||
|
||||
[Gateway API](https://gateway-api.sigs.k8s.io/) is already supported, and it supports a smooth migration from Ingress API to Gateway API.
|
||||
|
||||
Compared to ingress-nginx, the resource overhead has significantly decreased, and the speed at which route changes take effect has improved by ten times.
|
||||
@@ -141,13 +141,13 @@ If you are deploying on the cloud, it is recommended to use the [Enterprise Edit
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
- **Microservice gateway**:
|
||||
|
||||
Higress can function as a microservice gateway, which can discovery microservices from various service registries, such as Nacos, ZooKeeper, Consul, Eureka, etc.
|
||||
|
||||
|
||||
It deeply integrates with [Dubbo](https://github.com/apache/dubbo), [Nacos](https://github.com/alibaba/nacos), [Sentinel](https://github.com/alibaba/Sentinel) and other microservice technology stacks.
|
||||
|
||||
|
||||
- **Security gateway**:
|
||||
|
||||
Higress can be used as a security gateway, supporting WAF and various authentication strategies, such as key-auth, hmac-auth, jwt-auth, basic-auth, oidc, etc.
|
||||
@@ -166,15 +166,15 @@ If you are deploying on the cloud, it is recommended to use the [Enterprise Edit
|
||||
Supports true complete streaming processing of request/response bodies, Wasm plugins can easily customize the handling of streaming protocols such as SSE (Server-Sent Events).
|
||||
|
||||
In high-bandwidth scenarios such as AI businesses, it can significantly reduce memory overhead.
|
||||
|
||||
|
||||
- **Easy to Extend**
|
||||
|
||||
|
||||
Provides a rich official plugin library covering AI, traffic management, security protection and other common functions, meeting more than 90% of business scenario requirements.
|
||||
|
||||
Focuses on Wasm plugin extensions, ensuring memory safety through sandbox isolation, supporting multiple programming languages, allowing plugin versions to be upgraded independently, and achieving traffic-lossless hot updates of gateway logic.
|
||||
|
||||
- **Secure and Easy to Use**
|
||||
|
||||
|
||||
Based on Ingress API and Gateway API standards, provides out-of-the-box UI console, WAF protection plugin, IP/Cookie CC protection plugin ready to use.
|
||||
|
||||
Supports connecting to Let's Encrypt for automatic issuance and renewal of free certificates, and can be deployed outside of K8s, started with a single Docker command, convenient for individual developers to use.
|
||||
@@ -185,6 +185,25 @@ Join our Discord community! This is where you can connect with developers and ot
|
||||
|
||||
[](https://discord.gg/tSbww9VDaM)
|
||||
|
||||
### Code of Conduct
|
||||
|
||||
The Higress community follows the
|
||||
[**CNCF Code of Conduct**](https://github.com/cncf/foundation/blob/main/code-of-conduct.md).
|
||||
The project's adopted Code of Conduct is documented in
|
||||
[`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md). Please review it before
|
||||
participating in the community.
|
||||
|
||||
### Governance & Maintainers
|
||||
|
||||
Project governance, the maintainer roster, and the contribution model are
|
||||
described in [`GOVERNANCE.md`](./GOVERNANCE.md) and
|
||||
[`MAINTAINERS.md`](./MAINTAINERS.md). New contributors are encouraged to start
|
||||
with [`CONTRIBUTING_EN.md`](./CONTRIBUTING_EN.md).
|
||||
|
||||
### Security
|
||||
|
||||
Please report security vulnerabilities following the process described in
|
||||
[`SECURITY.md`](./SECURITY.md).
|
||||
|
||||
### Thanks
|
||||
|
||||
@@ -207,6 +226,23 @@ Higress would not be possible without the valuable open-source work of projects
|
||||
|
||||
[](https://star-history.com/#alibaba/higress&Date)
|
||||
|
||||
---
|
||||
|
||||
## Cloud Native Computing Foundation
|
||||
|
||||
<a href="https://www.cncf.io/projects/" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/cncf/artwork/master/other/cncf/horizontal/color/cncf-color.svg" alt="Cloud Native Computing Foundation" width="300"/>
|
||||
</a>
|
||||
|
||||
We are a [Cloud Native Computing Foundation](https://www.cncf.io/) sandbox project.
|
||||
|
||||
The Linux Foundation® (TLF) has registered trademarks and uses trademarks. For
|
||||
a list of TLF trademarks, see [Trademark Usage](https://www.linuxfoundation.org/legal/trademark-usage).
|
||||
|
||||
Copyright Higress a Series of LF Projects, LLC. For website terms of use,
|
||||
trademark policy and other project policies please see
|
||||
[https://lfprojects.org/policies/](https://lfprojects.org/policies/).
|
||||
|
||||
<p align="right" style="font-size: 14px; color: #555; margin-top: 20px;">
|
||||
<a href="#readme-top" style="text-decoration: none; color: #007bff; font-weight: bold;">
|
||||
↑ Back to Top ↑
|
||||
|
||||
36
README_JP.md
36
README_JP.md
@@ -8,6 +8,8 @@
|
||||
|
||||
[](https://github.com/alibaba/higress/actions)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://www.cncf.io/projects/)
|
||||
[](https://www.bestpractices.dev/projects/12667)
|
||||
|
||||
[**公式サイト**](https://higress.cn/) |
|
||||
[**ドキュメント**](https://higress.cn/docs/latest/overview/what-is-higress/) |
|
||||
@@ -190,6 +192,23 @@ K8sでのHelmデプロイなどの他のインストール方法については
|
||||
|
||||
## コミュニティ
|
||||
|
||||
### 行動規範(Code of Conduct)
|
||||
|
||||
Higress コミュニティは
|
||||
[**CNCF Code of Conduct**](https://github.com/cncf/foundation/blob/main/code-of-conduct.md)
|
||||
に従っています。プロジェクトが採用している行動規範は
|
||||
[`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) に記載されていますので、コミュニティに参加する前にご確認ください。
|
||||
|
||||
### ガバナンスとメンテナー
|
||||
|
||||
プロジェクトのガバナンス、メンテナー一覧、コントリビューションモデルについては
|
||||
[`GOVERNANCE.md`](./GOVERNANCE.md) と [`MAINTAINERS.md`](./MAINTAINERS.md) を参照してください。
|
||||
初めて貢献される方は [`CONTRIBUTING_EN.md`](./CONTRIBUTING_EN.md) からお読みください。
|
||||
|
||||
### セキュリティ
|
||||
|
||||
セキュリティに関する脆弱性は [`SECURITY.md`](./SECURITY.md) に記載された手順に従って報告してください。
|
||||
|
||||
### 感謝
|
||||
|
||||
EnvoyとIstioのオープンソースの取り組みがなければ、Higressは実現できませんでした。これらのプロジェクトに最も誠実な敬意を表します。
|
||||
@@ -221,6 +240,23 @@ WeChat公式アカウント:
|
||||
|
||||
[](https://star-history.com/#alibaba/higress&Date)
|
||||
|
||||
---
|
||||
|
||||
## Cloud Native Computing Foundation
|
||||
|
||||
<a href="https://www.cncf.io/projects/" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/cncf/artwork/master/other/cncf/horizontal/color/cncf-color.svg" alt="Cloud Native Computing Foundation" width="300"/>
|
||||
</a>
|
||||
|
||||
Higress は [Cloud Native Computing Foundation](https://www.cncf.io/) のサンドボックスプロジェクトです。
|
||||
|
||||
The Linux Foundation® (TLF) は登録商標を所有し使用しています。LF の商標一覧については
|
||||
[Trademark Usage](https://www.linuxfoundation.org/legal/trademark-usage) をご参照ください。
|
||||
|
||||
Copyright Higress a Series of LF Projects, LLC. ウェブサイトの利用規約、商標ポリシー、
|
||||
その他のプロジェクトポリシーについては
|
||||
[https://lfprojects.org/policies/](https://lfprojects.org/policies/) をご参照ください。
|
||||
|
||||
<p align="right" style="font-size: 14px; color: #555; margin-top: 20px;">
|
||||
<a href="#readme-top" style="text-decoration: none; color: #007bff; font-weight: bold;">
|
||||
↑ トップに戻る ↑
|
||||
|
||||
35
README_ZH.md
35
README_ZH.md
@@ -10,8 +10,10 @@
|
||||
|
||||
[](https://github.com/alibaba/higress/actions)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://www.cncf.io/projects/)
|
||||
[](https://www.bestpractices.dev/projects/12667)
|
||||
|
||||
<a href="https://trendshift.io/repositories/10918" target="_blank"><img src="https://trendshift.io/api/badge/repositories/10918" alt="alibaba%2Fhigress | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://www.producthunt.com/posts/higress?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-higress" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=951287&theme=light&t=1745492822283" alt="Higress - Global APIs as MCP powered by AI Gateway | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<a href="https://trendshift.io/repositories/26458" target="_blank"><img src="https://trendshift.io/api/badge/repositories/26458" alt="higress-group%2Fhigress | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://www.producthunt.com/posts/higress?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-higress" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=951287&theme=light&t=1745492822283" alt="Higress - Global APIs as MCP powered by AI Gateway | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
[**官网**](https://higress.cn/) |
|
||||
@@ -221,6 +223,21 @@ K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start
|
||||
|
||||
## 社区
|
||||
|
||||
### 行为准则(Code of Conduct)
|
||||
|
||||
Higress 社区遵循 [**CNCF Code of Conduct**](https://github.com/cncf/foundation/blob/main/code-of-conduct.md)。
|
||||
项目采纳的行为准则详见 [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md),参与社区前请先阅读。
|
||||
|
||||
### 治理与维护者
|
||||
|
||||
项目治理、维护者名单与贡献模型分别记录于 [`GOVERNANCE.md`](./GOVERNANCE.md) 与
|
||||
[`MAINTAINERS.md`](./MAINTAINERS.md),新贡献者请先查阅
|
||||
[`CONTRIBUTING_CN.md`](./CONTRIBUTING_CN.md)。
|
||||
|
||||
### 安全
|
||||
|
||||
如发现安全漏洞,请按照 [`SECURITY.md`](./SECURITY.md) 中的流程进行报告。
|
||||
|
||||
### 感谢
|
||||
|
||||
如果没有 Envoy 和 Istio 的开源工作,Higress 就不可能实现,在这里向这两个项目献上最诚挚的敬意。
|
||||
@@ -252,6 +269,22 @@ K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start
|
||||
|
||||
[](https://star-history.com/#alibaba/higress&Date)
|
||||
|
||||
---
|
||||
|
||||
## 云原生计算基金会(CNCF)
|
||||
|
||||
<a href="https://www.cncf.io/projects/" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/cncf/artwork/master/other/cncf/horizontal/color/cncf-color.svg" alt="Cloud Native Computing Foundation" width="300"/>
|
||||
</a>
|
||||
|
||||
Higress 是 [Cloud Native Computing Foundation](https://www.cncf.io/) 的沙箱(Sandbox)项目。
|
||||
|
||||
The Linux Foundation® (TLF) 拥有相关注册商标并对其加以使用。完整的 LF 商标清单见
|
||||
[Trademark Usage](https://www.linuxfoundation.org/legal/trademark-usage)。
|
||||
|
||||
Copyright Higress a Series of LF Projects, LLC. 项目相关的网站使用条款、商标策略及其他政策,
|
||||
请参见 [https://lfprojects.org/policies/](https://lfprojects.org/policies/)。
|
||||
|
||||
<p align="right" style="font-size: 14px; color: #555; margin-top: 20px;">
|
||||
<a href="#readme-top" style="text-decoration: none; color: #007bff; font-weight: bold;">
|
||||
↑ 返回顶部 ↑
|
||||
|
||||
77
SECURITY.md
77
SECURITY.md
@@ -1,15 +1,86 @@
|
||||
# Security Policy
|
||||
|
||||
The Higress team takes security seriously. We appreciate your efforts to
|
||||
responsibly disclose your findings and will make every effort to acknowledge
|
||||
your contributions.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0.0 | :x: |
|
||||
| < 1.0.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report any security issue or Higress crash report to [ASRC](https://security.alibaba.com/)(Alibaba Security Response Center) where the issue will be triaged appropriately.
|
||||
**Please do NOT report security vulnerabilities through public GitHub issues,
|
||||
discussions, or pull requests.**
|
||||
|
||||
Thank you in advance for helping to keep Higress secure.
|
||||
Instead, please report them through **both** of the following private channels:
|
||||
|
||||
1. **GitHub Private Security Advisory**:
|
||||
<https://github.com/higress-group/higress/security/advisories/new>
|
||||
2. **Alibaba Security Response Center (ASRC)**:
|
||||
<https://security.alibaba.com/>
|
||||
|
||||
Please include as much of the following information as possible to help us
|
||||
triage and address the issue:
|
||||
|
||||
- Type of issue (e.g., buffer overflow, injection, privilege escalation, etc.)
|
||||
- Full paths of source file(s) related to the issue (if known)
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact of the issue, including how an attacker might exploit it
|
||||
- Any suggested fix or mitigation (if available)
|
||||
|
||||
## Response Process
|
||||
|
||||
The Higress security team will follow these steps upon receiving a report:
|
||||
|
||||
1. **Acknowledgement**: We will acknowledge receipt of your report within
|
||||
**3 business days**.
|
||||
2. **Triage**: We will evaluate the report, confirm the vulnerability, and
|
||||
determine its severity and impact within **14 days**.
|
||||
3. **Fix Development**: We will develop a fix and coordinate with you on an
|
||||
appropriate disclosure timeline.
|
||||
4. **Disclosure**: We will publish a security advisory via
|
||||
[GitHub Security Advisories](https://github.com/higress-group/higress/security/advisories)
|
||||
and credit you for the discovery (unless you prefer to remain anonymous).
|
||||
|
||||
We aim to resolve critical vulnerabilities as quickly as possible and will
|
||||
keep you informed of our progress throughout the process.
|
||||
|
||||
## Security Response Team
|
||||
|
||||
The Higress security response is handled by the project maintainers listed in
|
||||
[`MAINTAINERS.md`](./MAINTAINERS.md). Security reports submitted via GitHub
|
||||
Private Security Advisory are visible to all current maintainers.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow a coordinated disclosure process:
|
||||
|
||||
- We ask reporters to give us a reasonable amount of time to address the issue
|
||||
before any public disclosure.
|
||||
- We will work with you to agree on a disclosure timeline, typically **90 days**
|
||||
from the initial report.
|
||||
- We will publish security advisories and, where appropriate, request CVE
|
||||
identifiers for confirmed vulnerabilities.
|
||||
- We will credit reporters in the advisory unless they request anonymity.
|
||||
|
||||
## Security-Related Configuration
|
||||
|
||||
For guidance on securely deploying and configuring Higress, please refer to
|
||||
the [official documentation](https://higress.cn/en/docs/latest/overview/what-is-higress/).
|
||||
Key security features include:
|
||||
|
||||
- Built-in WAF protection plugin
|
||||
- Authentication plugins (key-auth, hmac-auth, jwt-auth, basic-auth, OIDC)
|
||||
- IP/Cookie-based CC protection
|
||||
- TLS termination with automatic Let's Encrypt certificate management
|
||||
|
||||
---
|
||||
|
||||
Higress is a [Cloud Native Computing Foundation](https://www.cncf.io/)
|
||||
sandbox project.
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"istio.io/pkg/log"
|
||||
"istio.io/istio/pkg/log"
|
||||
|
||||
"github.com/alibaba/higress/v2/pkg/cmd"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ ARG BASE_DISTRIBUTION=debug
|
||||
# Version is the base image version from the TLD Makefile
|
||||
ARG BASE_VERSION=latest
|
||||
|
||||
ARG HUB
|
||||
ARG HUB=higress-registry.cn-hangzhou.cr.aliyuncs.com/higress
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
|
||||
Submodule envoy/envoy updated: 43287ff203...f468a1a3ac
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
appVersion: 2.2.1
|
||||
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.1
|
||||
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.1
|
||||
version: 2.2.3
|
||||
- name: higress-console
|
||||
repository: https://higress.io/helm-charts/
|
||||
version: 2.2.1
|
||||
digest: sha256:b74e3b6f0b00364a155532fd825398e0ff856f13ec90a256e05bbd9c6bead653
|
||||
generated: "2026-04-09T17:30:46.726657+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.1
|
||||
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.1
|
||||
version: 2.2.3
|
||||
- name: higress-console
|
||||
repository: "https://higress.io/helm-charts/"
|
||||
version: 2.2.1
|
||||
version: 2.2.3
|
||||
type: application
|
||||
version: 2.2.1
|
||||
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: 1778761e3d...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,
|
||||
|
||||
@@ -17,14 +17,12 @@ package cert
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/mholt/acmez"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
istiomodel "istio.io/istio/pilot/pkg/model"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
@@ -60,11 +58,7 @@ func InitCertMgr(opts *Option, clientSet kubernetes.Interface, config *Config, X
|
||||
var storage certmagic.Storage
|
||||
storage, _ = NewConfigmapStorage(opts.Namespace, clientSet)
|
||||
renewalWindowRatio := float64(config.RenewBeforeDays) / float64(RenewMaxDays)
|
||||
logger := zap.New(zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(zap.NewProductionEncoderConfig()),
|
||||
os.Stderr,
|
||||
zap.DebugLevel,
|
||||
))
|
||||
logger := zap.L()
|
||||
magicConfig := certmagic.Config{
|
||||
RenewalWindowRatio: renewalWindowRatio,
|
||||
Storage: storage,
|
||||
|
||||
@@ -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{}
|
||||
@@ -1759,7 +1883,7 @@ func constructProxyEnvoyFilters(proxyWrappers map[string]*common.ProxyWrapper, s
|
||||
continue
|
||||
}
|
||||
if !proxyConfig.UpstreamProtocol.IsSupportedByProxy() {
|
||||
IngressLog.Warnf("Proxy %s does not support upstream protocol %s, skipping EnvoyFilter construction for service %s")
|
||||
IngressLog.Warnf("Proxy %s does not support upstream protocol %s, skipping EnvoyFilter construction for service %s", proxyConfig.ProxyName, proxyConfig.UpstreamProtocol, serviceWrapper.ServiceName)
|
||||
continue
|
||||
}
|
||||
if proxyWrapper.EnvoyFilter == nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,14 +164,14 @@ func (c *DBClient) DescribeTable(table string) ([]map[string]interface{}, error)
|
||||
switch c.dbType {
|
||||
case MYSQL:
|
||||
sql = `
|
||||
select
|
||||
select
|
||||
column_name,
|
||||
column_type,
|
||||
is_nullable,
|
||||
column_key,
|
||||
column_default,
|
||||
extra,
|
||||
column_comment
|
||||
column_comment
|
||||
from information_schema.columns
|
||||
where table_schema = database() and table_name = ?
|
||||
`
|
||||
@@ -179,17 +179,17 @@ func (c *DBClient) DescribeTable(table string) ([]map[string]interface{}, error)
|
||||
|
||||
case POSTGRES:
|
||||
sql = `
|
||||
select
|
||||
select
|
||||
column_name,
|
||||
data_type as column_type,
|
||||
is_nullable,
|
||||
case
|
||||
case
|
||||
when column_default like 'nextval%' then 'auto_increment'
|
||||
when column_default is not null then 'default'
|
||||
else ''
|
||||
end as column_key,
|
||||
column_default,
|
||||
case
|
||||
case
|
||||
when column_default like 'nextval%' then 'auto_increment'
|
||||
else ''
|
||||
end as extra,
|
||||
@@ -202,7 +202,7 @@ func (c *DBClient) DescribeTable(table string) ([]map[string]interface{}, error)
|
||||
|
||||
case CLICKHOUSE:
|
||||
sql = `
|
||||
select
|
||||
select
|
||||
name as column_name,
|
||||
type as column_type,
|
||||
if(is_nullable, 'YES', 'NO') as is_nullable,
|
||||
@@ -217,7 +217,7 @@ func (c *DBClient) DescribeTable(table string) ([]map[string]interface{}, error)
|
||||
|
||||
case SQLITE:
|
||||
sql = `
|
||||
select
|
||||
select
|
||||
name as column_name,
|
||||
type as column_type,
|
||||
not (notnull = 1) as is_nullable,
|
||||
@@ -236,6 +236,10 @@ func (c *DBClient) DescribeTable(table string) ([]map[string]interface{}, error)
|
||||
|
||||
// ListTables List all tables in the connected database.
|
||||
func (c *DBClient) ListTables() ([]string, error) {
|
||||
if err := c.reconnectIfDbEmpty(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sql string
|
||||
switch c.dbType {
|
||||
case MYSQL:
|
||||
@@ -265,6 +269,10 @@ func (c *DBClient) ListTables() ([]string, error) {
|
||||
tables = append(tables, table)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating table rows: %w", err)
|
||||
}
|
||||
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
@@ -335,6 +343,10 @@ func (c *DBClient) Query(sql string, args ...interface{}) ([]map[string]interfac
|
||||
results = append(results, rowMap)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to iterate table rows in Query: %w", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -792,22 +792,36 @@ func (m *MilvusProvider) ListDocs(ctx context.Context, limit int) ([]schema.Docu
|
||||
fieldName := strings.ToLower(fieldMapping.StandardName)
|
||||
switch fieldName {
|
||||
case "id":
|
||||
if v, err := col.(*entity.ColumnVarChar).Get(i); err == nil {
|
||||
id = v.(string)
|
||||
if typedCol, ok := col.(*entity.ColumnVarChar); ok {
|
||||
if v, err := typedCol.Get(i); err == nil {
|
||||
if s, ok := v.(string); ok {
|
||||
id = s
|
||||
}
|
||||
}
|
||||
}
|
||||
case "content":
|
||||
if v, err := col.(*entity.ColumnVarChar).Get(i); err == nil {
|
||||
content = v.(string)
|
||||
if typedCol, ok := col.(*entity.ColumnVarChar); ok {
|
||||
if v, err := typedCol.Get(i); err == nil {
|
||||
if s, ok := v.(string); ok {
|
||||
content = s
|
||||
}
|
||||
}
|
||||
}
|
||||
case "metadata":
|
||||
if v, err := col.(*entity.ColumnJSONBytes).Get(i); err == nil {
|
||||
if bytes, ok := v.([]byte); ok {
|
||||
_ = json.Unmarshal(bytes, &metadata)
|
||||
if typedCol, ok := col.(*entity.ColumnJSONBytes); ok {
|
||||
if v, err := typedCol.Get(i); err == nil {
|
||||
if bytes, ok := v.([]byte); ok {
|
||||
_ = json.Unmarshal(bytes, &metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "created_at":
|
||||
if v, err := col.(*entity.ColumnInt64).Get(i); err == nil {
|
||||
createdAt = v.(int64)
|
||||
if typedCol, ok := col.(*entity.ColumnInt64); ok {
|
||||
if v, err := typedCol.Get(i); err == nil {
|
||||
if ts, ok := v.(int64); ok {
|
||||
createdAt = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
## 简介
|
||||
---
|
||||
title: AI 缓存
|
||||
keywords: [higress,ai cache]
|
||||
@@ -7,9 +6,7 @@ description: AI 缓存插件配置参考
|
||||
|
||||
**Note**
|
||||
|
||||
> 需要数据面的proxy wasm版本大于等于0.2.100
|
||||
> 编译时,需要带上版本的tag,例如:`tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags="custommalloc nottinygc_finalizer proxy_wasm_version_0_2_100" ./`
|
||||
>
|
||||
> 若使用 tinygo 编译,则需要数据面的proxy wasm版本大于等于0.2.100,且编译时需要带上版本的tag,例如:`tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags="custommalloc nottinygc_finalizer proxy_wasm_version_0_2_100" ./`
|
||||
|
||||
## 功能说明
|
||||
|
||||
@@ -36,16 +33,16 @@ LLM 结果缓存插件,默认配置方式可以直接用于 openai 协议的
|
||||
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| vector | string | optional | "" | 向量存储服务提供者类型,例如 dashvector |
|
||||
| embedding | string | optional | "" | 请求文本向量化服务类型,例如 dashscope |
|
||||
| cache | string | optional | "" | 缓存服务类型,例如 redis |
|
||||
| vector | object | optional | - | 向量存储服务配置,详见下文向量数据库服务配置 |
|
||||
| embedding | object | optional | - | 文本向量化服务配置,详见下文文本向量化服务配置 |
|
||||
| cache | object | optional | - | 缓存服务配置,详见下文缓存服务配置 |
|
||||
| cacheKeyStrategy | string | optional | "lastQuestion" | 决定如何根据历史问题生成缓存键的策略。可选值: "lastQuestion" (使用最后一个问题), "allQuestions" (拼接所有问题) 或 "disabled" (禁用缓存) |
|
||||
| enableSemanticCache | bool | optional | true | 是否启用语义化缓存, 若不启用,则使用字符串匹配的方式来查找缓存,此时需要配置cache服务 |
|
||||
| enableSemanticCache | bool | optional | false | 是否启用语义化缓存。若不启用,则使用字符串匹配的方式来查找缓存,此时需要配置cache服务。当配置了 vector provider 时,默认自动开启 |
|
||||
|
||||
根据是否需要启用语义缓存,可以只配置组件的组合为:
|
||||
1. `cache`: 仅启用字符串匹配缓存
|
||||
3. `vector (+ embedding)`: 启用语义化缓存, 其中若 `vector` 未提供字符串表征服务,则需要自行配置 `embedding` 服务
|
||||
2. `vector (+ embedding) + cache`: 启用语义化缓存并用缓存服务存储LLM响应以加速
|
||||
2. `vector (+ embedding)`: 启用语义化缓存, 其中若 `vector` 未提供字符串表征服务,则需要自行配置 `embedding` 服务
|
||||
3. `vector (+ embedding) + cache`: 启用语义化缓存并用缓存服务存储LLM响应以加速
|
||||
|
||||
注意若不配置相关组件,则可以忽略相应组件的`required`字段。
|
||||
|
||||
@@ -53,66 +50,69 @@ LLM 结果缓存插件,默认配置方式可以直接用于 openai 协议的
|
||||
## 向量数据库服务(vector)
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| vector.type | string | required | "" | 向量存储服务提供者类型,例如 dashvector |
|
||||
| vector.serviceName | string | required | "" | 向量存储服务名称 |
|
||||
| vector.serviceHost | string | required | "" | 向量存储服务域名 |
|
||||
| vector.type | string | required | - | 向量存储服务提供者类型,例如 dashvector、chroma、elasticsearch、weaviate、pinecone、qdrant、milvus |
|
||||
| vector.serviceName | string | required | - | 向量存储服务名称 |
|
||||
| vector.serviceHost | string | optional | - | 向量存储服务域名。部分 provider(如 dashvector、pinecone)要求必填 |
|
||||
| vector.servicePort | int64 | optional | 443 | 向量存储服务端口 |
|
||||
| vector.apiKey | string | optional | "" | 向量存储服务 API Key |
|
||||
| vector.topK | int | optional | 1 | 返回TopK结果,默认为 1 |
|
||||
| vector.apiKey | string | optional | - | 向量存储服务 API Key |
|
||||
| vector.topK | int | optional | 1 | 返回TopK结果 |
|
||||
| vector.timeout | uint32 | optional | 10000 | 请求向量存储服务的超时时间,单位为毫秒。默认值是10000,即10秒 |
|
||||
| vector.collectionID | string | optional | "" | 向量存储服务 Collection ID |
|
||||
| vector.collectionID | string | optional | - | 向量存储服务 Collection ID |
|
||||
| vector.threshold | float64 | optional | 1000 | 向量相似度度量阈值 |
|
||||
| vector.thresholdRelation | string | optional | lt | 相似度度量方式有 `Cosine`, `DotProduct`, `Euclidean` 等,前两者值越大相似度越高,后者值越小相似度越高。对于 `Cosine` 和 `DotProduct` 选择 `gt`,对于 `Euclidean` 则选择 `lt`。默认为 `lt`,所有条件包括 `lt` (less than,小于)、`lte` (less than or equal to,小等于)、`gt` (greater than,大于)、`gte` (greater than or equal to,大等于) |
|
||||
| vector.thresholdRelation | string | optional | "lt" | 相似度度量比较方式。相似度度量方式有 `Cosine`, `DotProduct`, `Euclidean` 等,前两者值越大相似度越高,后者值越小相似度越高。对于 `Cosine` 和 `DotProduct` 选择 `gt`,对于 `Euclidean` 则选择 `lt`。所有可选值包括 `lt` (less than,小于)、`lte` (less than or equal to,小等于)、`gt` (greater than,大于)、`gte` (greater than or equal to,大等于) |
|
||||
| vector.esUsername | string | optional | - | ElasticSearch 用户名,仅用于 elasticsearch 类型 |
|
||||
| vector.esPassword | string | optional | - | ElasticSearch 密码,仅用于 elasticsearch 类型 |
|
||||
|
||||
## 文本向量化服务(embedding)
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| embedding.type | string | required | "" | 请求文本向量化服务类型,例如 dashscope |
|
||||
| embedding.serviceName | string | required | "" | 请求文本向量化服务名称 |
|
||||
| embedding.serviceHost | string | optional | "" | 请求文本向量化服务域名 |
|
||||
| embedding.servicePort | int64 | optional | 443 | 请求文本向量化服务端口 |
|
||||
| embedding.apiKey | string | optional | "" | 请求文本向量化服务的 API Key |
|
||||
| embedding.type | string | required | - | 请求文本向量化服务类型,例如 dashscope、openai、azure、cohere、ollama、huggingface、textin、xfyun |
|
||||
| embedding.serviceName | string | required | - | 请求文本向量化服务名称 |
|
||||
| embedding.serviceHost | string | optional | - | 请求文本向量化服务域名 |
|
||||
| embedding.servicePort | int64 | optional | 443 | 请求文本向量化服务端口。不同 provider 默认值可能不同,ollama 默认为 11434 |
|
||||
| embedding.timeout | uint32 | optional | 10000 | 请求文本向量化服务的超时时间,单位为毫秒。默认值是10000,即10秒 |
|
||||
| embedding.model | string | optional | "" | 请求文本向量化服务的模型名称 |
|
||||
| embedding.model | string | optional | - | 请求文本向量化服务的模型名称 |
|
||||
| embedding.apiKey | string | optional | - | 请求文本向量化服务的 API Key |
|
||||
|
||||
|
||||
## 缓存服务(cache)
|
||||
| cache.type | string | required | "" | 缓存服务类型,例如 redis |
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cache.serviceName | string | required | "" | 缓存服务名称 |
|
||||
| cache.serviceHost | string | required | "" | 缓存服务域名 |
|
||||
| cache.servicePort | int64 | optional | 6379 | 缓存服务端口 |
|
||||
| cache.username | string | optional | "" | 缓存服务用户名 |
|
||||
| cache.password | string | optional | "" | 缓存服务密码 |
|
||||
| cache.type | string | required | - | 缓存服务类型,例如 redis |
|
||||
| cache.serviceName | string | required | - | 缓存服务名称 |
|
||||
| cache.serviceHost | string | optional | - | 缓存服务域名 |
|
||||
| cache.servicePort | int64 | optional | 6379 | 缓存服务端口。若 serviceName 以 .static 结尾,则默认值为 80 |
|
||||
| cache.username | string | optional | - | 缓存服务用户名 |
|
||||
| cache.password | string | optional | - | 缓存服务密码 |
|
||||
| cache.timeout | uint32 | optional | 10000 | 缓存服务的超时时间,单位为毫秒。默认值是10000,即10秒 |
|
||||
| cache.cacheTTL | int | optional | 0 | 缓存过期时间,单位为秒。默认值是 0,即 永不过期|
|
||||
| cache.cacheKeyPrefix | string | optional | "higress-ai-cache:" | 缓存 Key 的前缀,默认值为 "higress-ai-cache:" |
|
||||
| cache.cacheTTL | int | optional | 0 | 缓存过期时间,单位为秒。默认值是 0,即永不过期 |
|
||||
| cache.cacheKeyPrefix | string | optional | "higress-ai-cache:" | 缓存 Key 的前缀 |
|
||||
| cache.database | int | optional | 0 | 使用的数据库id,仅限redis,例如配置为1,对应`SELECT 1` |
|
||||
|
||||
|
||||
## 其他配置
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cacheKeyFrom | string | optional | "messages.@reverse.0.content" | 从请求 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
|
||||
| cacheValueFrom | string | optional | "choices.0.message.content" | 从响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
|
||||
| cacheStreamValueFrom | string | optional | "choices.0.delta.content" | 从流式响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
|
||||
| cacheToolCallsFrom | string | optional | "choices.0.delta.content.tool_calls" | 从请求 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
|
||||
| responseTemplate | string | optional | `{"id":"ai-cache.hit","choices":[{"index":0,"message":{"role":"assistant","content":%s},"finish_reason":"stop"}],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}` | 返回 HTTP 响应的模版,用 %s 标记需要被 cache value 替换的部分 |
|
||||
| streamResponseTemplate | string | optional | `data:{"id":"ai-cache.hit","choices":[{"index":0,"delta":{"role":"assistant","content":%s},"finish_reason":"stop"}],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}\n\ndata:[DONE]\n\n` | 返回流式 HTTP 响应的模版,用 %s 标记需要被 cache value 替换的部分 |
|
||||
| cacheKeyFrom | string | optional | "messages.@reverse.0.content" | 从请求 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
|
||||
| cacheValueFrom | string | optional | "choices.0.message.content" | 从响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
|
||||
| cacheStreamValueFrom | string | optional | "choices.0.delta.content" | 从流式响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
|
||||
| cacheToolCallsFrom | string | optional | "choices.0.delta.content.tool_calls" | 从流式响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
|
||||
| responseTemplate | string | optional | `{"id":"from-cache","choices":[{"index":0,"message":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"from-cache","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}` | 返回 HTTP 响应的模版,用 %s 标记需要被 cache value 替换的部分 |
|
||||
| streamResponseTemplate | string | optional | `data:{"id":"from-cache","choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"from-cache","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}\n\ndata:[DONE]\n\n`| 返回流式 HTTP 响应的模版,用 %s 标记需要被 cache value 替换的部分 |
|
||||
|
||||
## 文本向量化提供商特有配置
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
Azure OpenAI 所对应的 `embedding.type` 为 `azure`。它需要提前创建[Azure OpenAI 账户](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/overview),然后您需要在[Azure AI Foundry](https://ai.azure.com/resource/deployments)中挑选一个模型并将其部署,点击您部署好的模型,您可以在终结点中看到目标 URI 以及密钥。请将 URI 中的 host 填入`embedding.serviceHost`,密钥填入`apiKey`。
|
||||
Azure OpenAI 所对应的 `embedding.type` 为 `azure`。它需要提前创建[Azure OpenAI 账户](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/overview),然后您需要在[Azure AI Foundry](https://ai.azure.com/resource/deployments)中挑选一个模型并将其部署,点击您部署好的模型,您可以在终结点中看到目标 URI 以及密钥。请将 URI 中的 host 填入`embedding.serviceHost`,密钥填入`embedding.apiKey`。
|
||||
|
||||
一个完整的 URI 示例为 https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/embeddings?api-version=2024-10-21,您需要将`YOUR_RESOURCE_NAME.openai.azure.com`填入`embedding.serviceHost`。
|
||||
|
||||
它特有的配置字段如下:
|
||||
|
||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 填写值 |
|
||||
| ---------------------- | -------- | -------- | ------ | ------- | ---------------------------- |
|
||||
| `embedding.apiVersion` | string | 必填 | - | api版本 | 获取到的URI中api-version的值 |
|
||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|
||||
| ---------------------- | -------- | -------- | ------ | ------- |
|
||||
| `embedding.apiVersion` | string | 必填 | - | api版本,获取到的URI中api-version的值 |
|
||||
|
||||
需要注意的是您必须要指定`embedding.serviceHost`,如`YOUR_RESOURCE_NAME.openai.azure.com`。模型默认使用了`text-embedding-ada-002`,如需其他模型,请在`embedding.model`中进行指定。
|
||||
|
||||
@@ -122,7 +122,7 @@ Cohere 所对应的 `embedding.type` 为 `cohere`。它并无特有的配置字
|
||||
|
||||
### OpenAI
|
||||
|
||||
OpenAI 所对应的 `embedding.type` 为 `openai`。它并无特有的配置字段。需要提前创建 [API Key](https://platform.openai.com/settings/organization/api-keys),并将其填入`embedding.apiKey`,一个 API Key 的示例为` sk-xxxxxxx`。
|
||||
OpenAI 所对应的 `embedding.type` 为 `openai`。它并无特有的配置字段。需要提前创建 [API Key](https://platform.openai.com/settings/organization/api-keys),并将其填入`embedding.apiKey`,一个 API Key 的示例为`sk-xxxxxxx`。
|
||||
|
||||
### Ollama
|
||||
|
||||
@@ -130,32 +130,38 @@ Ollama 所对应的 `embedding.type` 为 `ollama`。它并无特有的配置字
|
||||
|
||||
### Hugging Face
|
||||
|
||||
Hugging Face 所对应的 `embedding.type` 为 `huggingface`。它并无特有的配置字段。需要提前创建 [hf_token](https://huggingface.co/blog/getting-started-with-embeddings),并将其填入`embedding.apiKey`,一个 hf_token 的示例为` hf_xxxxxxx`。
|
||||
Hugging Face 所对应的 `embedding.type` 为 `huggingface`。它并无特有的配置字段。需要提前创建 [hf_token](https://huggingface.co/blog/getting-started-with-embeddings),并将其填入`embedding.apiKey`,一个 hf_token 的示例为`hf_xxxxxxx`。
|
||||
|
||||
`embedding.model`默认指定为`sentence-transformers/all-MiniLM-L6-v2`
|
||||
|
||||
### Textln
|
||||
### DashScope
|
||||
|
||||
Textln 所对应的 `embedding.type` 为 `textln`。它需要提前获取[`app-id` 和`secret-code`](https://www.textin.com/document/acge_text_embedding)。
|
||||
DashScope 所对应的 `embedding.type` 为 `dashscope`。需要提前创建 [API Key](https://help.aliyun.com/document_detail/2712195.html),并将其填入`embedding.apiKey`。
|
||||
|
||||
`embedding.model`默认指定为`text-embedding-v2`,还可选用`text-embedding-v1`等模型。
|
||||
|
||||
### TextIn
|
||||
|
||||
TextIn 所对应的 `embedding.type` 为 `textin`。它需要提前获取[`app-id` 和`secret-code`](https://www.textin.com/document/acge_text_embedding)。
|
||||
|
||||
它特有的配置字段如下:
|
||||
|
||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 填写值 |
|
||||
| ------------------------------- | -------- | -------- | ------ | -------------------- | ------------------ |
|
||||
| `embedding.textinAppId` | string | 必填 | - | 应用 ID | 获取的 app-id |
|
||||
| `embedding.textinSecretCode` | string | 必填 | - | 调用 API 所需 Secret | 获取的 secret-code |
|
||||
| `embedding.textinMatryoshkaDim` | int | 必填 | - | 返回的单个向量长度 | |
|
||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|
||||
| ------------------------------- | -------- | -------- | ------ | ------------------ |
|
||||
| `embedding.textinAppId` | string | 必填 | - | 应用 ID,获取的 app-id |
|
||||
| `embedding.textinSecretCode` | string | 必填 | - | 调用 API 所需 Secret,获取的 secret-code |
|
||||
| `embedding.textinMatryoshkaDim` | int | 必填 | - | 返回的单个向量长度 |
|
||||
|
||||
### 讯飞星火
|
||||
|
||||
讯飞星火 所对应的 `embedding.type` 为 `xfyun`。它需要提前创建[应用](https://console.xfyun.cn/services/emb),获取`APPID` 、`APISecret`和`APIKey`,并将`APIKey`填入`embedding.apiKey`中。
|
||||
讯飞星火 所对应的 `embedding.type` 为 `xfyun`。它需要提前创建[应用](https://console.xfyun.cn/services/emb),获取`APPID`、`APISecret`和`APIKey`,并将`APIKey`填入`embedding.apiKey`中。
|
||||
|
||||
它特有的配置字段如下:
|
||||
|
||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 填写值 |
|
||||
| --------------------- | -------- | -------- | ------ | -------------------- | ---------------- |
|
||||
| `embedding.appId` | string | 必填 | - | 应用 ID | 获取的 APPID |
|
||||
| `embedding.apiSecret` | string | 必填 | - | 调用 API 所需 Secret | 获取的 APISecret |
|
||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|
||||
| --------------------- | -------- | -------- | ------ | -------------------- |
|
||||
| `embedding.appId` | string | 必填 | - | 应用 ID,获取的 APPID |
|
||||
| `embedding.apiSecret` | string | 必填 | - | 调用 API 所需 Secret,获取的 APISecret |
|
||||
|
||||
## 向量数据库提供商特有配置
|
||||
|
||||
@@ -171,9 +177,9 @@ ElasticSearch 所对应的 `vector.type` 为 `elasticsearch`。需要提前创
|
||||
当前依赖于 [KNN](https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html) 方法,请保证 ES 版本支持 `KNN`,当前已在 `8.16` 版本测试。
|
||||
|
||||
它特有的配置字段如下:
|
||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|
||||
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|
||||
|-------------------|----------|----------|--------|-------------------------------------------------------------------------------|
|
||||
| `vector.esUsername` | string | 非必填 | - | ElasticSearch 用户名 |
|
||||
| `vector.esUsername` | string | 非必填 | - | ElasticSearch 用户名 |
|
||||
| `vector.esPassword` | string | 非必填 | - | ElasticSearch 密码 |
|
||||
|
||||
|
||||
@@ -191,8 +197,7 @@ Pinecone 中的 `Namespace` 参数通过插件的 `vector.collectionID` 进行
|
||||
Qdrant 所对应的 `vector.type` 为 `qdrant`。它并无特有的配置字段。需要提前创建 Collection,并填写 Collection Name 至配置项 `vector.collectionID`。
|
||||
|
||||
### Weaviate
|
||||
Weaviate 所对应的 `vector.type` 为 `weaviate`。它并无特有的配置字段。
|
||||
需要提前创建 Collection,并填写 Collection Name 至配置项 `vector.collectionID`。
|
||||
Weaviate 所对应的 `vector.type` 为 `weaviate`。它并无特有的配置字段。需要提前创建 Collection,并填写 Collection Name 至配置项 `vector.collectionID`。
|
||||
|
||||
需要注意的是 Weaviate 会设置首字母自动大写,在填写配置 `collectionID` 的时候需要将首字母设置为大写。
|
||||
|
||||
@@ -210,7 +215,7 @@ vector:
|
||||
type: dashvector
|
||||
serviceName: my_dashvector.dns
|
||||
collectionID: [Your Collection ID]
|
||||
serviceDomain: [Your domain]
|
||||
serviceHost: [Your domain]
|
||||
apiKey: [Your key]
|
||||
|
||||
cache:
|
||||
@@ -221,15 +226,6 @@ cache:
|
||||
|
||||
```
|
||||
|
||||
旧版本配置兼容
|
||||
```yaml
|
||||
redis:
|
||||
serviceName: my_redis.dns
|
||||
servicePort: 6379
|
||||
timeout: 100
|
||||
database: 1
|
||||
```
|
||||
|
||||
## 进阶用法
|
||||
当前默认的缓存 key 是基于 GJSON PATH 的表达式:`messages.@reverse.0.content` 提取,含义是把 messages 数组反转后取第一项的 content;
|
||||
|
||||
|
||||
@@ -3,46 +3,243 @@ title: AI Cache
|
||||
keywords: [higress,ai cache]
|
||||
description: AI Cache Plugin Configuration Reference
|
||||
---
|
||||
|
||||
## Function Description
|
||||
LLM result caching plugin, the default configuration can be directly used for result caching under the OpenAI protocol, and it supports caching of both streaming and non-streaming responses.
|
||||
|
||||
LLM result caching plugin. The default configuration can be directly used for OpenAI protocol result caching, and supports caching of both streaming and non-streaming responses.
|
||||
|
||||
**Tips**
|
||||
|
||||
When carrying the request header `x-higress-skip-ai-cache: on`, the current request will not use content from the cache but will be directly forwarded to the backend service. Additionally, the response content from this request will not be cached.
|
||||
|
||||
## Runtime Properties
|
||||
|
||||
Plugin Execution Phase: `Authentication Phase`
|
||||
Plugin Execution Priority: `10`
|
||||
|
||||
## Configuration Description
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| -------- | -------- | -------- | -------- | -------- |
|
||||
| cacheKeyFrom.requestBody | string | optional | "messages.@reverse.0.content" | Extracts a string from the request Body based on [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) syntax |
|
||||
| cacheValueFrom.responseBody | string | optional | "choices.0.message.content" | Extracts a string from the response Body based on [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) syntax |
|
||||
| cacheStreamValueFrom.responseBody | string | optional | "choices.0.delta.content" | Extracts a string from the streaming response Body based on [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) syntax |
|
||||
| cacheKeyPrefix | string | optional | "higress-ai-cache:" | Prefix for the Redis cache key |
|
||||
| cacheTTL | integer | optional | 0 | Cache expiration time in seconds, default value is 0, which means never expire |
|
||||
| redis.serviceName | string | required | - | The complete FQDN name of the Redis service, including the service type, e.g., my-redis.dns, redis.my-ns.svc.cluster.local |
|
||||
| redis.servicePort | integer | optional | 6379 | Redis service port |
|
||||
| redis.timeout | integer | optional | 1000 | Timeout for requests to Redis, in milliseconds |
|
||||
| redis.username | string | optional | - | Username for logging into Redis |
|
||||
| redis.database | int | optional | 0 | The database ID used, limited to Redis, for example, configured as 1, corresponds to `SELECT 1`. |
|
||||
| redis.password | string | optional | - | Password for logging into Redis |
|
||||
| returnResponseTemplate | string | optional | `{"id":"from-cache","choices":[%s],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}` | Template for returning HTTP response, with %s marking the part to be replaced by cache value |
|
||||
| returnStreamResponseTemplate | string | optional | `data:{"id":"from-cache","choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}\n\ndata:[DONE]\n\n` | Template for returning streaming HTTP response, with %s marking the part to be replaced by cache value |
|
||||
|
||||
The configuration is divided into 3 parts: Vector Database (vector), Text Embedding Service (embedding), and Cache Service (cache). It also provides fine-grained LLM request/response extraction parameter configurations.
|
||||
|
||||
This plugin supports both vector database-based semantic caching and string matching-based caching methods. If both vector database and cache database are configured, the cache database is used first, and the vector database capability is used when cache misses occur.
|
||||
|
||||
*Note*: Vector database (vector) and cache database (cache) cannot both be empty, otherwise this plugin cannot provide caching services.
|
||||
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| vector | object | optional | - | Vector storage service configuration, see Vector Database Service section below |
|
||||
| embedding | object | optional | - | Text embedding service configuration, see Text Embedding Service section below |
|
||||
| cache | object | optional | - | Cache service configuration, see Cache Service section below |
|
||||
| cacheKeyStrategy | string | optional | "lastQuestion" | Strategy for generating cache key from historical questions. Options: "lastQuestion" (use last question), "allQuestions" (concatenate all questions), or "disabled" (disable caching) |
|
||||
| enableSemanticCache | bool | optional | false | Whether to enable semantic caching. If disabled, string matching is used to find cache, requiring cache service configuration. Automatically enabled when a vector provider is configured |
|
||||
|
||||
Depending on whether semantic caching is needed, you can configure component combinations as follows:
|
||||
1. `cache`: Enable string matching cache only
|
||||
2. `vector (+ embedding)`: Enable semantic caching. If `vector` does not provide string representation service, you need to configure `embedding` service separately
|
||||
3. `vector (+ embedding) + cache`: Enable semantic caching and use cache service to store LLM responses for acceleration
|
||||
|
||||
If you do not configure a related component, you can ignore the `required` fields of that component.
|
||||
|
||||
## Vector Database Service (vector)
|
||||
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| vector.type | string | required | - | Vector storage service provider type, e.g., dashvector, chroma, elasticsearch, weaviate, pinecone, qdrant, milvus |
|
||||
| vector.serviceName | string | required | - | Vector storage service name |
|
||||
| vector.serviceHost | string | optional | - | Vector storage service domain. Required for some providers (e.g., dashvector, pinecone) |
|
||||
| vector.servicePort | int64 | optional | 443 | Vector storage service port |
|
||||
| vector.apiKey | string | optional | - | Vector storage service API Key |
|
||||
| vector.topK | int | optional | 1 | Return TopK results |
|
||||
| vector.timeout | uint32 | optional | 10000 | Timeout for requesting vector storage service, in milliseconds. Default is 10000 (10 seconds) |
|
||||
| vector.collectionID | string | optional | - | Vector storage service Collection ID |
|
||||
| vector.threshold | float64 | optional | 1000 | Vector similarity measurement threshold |
|
||||
| vector.thresholdRelation | string | optional | "lt" | Similarity measurement comparison method. Similarity measurement methods include `Cosine`, `DotProduct`, `Euclidean`, etc. The first two have higher similarity with larger values, while the latter has higher similarity with smaller values. Use `gt` for `Cosine` and `DotProduct`, and `lt` for `Euclidean`. All options include `lt` (less than), `lte` (less than or equal to), `gt` (greater than), `gte` (greater than or equal to) |
|
||||
| vector.esUsername | string | optional | - | ElasticSearch username, only for elasticsearch type |
|
||||
| vector.esPassword | string | optional | - | ElasticSearch password, only for elasticsearch type |
|
||||
|
||||
## Text Embedding Service (embedding)
|
||||
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| embedding.type | string | required | - | Text embedding service type, e.g., dashscope, openai, azure, cohere, ollama, huggingface, textin, xfyun |
|
||||
| embedding.serviceName | string | required | - | Text embedding service name |
|
||||
| embedding.serviceHost | string | optional | - | Text embedding service domain |
|
||||
| embedding.servicePort | int64 | optional | 443 | Text embedding service port. Default varies by provider; ollama defaults to 11434 |
|
||||
| embedding.timeout | uint32 | optional | 10000 | Timeout for requesting text embedding service, in milliseconds. Default is 10000 (10 seconds) |
|
||||
| embedding.model | string | optional | - | Model name for text embedding service |
|
||||
| embedding.apiKey | string | optional | - | API Key for text embedding service |
|
||||
|
||||
## Cache Service (cache)
|
||||
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cache.type | string | required | - | Cache service type, e.g., redis |
|
||||
| cache.serviceName | string | required | - | Cache service name |
|
||||
| cache.serviceHost | string | optional | - | Cache service domain |
|
||||
| cache.servicePort | int64 | optional | 6379 | Cache service port. If serviceName ends with .static, default is 80 |
|
||||
| cache.username | string | optional | - | Cache service username |
|
||||
| cache.password | string | optional | - | Cache service password |
|
||||
| cache.timeout | uint32 | optional | 10000 | Cache service timeout, in milliseconds. Default is 10000 (10 seconds) |
|
||||
| cache.cacheTTL | int | optional | 0 | Cache expiration time, in seconds. Default is 0 (never expire) |
|
||||
| cache.cacheKeyPrefix | string | optional | "higress-ai-cache:" | Prefix for cache keys |
|
||||
| cache.database | int | optional | 0 | Database ID to use, only for Redis. For example, configure as 1 for `SELECT 1` |
|
||||
|
||||
## Other Configurations
|
||||
|
||||
| Name | Type | Requirement | Default | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cacheKeyFrom | string | optional | "messages.@reverse.0.content" | Extract string from request Body using [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) syntax |
|
||||
| cacheValueFrom | string | optional | "choices.0.message.content" | Extract string from response Body using [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) syntax |
|
||||
| cacheStreamValueFrom | string | optional | "choices.0.delta.content" | Extract string from streaming response Body using [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) syntax |
|
||||
| cacheToolCallsFrom | string | optional | "choices.0.delta.content.tool_calls" | Extract string from streaming response Body using [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) syntax |
|
||||
| responseTemplate | string | optional | `{"id":"from-cache","choices":[{"index":0,"message":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"from-cache","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}` | Template for returning HTTP response, with %s marking the part to be replaced by cache value |
|
||||
| streamResponseTemplate | string | optional | `data:{"id":"from-cache","choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"from-cache","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}\n\ndata:[DONE]\n\n` | Template for returning streaming HTTP response, with %s marking the part to be replaced by cache value |
|
||||
|
||||
## Text Embedding Provider Specific Configurations
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
For Azure OpenAI, set `embedding.type` to `azure`. You need to first create an [Azure OpenAI account](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/overview), then select and deploy a model in [Azure AI Foundry](https://ai.azure.com/resource/deployments). Click on your deployed model to see the target URI and key in the endpoint section. Please enter the host from the URI in `embedding.serviceHost` and the key in `embedding.apiKey`.
|
||||
|
||||
A complete URI example is https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/embeddings?api-version=2024-10-21. You need to enter `YOUR_RESOURCE_NAME.openai.azure.com` in `embedding.serviceHost`.
|
||||
|
||||
Specific configuration fields:
|
||||
|
||||
| Name | Data Type | Requirement | Default | Description |
|
||||
| ---------------------- | -------- | -------- | ------ | ------- |
|
||||
| `embedding.apiVersion` | string | required | - | API version, the api-version value from the obtained URI |
|
||||
|
||||
Note that you must specify `embedding.serviceHost`, such as `YOUR_RESOURCE_NAME.openai.azure.com`. The default model is `text-embedding-ada-002`. For other models, specify in `embedding.model`.
|
||||
|
||||
### Cohere
|
||||
|
||||
For Cohere, set `embedding.type` to `cohere`. There are no specific configuration fields. You need to create an [API Key](https://docs.cohere.com/reference/embed) and enter it in `embedding.apiKey`.
|
||||
|
||||
### OpenAI
|
||||
|
||||
For OpenAI, set `embedding.type` to `openai`. There are no specific configuration fields. You need to create an [API Key](https://platform.openai.com/settings/organization/api-keys) and enter it in `embedding.apiKey`. An API Key example is `sk-xxxxxxx`.
|
||||
|
||||
### Ollama
|
||||
|
||||
For Ollama, set `embedding.type` to `ollama`. There are no specific configuration fields.
|
||||
|
||||
### Hugging Face
|
||||
|
||||
For Hugging Face, set `embedding.type` to `huggingface`. There are no specific configuration fields. You need to create an [hf_token](https://huggingface.co/blog/getting-started-with-embeddings) and enter it in `embedding.apiKey`. An hf_token example is `hf_xxxxxxx`.
|
||||
|
||||
`embedding.model` defaults to `sentence-transformers/all-MiniLM-L6-v2`.
|
||||
|
||||
### DashScope
|
||||
|
||||
For DashScope, set `embedding.type` to `dashscope`. You need to create an [API Key](https://help.aliyun.com/document_detail/2712195.html) and enter it in `embedding.apiKey`.
|
||||
|
||||
`embedding.model` defaults to `text-embedding-v2`. Other models like `text-embedding-v1` can also be used.
|
||||
|
||||
### TextIn
|
||||
|
||||
For TextIn, set `embedding.type` to `textin`. You need to first obtain [`app-id` and `secret-code`](https://www.textin.com/document/acge_text_embedding).
|
||||
|
||||
Specific configuration fields:
|
||||
|
||||
| Name | Data Type | Requirement | Default | Description |
|
||||
| ------------------------------- | -------- | -------- | ------ | ------------------ |
|
||||
| `embedding.textinAppId` | string | required | - | Application ID, obtained app-id |
|
||||
| `embedding.textinSecretCode` | string | required | - | Secret for calling API, obtained secret-code |
|
||||
| `embedding.textinMatryoshkaDim` | int | required | - | Dimension of returned single vector |
|
||||
|
||||
### Xfyun (讯飞星火)
|
||||
|
||||
For Xfyun, set `embedding.type` to `xfyun`. You need to first create an [application](https://console.xfyun.cn/services/emb) to obtain `APPID`, `APISecret`, and `APIKey`, and enter `APIKey` in `embedding.apiKey`.
|
||||
|
||||
Specific configuration fields:
|
||||
|
||||
| Name | Data Type | Requirement | Default | Description |
|
||||
| --------------------- | -------- | -------- | ------ | -------------------- |
|
||||
| `embedding.appId` | string | required | - | Application ID, obtained APPID |
|
||||
| `embedding.apiSecret` | string | required | - | Secret for calling API, obtained APISecret |
|
||||
|
||||
## Vector Database Provider Specific Configurations
|
||||
|
||||
### Chroma
|
||||
|
||||
For Chroma, set `vector.type` to `chroma`. There are no specific configuration fields. You need to create a Collection in advance and fill in the Collection ID in `vector.collectionID`. A Collection ID example is `52bbb8b3-724c-477b-a4ce-d5b578214612`.
|
||||
|
||||
### DashVector
|
||||
|
||||
For DashVector, set `vector.type` to `dashvector`. There are no specific configuration fields. You need to create a Collection in advance and fill in the `Collection Name` in `vector.collectionID`.
|
||||
|
||||
### ElasticSearch
|
||||
|
||||
For ElasticSearch, set `vector.type` to `elasticsearch`. You need to create an Index in advance and fill in the Index Name in `vector.collectionID`.
|
||||
|
||||
It currently relies on the [KNN](https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html) method. Please ensure your ES version supports `KNN`. It has been tested on version `8.16`.
|
||||
|
||||
Specific configuration fields:
|
||||
|
||||
| Name | Data Type | Requirement | Default | Description |
|
||||
|-------------------|----------|----------|--------|-------------------------------------------------------------------------------|
|
||||
| `vector.esUsername` | string | optional | - | ElasticSearch username |
|
||||
| `vector.esPassword` | string | optional | - | ElasticSearch password |
|
||||
|
||||
`vector.esUsername` and `vector.esPassword` are used for Basic authentication. API Key authentication is also supported. When `vector.apiKey` is filled in, API Key authentication is enabled. For SaaS versions, you need to fill in the `encoded` value.
|
||||
|
||||
### Milvus
|
||||
|
||||
For Milvus, set `vector.type` to `milvus`. There are no specific configuration fields. You need to create a Collection in advance and fill in the Collection Name in `vector.collectionID`.
|
||||
|
||||
### Pinecone
|
||||
|
||||
For Pinecone, set `vector.type` to `pinecone`. There are no specific configuration fields. You need to create an Index in advance and fill in the Index access domain in `vector.serviceHost`.
|
||||
|
||||
The `Namespace` parameter in Pinecone is configured through the plugin's `vector.collectionID`. If `vector.collectionID` is not filled in, it defaults to the Default Namespace.
|
||||
|
||||
### Qdrant
|
||||
|
||||
For Qdrant, set `vector.type` to `qdrant`. There are no specific configuration fields. You need to create a Collection in advance and fill in the Collection Name in `vector.collectionID`.
|
||||
|
||||
### Weaviate
|
||||
|
||||
For Weaviate, set `vector.type` to `weaviate`. There are no specific configuration fields. You need to create a Collection in advance and fill in the Collection Name in `vector.collectionID`.
|
||||
|
||||
Note that Weaviate automatically capitalizes the first letter, so when filling in `collectionID`, the first letter should be capitalized.
|
||||
|
||||
If using SaaS, you need to fill in the `vector.serviceHost` parameter.
|
||||
|
||||
## Configuration Example
|
||||
```yaml
|
||||
redis:
|
||||
serviceName: my-redis.dns
|
||||
timeout: 2000
|
||||
|
||||
### Basic Configuration
|
||||
```yaml
|
||||
embedding:
|
||||
type: dashscope
|
||||
serviceName: my_dashscope.dns
|
||||
apiKey: [Your Key]
|
||||
|
||||
vector:
|
||||
type: dashvector
|
||||
serviceName: my_dashvector.dns
|
||||
collectionID: [Your Collection ID]
|
||||
serviceHost: [Your domain]
|
||||
apiKey: [Your key]
|
||||
|
||||
cache:
|
||||
type: redis
|
||||
serviceName: my_redis.dns
|
||||
servicePort: 6379
|
||||
database: 1
|
||||
```
|
||||
timeout: 100
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
The current default cache key is based on the GJSON PATH expression: `messages.@reverse.0.content`, meaning to get the content of the first item after reversing the messages array;
|
||||
GJSON PATH supports conditional syntax, for instance, if you want to take the content of the last role as user as the key, it can be written as: `messages.@reverse.#(role=="user").content`;
|
||||
If you want to concatenate all the content with role as user into an array as the key, it can be written as: `messages.@reverse.#(role=="user")#.content`;
|
||||
It also supports pipeline syntax, for example, if you want to take the second role as user as the key, it can be written as: `messages.@reverse.#(role=="user")#.content|1`.
|
||||
For more usage, you can refer to the [official documentation](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) and use the [GJSON Playground](https://gjson.dev/) for syntax testing.
|
||||
|
||||
The current default cache key is extracted based on the GJSON PATH expression: `messages.@reverse.0.content`, which means reversing the messages array and taking the content of the first item.
|
||||
|
||||
GJSON PATH supports conditional syntax. For example, to get the content of the last role as user as the key, you can write: `messages.@reverse.#(role=="user").content`;
|
||||
|
||||
If you want to concatenate all content with role as user into an array as the key, you can write: `messages.@reverse.#(role=="user")#.content`;
|
||||
|
||||
It also supports pipeline syntax. For example, to get the second-to-last role as user as the key, you can write: `messages.@reverse.#(role=="user")#.content|1`.
|
||||
|
||||
For more usage, please refer to the [official documentation](https://github.com/tidwall/gjson/blob/master/SYNTAX.md). You can use the [GJSON Playground](https://gjson.dev/) for syntax testing.
|
||||
|
||||
## FAQ
|
||||
|
||||
1. If the returned error is `error status returned by host: bad argument`, please check if `serviceName` correctly includes the service type suffix (.dns, etc.).
|
||||
|
||||
@@ -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 请求体用于文本估算,超出部分不会被处理。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user