Compare commits

..

97 Commits

Author SHA1 Message Date
澄潭
8f1f935bc9 docs: add AGENTS.md with repo/plugin index for AI agents
Signed-off-by: 澄潭 <zty98751@alibaba-inc.com>
2026-07-07 00:54:23 +08:00
澄潭
4db97b6a36 test: build #4034 repro plugin in CI (VERSION -alpha)
CI build-wasm-plugins.sh only compiles wasm-go plugins whose VERSION ends
in -alpha; VERSION was "1" so the plugin was skipped and the file:// wasm was
absent in CI, making the wasm filter fail closed (HTTP 500 on every request).
Bump VERSION to 1.0.0-alpha so CI builds the plugin.

Signed-off-by: 澄潭 <zty98751@alibaba-inc.com>
2026-07-07 00:39:16 +08:00
澄潭
bf30043807 test: add #4034 conformance repro (redis-failure inject CPU spin)
Adds a wasm-go conformance case reproducing #4034: a deferred async
redis-failure callback synchronously calls injectEncodedDataToFilterChain,
which pre-fix makes WasmBase::doAfterVmCallActions re-queue an action forever
and spins a worker at 100% CPU. Redis is pointed at an unroutable endpoint so
the failure callbacks fire under concurrency. With the drain-to-local fix the
gateway stays responsive (200); a spinning worker would fail via timeout.

Validated end-to-end against the rebuilt gateway image.

Signed-off-by: 澄潭 <zty98751@alibaba-inc.com>
2026-07-06 21:48:47 +08:00
澄潭
0a8aea12d6 fix: point ENVOY_LATEST_IMAGE_TAG at rebuilt gateway for #4034 drain-to-local
Use the gateway image built from the envoy submodule that carries the
proxy-wasm-cpp-host doAfterVmCallActions drain-to-local fix, so e2e and
dev installs exercise the #4034 CPU-spin fix.

Signed-off-by: 澄潭 <zty98751@alibaba-inc.com>
2026-07-06 21:36:14 +08:00
澄潭
91244c578a fix: bump envoy submodule to include doAfterVmCallActions drain-to-local (#4034)
Advances the envoy/envoy submodule from f49d591e to f468a1a3 (higress-group/envoy
envoy-1.36 tip), which carries the proxy_wasm_cpp_host pin bump (higress-group/envoy#28)
picking up higress-group/proxy-wasm-cpp-host#12.

That fix makes WasmBase::doAfterVmCallActions reentry-safe (drain the
after_vm_call_actions_ queue into a stack-local before iterating), resolving the
worker-thread CPU 100% infinite loop when a Wasm plugin re-enters via
onRedisCallFailure + injectEncodedDataToFilterChain (#4034).

Signed-off-by: 澄潭 <zty98751@alibaba-inc.com>
2026-07-06 15:45:03 +08:00
澄潭
d5f2b624fb feat: initialize issue-spec workflow for Higress (#4059)
Signed-off-by: johnlanni <johnlanni@users.noreply.github.com>
Co-authored-by: johnlanni <johnlanni@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:55:53 +08:00
澄潭
6c98ea74e0 fix: avoid waiting for missing request bodies in transformer (#4024)
Signed-off-by: johnlanni <zty98751@alibaba-inc.com>
2026-06-25 17:36:01 +08:00
EndlessSeeker
39ec41aab6 Release v2.2.3 (#4019)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-06-25 10:34:30 +08:00
澄潭
910c26ec14 chore: update envoy submodule (#4017)
Signed-off-by: zty98751 <zty98751@alibaba-inc.com>
2026-06-24 19:06:16 +08:00
EndlessSeeker
582af464c0 Revert "fix: Skip TLS certificate verification for HTTPS upstreams" (#4016)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-06-24 15:39:32 +08:00
EndlessSeeker
04389fd0c6 fix: harden cluster key rate limit cookie parsing (#4012)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-06-24 15:03:05 +08:00
EndlessSeeker
10498a2c86 fix(oidc): fail closed when verifier is unavailable (#4013)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
Co-authored-by: Jingze <52855280+Jing-ze@users.noreply.github.com>
2026-06-24 14:22:24 +08:00
yangjuan
e958290283 feat(ai-context-limit): add context window limit wasm plugin (#4000)
Signed-off-by: Cai Rui <cairui@U-7VTK6WQN-2207.local>
2026-06-24 13:40:51 +08:00
DENG
6d1c55c1fb fix(ai-proxy): add ids for Vertex tool calls (#3990)
Signed-off-by: DENG <33118163+XinhhD@users.noreply.github.com>
Co-authored-by: woody <yaodiwu618@gmail.com>
2026-06-23 20:34:54 +08:00
9r0k
e57f18e581 feat(ai-proxy): vLLM passthrough for Anthropic Messages and newer OpenAI endpoints (#3989)
Signed-off-by: Rand01ph <tanyawei1991@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: woody <yaodiwu618@gmail.com>
2026-06-23 19:34:54 +08:00
Kent Dong
1694f48fd9 fix(ai-cache): handle SSE first chunk with role only (fixes #3953) (#3962)
Signed-off-by: CH3CHO <ch3cho@qq.com>
Co-authored-by: woody <yaodiwu618@gmail.com>
2026-06-23 17:38:50 +08:00
cyberslack_lee
7c94b5a822 fix: fix some typo errors (#3988)
Co-authored-by: woody <yaodiwu618@gmail.com>
2026-06-23 11:08:44 +08:00
geekspeng
ab0085362c fix(helm): apply imagePullPolicy to controller container and promtail sidecar (#4002)
Signed-off-by: geekspeng <geekspeng@icloud.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 22:33:03 +08:00
zijiren
9c13b6418c feat: Enhance SSL passthrough support (#3943)
Signed-off-by: zijiren233 <pyh1670605849@gmail.com>
2026-06-22 21:06:42 +08:00
EndlessSeeker
f060c9f51d fix: preserve ingress load balancer hostnames (#3994)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-06-22 11:34:38 +08:00
Heracles
bd6039f53b fix(helm): use Chart.Version as default plugin-server image tag (#3998)
Signed-off-by: yqz <2678785492@qq.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 09:28:36 +08:00
小画家
36bd830029 bugfix: fix golang-filter architecture mismatch during local gateway build on arm64 macOS (#3905)
Signed-off-by: 胥寅 <xuuyin@dingtalk.com>
Co-authored-by: 胥寅 <xuuyin@dingtalk.com>
2026-06-21 18:13:58 +08:00
澄潭
4db4633560 chore: update envoy submodule (#3987)
Signed-off-by: zty98751 <zty98751@alibaba-inc.com>
2026-06-17 20:28:15 +08:00
EndlessSeeker
efaef2e3d0 Support configurable GatewayClass isolation (#3981)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-06-17 20:27:45 +08:00
geekspeng
e758504d72 fix(helm): move imagePullPolicy from PodSpec to container level in gateway pod template (#3924)
Signed-off-by: geekspeng <geekspeng@icloud.com>
2026-06-17 16:22:51 +08:00
DENG
3c3a067f07 fix(ai-proxy): include tool call type in Claude stream deltas (#3985)
Signed-off-by: DENG <33118163+XinhhD@users.noreply.github.com>
Co-authored-by: woody <yaodiwu618@gmail.com>
2026-06-17 12:18:18 +08:00
DENG
7e11e2f320 fix(ai-proxy): preserve Vertex thoughtSignature in OpenAI tool calls (#3973)
Signed-off-by: DENG <33118163+XinhhD@users.noreply.github.com>
2026-06-17 11:40:38 +08:00
DENG
7abf27a2a3 fix(ai-proxy): clean client Authorization header for api-key providers (#3956)
Signed-off-by: DENG <33118163+XinhhD@users.noreply.github.com>
Co-authored-by: woody <yaodiwu618@gmail.com>
2026-06-16 16:19:37 +08:00
EndlessSeeker
d1f95f6e54 fix: set gateway status address type by value (#3980)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-06-16 13:41:25 +08:00
EndlessSeeker
83c12d571d feat(helm): allow skipping IngressClass creation (#3979)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-06-16 11:26:56 +08:00
Jingze
45fc5a31bc test(wasm-plugins): lift unit-test coverage to ≥90% across 9 plugins (#3879)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
Co-authored-by: woody <yaodiwu618@gmail.com>
2026-06-15 20:33:10 +08:00
Jingze
3065d4e071 test(traffic-tag): cover operator matrix, weight CDF boundaries, and parser reject paths (#3878)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
2026-06-15 20:32:38 +08:00
Jingze
c65d4cd938 test(hmac-auth-apisix): cover algorithm matrix, clock skew, and auth edge cases (#3875)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:31:02 +08:00
Jingze
547b7bf45a test(transformer): add end-to-end coverage and fix silent error wrapping (#3873)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
2026-06-15 20:29:51 +08:00
Jingze
bf0b1e96c5 test(wasm-go/mcp): expand unit test coverage for mcp-server framework (#3871)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:29:20 +08:00
EndlessSeeker
c69526b30e fix(helm): disable alpha Gateway API watches by default (#3971)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-06-15 18:52:28 +08:00
EndlessSeeker
73e7510eca fix: merge InferencePool route configs during HTTPRoute merge (#3964) 2026-06-12 18:05:24 +08:00
yangjuan
2897c1e83a feat(model-router): add keepOriginalModelName option to preserve full model name (#3916)
Signed-off-by: Cai Rui <yangjuan.cr@alibaba-inc.com>
2026-06-07 15:49:45 +08:00
FAUST
b69b5c7c10 doc: add missed chinese readme for extensions (#3890)
Signed-off-by: zhoujinyu <2319109590@qq.com>
2026-06-07 12:26:08 +08:00
Jingze
57db9ccc46 fix(oidc): fix Set-Cookie header corruption during session refresh (#3928)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 17:58:20 +08:00
澄潭
137d76332e fix: rebuild mcp filter on high memory usage (#3922)
Signed-off-by: johnlanni <zty98751@alibaba-inc.com>
2026-06-05 10:57:43 +08:00
澄潭
d888ab23de fix(wasm): remove request-count rebuild triggers (#3923)
Signed-off-by: johnlanni <zty98751@alibaba-inc.com>
2026-06-05 10:57:24 +08:00
Jingze
29b5a533e6 fix(oidc): bump oauth2-proxy to fix nil panic in verifier callback (#3914)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 10:52:24 +08:00
Jingze
071086904d fix(ai-proxy): strip anthropic-beta and anthropic-version headers in vertex passthrough (#3904)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
2026-06-02 20:26:30 +08:00
JianweiWang
58ffdae6ba feat(ai-security-guard): add Embedding API content detection support (#3895)
Signed-off-by: root <jianwei.wjw@alibaba-inc.com>
2026-06-02 12:11:00 +08:00
JianweiWang
a45b1dde6c fix(ai-statistics): skip empty streaming model values (#3851)
Signed-off-by: jianwei.wjw <jianwei.wjw@alibaba-inc.com>
2026-06-02 12:10:46 +08:00
zat366
52c99eb27d feat(ai-load-balancer): add cluster_hash load balancing policy with FNV-1a consistent hashing (#3898)
Signed-off-by: zat366 <authentic.zhao@gmail.com>
2026-06-01 10:19:46 +08:00
JianweiWang
c21a38e783 feat(ai-security-guard): structured x_higress deny response, error-path metrics, and AI logging (#3894)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: rinfx <yucheng.lxr@alibaba-inc.com>
2026-05-29 10:45:10 +08:00
Jingze
385f8d8b4e fix(ai-proxy): natively route Anthropic Messages to vertex :rawPredict (#3860)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:54:56 +08:00
Betula-L
a86aaadaa4 feat(jwt-auth): support remote JWKS (#3838)
Signed-off-by: Betula-L <6059935+Betula-L@users.noreply.github.com>
Co-authored-by: Betula-L <6059935+Betula-L@users.noreply.github.com>
2026-05-25 16:04:10 +08:00
Kent Dong
e6fc09b14f fix: Fix test cases in registry/nacos/mcpserver/watcher_test.go (#3869)
Signed-off-by: CH3CHO <ch3cho@qq.com>
2026-05-25 15:15:03 +08:00
Kent Dong
b8ff815100 fix: Fix the description of basePath related fields of ai-proxy plugin (#3870)
Signed-off-by: CH3CHO <ch3cho@qq.com>
2026-05-25 15:02:39 +08:00
zat366
632c6ca655 fix(ai-proxy): use HasSuffix instead of Contains in claude.GetApiName to prevent sub-path misidentification (#3839)
Signed-off-by: zat366 <authentic.zhao@gmail.com>
2026-05-25 14:34:45 +08:00
Ghxst
8a0f8a8208 feature: support plural key-auth credentials (#3849)
Signed-off-by: GHX5T-SOL <200635707+GHX5T-SOL@users.noreply.github.com>
Co-authored-by: GHX5T-SOL <200635707+GHX5T-SOL@users.noreply.github.com>
2026-05-24 17:56:58 +08:00
Jingze
b99e0c5027 fix(ai-proxy): re-anchor X-HI-ORIGINAL-AUTH on first hop to fix cascaded-gateway 401 (#3864)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:01:08 +08:00
muicoder
34040d1b99 fix #3858 arm64 image corruption caused by overriding BuildKit TARGETARCH (#3859)
Signed-off-by: muicoder <muicoder@gmail.com>
2026-05-22 11:17:56 +08:00
github-actions[bot]
870ed27435 Add release notes (#3854)
Co-authored-by: EndlessSeeker <153817598+EndlessSeeker@users.noreply.github.com>
2026-05-21 17:40:55 +08:00
EndlessSeeker
1ccafab24b Release v2.2.2 (#3852)
Signed-off-by: 如漫 <zhaoyuanxiao.zyx@alibaba-inc.com>
Signed-off-by: EndlessSeeker <1766508902@qq.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 14:50:10 +08:00
woody
37b038f797 feat: route bedrock messages through mantle (#3820)
Signed-off-by: wydream <yaodiwu618@gmail.com>
Signed-off-by: woody <yaodiwu618@gmail.com>
2026-05-20 18:13:04 +08:00
woody
739d47ba9c feat(ai-proxy): add cooldownDuration support for failover token recovery (#3700)
Signed-off-by: wydream <yaodiwu618@gmail.com>
Signed-off-by: woody <yaodiwu618@gmail.com>
2026-05-20 18:11:11 +08:00
woody
e7651f3d3e fix(ai-proxy): support azure openai v1 service urls (#3765)
Signed-off-by: wydream <yaodiwu618@gmail.com>
Co-authored-by: EndlessSeeker <153817598+EndlessSeeker@users.noreply.github.com>
2026-05-20 13:48:35 +08:00
woody
e1e631263c Fix/claude thinking tool call conversion (#3756)
Signed-off-by: wydream <yaodiwu618@gmail.com>
2026-05-20 11:34:07 +08:00
Kent Dong
f1dfc8f3d2 fix: Fix a field tag typo in ai-proxy (#3829)
Signed-off-by: CH3CHO <ch3cho@qq.com>
2026-05-19 15:02:36 +08:00
EndlessSeeker
73c93a18b0 docs: add OpenSSF Best Practices badge to README (#3830)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 09:46:00 +08:00
Kent Dong
ba774da55e feat(ext-auth): add support for allowed properties forwarding in external authorization requests (#3694)
Signed-off-by: CH3CHO <ch3cho@qq.com>
2026-05-15 16:03:50 +08:00
rinfx
e497d8017a feat(model-mapper): sync model header on remap and disable reroute (#3827)
Signed-off-by: 钰诚 <yucheng.lxr@alibaba-inc.com>
2026-05-15 14:38:54 +08:00
woody
a93847e07f Add Kling provider support (#3742)
Signed-off-by: wydream <yaodiwu618@gmail.com>
2026-05-14 16:18:00 +08:00
澄潭
b032f344e6 feat: add nginx rewrite compatible wasm plugin (#3823)
Signed-off-by: johnlanni <zty98751@alibaba-inc.com>
2026-05-14 11:24:58 +08:00
woody
c7eed0c0c1 fix(vertex): inject api key for express raw endpoints (#3777)
Signed-off-by: wydream <yaodiwu618@gmail.com>
Co-authored-by: EndlessSeeker <153817598+EndlessSeeker@users.noreply.github.com>
2026-05-13 10:18:29 +08:00
zat366
f8d81a7eb4 feat(ai-quota): add enable_path_suffixes configuration and update rel… (#3748)
Signed-off-by: zat366 <authentic.zhao@gmail.com>
Co-authored-by: Kent Dong <ch3cho@qq.com>
Co-authored-by: EndlessSeeker <153817598+EndlessSeeker@users.noreply.github.com>
2026-05-12 17:44:10 +08:00
Srikanth Patchava
29da03c371 fix: nil guards, safe type assertions, panic prevention, and rate limiter plugin (#3757)
Signed-off-by: Srikanth Patchava <spatchava@meta.com>
Signed-off-by: Srikanth Patchava <srpatcha@users.noreply.github.com>
Co-authored-by: Srikanth Patchava <srpatcha@users.noreply.github.com>
2026-05-12 11:00:37 +08:00
Jingze
1c4fe1c9f9 test(ai-proxy): expand wasm integration tests, coverage, and provider matrix (#3790)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
2026-05-12 10:20:08 +08:00
Jingze
678ea5660e fix(wasm): resolve stale route_name in wasm context after reroute (#3576)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com>
2026-05-12 10:19:36 +08:00
Kent Dong
148ef6905e fix: fix logging for unsupported upstream protocols in EnvoyFilter construction (#3801)
Signed-off-by: CH3CHO <ch3cho@qq.com>
2026-05-12 09:30:49 +08:00
Betula-L
6d50e2da42 fix(ai-proxy): preserve empty Claude tool inputs (#3799)
Signed-off-by: Betula-L <6059935+Betula-L@users.noreply.github.com>
Co-authored-by: Betula-L <6059935+Betula-L@users.noreply.github.com>
2026-05-09 21:35:35 +08:00
Betula-L
b77a074831 fix(ai-proxy): preserve Bedrock Claude reasoning blocks (#3788)
Signed-off-by: Betula-L <6059935+Betula-L@users.noreply.github.com>
Co-authored-by: Betula-L <6059935+Betula-L@users.noreply.github.com>
2026-05-08 10:27:48 +08:00
Betula-L
6199fe414d bugfix: map bedrock tool-call indexes and tool_choice (#3786)
Signed-off-by: Betula-L <6059935+Betula-L@users.noreply.github.com>
Co-authored-by: Betula-L <6059935+Betula-L@users.noreply.github.com>
2026-05-06 19:48:42 +08:00
rinfx
4aba4a9860 add cached token usage when converting openai to claude (#3766)
Signed-off-by: 钰诚 <yucheng.lxr@alibaba-inc.com>
2026-05-06 10:53:08 +08:00
Kent Dong
4e0d691432 fix: fix non-json logs with log_as_json enabled (#3779)
Signed-off-by: CH3CHO <ch3cho@qq.com>
2026-04-30 18:03:33 +08:00
Kent Dong
74c68180c8 fix: Skip TLS certificate verification for HTTPS upstreams (#3770)
Signed-off-by: CH3CHO <ch3cho@qq.com>
2026-04-29 17:15:54 +08:00
EndlessSeeker
9b9f6b900a docs: update SECURITY.md, CONTRIBUTING docs for CNCF/OpenSSF compliance (#3764)
Signed-off-by: EndlessSeeker <1766508902@qq.com>
2026-04-28 17:28:26 +08:00
JianweiWang
5173b4b2b8 feat(ai-security-guard): add fallback JSON paths for response content extraction (#3738)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: rinfx <yucheng.lxr@alibaba-inc.com>
2026-04-28 14:58:59 +08:00
澄潭
1d33067372 docs: add MAINTAINERS.md (#3754)
Signed-off-by: johnlanni <zty98751@alibaba-inc.com>
2026-04-24 16:25:24 +08:00
澄潭
fb8e1ef33f Update README.md 2026-04-21 14:50:23 +08:00
澄潭
a93f77d838 feat(ai-prompt-decorator): add literal/regex replace rules for message content (#3739) 2026-04-20 20:52:36 +08:00
Kent Dong
90ccfc7ec5 feat(envoy-build): add patch command check and improve error handling logic (#3734) 2026-04-20 13:41:42 +08:00
Kent Dong
d72363d8d1 fix(build): Make sure golang-filter is built with the correct TARGET_ARCH (#3682)
Co-authored-by: EndlessSeeker <153817598+EndlessSeeker@users.noreply.github.com>
2026-04-20 11:24:20 +08:00
Kent Dong
784cffa5e2 fix: Add default values to Dockerfile.higress to fix build warnings (#3425) 2026-04-20 10:52:15 +08:00
Kent Dong
8b8a710305 refactor(ai-cache): update README files to match latest config parsing code (#3730) 2026-04-20 09:45:39 +08:00
woody
65405965b6 fix(ai-proxy): harden Claude stream conversion compatibility (#3733) 2026-04-18 21:27:27 +08:00
JianweiWang
9128cbf729 fix(ai-security-guard): 移除Suggestion=block的兜底逻辑,改为基于阈值判断 || fix(ai-security-guard): Remove the cover-up logic of Suggestion=block and change it to based on threshold judgment (#3731)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-17 14:42:22 +08:00
woody
94b40aab9a feat(ai-proxy): support Qwen reranks and conversations paths (#3724) 2026-04-16 15:52:58 +08:00
woody
26e17c48eb fix(ai-proxy): migrate Qwen responses endpoint to new compatible path (#3722) 2026-04-16 10:13:25 +08:00
JianweiWang
b1187cc14d feat(ai-security-guard): enhance risk action resolution and support sensitive data masking (#3690)
Co-authored-by: rinfx <yucheng.lxr@alibaba-inc.com>
2026-04-15 11:14:56 +08:00
johnlanni
e2beb6cd45 Revert "feat(model-mapper): 新增 modelToHeader 配置项并优化 header 更新逻辑 || feat(model-mapper): Added modelToHeader configuration item and optimized header update logic (#3689)"
This reverts commit 60ce07d297.
2026-04-11 07:57:38 +08:00
github-actions[bot]
899633ea5e Add release notes (#3696)
Co-authored-by: EndlessSeeker <153817598+EndlessSeeker@users.noreply.github.com>
2026-04-10 13:56:25 +08:00
woody
bf96860a78 fix(vertex): add API Key auth for Vertex Raw Express Mode and fix tok… (#3695) 2026-04-10 09:55:27 +08:00
327 changed files with 45180 additions and 1550 deletions

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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
View 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 }}

View File

@@ -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
View File

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

View File

@@ -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
View 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.

View File

@@ -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.

View File

@@ -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)。
## 参与帮助任何事情

View File

@@ -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.

View File

@@ -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
View 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
View 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).

View File

@@ -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'

View File

@@ -7,12 +7,14 @@
<h4 align="center"> AI Native API Gateway </h4>
<div align="center">
[![Build Status](https://github.com/alibaba/higress/actions/workflows/build-and-test.yaml/badge.svg?branch=main)](https://github.com/alibaba/higress/actions)
[![license](https://img.shields.io/github/license/alibaba/higress.svg)](https://www.apache.org/licenses/LICENSE-2.0.html)
[![discord](https://img.shields.io/discord/1364956090566971515?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/tSbww9VDaM)
[![CNCF Sandbox](https://img.shields.io/badge/CNCF-Sandbox-30638E?logo=linuxfoundation&logoColor=white)](https://www.cncf.io/projects/)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/12667/badge)](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&#0032;APIs&#0032;as&#0032;MCP&#0032;powered&#0032;by&#0032;AI&#0032;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&#0032;APIs&#0032;as&#0032;MCP&#0032;powered&#0032;by&#0032;AI&#0032;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:
[![Deploy on AlibabaCloud](https://img.alicdn.com/imgextra/i1/O1CN01e6vwe71EWTHoZEcpK_!!6000000000359-55-tps-170-40.svg)](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
![](https://img.alicdn.com/imgextra/i1/O1CN01bhEtb229eeMNBWmdP_!!6000000008093-2-tps-750-547.png)
- **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
[![discord](https://img.shields.io/discord/1364956090566971515?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=for-the-badge)](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
[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/higress&type=Date)](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 ↑

View File

@@ -8,6 +8,8 @@
[![Build Status](https://github.com/alibaba/higress/actions/workflows/build-and-test.yaml/badge.svg?branch=main)](https://github.com/alibaba/higress/actions)
[![license](https://img.shields.io/github/license/alibaba/higress.svg)](https://www.apache.org/licenses/LICENSE-2.0.html)
[![CNCF Sandbox](https://img.shields.io/badge/CNCF-Sandbox-30638E?logo=linuxfoundation&logoColor=white)](https://www.cncf.io/projects/)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/12667/badge)](https://www.bestpractices.dev/projects/12667)
[**公式サイト**](https://higress.cn/) &nbsp; |
&nbsp; [**ドキュメント**](https://higress.cn/docs/latest/overview/what-is-higress/) &nbsp; |
@@ -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://api.star-history.com/svg?repos=alibaba/higress&type=Date)](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;">
↑ トップに戻る ↑

View File

@@ -10,8 +10,10 @@
[![Build Status](https://github.com/alibaba/higress/actions/workflows/build-and-test.yaml/badge.svg?branch=main)](https://github.com/alibaba/higress/actions)
[![license](https://img.shields.io/github/license/alibaba/higress.svg)](https://www.apache.org/licenses/LICENSE-2.0.html)
[![CNCF Sandbox](https://img.shields.io/badge/CNCF-Sandbox-30638E?logo=linuxfoundation&logoColor=white)](https://www.cncf.io/projects/)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/12667/badge)](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&#0032;APIs&#0032;as&#0032;MCP&#0032;powered&#0032;by&#0032;AI&#0032;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&#0032;APIs&#0032;as&#0032;MCP&#0032;powered&#0032;by&#0032;AI&#0032;Gateway | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
[**官网**](https://higress.cn/) &nbsp; |
@@ -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
[![Star History](https://api.star-history.com/svg?repos=alibaba/higress&type=Date)](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;">
↑ 返回顶部 ↑

View File

@@ -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.

View File

@@ -1 +1 @@
v2.2.1
v2.2.3

View File

@@ -18,7 +18,7 @@ import (
"fmt"
"os"
"istio.io/pkg/log"
"istio.io/istio/pkg/log"
"github.com/alibaba/higress/v2/pkg/cmd"
)

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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"

View File

@@ -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 }}

View File

@@ -23,7 +23,7 @@ spec:
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: {{ .Values.pluginServer.hub | default .Values.global.hub }}/higress/{{ .Values.pluginServer.image | default "plugin-server" }}:{{ .Values.pluginServer.tag | default "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 }}

View File

@@ -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.

View File

@@ -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"

View File

@@ -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

View File

@@ -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. |

View File

@@ -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 支持 |
| ... | ... | ... | ... |

View File

@@ -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,

View File

@@ -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,

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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{},

View File

@@ -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 ||

View File

@@ -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 {

View 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
}

View 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)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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}

View File

@@ -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++

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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))
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()

View File

@@ -16,13 +16,13 @@ package istio
import (
"fmt"
"github.com/alibaba/higress/v2/pkg/config/constants"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
gateway "sigs.k8s.io/gateway-api/apis/v1beta1"
"github.com/alibaba/higress/v2/pkg/config/constants"
"istio.io/istio/pkg/kube"
"istio.io/istio/pkg/kube/kclient/clienttest"
"istio.io/istio/pkg/test"
@@ -30,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)
}
}

View File

@@ -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
}

View File

@@ -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, "/", "~")

View 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")
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -409,7 +409,7 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
Protocol: string(protocol.HTTP),
Name: common.CreateConvertedName("http-80-ingress", c.options.ClusterId.String()),
},
Hosts: []string{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")

View File

@@ -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

View File

@@ -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 {

View File

@@ -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,
},
},
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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 认证,所有请求均需要经过认证后才能访问。

View File

@@ -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

View File

@@ -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);

View File

@@ -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 .

View File

@@ -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"

View File

@@ -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

View File

@@ -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.).

View File

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

View File

@@ -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)

View File

@@ -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
}

View File

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

View 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

View 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