From ee675538169d60ddb15d1f5150568cc90de5eff2 Mon Sep 17 00:00:00 2001
From: mamba <371510756@qq.com>
Date: Sun, 22 Sep 2024 16:49:54 +0800
Subject: [PATCH 01/16] [frontend-gray] Increase gray types according to the
ratio-weight gray (#1291)
---
.../extensions/frontend-gray/README.md | 82 ++++++++-
.../extensions/frontend-gray/config/config.go | 80 ++++++---
.../extensions/frontend-gray/envoy.yaml | 25 ++-
.../wasm-go/extensions/frontend-gray/main.go | 160 ++++++++++++------
.../extensions/frontend-gray/util/utils.go | 138 +++++++++++----
.../frontend-gray/util/utils_test.go | 24 ++-
6 files changed, 389 insertions(+), 120 deletions(-)
diff --git a/plugins/wasm-go/extensions/frontend-gray/README.md b/plugins/wasm-go/extensions/frontend-gray/README.md
index 6c5db2c3ac..1711969c90 100644
--- a/plugins/wasm-go/extensions/frontend-gray/README.md
+++ b/plugins/wasm-go/extensions/frontend-gray/README.md
@@ -17,11 +17,15 @@ description: 前端灰度插件配置参考
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|----|-----|----------------------------------------------------------------------------------------------------|
| `grayKey` | string | 非必填 | - | 用户ID的唯一标识,可以来自Cookie或者Header中,比如 userid,如果没有填写则使用`rules[].grayTagKey`和`rules[].grayTagValue`过滤灰度规则 |
-| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出,比如:`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` |
-| `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 |
-| `rewrite` | object | 必填 | - | 重写配置,一般用于OSS/CDN前端部署的重写配置 |
-| `baseDeployment` | object | 非必填 | - | 配置Base基线规则的配置 |
-| `grayDeployments` | array of object | 非必填 | - | 配置Gray灰度的生效规则,以及生效版本 |
+| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出,比如:`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` |
+| `userStickyMaxAge` | int | 非必填 | 172800 | 用户粘滞的时长:单位为秒,默认为`172800`,2天时间 |
+| `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 |
+| `rewrite` | object | 必填 | - | 重写配置,一般用于OSS/CDN前端部署的重写配置 |
+| `baseDeployment` | object | 非必填 | - | 配置Base基线规则的配置 |
+| `grayDeployments` | array of object | 非必填 | - | 配置Gray灰度的生效规则,以及生效版本 |
+| `backendGrayTag` | string | 非必填 | `x-mse-tag` | 后端灰度版本Tag,如果配置了,cookie中将携带值为`${backendGrayTag}:${grayDeployments[].backendVersion}` |
+| `injection` | object | 非必填 | - | 往首页HTML中注入全局信息,比如`` |
+
`rules`字段配置说明:
@@ -56,12 +60,30 @@ description: 前端灰度插件配置参考
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|-------------------------------------------------|
| `version` | string | 必填 | - | Gray版本的版本号,如果命中灰度规则,则使用此版本。如果是非CDN部署,在header添加`x-higress-tag` |
-| `backendVersion` | string | 必填 | - | 后端灰度版本,会在`XHR/Fetch`请求的header头添加 `x-mse-tag`到后端 |
+| `backendVersion` | string | 必填 | - | 后端灰度版本,配合`key`为`${backendGrayTag}`,写入cookie中 |
| `name` | string | 必填 | - | 规则名称和`rules[].name`关联, |
| `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 |
+| `weight` | int | 非必填 | - | 按照比例灰度,比如`50`。注意:灰度规则权重总和不能超过100,如果同时配置了`grayKey`以及`grayDeployments[0].weight`按照比例灰度优先生效 |
+> 为了实现按比例(weight) 进行灰度发布,并确保用户粘滞,我们需要确认客户端的唯一性。如果配置了 grayKey,则将其用作唯一标识;如果未配置 grayKey,则使用客户端的访问 IP 地址作为唯一标识。
+
+
+`injection`字段配置说明:
+
+| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
+|--------|--------|------|-----|-------------------------------------------------|
+| `head` | array of string | 非必填 | - | 注入head信息,比如`` |
+| `body` | object | 非必填 | - | 注入Body |
+
+`injection.body`字段配置说明:
+| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
+|--------|--------|------|-----|-------------------------------------------------|
+| `first` | array of string | 非必填 | - | 注入body标签的首部 |
+| `after` | array of string | 非必填 | - | 注入body标签的尾部 |
+
+
## 配置示例
-### 基础配置
+### 基础配置(按用户灰度)
```yml
grayKey: userid
rules:
@@ -94,6 +116,24 @@ cookie中的用户唯一标识为 `userid`,当前灰度规则配置了`beta-us
否则使用`version: base`版本
+### 按比例灰度
+```yml
+grayKey: userid
+rules:
+- name: inner-user
+ grayKeyValue:
+ - '00000001'
+ - '00000005'
+baseDeployment:
+ version: base
+grayDeployments:
+ - name: beta-user
+ version: gray
+ enabled: true
+ weight: 80
+```
+总的灰度规则为100%,其中灰度版本的权重为`80%`,基线版本为`20%`。一旦用户命中了灰度规则,会根据IP固定这个用户的灰度版本(否则会在下次请求时随机选择一个灰度版本)。
+
### 用户信息存在JSON中
```yml
@@ -174,3 +214,31 @@ grayDeployments:
- `/app1/js/a.js` => `/mfe/app1/v1.0.0/js/a.js`
- `/app1/js/template/a.js` => `/mfe/app1/v1.0.0/js/template/a.js`
+
+### 往HTML首页注入代码
+```yml
+grayKey: userid
+rules:
+- name: inner-user
+ grayKeyValue:
+ - '00000001'
+ - '00000005'
+baseDeployment:
+ version: base
+grayDeployments:
+ - name: beta-user
+ version: gray
+ enabled: true
+ weight: 80
+injection:
+ head:
+ -
+ body:
+ first:
+ -
+ -
+ last:
+ -
+ -
+```
+通过 `injection`往HTML首页注入代码,可以在`head`标签注入代码,也可以在`body`标签的`first`和`last`位置注入代码。
\ No newline at end of file
diff --git a/plugins/wasm-go/extensions/frontend-gray/config/config.go b/plugins/wasm-go/extensions/frontend-gray/config/config.go
index efadd07b14..fd1c26b154 100644
--- a/plugins/wasm-go/extensions/frontend-gray/config/config.go
+++ b/plugins/wasm-go/extensions/frontend-gray/config/config.go
@@ -1,16 +1,17 @@
package config
import (
+ "strings"
+
"github.com/tidwall/gjson"
)
const (
- XHigressTag = "x-higress-tag"
- XPreHigressTag = "x-pre-higress-tag"
- XMseTag = "x-mse-tag"
- IsHTML = "is_html"
- IsIndex = "is_index"
- NotFound = "not_found"
+ XHigressTag = "x-higress-tag"
+ XUniqueClientId = "x-unique-client"
+ XPreHigressTag = "x-pre-higress-tag"
+ IsPageRequest = "is-page-request"
+ IsNotFound = "is-not-found"
)
type LogInfo func(format string, args ...interface{})
@@ -22,16 +23,12 @@ type GrayRule struct {
GrayTagValue []string
}
-type BaseDeployment struct {
- Name string
- Version string
-}
-
-type GrayDeployment struct {
+type Deployment struct {
Name string
Enabled bool
Version string
BackendVersion string
+ Weight int
}
type Rewrite struct {
@@ -41,13 +38,27 @@ type Rewrite struct {
File map[string]string
}
+type Injection struct {
+ Head []string
+ Body *BodyInjection
+}
+
+type BodyInjection struct {
+ First []string
+ Last []string
+}
+
type GrayConfig struct {
- GrayKey string
- GraySubKey string
- Rules []*GrayRule
- Rewrite *Rewrite
- BaseDeployment *BaseDeployment
- GrayDeployments []*GrayDeployment
+ UserStickyMaxAge string
+ TotalGrayWeight int
+ GrayKey string
+ GraySubKey string
+ Rules []*GrayRule
+ Rewrite *Rewrite
+ BaseDeployment *Deployment
+ GrayDeployments []*Deployment
+ BackendGrayTag string
+ Injection *Injection
}
func convertToStringList(results []gjson.Result) []string {
@@ -71,6 +82,17 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
// 解析 GrayKey
grayConfig.GrayKey = json.Get("grayKey").String()
grayConfig.GraySubKey = json.Get("graySubKey").String()
+ grayConfig.BackendGrayTag = json.Get("backendGrayTag").String()
+ grayConfig.UserStickyMaxAge = json.Get("userStickyMaxAge").String()
+
+ if grayConfig.UserStickyMaxAge == "" {
+ // 默认值2天
+ grayConfig.UserStickyMaxAge = "172800"
+ }
+
+ if grayConfig.BackendGrayTag == "" {
+ grayConfig.BackendGrayTag = "x-mse-tag"
+ }
// 解析 Rules
rules := json.Get("rules").Array()
@@ -94,16 +116,30 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
baseDeployment := json.Get("baseDeployment")
grayDeployments := json.Get("grayDeployments").Array()
- grayConfig.BaseDeployment = &BaseDeployment{
+ grayConfig.BaseDeployment = &Deployment{
Name: baseDeployment.Get("name").String(),
- Version: baseDeployment.Get("version").String(),
+ Version: strings.Trim(baseDeployment.Get("version").String(), " "),
}
for _, item := range grayDeployments {
- grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &GrayDeployment{
+ if !item.Get("enabled").Bool() {
+ continue
+ }
+ grayWeight := int(item.Get("weight").Int())
+ grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &Deployment{
Name: item.Get("name").String(),
Enabled: item.Get("enabled").Bool(),
- Version: item.Get("version").String(),
+ Version: strings.Trim(item.Get("version").String(), " "),
BackendVersion: item.Get("backendVersion").String(),
+ Weight: grayWeight,
})
+ grayConfig.TotalGrayWeight += grayWeight
+ }
+
+ grayConfig.Injection = &Injection{
+ Head: convertToStringList(json.Get("injection.head").Array()),
+ Body: &BodyInjection{
+ First: convertToStringList(json.Get("injection.body.first").Array()),
+ Last: convertToStringList(json.Get("injection.body.last").Array()),
+ },
}
}
diff --git a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml
index ec7620bf06..f454a7f072 100644
--- a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml
+++ b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml
@@ -48,6 +48,8 @@ static_resources:
value: |
{
"grayKey": "userId",
+ "backendGrayTag": "x-mse-tag",
+ "userStickyMaxAge": 172800,
"rules": [
{
"name": "inner-user",
@@ -71,7 +73,7 @@ static_resources:
],
"rewrite": {
"host": "frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com",
- "notFoundUri": "/mfe/app1/dev/404.html",
+ "notFoundUri": "/mfe/app1/{version}/333.html",
"indexRouting": {
"/app1": "/mfe/app1/{version}/index.html",
"/": "/mfe/app1/{version}/index.html"
@@ -88,10 +90,25 @@ static_resources:
{
"name": "beta-user",
"version": "0.0.1",
- "backendVersion": "beta",
- "enabled": true
+ "enabled": true,
+ "weight": 50
}
- ]
+ ],
+ "injection": {
+ "head": [
+ ""
+ ],
+ "body": {
+ "first": [
+ "",
+ ""
+ ],
+ "last": [
+ "",
+ ""
+ ]
+ }
+ }
}
- name: envoy.filters.http.router
typed_config:
diff --git a/plugins/wasm-go/extensions/frontend-gray/main.go b/plugins/wasm-go/extensions/frontend-gray/main.go
index 365c200f95..e1fc7a8b83 100644
--- a/plugins/wasm-go/extensions/frontend-gray/main.go
+++ b/plugins/wasm-go/extensions/frontend-gray/main.go
@@ -28,6 +28,7 @@ func main() {
func parseConfig(json gjson.Result, grayConfig *config.GrayConfig, log wrapper.Log) error {
// 解析json 为GrayConfig
config.JsonToGrayConfig(json, grayConfig)
+ log.Infof("Rewrite: %v, GrayDeployments: %v", json.Get("rewrite"), json.Get("grayDeployments"))
return nil
}
@@ -40,10 +41,12 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
path, _ := proxywasm.GetHttpRequestHeader(":path")
fetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode")
- isIndex := util.IsIndexRequest(fetchMode, path)
+ isPageRequest := util.IsPageRequest(fetchMode, path)
hasRewrite := len(grayConfig.Rewrite.File) > 0 || len(grayConfig.Rewrite.Index) > 0
- grayKeyValue := util.GetGrayKey(util.ExtractCookieValueByKey(cookies, grayConfig.GrayKey), grayConfig.GraySubKey)
-
+ grayKeyValueByCookie := util.ExtractCookieValueByKey(cookies, grayConfig.GrayKey)
+ grayKeyValueByHeader, _ := proxywasm.GetHttpRequestHeader(grayConfig.GrayKey)
+ // 优先从cookie中获取,否则从header中获取
+ grayKeyValue := util.GetGrayKey(grayKeyValueByCookie, grayKeyValueByHeader, grayConfig.GraySubKey)
// 如果有重写的配置,则进行重写
if hasRewrite {
// 禁止重新路由,要在更改Header之前操作,否则会失效
@@ -53,22 +56,34 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
// 删除Accept-Encoding,避免压缩, 如果是压缩的内容,后续插件就没法处理了
_ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
_ = proxywasm.RemoveHttpRequestHeader("Content-Length")
-
- grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue, log.Infof)
- frontendVersion := util.GetVersion(grayConfig.BaseDeployment.Version, cookies, isIndex)
- backendVersion := ""
-
- // 命中灰度规则
- if grayDeployment != nil {
- frontendVersion = util.GetVersion(grayDeployment.Version, cookies, isIndex)
- backendVersion = grayDeployment.BackendVersion
+ deployment := &config.Deployment{}
+
+ preVersion, preUniqueClientId := util.GetXPreHigressVersion(cookies)
+ // 客户端唯一ID,用于在按照比率灰度时候 客户访问黏贴
+ uniqueClientId := grayKeyValue
+ if uniqueClientId == "" {
+ xForwardedFor, _ := proxywasm.GetHttpRequestHeader("X-Forwarded-For")
+ uniqueClientId = util.GetRealIpFromXff(xForwardedFor)
}
- proxywasm.AddHttpRequestHeader(config.XHigressTag, frontendVersion)
+ // 如果没有配置比例,则进行灰度规则匹配
+ if isPageRequest {
+ log.Infof("grayConfig.TotalGrayWeight==== %v", grayConfig.TotalGrayWeight)
+ if grayConfig.TotalGrayWeight > 0 {
+ deployment = util.FilterGrayWeight(&grayConfig, preVersion, preUniqueClientId, uniqueClientId)
+ } else {
+ deployment = util.FilterGrayRule(&grayConfig, grayKeyValue)
+ }
+ log.Infof("index deployment: %v, path: %v, backend: %v, xPreHigressVersion: %s,%s", deployment, path, deployment.BackendVersion, preVersion, preUniqueClientId)
+ } else {
+ deployment = util.GetVersion(grayConfig, deployment, preVersion, isPageRequest)
+ }
+ proxywasm.AddHttpRequestHeader(config.XHigressTag, deployment.Version)
- ctx.SetContext(config.XPreHigressTag, frontendVersion)
- ctx.SetContext(config.XMseTag, backendVersion)
- ctx.SetContext(config.IsIndex, isIndex)
+ ctx.SetContext(config.XPreHigressTag, deployment.Version)
+ ctx.SetContext(grayConfig.BackendGrayTag, deployment.BackendVersion)
+ ctx.SetContext(config.IsPageRequest, isPageRequest)
+ ctx.SetContext(config.XUniqueClientId, uniqueClientId)
rewrite := grayConfig.Rewrite
if rewrite.Host != "" {
@@ -77,12 +92,12 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
if hasRewrite {
rewritePath := path
- if isIndex {
- rewritePath = util.IndexRewrite(path, frontendVersion, grayConfig.Rewrite.Index)
+ if isPageRequest {
+ rewritePath = util.IndexRewrite(path, deployment.Version, grayConfig.Rewrite.Index)
} else {
- rewritePath = util.PrefixFileRewrite(path, frontendVersion, grayConfig.Rewrite.File)
+ rewritePath = util.PrefixFileRewrite(path, deployment.Version, grayConfig.Rewrite.File)
}
- log.Infof("rewrite path: %s %s %v", path, frontendVersion, rewritePath)
+ log.Infof("rewrite path: %s %s %v", path, deployment.Version, rewritePath)
proxywasm.ReplaceHttpRequestHeader(":path", rewritePath)
}
@@ -95,15 +110,34 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
}
status, err := proxywasm.GetHttpResponseHeader(":status")
contentType, _ := proxywasm.GetHttpResponseHeader("Content-Type")
+
+ if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
+ // 删除Content-Disposition,避免自动下载文件
+ proxywasm.RemoveHttpResponseHeader("Content-Disposition")
+ }
+
+ isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool)
+ if !ok {
+ isPageRequest = false // 默认值
+ }
+
if err != nil || status != "200" {
- isIndex := ctx.GetContext(config.IsIndex)
if status == "404" {
- if grayConfig.Rewrite.NotFound != "" && isIndex != nil && isIndex.(bool) {
- ctx.SetContext(config.NotFound, true)
+ if grayConfig.Rewrite.NotFound != "" && isPageRequest {
+ ctx.SetContext(config.IsNotFound, true)
responseHeaders, _ := proxywasm.GetHttpResponseHeaders()
headersMap := util.ConvertHeaders(responseHeaders)
- headersMap[":status"][0] = "200"
- headersMap["content-type"][0] = "text/html"
+ if _, ok := headersMap[":status"]; !ok {
+ headersMap[":status"] = []string{"200"} // 如果没有初始化,设定默认值
+ } else {
+ headersMap[":status"][0] = "200" // 修改现有值
+ }
+ if _, ok := headersMap["content-type"]; !ok {
+ headersMap["content-type"] = []string{"text/html"} // 如果没有初始化,设定默认值
+ } else {
+ headersMap["content-type"][0] = "text/html" // 修改现有值
+ }
+ // 删除 content-length 键
delete(headersMap, "content-length")
proxywasm.ReplaceHttpResponseHeaders(util.ReconvertHeaders(headersMap))
ctx.BufferResponseBody()
@@ -119,24 +153,22 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
// 删除content-length,可能要修改Response返回值
proxywasm.RemoveHttpResponseHeader("Content-Length")
- // 删除Content-Disposition,避免自动下载文件
- proxywasm.RemoveHttpResponseHeader("Content-Disposition")
-
- if strings.HasPrefix(contentType, "text/html") {
- ctx.SetContext(config.IsHTML, true)
+ if strings.HasPrefix(contentType, "text/html") || isPageRequest {
// 不会进去Streaming 的Body处理
ctx.BufferResponseBody()
- // 添加Cache-Control 头部,禁止缓存
- proxywasm.ReplaceHttpRequestHeader("Cache-Control", "no-cache, no-store")
+ proxywasm.ReplaceHttpResponseHeader("Cache-Control", "no-cache, no-store")
frontendVersion := ctx.GetContext(config.XPreHigressTag).(string)
- backendVersion := ctx.GetContext(config.XMseTag).(string)
-
- // 设置当前的前端版本
- proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Path=/;", config.XPreHigressTag, frontendVersion))
- // 设置后端的前端版本
- proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Path=/;", config.XMseTag, backendVersion))
+ xUniqueClient := ctx.GetContext(config.XUniqueClientId).(string)
+
+ // 设置前端的版本
+ proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s,%s; Max-Age=%s; Path=/;", config.XPreHigressTag, frontendVersion, xUniqueClient, grayConfig.UserStickyMaxAge))
+ // 设置后端的版本
+ if util.IsBackendGrayEnabled(grayConfig) {
+ backendVersion := ctx.GetContext(grayConfig.BackendGrayTag).(string)
+ proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%s; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.UserStickyMaxAge))
+ }
}
return types.ActionContinue
}
@@ -145,26 +177,52 @@ func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, b
if !util.IsGrayEnabled(grayConfig) {
return types.ActionContinue
}
- backendVersion := ctx.GetContext(config.XMseTag)
- isHtml := ctx.GetContext(config.IsHTML)
- isIndex := ctx.GetContext(config.IsIndex)
- notFoundUri := ctx.GetContext(config.NotFound)
- if isIndex != nil && isIndex.(bool) && notFoundUri != nil && notFoundUri.(bool) && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" {
+ isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool)
+ if !ok {
+ isPageRequest = false // 默认值
+ }
+ frontendVersion := ctx.GetContext(config.XPreHigressTag).(string)
+
+ isNotFound, ok := ctx.GetContext(config.IsNotFound).(bool)
+ if !ok {
+ isNotFound = false // 默认值
+ }
+
+ if isPageRequest && isNotFound && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" {
client := wrapper.NewClusterClient(wrapper.RouteCluster{Host: grayConfig.Rewrite.Host})
- client.Get(grayConfig.Rewrite.NotFound, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
+
+ client.Get(strings.Replace(grayConfig.Rewrite.NotFound, "{version}", frontendVersion, -1), nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
proxywasm.ReplaceHttpResponseBody(responseBody)
proxywasm.ResumeHttpResponse()
}, 1500)
return types.ActionPause
}
- // 以text/html 开头,将 cookie转到cookie
- if isHtml != nil && isHtml.(bool) && backendVersion != nil && backendVersion.(string) != "" {
- newText := strings.ReplaceAll(string(body), "", `
- `)
- if err := proxywasm.ReplaceHttpResponseBody([]byte(newText)); err != nil {
+ if isPageRequest {
+ // 将原始字节转换为字符串
+ newBody := string(body)
+
+ // 收集需要插入的内容
+ headInjection := strings.Join(grayConfig.Injection.Head, "\n")
+ bodyFirstInjection := strings.Join(grayConfig.Injection.Body.First, "\n")
+ bodyLastInjection := strings.Join(grayConfig.Injection.Body.Last, "\n")
+
+ // 使用 strings.Builder 来提高性能
+ var sb strings.Builder
+ // 预分配内存,避免多次内存分配
+ sb.Grow(len(newBody) + len(headInjection) + len(bodyFirstInjection) + len(bodyLastInjection))
+ sb.WriteString(newBody)
+
+ // 进行替换
+ content := sb.String()
+ content = strings.ReplaceAll(content, "", fmt.Sprintf("%s\n", headInjection))
+ content = strings.ReplaceAll(content, "
", fmt.Sprintf("\n%s", bodyFirstInjection))
+ content = strings.ReplaceAll(content, "", fmt.Sprintf("%s\n", bodyLastInjection))
+
+ // 最终结果
+ newBody = content
+
+ if err := proxywasm.ReplaceHttpResponseBody([]byte(newBody)); err != nil {
return types.ActionContinue
}
}
diff --git a/plugins/wasm-go/extensions/frontend-gray/util/utils.go b/plugins/wasm-go/extensions/frontend-gray/util/utils.go
index 9b0cb52080..a8c096816e 100644
--- a/plugins/wasm-go/extensions/frontend-gray/util/utils.go
+++ b/plugins/wasm-go/extensions/frontend-gray/util/utils.go
@@ -1,11 +1,14 @@
package util
import (
+ "fmt"
+ "math/rand"
"net/url"
"path"
"path/filepath"
"sort"
"strings"
+ "time"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
@@ -14,22 +17,53 @@ import (
"github.com/tidwall/gjson"
)
+func LogInfof(format string, args ...interface{}) {
+ format = fmt.Sprintf("[%s] %s", "frontend-gray", format)
+ proxywasm.LogInfof(format, args...)
+}
+
+func GetXPreHigressVersion(cookies string) (string, string) {
+ xPreHigressVersion := ExtractCookieValueByKey(cookies, config.XPreHigressTag)
+ preVersions := strings.Split(xPreHigressVersion, ",")
+ if len(preVersions) == 0 {
+ return "", ""
+ }
+ if len(preVersions) == 1 {
+ return preVersions[0], ""
+ }
+
+ return strings.TrimSpace(preVersions[0]), strings.TrimSpace(preVersions[1])
+}
+
+// 从xff中获取真实的IP
+func GetRealIpFromXff(xff string) string {
+ if xff != "" {
+ // 通常客户端的真实 IP 是 XFF 头中的第一个 IP
+ ips := strings.Split(xff, ",")
+ if len(ips) > 0 {
+ return strings.TrimSpace(ips[0])
+ }
+ }
+ return ""
+}
+
func IsGrayEnabled(grayConfig config.GrayConfig) bool {
// 检查是否存在重写主机
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
return true
}
- // 检查灰度部署是否为 nil 或空
- grayDeployments := grayConfig.GrayDeployments
- if grayDeployments != nil && len(grayDeployments) > 0 {
- for _, grayDeployment := range grayDeployments {
- if grayDeployment.Enabled {
- return true
- }
+ // 检查是否存在灰度版本配置
+ return len(grayConfig.GrayDeployments) > 0
+}
+
+// 是否启用后端的灰度(全链路灰度)
+func IsBackendGrayEnabled(grayConfig config.GrayConfig) bool {
+ for _, deployment := range grayConfig.GrayDeployments {
+ if deployment.BackendVersion != "" {
+ return true
}
}
-
return false
}
@@ -98,12 +132,11 @@ var indexSuffixes = []string{
".html", ".htm", ".jsp", ".php", ".asp", ".aspx", ".erb", ".ejs", ".twig",
}
-// IsIndexRequest determines if the request is an index request
-func IsIndexRequest(fetchMode string, p string) bool {
+func IsPageRequest(fetchMode string, myPath string) bool {
if fetchMode == "cors" {
return false
}
- ext := path.Ext(p)
+ ext := path.Ext(myPath)
return ext == "" || ContainsValue(indexSuffixes, ext)
}
@@ -133,22 +166,25 @@ func PrefixFileRewrite(path, version string, matchRules map[string]string) strin
return filepath.Clean(newPath)
}
-func GetVersion(version string, cookies string, isIndex bool) string {
- if isIndex {
- return version
+func GetVersion(grayConfig config.GrayConfig, deployment *config.Deployment, xPreHigressVersion string, isPageRequest bool) *config.Deployment {
+ if isPageRequest {
+ return deployment
}
- // 来自Cookie中的版本
- cookieVersion := ExtractCookieValueByKey(cookies, config.XPreHigressTag)
// cookie 中为空,返回当前版本
- if cookieVersion == "" {
- return version
+ if xPreHigressVersion == "" {
+ return deployment
}
// cookie 中和当前版本不相同,返回cookie中值
- if cookieVersion != version {
- return cookieVersion
+ if xPreHigressVersion != deployment.Version {
+ deployments := append(grayConfig.GrayDeployments, grayConfig.BaseDeployment)
+ for _, curDeployment := range deployments {
+ if curDeployment.Version == xPreHigressVersion {
+ return curDeployment
+ }
+ }
}
- return version
+ return grayConfig.BaseDeployment
}
// 从cookie中解析出灰度信息
@@ -169,7 +205,12 @@ func getBySubKey(grayInfoStr string, graySubKey string) string {
return value.String()
}
-func GetGrayKey(grayKeyValue string, graySubKey string) string {
+func GetGrayKey(grayKeyValueByCookie string, grayKeyValueByHeader string, graySubKey string) string {
+ grayKeyValue := grayKeyValueByCookie
+ if grayKeyValueByCookie == "" {
+ grayKeyValue = grayKeyValueByHeader
+ }
+
// 如果有子key, 尝试从子key中获取值
if graySubKey != "" {
subKeyValue := getBySubKey(grayKeyValue, graySubKey)
@@ -181,18 +222,13 @@ func GetGrayKey(grayKeyValue string, graySubKey string) string {
}
// FilterGrayRule 过滤灰度规则
-func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, logInfof func(format string, args ...interface{})) *config.GrayDeployment {
- for _, grayDeployment := range grayConfig.GrayDeployments {
- if !grayDeployment.Enabled {
- // 跳过Enabled=false
- continue
- }
- grayRule := GetRule(grayConfig.Rules, grayDeployment.Name)
+func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string) *config.Deployment {
+ for _, deployment := range grayConfig.GrayDeployments {
+ grayRule := GetRule(grayConfig.Rules, deployment.Name)
// 首先:先校验用户名单ID
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
if ContainsValue(grayRule.GrayKeyValue, grayKeyValue) {
- logInfof("frontendVersion: %s, grayKeyValue: %s", grayDeployment.Version, grayKeyValue)
- return grayDeployment
+ return deployment
}
}
// 第二:校验Cookie中的 GrayTagKey
@@ -200,11 +236,45 @@ func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, logInfof
cookieStr, _ := proxywasm.GetHttpRequestHeader("cookie")
grayTagValue := ExtractCookieValueByKey(cookieStr, grayRule.GrayTagKey)
if ContainsValue(grayRule.GrayTagValue, grayTagValue) {
- logInfof("frontendVersion: %s, grayTag: %s=%s", grayDeployment.Version, grayRule.GrayTagKey, grayTagValue)
- return grayDeployment
+ return deployment
+ }
+ }
+ }
+ return grayConfig.BaseDeployment
+}
+
+func FilterGrayWeight(grayConfig *config.GrayConfig, preVersion string, preUniqueClientId string, uniqueClientId string) *config.Deployment {
+ // 如果没有灰度权重,直接返回基础版本
+ if grayConfig.TotalGrayWeight == 0 {
+ return grayConfig.BaseDeployment
+ }
+
+ deployments := append(grayConfig.GrayDeployments, grayConfig.BaseDeployment)
+ LogInfof("preVersion: %s, preUniqueClientId: %s, uniqueClientId: %s", preVersion, preUniqueClientId, uniqueClientId)
+ // 用户粘滞,确保每个用户每次访问的都是走同一版本
+ if preVersion != "" && uniqueClientId == preUniqueClientId {
+ for _, deployment := range deployments {
+ if deployment.Version == preVersion {
+ return deployment
}
}
}
- logInfof("frontendVersion: %s, grayKeyValue: %s", grayConfig.BaseDeployment.Version, grayKeyValue)
+
+ totalWeight := 100
+ // 如果总权重小于100,则将基础版本也加入到总版本列表中
+ if grayConfig.TotalGrayWeight <= totalWeight {
+ grayConfig.BaseDeployment.Weight = 100 - grayConfig.TotalGrayWeight
+ } else {
+ totalWeight = grayConfig.TotalGrayWeight
+ }
+ rand.Seed(time.Now().UnixNano())
+ randWeight := rand.Intn(totalWeight)
+ sumWeight := 0
+ for _, deployment := range deployments {
+ sumWeight += deployment.Weight
+ if randWeight < sumWeight {
+ return deployment
+ }
+ }
return nil
}
diff --git a/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go b/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go
index 147c32311b..7ba014225f 100644
--- a/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go
+++ b/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go
@@ -3,7 +3,9 @@ package util
import (
"testing"
+ "github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
"github.com/stretchr/testify/assert"
+ "github.com/tidwall/gjson"
)
func TestExtractCookieValueByKey(t *testing.T) {
@@ -80,7 +82,7 @@ func TestPrefixFileRewrite(t *testing.T) {
}
}
-func TestIsIndexRequest(t *testing.T) {
+func TestIsPageRequest(t *testing.T) {
var tests = []struct {
fetchMode string
p string
@@ -97,8 +99,26 @@ func TestIsIndexRequest(t *testing.T) {
for _, test := range tests {
testPath := test.p
t.Run(testPath, func(t *testing.T) {
- output := IsIndexRequest(test.fetchMode, testPath)
+ output := IsPageRequest(test.fetchMode, testPath)
assert.Equal(t, test.output, output)
})
}
}
+
+func TestFilterGrayWeight(t *testing.T) {
+ var tests = []struct {
+ name string
+ input string
+ }{
+ {"demo", `{"grayKey":"userId","rules":[{"name":"inner-user","grayKeyValue":["00000001","00000005"]},{"name":"beta-user","grayKeyValue":["noah","00000003"],"grayTagKey":"level","grayTagValue":["level3","level5"]}],"rewrite":{"host":"frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com","notFoundUri":"/mfe/app1/dev/404.html","indexRouting":{"/app1":"/mfe/app1/{version}/index.html","/":"/mfe/app1/{version}/index.html"},"fileRouting":{"/":"/mfe/app1/{version}","/app1":"/mfe/app1/{version}"}},"baseDeployment":{"version":"dev"},"grayDeployments":[{"name":"beta-user","version":"0.0.1","backendVersion":"beta","enabled":true,"weight":50}]}`},
+ }
+ for _, test := range tests {
+ testName := test.name
+ t.Run(testName, func(t *testing.T) {
+ grayConfig := &config.GrayConfig{}
+ config.JsonToGrayConfig(gjson.Parse(test.input), grayConfig)
+ result := FilterGrayWeight(grayConfig, "base", "1.0.1", "192.168.1.1")
+ t.Logf("result-----: %v", result)
+ })
+ }
+}
From c923e5cb42d6ea31af7c6f9019be5136071d9a2e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?brother-=E6=88=8E?=
Date: Mon, 23 Sep 2024 13:53:08 +0800
Subject: [PATCH 02/16] feat: add annotation for mirror svc (#1121)
---
pkg/ingress/kube/annotations/annotations.go | 4 +
pkg/ingress/kube/annotations/mirror.go | 118 +++++++++++++
pkg/ingress/kube/annotations/mirror_test.go | 163 ++++++++++++++++++
pkg/ingress/kube/util/util.go | 43 +++++
plugins/wasm-go/extensions/oidc/go.mod | 4 +-
test/e2e/conformance/base/manifests.yaml | 49 ++++++
.../tests/httproute-mirror-target-service.go | 107 ++++++++++++
.../httproute-mirror-target-service.yaml | 32 ++++
8 files changed, 519 insertions(+), 1 deletion(-)
create mode 100644 pkg/ingress/kube/annotations/mirror.go
create mode 100644 pkg/ingress/kube/annotations/mirror_test.go
create mode 100644 test/e2e/conformance/tests/httproute-mirror-target-service.go
create mode 100644 test/e2e/conformance/tests/httproute-mirror-target-service.yaml
diff --git a/pkg/ingress/kube/annotations/annotations.go b/pkg/ingress/kube/annotations/annotations.go
index be9afe2675..36e4a6dea0 100644
--- a/pkg/ingress/kube/annotations/annotations.go
+++ b/pkg/ingress/kube/annotations/annotations.go
@@ -69,6 +69,8 @@ type Ingress struct {
Auth *AuthConfig
+ Mirror *MirrorConfig
+
Destination *DestinationConfig
IgnoreCase *IgnoreCaseConfig
@@ -161,6 +163,7 @@ func NewAnnotationHandlerManager() AnnotationHandler {
localRateLimit{},
fallback{},
auth{},
+ mirror{},
destination{},
ignoreCaseMatching{},
match{},
@@ -182,6 +185,7 @@ func NewAnnotationHandlerManager() AnnotationHandler {
retry{},
localRateLimit{},
fallback{},
+ mirror{},
ignoreCaseMatching{},
match{},
headerControl{},
diff --git a/pkg/ingress/kube/annotations/mirror.go b/pkg/ingress/kube/annotations/mirror.go
new file mode 100644
index 0000000000..fa1098c58e
--- /dev/null
+++ b/pkg/ingress/kube/annotations/mirror.go
@@ -0,0 +1,118 @@
+// Copyright (c) 2023 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 (
+ "github.com/alibaba/higress/pkg/ingress/kube/util"
+ . "github.com/alibaba/higress/pkg/ingress/log"
+ wrappers "google.golang.org/protobuf/types/known/wrapperspb"
+ networking "istio.io/api/networking/v1alpha3"
+)
+
+const (
+ mirrorTargetService = "mirror-target-service"
+ mirrorPercentage = "mirror-percentage"
+)
+
+var (
+ _ Parser = &mirror{}
+ _ RouteHandler = &mirror{}
+)
+
+type MirrorConfig struct {
+ util.ServiceInfo
+ Percentage *wrappers.DoubleValue
+}
+
+type mirror struct{}
+
+func (m mirror) Parse(annotations Annotations, config *Ingress, globalContext *GlobalContext) error {
+ if !needMirror(annotations) {
+ return nil
+ }
+
+ target, err := annotations.ParseStringASAP(mirrorTargetService)
+ if err != nil {
+ IngressLog.Errorf("Get mirror target service fail, err: %v", err)
+ return nil
+ }
+
+ serviceInfo, err := util.ParseServiceInfo(target, config.Namespace)
+ if err != nil {
+ IngressLog.Errorf("Get mirror target service fail, err: %v", err)
+ return nil
+ }
+
+ serviceLister, exist := globalContext.ClusterServiceList[config.ClusterId]
+ if !exist {
+ IngressLog.Errorf("service lister of cluster %s doesn't exist", config.ClusterId)
+ return nil
+ }
+
+ service, err := serviceLister.Services(serviceInfo.Namespace).Get(serviceInfo.Name)
+ if err != nil {
+ IngressLog.Errorf("Mirror service %s/%s within ingress %s/%s is not found, with err: %v",
+ serviceInfo.Namespace, serviceInfo.Name, config.Namespace, config.Name, err)
+ return nil
+ }
+ if service == nil {
+ IngressLog.Errorf("service %s/%s within ingress %s/%s is empty value",
+ serviceInfo.Namespace, serviceInfo.Name, config.Namespace, config.Name)
+ return nil
+ }
+
+ if serviceInfo.Port == 0 {
+ // Use the first port
+ serviceInfo.Port = uint32(service.Spec.Ports[0].Port)
+ }
+
+ var percentage *wrappers.DoubleValue
+ if value, err := annotations.ParseIntASAP(mirrorPercentage); err == nil {
+ if value < 100 {
+ percentage = &wrappers.DoubleValue{
+ Value: float64(value),
+ }
+ }
+ }
+
+ config.Mirror = &MirrorConfig{
+ ServiceInfo: serviceInfo,
+ Percentage: percentage,
+ }
+ return nil
+}
+
+func (m mirror) ApplyRoute(route *networking.HTTPRoute, config *Ingress) {
+ if config.Mirror == nil {
+ return
+ }
+
+ route.Mirror = &networking.Destination{
+ Host: util.CreateServiceFQDN(config.Mirror.Namespace, config.Mirror.Name),
+ Port: &networking.PortSelector{
+ Number: config.Mirror.Port,
+ },
+ }
+
+ if config.Mirror.Percentage != nil {
+ route.MirrorPercentage = &networking.Percent{
+ Value: config.Mirror.Percentage.GetValue(),
+ }
+ }
+}
+
+func needMirror(annotations Annotations) bool {
+ return annotations.HasASAP(mirrorTargetService)
+}
diff --git a/pkg/ingress/kube/annotations/mirror_test.go b/pkg/ingress/kube/annotations/mirror_test.go
new file mode 100644
index 0000000000..1a35d9f700
--- /dev/null
+++ b/pkg/ingress/kube/annotations/mirror_test.go
@@ -0,0 +1,163 @@
+// 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 (
+ "github.com/alibaba/higress/pkg/ingress/kube/util"
+ "github.com/golang/protobuf/proto"
+ networking "istio.io/api/networking/v1alpha3"
+ "istio.io/istio/pilot/pkg/model"
+ "reflect"
+ "testing"
+)
+
+func TestParseMirror(t *testing.T) {
+ testCases := []struct {
+ input []map[string]string
+ expect *MirrorConfig
+ }{
+ {},
+ {
+ input: []map[string]string{
+ {buildHigressAnnotationKey(mirrorTargetService): "test/app"},
+ {buildNginxAnnotationKey(mirrorTargetService): "test/app"},
+ },
+ expect: &MirrorConfig{
+ ServiceInfo: util.ServiceInfo{
+ NamespacedName: model.NamespacedName{
+ Namespace: "test",
+ Name: "app",
+ },
+ Port: 80,
+ },
+ },
+ },
+ {
+ input: []map[string]string{
+ {buildHigressAnnotationKey(mirrorTargetService): "test/app:8080"},
+ {buildNginxAnnotationKey(mirrorTargetService): "test/app:8080"},
+ },
+ expect: &MirrorConfig{
+ ServiceInfo: util.ServiceInfo{
+ NamespacedName: model.NamespacedName{
+ Namespace: "test",
+ Name: "app",
+ },
+ Port: 8080,
+ },
+ },
+ },
+ {
+ input: []map[string]string{
+ {buildHigressAnnotationKey(mirrorTargetService): "test/app:hi"},
+ {buildNginxAnnotationKey(mirrorTargetService): "test/app:hi"},
+ },
+ expect: &MirrorConfig{
+ ServiceInfo: util.ServiceInfo{
+ NamespacedName: model.NamespacedName{
+ Namespace: "test",
+ Name: "app",
+ },
+ Port: 80,
+ },
+ },
+ },
+ {
+ input: []map[string]string{
+ {buildHigressAnnotationKey(mirrorTargetService): "test/app"},
+ {buildNginxAnnotationKey(mirrorTargetService): "test/app"},
+ },
+ expect: &MirrorConfig{
+ ServiceInfo: util.ServiceInfo{
+ NamespacedName: model.NamespacedName{
+ Namespace: "test",
+ Name: "app",
+ },
+ Port: 80,
+ },
+ },
+ },
+ }
+
+ mirror := mirror{}
+
+ for _, testCase := range testCases {
+ t.Run("", func(t *testing.T) {
+ config := &Ingress{
+ Meta: Meta{
+ Namespace: "test",
+ ClusterId: "cluster",
+ },
+ }
+ globalContext, cancel := initGlobalContextForService()
+ defer cancel()
+
+ for _, in := range testCase.input {
+ _ = mirror.Parse(in, config, globalContext)
+ if !reflect.DeepEqual(testCase.expect, config.Mirror) {
+ t.Log("expect:", *testCase.expect)
+ t.Log("actual:", *config.Mirror)
+ t.Fatal("Should be equal")
+ }
+ }
+ })
+ }
+}
+
+func TestMirror_ApplyRoute(t *testing.T) {
+ testCases := []struct {
+ config *Ingress
+ input *networking.HTTPRoute
+ expect *networking.HTTPRoute
+ }{
+ {
+ config: &Ingress{},
+ input: &networking.HTTPRoute{},
+ expect: &networking.HTTPRoute{},
+ },
+ {
+ config: &Ingress{
+ Mirror: &MirrorConfig{
+ ServiceInfo: util.ServiceInfo{
+ NamespacedName: model.NamespacedName{
+ Namespace: "default",
+ Name: "test",
+ },
+ Port: 8080,
+ },
+ },
+ },
+ input: &networking.HTTPRoute{},
+ expect: &networking.HTTPRoute{
+ Mirror: &networking.Destination{
+ Host: "test.default.svc.cluster.local",
+ Port: &networking.PortSelector{
+ Number: 8080,
+ },
+ },
+ },
+ },
+ }
+
+ mirror := mirror{}
+ for _, testCase := range testCases {
+ t.Run("", func(t *testing.T) {
+ mirror.ApplyRoute(testCase.input, testCase.config)
+ if !proto.Equal(testCase.input, testCase.expect) {
+ t.Fatal("Must be equal.")
+ }
+ })
+ }
+}
diff --git a/pkg/ingress/kube/util/util.go b/pkg/ingress/kube/util/util.go
index ec9c68870d..597f56f2c0 100644
--- a/pkg/ingress/kube/util/util.go
+++ b/pkg/ingress/kube/util/util.go
@@ -20,8 +20,10 @@ import (
"encoding/hex"
"errors"
"fmt"
+ "istio.io/istio/pilot/pkg/model"
"os"
"path"
+ "strconv"
"strings"
"github.com/golang/protobuf/jsonpb"
@@ -113,3 +115,44 @@ func BuildPatchStruct(config string) *_struct.Struct {
}
return val
}
+
+type ServiceInfo struct {
+ model.NamespacedName
+ Port uint32
+}
+
+// convertToPort converts a port string to a uint32.
+func convertToPort(v string) (uint32, error) {
+ p, err := strconv.ParseUint(v, 10, 32)
+ if err != nil || p > 65535 {
+ return 0, fmt.Errorf("invalid port %s: %v", v, err)
+ }
+ return uint32(p), nil
+}
+
+func ParseServiceInfo(service string, ingressNamespace string) (ServiceInfo, error) {
+ parts := strings.Split(service, ":")
+ namespacedName := SplitNamespacedName(parts[0])
+
+ if namespacedName.Name == "" {
+ return ServiceInfo{}, errors.New("service name can not be empty")
+ }
+
+ if namespacedName.Namespace == "" {
+ namespacedName.Namespace = ingressNamespace
+ }
+
+ var port uint32
+ if len(parts) == 2 {
+ // If port parse fail, we ignore port and pick the first one.
+ port, _ = convertToPort(parts[1])
+ }
+
+ return ServiceInfo{
+ NamespacedName: model.NamespacedName{
+ Name: namespacedName.Name,
+ Namespace: namespacedName.Namespace,
+ },
+ Port: port,
+ }, nil
+}
diff --git a/plugins/wasm-go/extensions/oidc/go.mod b/plugins/wasm-go/extensions/oidc/go.mod
index 753be9a2db..89ef5f4c7b 100644
--- a/plugins/wasm-go/extensions/oidc/go.mod
+++ b/plugins/wasm-go/extensions/oidc/go.mod
@@ -1,6 +1,8 @@
module github.com/alibaba/higress/plugins/wasm-go/extensions/oidc
-go 1.19
+go 1.21
+
+toolchain go1.22.5
replace github.com/alibaba/higress/plugins/wasm-go => ../..
diff --git a/test/e2e/conformance/base/manifests.yaml b/test/e2e/conformance/base/manifests.yaml
index eec4b0c316..235dce14dc 100644
--- a/test/e2e/conformance/base/manifests.yaml
+++ b/test/e2e/conformance/base/manifests.yaml
@@ -178,6 +178,55 @@ spec:
---
apiVersion: v1
kind: Service
+metadata:
+ name: infra-backend-mirror
+ namespace: higress-conformance-infra
+spec:
+ selector:
+ app: infra-backend-mirror
+ ports:
+ - protocol: TCP
+ port: 8080
+ targetPort: 3000
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: infra-backend-mirror
+ namespace: higress-conformance-infra
+ labels:
+ app: infra-backend-mirror
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: infra-backend-mirror
+ template:
+ metadata:
+ labels:
+ app: infra-backend-mirror
+ spec:
+ containers:
+ - name: infra-backend-mirror
+ # image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e
+
+ # From https://github.com/Uncle-Justice/echo-server
+ image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0
+ env:
+ - name: POD_NAME
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.name
+ - name: NAMESPACE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.namespace
+ resources:
+ requests:
+ cpu: 10m
+---
+apiVersion: v1
+kind: Service
metadata:
name: infra-backend-echo-body-v1
namespace: higress-conformance-infra
diff --git a/test/e2e/conformance/tests/httproute-mirror-target-service.go b/test/e2e/conformance/tests/httproute-mirror-target-service.go
new file mode 100644
index 0000000000..1e138ac188
--- /dev/null
+++ b/test/e2e/conformance/tests/httproute-mirror-target-service.go
@@ -0,0 +1,107 @@
+// 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 tests
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/alibaba/higress/test/e2e/conformance/utils/http"
+ "github.com/alibaba/higress/test/e2e/conformance/utils/suite"
+ v1 "k8s.io/api/core/v1"
+ meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes"
+ "sigs.k8s.io/controller-runtime/pkg/client/config"
+)
+
+func init() {
+ Register(HTTPRouteMirrorTargetService)
+}
+
+var HTTPRouteMirrorTargetService = suite.ConformanceTest{
+ ShortName: "HTTPRouteMirrorTargetService",
+ Description: "The Ingress in the higress-conformance-infra namespace mirror request to target service",
+ Features: []suite.SupportedFeature{suite.HTTPConformanceFeature},
+ Manifests: []string{"tests/httproute-mirror-target-service.yaml"},
+ Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
+ testcases := []http.Assertion{
+ {
+ Meta: http.AssertionMeta{
+ TargetBackend: "infra-backend-v1",
+ TargetNamespace: "higress-conformance-infra",
+ },
+ Request: http.AssertionRequest{
+ ActualRequest: http.Request{
+ Path: "/mirror",
+ },
+ },
+ Response: http.AssertionResponse{
+ ExpectedResponse: http.Response{
+ StatusCode: 200,
+ },
+ },
+ },
+ }
+
+ t.Run("HTTPRoute mirror request to target service", func(t *testing.T) {
+ for _, testcase := range testcases {
+ http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase)
+ //check mirror's logs for request
+ cfg, err := config.GetConfig()
+ if err != nil {
+ t.Fatalf("[httproute-mirror] get config failed.")
+ return
+ }
+ clientSet, err := kubernetes.NewForConfig(cfg)
+ if err != nil {
+ t.Fatalf("[httproute-mirror] init clientset failed.")
+ return
+ }
+ pods, err := clientSet.CoreV1().Pods("higress-conformance-infra").List(context.Background(), meta_v1.ListOptions{
+ LabelSelector: meta_v1.FormatLabelSelector(&meta_v1.LabelSelector{MatchLabels: map[string]string{"app": "infra-backend-mirror"}}),
+ })
+ if err != nil || len(pods.Items) == 0 {
+ t.Fatalf("[httproute-mirror] get pods by label of [\"app\": \"infra-backend-mirror\"] failed.")
+ return
+ }
+ req := clientSet.CoreV1().Pods("higress-conformance-infra").GetLogs(pods.Items[0].Name, &v1.PodLogOptions{
+ Container: "infra-backend-mirror",
+ SinceTime: &meta_v1.Time{Time: time.Now().Add(-time.Second * 10)},
+ })
+ podLogs, err := req.Stream(context.Background())
+ defer podLogs.Close()
+ if err != nil {
+ t.Fatalf("[httproute-mirror] init pod logs stream failed.")
+ return
+ }
+
+ podBuf := new(bytes.Buffer)
+ _, err = io.Copy(podBuf, podLogs)
+ if err != nil {
+ t.Fatalf("[httproute-mirror] read pod logs stream failed.")
+ return
+ }
+ if !strings.Contains(podBuf.String(), "Echoing back request made to /mirror") {
+ t.Fatalf("[httproute-mirror] mirror pod hasn't received any mirror requests in logs.")
+ return
+ }
+ }
+ })
+ },
+}
diff --git a/test/e2e/conformance/tests/httproute-mirror-target-service.yaml b/test/e2e/conformance/tests/httproute-mirror-target-service.yaml
new file mode 100644
index 0000000000..0bcc20fa70
--- /dev/null
+++ b/test/e2e/conformance/tests/httproute-mirror-target-service.yaml
@@ -0,0 +1,32 @@
+# 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.
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: higress-conformance-infra-mirror-target-service
+ namespace: higress-conformance-infra
+ annotations:
+ nginx.ingress.kubernetes.io/mirror-target-service: "infra-backend-mirror"
+spec:
+ ingressClassName: higress
+ rules:
+ - http:
+ paths:
+ - pathType: Prefix
+ path: "/mirror"
+ backend:
+ service:
+ name: infra-backend-v1
+ port:
+ number: 8080
From 86239c4a4b39cbff03f3e3b808aece2a7c63d4cc Mon Sep 17 00:00:00 2001
From: fengxsong
Date: Mon, 23 Sep 2024 14:54:06 +0800
Subject: [PATCH 03/16] feat: create podmonitor cr in helm chart (#1157)
Signed-off-by: fengxusong
---
helm/core/templates/_helpers.tpl | 12 ++
helm/core/templates/_pod.tpl | 314 +++++++++++++++++++++++++++
helm/core/templates/daemonset.yaml | 311 +--------------------------
helm/core/templates/deployment.yaml | 315 +---------------------------
helm/core/templates/podmonitor.yaml | 45 ++++
helm/core/values.yaml | 145 ++++++-------
6 files changed, 456 insertions(+), 686 deletions(-)
create mode 100644 helm/core/templates/_pod.tpl
create mode 100644 helm/core/templates/podmonitor.yaml
diff --git a/helm/core/templates/_helpers.tpl b/helm/core/templates/_helpers.tpl
index ec992a390a..f107323421 100644
--- a/helm/core/templates/_helpers.tpl
+++ b/helm/core/templates/_helpers.tpl
@@ -101,3 +101,15 @@ higress: {{ include "controller.name" . }}
true
{{- end }}
{{- end }}
+
+{{- define "gateway.podMonitor.gvk" -}}
+{{- if eq .Values.gateway.metrics.provider "monitoring.coreos.com" -}}
+apiVersion: monitoring.coreos.com/v1
+kind: PodMonitor
+{{- else if eq .Values.gateway.metrics.provider "operator.victoriametrics.com" -}}
+apiVersion: operator.victoriametrics.com/v1beta1
+kind: VMPodScrape
+{{- else -}}
+{{- fail "unexpected gateway.metrics.provider" -}}
+{{- end -}}
+{{- end -}}
diff --git a/helm/core/templates/_pod.tpl b/helm/core/templates/_pod.tpl
new file mode 100644
index 0000000000..657a4f29d4
--- /dev/null
+++ b/helm/core/templates/_pod.tpl
@@ -0,0 +1,314 @@
+
+{{/*
+Rendering the pod template of gateway component.
+*/}}
+{{- define "gateway.podTemplate" -}}
+{{- $o11y := .Values.global.o11y -}}
+template:
+ metadata:
+ annotations:
+ {{- if .Values.global.enableHigressIstio }}
+ "enableHigressIstio": "true"
+ {{- end }}
+ {{- if .Values.gateway.podAnnotations }}
+ {{- toYaml .Values.gateway.podAnnotations | nindent 6 }}
+ {{- end }}
+ labels:
+ sidecar.istio.io/inject: "false"
+ {{- with .Values.gateway.revision }}
+ istio.io/rev: {{ . }}
+ {{- end }}
+ {{- include "gateway.selectorLabels" . | nindent 6 }}
+ spec:
+ {{- with .Values.gateway.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 6 }}
+ {{- end }}
+ serviceAccountName: {{ include "gateway.serviceAccountName" . }}
+ {{- if .Values.global.priorityClassName }}
+ priorityClassName: "{{ .Values.global.priorityClassName }}"
+ {{- end }}
+ securityContext:
+ {{- if .Values.gateway.securityContext }}
+ {{- toYaml .Values.gateway.securityContext | nindent 6 }}
+ {{- else if and .Values.gateway.unprivilegedPortSupported (and (not .Values.gateway.hostNetwork) (semverCompare ">=1.22-0" .Capabilities.KubeVersion.GitVersion)) }}
+ # Safe since 1.22: https://github.com/kubernetes/kubernetes/pull/103326
+ sysctls:
+ - name: net.ipv4.ip_unprivileged_port_start
+ value: "0"
+ {{- end }}
+ containers:
+ - name: higress-gateway
+ image: "{{ .Values.gateway.hub | default .Values.global.hub }}/{{ .Values.gateway.image | default "gateway" }}:{{ .Values.gateway.tag | default .Chart.AppVersion }}"
+ args:
+ - proxy
+ - router
+ - --domain
+ - $(POD_NAMESPACE).svc.cluster.local
+ - --proxyLogLevel=warning
+ - --proxyComponentLogLevel=misc:error
+ - --log_output_level=all:info
+ - --serviceCluster=higress-gateway
+ securityContext:
+ {{- if .Values.gateway.containerSecurityContext }}
+ {{- toYaml .Values.gateway.containerSecurityContext | nindent 10 }}
+ {{- else if and .Values.gateway.unprivilegedPortSupported (and (not .Values.gateway.hostNetwork) (semverCompare ">=1.22-0" .Capabilities.KubeVersion.GitVersion)) }}
+ # Safe since 1.22: https://github.com/kubernetes/kubernetes/pull/103326
+ capabilities:
+ drop:
+ - ALL
+ allowPrivilegeEscalation: false
+ privileged: false
+ # When enabling lite metrics, the configuration template files need to be replaced.
+ {{- if not .Values.global.liteMetrics }}
+ readOnlyRootFilesystem: true
+ {{- end }}
+ runAsUser: 1337
+ runAsGroup: 1337
+ runAsNonRoot: true
+ {{- else }}
+ capabilities:
+ drop:
+ - ALL
+ add:
+ - NET_BIND_SERVICE
+ runAsUser: 0
+ runAsGroup: 1337
+ runAsNonRoot: false
+ allowPrivilegeEscalation: true
+ {{- end }}
+ env:
+ - name: NODE_NAME
+ valueFrom:
+ fieldRef:
+ apiVersion: v1
+ fieldPath: spec.nodeName
+ - name: POD_NAME
+ valueFrom:
+ fieldRef:
+ apiVersion: v1
+ fieldPath: metadata.name
+ - name: POD_NAMESPACE
+ valueFrom:
+ fieldRef:
+ apiVersion: v1
+ fieldPath: metadata.namespace
+ - name: INSTANCE_IP
+ valueFrom:
+ fieldRef:
+ apiVersion: v1
+ fieldPath: status.podIP
+ - name: HOST_IP
+ valueFrom:
+ fieldRef:
+ apiVersion: v1
+ fieldPath: status.hostIP
+ - name: SERVICE_ACCOUNT
+ valueFrom:
+ fieldRef:
+ fieldPath: spec.serviceAccountName
+ - name: PROXY_XDS_VIA_AGENT
+ value: "true"
+ - name: ENABLE_INGRESS_GATEWAY_SDS
+ value: "false"
+ - name: JWT_POLICY
+ value: {{ include "controller.jwtPolicy" . }}
+ - name: ISTIO_META_HTTP10
+ value: "1"
+ - name: ISTIO_META_CLUSTER_ID
+ value: "{{ $.Values.clusterName | default `Kubernetes` }}"
+ - name: INSTANCE_NAME
+ value: "higress-gateway"
+ {{- if .Values.global.liteMetrics }}
+ - name: LITE_METRICS
+ value: "on"
+ {{- end }}
+ {{- if include "skywalking.enabled" . }}
+ - name: ISTIO_BOOTSTRAP_OVERRIDE
+ value: /etc/istio/custom-bootstrap/custom_bootstrap.json
+ {{- end }}
+ {{- with .Values.gateway.networkGateway }}
+ - name: ISTIO_META_REQUESTED_NETWORK_VIEW
+ value: "{{.}}"
+ {{- end }}
+ {{- range $key, $val := .Values.env }}
+ - name: {{ $key }}
+ value: {{ $val | quote }}
+ {{- end }}
+ ports:
+ - containerPort: 15020
+ protocol: TCP
+ name: istio-prom
+ - containerPort: 15090
+ protocol: TCP
+ name: http-envoy-prom
+ {{- if or .Values.global.local .Values.global.kind }}
+ - containerPort: {{ .Values.gateway.httpPort }}
+ hostPort: {{ .Values.gateway.httpPort }}
+ name: http
+ protocol: TCP
+ - containerPort: {{ .Values.gateway.httpsPort }}
+ hostPort: {{ .Values.gateway.httpsPort }}
+ name: https
+ protocol: TCP
+ {{- end }}
+ readinessProbe:
+ failureThreshold: {{ .Values.gateway.readinessFailureThreshold }}
+ httpGet:
+ path: /healthz/ready
+ port: 15021
+ scheme: HTTP
+ initialDelaySeconds: {{ .Values.gateway.readinessInitialDelaySeconds }}
+ periodSeconds: {{ .Values.gateway.readinessPeriodSeconds }}
+ successThreshold: {{ .Values.gateway.readinessSuccessThreshold }}
+ timeoutSeconds: {{ .Values.gateway.readinessTimeoutSeconds }}
+ {{- if not (or .Values.global.local .Values.global.kind) }}
+ resources:
+ {{- toYaml .Values.gateway.resources | nindent 10 }}
+ {{- end }}
+ volumeMounts:
+ {{- if eq (include "controller.jwtPolicy" .) "third-party-jwt" }}
+ - name: istio-token
+ mountPath: /var/run/secrets/tokens
+ readOnly: true
+ {{- end }}
+ - name: config
+ mountPath: /etc/istio/config
+ - name: istio-ca-root-cert
+ mountPath: /var/run/secrets/istio
+ - name: istio-data
+ mountPath: /var/lib/istio/data
+ - name: podinfo
+ mountPath: /etc/istio/pod
+ - name: proxy-socket
+ mountPath: /etc/istio/proxy
+ {{- if include "skywalking.enabled" . }}
+ - mountPath: /etc/istio/custom-bootstrap
+ name: custom-bootstrap-volume
+ {{- end }}
+ {{- if .Values.global.volumeWasmPlugins }}
+ - mountPath: /opt/plugins
+ name: local-wasmplugins-volume
+ {{- end }}
+ {{- if $o11y.enabled }}
+ - mountPath: /var/log/proxy
+ name: log
+ {{- end }}
+ {{- if $o11y.enabled }}
+ {{- $config := $o11y.promtail }}
+ - name: promtail
+ image: {{ $config.image.repository }}:{{ $config.image.tag }}
+ imagePullPolicy: IfNotPresent
+ args:
+ - -config.file=/etc/promtail/promtail.yaml
+ env:
+ - name: 'HOSTNAME'
+ valueFrom:
+ fieldRef:
+ fieldPath: 'spec.nodeName'
+ ports:
+ - containerPort: {{ $config.port }}
+ name: http-metrics
+ protocol: TCP
+ readinessProbe:
+ failureThreshold: 3
+ httpGet:
+ path: /ready
+ port: {{ $config.port }}
+ scheme: HTTP
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ successThreshold: 1
+ timeoutSeconds: 1
+ volumeMounts:
+ - name: promtail-config
+ mountPath: "/etc/promtail"
+ - name: log
+ mountPath: /var/log/proxy
+ - name: tmp
+ mountPath: /tmp
+ {{- end }}
+ {{- if .Values.gateway.hostNetwork }}
+ hostNetwork: {{ .Values.gateway.hostNetwork }}
+ dnsPolicy: ClusterFirstWithHostNet
+ {{- end }}
+ {{- with .Values.gateway.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 6 }}
+ {{- end }}
+ {{- with .Values.gateway.affinity }}
+ affinity:
+ {{- toYaml . | nindent 6 }}
+ {{- end }}
+ {{- with .Values.gateway.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 6 }}
+ {{- end }}
+ volumes:
+ {{- if eq (include "controller.jwtPolicy" .) "third-party-jwt" }}
+ - name: istio-token
+ projected:
+ sources:
+ - serviceAccountToken:
+ audience: istio-ca
+ expirationSeconds: 43200
+ path: istio-token
+ {{- end }}
+ - name: istio-ca-root-cert
+ configMap:
+ {{- if .Values.global.enableHigressIstio }}
+ name: istio-ca-root-cert
+ {{- else }}
+ name: higress-ca-root-cert
+ {{- end }}
+ - name: config
+ configMap:
+ name: higress-config
+ {{- if include "skywalking.enabled" . }}
+ - configMap:
+ defaultMode: 420
+ name: higress-custom-bootstrap
+ name: custom-bootstrap-volume
+ {{- end }}
+ - name: istio-data
+ emptyDir: {}
+ - name: proxy-socket
+ emptyDir: {}
+ {{- if $o11y.enabled }}
+ - name: log
+ emptyDir: {}
+ - name: tmp
+ emptyDir: {}
+ - name: promtail-config
+ configMap:
+ name: higress-promtail
+ {{- end }}
+ - name: podinfo
+ downwardAPI:
+ defaultMode: 420
+ items:
+ - fieldRef:
+ apiVersion: v1
+ fieldPath: metadata.labels
+ path: labels
+ - fieldRef:
+ apiVersion: v1
+ fieldPath: metadata.annotations
+ path: annotations
+ - path: cpu-request
+ resourceFieldRef:
+ containerName: higress-gateway
+ divisor: 1m
+ resource: requests.cpu
+ - path: cpu-limit
+ resourceFieldRef:
+ containerName: higress-gateway
+ divisor: 1m
+ resource: limits.cpu
+ {{- if .Values.global.volumeWasmPlugins }}
+ - name: local-wasmplugins-volume
+ hostPath:
+ path: /opt/plugins
+ type: Directory
+ {{- end }}
+{{- end -}}
diff --git a/helm/core/templates/daemonset.yaml b/helm/core/templates/daemonset.yaml
index 1e59512665..d1acd4a2aa 100644
--- a/helm/core/templates/daemonset.yaml
+++ b/helm/core/templates/daemonset.yaml
@@ -6,10 +6,12 @@
{{- if $kernelVersion }}
{{- $kernelVersion = regexFind "^(\\d+\\.\\d+\\.\\d+)" $kernelVersion }}
{{- if and $kernelVersion (semverCompare "<4.11.0" $kernelVersion) }}
- {{- $unprivilegedPortSupported = false }}
+ {{- $unprivilegedPortSupported = false }}
{{- end }}
{{- end }}
{{- end -}}
+{{- $_ := set .Values.gateway "unprivilegedPortSupported" $unprivilegedPortSupported -}}
+
apiVersion: apps/v1
kind: DaemonSet
metadata:
@@ -23,310 +25,5 @@ spec:
selector:
matchLabels:
{{- include "gateway.selectorLabels" . | nindent 6 }}
- template:
- metadata:
- annotations:
- {{- if .Values.global.enableHigressIstio }}
- "enableHigressIstio": "true"
- {{- end }}
- {{- if .Values.gateway.podAnnotations }}
- {{- toYaml .Values.gateway.podAnnotations | nindent 8 }}
- {{- end }}
- labels:
- sidecar.istio.io/inject: "false"
- {{- with .Values.gateway.revision }}
- istio.io/rev: {{ . }}
- {{- end }}
- {{- include "gateway.selectorLabels" . | nindent 8 }}
- spec:
- {{- with .Values.gateway.imagePullSecrets }}
- imagePullSecrets:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- serviceAccountName: {{ include "gateway.serviceAccountName" . }}
- {{- if .Values.global.priorityClassName }}
- priorityClassName: "{{ .Values.global.priorityClassName }}"
- {{- end }}
- securityContext:
- {{- if .Values.gateway.securityContext }}
- {{- toYaml .Values.gateway.securityContext | nindent 8 }}
- {{- else if and $unprivilegedPortSupported (and (not .Values.gateway.hostNetwork) (semverCompare ">=1.22-0" .Capabilities.KubeVersion.GitVersion)) }}
- # Safe since 1.22: https://github.com/kubernetes/kubernetes/pull/103326
- sysctls:
- - name: net.ipv4.ip_unprivileged_port_start
- value: "0"
- {{- end }}
- containers:
- {{- if $o11y.enabled }}
- {{- $config := $o11y.promtail }}
- - name: promtail
- image: {{ $config.image.repository }}:{{ $config.image.tag }}
- imagePullPolicy: IfNotPresent
- args:
- - -config.file=/etc/promtail/promtail.yaml
- env:
- - name: 'HOSTNAME'
- valueFrom:
- fieldRef:
- fieldPath: 'spec.nodeName'
- ports:
- - containerPort: {{ $config.port }}
- name: http-metrics
- protocol: TCP
- readinessProbe:
- failureThreshold: 3
- httpGet:
- path: /ready
- port: {{ $config.port }}
- scheme: HTTP
- initialDelaySeconds: 10
- periodSeconds: 10
- successThreshold: 1
- timeoutSeconds: 1
- volumeMounts:
- - name: promtail-config
- mountPath: "/etc/promtail"
- - name: log
- mountPath: /var/log/proxy
- - name: tmp
- mountPath: /tmp
- {{- end }}
- - name: higress-gateway
- image: "{{ .Values.gateway.hub | default .Values.global.hub }}/{{ .Values.gateway.image | default "gateway" }}:{{ .Values.gateway.tag | default .Chart.AppVersion }}"
- args:
- - proxy
- - router
- - --domain
- - $(POD_NAMESPACE).svc.cluster.local
- - --proxyLogLevel=warning
- - --proxyComponentLogLevel=misc:error
- - --log_output_level=all:info
- - --serviceCluster=higress-gateway
- securityContext:
- {{- if .Values.gateway.containerSecurityContext }}
- {{- toYaml .Values.gateway.containerSecurityContext | nindent 12 }}
- {{- else if and $unprivilegedPortSupported (and (not .Values.gateway.hostNetwork) (semverCompare ">=1.22-0" .Capabilities.KubeVersion.GitVersion)) }}
- # Safe since 1.22: https://github.com/kubernetes/kubernetes/pull/103326
- capabilities:
- drop:
- - ALL
- allowPrivilegeEscalation: false
- privileged: false
- # When enabling lite metrics, the configuration template files need to be replaced.
- {{- if not .Values.global.liteMetrics }}
- readOnlyRootFilesystem: true
- {{- end }}
- runAsUser: 1337
- runAsGroup: 1337
- runAsNonRoot: true
- {{- else }}
- capabilities:
- drop:
- - ALL
- add:
- - NET_BIND_SERVICE
- runAsUser: 0
- runAsGroup: 1337
- runAsNonRoot: false
- allowPrivilegeEscalation: true
- {{- end }}
- env:
- - name: NODE_NAME
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: spec.nodeName
- - name: POD_NAME
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: metadata.name
- - name: POD_NAMESPACE
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: metadata.namespace
- - name: INSTANCE_IP
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: status.podIP
- - name: HOST_IP
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: status.hostIP
- - name: SERVICE_ACCOUNT
- valueFrom:
- fieldRef:
- fieldPath: spec.serviceAccountName
- - name: PILOT_XDS_SEND_TIMEOUT
- value: 60s
- - name: PROXY_XDS_VIA_AGENT
- value: "true"
- - name: ENABLE_INGRESS_GATEWAY_SDS
- value: "false"
- - name: JWT_POLICY
- value: {{ include "controller.jwtPolicy" . }}
- - name: ISTIO_META_HTTP10
- value: "1"
- - name: ISTIO_META_CLUSTER_ID
- value: "{{ $.Values.clusterName | default `Kubernetes` }}"
- - name: INSTANCE_NAME
- value: "higress-gateway"
- {{- if .Values.global.liteMetrics }}
- - name: LITE_METRICS
- value: "on"
- {{- end }}
- {{- if include "skywalking.enabled" . }}
- - name: ISTIO_BOOTSTRAP_OVERRIDE
- value: /etc/istio/custom-bootstrap/custom_bootstrap.json
- {{- end }}
- {{- with .Values.gateway.networkGateway }}
- - name: ISTIO_META_REQUESTED_NETWORK_VIEW
- value: "{{.}}"
- {{- end }}
- {{- range $key, $val := .Values.env }}
- - name: {{ $key }}
- value: {{ $val | quote }}
- {{- end }}
- ports:
- - containerPort: 15090
- protocol: TCP
- name: http-envoy-prom
- {{- if or .Values.global.local .Values.global.kind }}
- - containerPort: {{ .Values.gateway.httpPort }}
- hostPort: {{ .Values.gateway.httpPort }}
- name: http
- protocol: TCP
- - containerPort: {{ .Values.gateway.httpsPort }}
- hostPort: {{ .Values.gateway.httpsPort }}
- name: https
- protocol: TCP
- {{- end }}
- readinessProbe:
- failureThreshold: {{ .Values.gateway.readinessFailureThreshold }}
- httpGet:
- path: /healthz/ready
- port: 15021
- scheme: HTTP
- initialDelaySeconds: {{ .Values.gateway.readinessInitialDelaySeconds }}
- periodSeconds: {{ .Values.gateway.readinessPeriodSeconds }}
- successThreshold: {{ .Values.gateway.readinessSuccessThreshold }}
- timeoutSeconds: {{ .Values.gateway.readinessTimeoutSeconds }}
- {{- if not (or .Values.global.local .Values.global.kind) }}
- resources:
- {{- toYaml .Values.gateway.resources | nindent 12 }}
- {{- end }}
- volumeMounts:
- {{- if eq (include "controller.jwtPolicy" .) "third-party-jwt" }}
- - name: istio-token
- mountPath: /var/run/secrets/tokens
- readOnly: true
- {{- end }}
- - name: config
- mountPath: /etc/istio/config
- - name: istio-ca-root-cert
- mountPath: /var/run/secrets/istio
- - name: istio-data
- mountPath: /var/lib/istio/data
- - name: podinfo
- mountPath: /etc/istio/pod
- - name: proxy-socket
- mountPath: /etc/istio/proxy
- {{- if include "skywalking.enabled" . }}
- - mountPath: /etc/istio/custom-bootstrap
- name: custom-bootstrap-volume
- {{- end }}
- {{- if .Values.global.volumeWasmPlugins }}
- - mountPath: /opt/plugins
- name: local-wasmplugins-volume
- {{- end }}
- {{- if $o11y.enabled }}
- - mountPath: /var/log/proxy
- name: log
- {{- end }}
- {{- if .Values.gateway.hostNetwork }}
- hostNetwork: {{ .Values.gateway.hostNetwork }}
- dnsPolicy: ClusterFirstWithHostNet
- {{- end }}
- {{- with .Values.gateway.nodeSelector }}
- nodeSelector:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- {{- with .Values.gateway.affinity }}
- affinity:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- {{- with .Values.gateway.tolerations }}
- tolerations:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- volumes:
- {{- if eq (include "controller.jwtPolicy" .) "third-party-jwt" }}
- - name: istio-token
- projected:
- sources:
- - serviceAccountToken:
- audience: istio-ca
- expirationSeconds: 43200
- path: istio-token
- {{- end }}
- - name: istio-ca-root-cert
- configMap:
- {{- if .Values.global.enableHigressIstio }}
- name: istio-ca-root-cert
- {{- else }}
- name: higress-ca-root-cert
- {{- end }}
- - name: config
- configMap:
- name: higress-config
- {{- if include "skywalking.enabled" . }}
- - configMap:
- defaultMode: 420
- name: higress-custom-bootstrap
- name: custom-bootstrap-volume
- {{- end }}
- - name: istio-data
- emptyDir: {}
- - name: proxy-socket
- emptyDir: {}
- {{- if $o11y.enabled }}
- - name: log
- emptyDir: {}
- - name: tmp
- emptyDir: {}
- - name: promtail-config
- configMap:
- name: higress-promtail
- {{- end }}
- - name: podinfo
- downwardAPI:
- defaultMode: 420
- items:
- - fieldRef:
- apiVersion: v1
- fieldPath: metadata.labels
- path: labels
- - fieldRef:
- apiVersion: v1
- fieldPath: metadata.annotations
- path: annotations
- - path: cpu-request
- resourceFieldRef:
- containerName: higress-gateway
- divisor: 1m
- resource: requests.cpu
- - path: cpu-limit
- resourceFieldRef:
- containerName: higress-gateway
- divisor: 1m
- resource: limits.cpu
- {{- if .Values.global.volumeWasmPlugins }}
- - name: local-wasmplugins-volume
- hostPath:
- path: /opt/plugins
- type: Directory
- {{- end }}
+ {{- include "gateway.podTemplate" $ | nindent 2 -}}
{{- end }}
diff --git a/helm/core/templates/deployment.yaml b/helm/core/templates/deployment.yaml
index 655341a504..f3d2311301 100644
--- a/helm/core/templates/deployment.yaml
+++ b/helm/core/templates/deployment.yaml
@@ -1,15 +1,16 @@
{{- if eq .Values.gateway.kind "Deployment" -}}
-{{- $o11y := .Values.global.o11y }}
{{- $unprivilegedPortSupported := true }}
{{- range $index, $node := (lookup "v1" "Node" "default" "").items }}
{{- $kernelVersion := $node.status.nodeInfo.kernelVersion }}
{{- if $kernelVersion }}
{{- $kernelVersion = regexFind "^(\\d+\\.\\d+\\.\\d+)" $kernelVersion }}
{{- if and $kernelVersion (semverCompare "<4.11.0" $kernelVersion) }}
- {{- $unprivilegedPortSupported = false }}
+ {{- $unprivilegedPortSupported = false }}
{{- end }}
{{- end }}
{{- end -}}
+{{- $_ := set .Values.gateway "unprivilegedPortSupported" $unprivilegedPortSupported -}}
+
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -38,311 +39,7 @@ spec:
{{- else }}
maxUnavailable: {{ .Values.gateway.rollingMaxUnavailable }}
{{- end }}
- template:
- metadata:
- annotations:
- {{- if .Values.global.enableHigressIstio }}
- "enableHigressIstio": "true"
- {{- end }}
- {{- if .Values.gateway.podAnnotations }}
- {{- toYaml .Values.gateway.podAnnotations | nindent 8 }}
- {{- end }}
- labels:
- sidecar.istio.io/inject: "false"
- {{- with .Values.gateway.revision }}
- istio.io/rev: {{ . }}
- {{- end }}
- {{- include "gateway.selectorLabels" . | nindent 8 }}
- spec:
- {{- with .Values.gateway.imagePullSecrets }}
- imagePullSecrets:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- serviceAccountName: {{ include "gateway.serviceAccountName" . }}
- {{- if .Values.global.priorityClassName }}
- priorityClassName: "{{ .Values.global.priorityClassName }}"
- {{- end }}
- securityContext:
- {{- if .Values.gateway.securityContext }}
- {{- toYaml .Values.gateway.securityContext | nindent 8 }}
- {{- else if and $unprivilegedPortSupported (and (not .Values.gateway.hostNetwork) (semverCompare ">=1.22-0" .Capabilities.KubeVersion.GitVersion)) }}
- # Safe since 1.22: https://github.com/kubernetes/kubernetes/pull/103326
- sysctls:
- - name: net.ipv4.ip_unprivileged_port_start
- value: "0"
- {{- end }}
- containers:
- {{- if $o11y.enabled }}
- {{- $config := $o11y.promtail }}
- - name: promtail
- image: {{ $config.image.repository }}:{{ $config.image.tag }}
- imagePullPolicy: IfNotPresent
- args:
- - -config.file=/etc/promtail/promtail.yaml
- env:
- - name: 'HOSTNAME'
- valueFrom:
- fieldRef:
- fieldPath: 'spec.nodeName'
- ports:
- - containerPort: {{ $config.port }}
- name: http-metrics
- protocol: TCP
- readinessProbe:
- failureThreshold: 3
- httpGet:
- path: /ready
- port: {{ $config.port }}
- scheme: HTTP
- initialDelaySeconds: 10
- periodSeconds: 10
- successThreshold: 1
- timeoutSeconds: 1
- volumeMounts:
- - name: promtail-config
- mountPath: "/etc/promtail"
- - name: log
- mountPath: /var/log/proxy
- - name: tmp
- mountPath: /tmp
- {{- end }}
- - name: higress-gateway
- image: "{{ .Values.gateway.hub | default .Values.global.hub }}/{{ .Values.gateway.image | default "gateway" }}:{{ .Values.gateway.tag | default .Chart.AppVersion }}"
- args:
- - proxy
- - router
- - --domain
- - $(POD_NAMESPACE).svc.cluster.local
- - --proxyLogLevel=warning
- - --proxyComponentLogLevel=misc:error
- - --log_output_level=all:info
- - --serviceCluster=higress-gateway
- securityContext:
- {{- if .Values.gateway.containerSecurityContext }}
- {{- toYaml .Values.gateway.containerSecurityContext | nindent 12 }}
- {{- else if and $unprivilegedPortSupported (and (not .Values.gateway.hostNetwork) (semverCompare ">=1.22-0" .Capabilities.KubeVersion.GitVersion)) }}
- # Safe since 1.22: https://github.com/kubernetes/kubernetes/pull/103326
- capabilities:
- drop:
- - ALL
- allowPrivilegeEscalation: false
- privileged: false
- # When enabling lite metrics, the configuration template files need to be replaced.
- {{- if not .Values.global.liteMetrics }}
- readOnlyRootFilesystem: true
- {{- end }}
- runAsUser: 1337
- runAsGroup: 1337
- runAsNonRoot: true
- {{- else }}
- capabilities:
- drop:
- - ALL
- add:
- - NET_BIND_SERVICE
- runAsUser: 0
- runAsGroup: 1337
- runAsNonRoot: false
- allowPrivilegeEscalation: true
- {{- end }}
- env:
- - name: NODE_NAME
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: spec.nodeName
- - name: POD_NAME
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: metadata.name
- - name: POD_NAMESPACE
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: metadata.namespace
- - name: INSTANCE_IP
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: status.podIP
- - name: HOST_IP
- valueFrom:
- fieldRef:
- apiVersion: v1
- fieldPath: status.hostIP
- - name: SERVICE_ACCOUNT
- valueFrom:
- fieldRef:
- fieldPath: spec.serviceAccountName
- - name: PROXY_XDS_VIA_AGENT
- value: "true"
- - name: ENABLE_INGRESS_GATEWAY_SDS
- value: "false"
- - name: JWT_POLICY
- value: {{ include "controller.jwtPolicy" . }}
- - name: ISTIO_META_HTTP10
- value: "1"
- - name: ISTIO_META_CLUSTER_ID
- value: "{{ $.Values.clusterName | default `Kubernetes` }}"
- - name: INSTANCE_NAME
- value: "higress-gateway"
- {{- if .Values.global.liteMetrics }}
- - name: LITE_METRICS
- value: "on"
- {{- end }}
- {{- if include "skywalking.enabled" . }}
- - name: ISTIO_BOOTSTRAP_OVERRIDE
- value: /etc/istio/custom-bootstrap/custom_bootstrap.json
- {{- end }}
- {{- with .Values.gateway.networkGateway }}
- - name: ISTIO_META_REQUESTED_NETWORK_VIEW
- value: "{{.}}"
- {{- end }}
- {{- range $key, $val := .Values.env }}
- - name: {{ $key }}
- value: {{ $val | quote }}
- {{- end }}
- ports:
- - containerPort: 15020
- protocol: TCP
- name: istio-prom
- - containerPort: 15090
- protocol: TCP
- name: http-envoy-prom
- {{- if or .Values.global.local .Values.global.kind }}
- - containerPort: {{ .Values.gateway.httpPort }}
- hostPort: {{ .Values.gateway.httpPort }}
- name: http
- protocol: TCP
- - containerPort: {{ .Values.gateway.httpsPort }}
- hostPort: {{ .Values.gateway.httpsPort }}
- name: https
- protocol: TCP
- {{- end }}
- readinessProbe:
- failureThreshold: {{ .Values.gateway.readinessFailureThreshold }}
- httpGet:
- path: /healthz/ready
- port: 15021
- scheme: HTTP
- initialDelaySeconds: {{ .Values.gateway.readinessInitialDelaySeconds }}
- periodSeconds: {{ .Values.gateway.readinessPeriodSeconds }}
- successThreshold: {{ .Values.gateway.readinessSuccessThreshold }}
- timeoutSeconds: {{ .Values.gateway.readinessTimeoutSeconds }}
- {{- if not (or .Values.global.local .Values.global.kind) }}
- resources:
- {{- toYaml .Values.gateway.resources | nindent 12 }}
- {{- end }}
- volumeMounts:
- {{- if eq (include "controller.jwtPolicy" .) "third-party-jwt" }}
- - name: istio-token
- mountPath: /var/run/secrets/tokens
- readOnly: true
- {{- end }}
- - name: config
- mountPath: /etc/istio/config
- - name: istio-ca-root-cert
- mountPath: /var/run/secrets/istio
- - name: istio-data
- mountPath: /var/lib/istio/data
- - name: podinfo
- mountPath: /etc/istio/pod
- - name: proxy-socket
- mountPath: /etc/istio/proxy
- {{- if include "skywalking.enabled" . }}
- - mountPath: /etc/istio/custom-bootstrap
- name: custom-bootstrap-volume
- {{- end }}
- {{- if .Values.global.volumeWasmPlugins }}
- - mountPath: /opt/plugins
- name: local-wasmplugins-volume
- {{- end }}
- {{- if $o11y.enabled }}
- - mountPath: /var/log/proxy
- name: log
- {{- end }}
- {{- if .Values.gateway.hostNetwork }}
- hostNetwork: {{ .Values.gateway.hostNetwork }}
- dnsPolicy: ClusterFirstWithHostNet
- {{- end }}
- {{- with .Values.gateway.nodeSelector }}
- nodeSelector:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- {{- with .Values.gateway.affinity }}
- affinity:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- {{- with .Values.gateway.tolerations }}
- tolerations:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- volumes:
- {{- if eq (include "controller.jwtPolicy" .) "third-party-jwt" }}
- - name: istio-token
- projected:
- sources:
- - serviceAccountToken:
- audience: istio-ca
- expirationSeconds: 43200
- path: istio-token
- {{- end }}
- - name: istio-ca-root-cert
- configMap:
- {{- if .Values.global.enableHigressIstio }}
- name: istio-ca-root-cert
- {{- else }}
- name: higress-ca-root-cert
- {{- end }}
- - name: config
- configMap:
- name: higress-config
- {{- if include "skywalking.enabled" . }}
- - configMap:
- defaultMode: 420
- name: higress-custom-bootstrap
- name: custom-bootstrap-volume
- {{- end }}
- - name: istio-data
- emptyDir: {}
- - name: proxy-socket
- emptyDir: {}
- {{- if $o11y.enabled }}
- - name: log
- emptyDir: {}
- - name: tmp
- emptyDir: {}
- - name: promtail-config
- configMap:
- name: higress-promtail
- {{- end }}
- - name: podinfo
- downwardAPI:
- defaultMode: 420
- items:
- - fieldRef:
- apiVersion: v1
- fieldPath: metadata.labels
- path: labels
- - fieldRef:
- apiVersion: v1
- fieldPath: metadata.annotations
- path: annotations
- - path: cpu-request
- resourceFieldRef:
- containerName: higress-gateway
- divisor: 1m
- resource: requests.cpu
- - path: cpu-limit
- resourceFieldRef:
- containerName: higress-gateway
- divisor: 1m
- resource: limits.cpu
- {{- if .Values.global.volumeWasmPlugins }}
- - name: local-wasmplugins-volume
- hostPath:
- path: /opt/plugins
- type: Directory
- {{- end }}
+
+ {{- include "gateway.podTemplate" $ | nindent 2 -}}
+
{{- end }}
diff --git a/helm/core/templates/podmonitor.yaml b/helm/core/templates/podmonitor.yaml
new file mode 100644
index 0000000000..8b81f7fff3
--- /dev/null
+++ b/helm/core/templates/podmonitor.yaml
@@ -0,0 +1,45 @@
+{{- if .Values.gateway.metrics.enabled }}
+{{- include "gateway.podMonitor.gvk" . }}
+metadata:
+ name: {{ printf "%s-metrics" (include "gateway.name" .) | trunc 63 | trimSuffix "-" }}
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "gateway.labels" . | nindent 4}}
+ annotations:
+ {{- .Values.gateway.annotations | toYaml | nindent 4 }}
+spec:
+ jobLabel: "app.kubernetes.io/name"
+ selector:
+ matchLabels:
+ {{- include "gateway.selectorLabels" . | nindent 6 }}
+ namespaceSelector:
+ matchNames:
+ - {{ .Release.Namespace }}
+ podMetricsEndpoints:
+ - port: istio-prom
+ path: /stats/prometheus
+ {{- if .Values.gateway.metrics.interval }}
+ interval: {{ .Values.gateway.metrics.interval }}
+ {{- end }}
+ {{- if .Values.gateway.metrics.scrapeTimeout }}
+ scrapeTimeout: {{ .Values.gateway.metrics.scrapeTimeout }}
+ {{- end }}
+ {{- if .Values.gateway.metrics.honorLabels }}
+ honorLabels: {{ .Values.gateway.metrics.honorLabels }}
+ {{- end }}
+ {{- if .Values.gateway.metrics.metricRelabelings }}
+ metricRelabelings: {{ toYaml .Values.gateway.metrics.metricRelabelings | nindent 8 }}
+ {{- end }}
+ {{- if .Values.gateway.metrics.relabelings }}
+ relabelings: {{ toYaml .Values.gateway.metrics.relabelings | nindent 8 }}
+ {{- end }}
+ {{- if .Values.gateway.metrics.metricRelabelConfigs }}
+ metricRelabelings: {{ toYaml .Values.gateway.metrics.metricRelabelConfigs | nindent 8 }}
+ {{- end }}
+ {{- if .Values.gateway.metrics.relabelConfigs }}
+ relabelings: {{ toYaml .Values.gateway.metrics.relabelConfigs | nindent 8 }}
+ {{- end }}
+ {{- if $.Values.gateway.metrics.rawSpec }}
+ {{- $.Values.gateway.metrics.rawSpec | toYaml | nindent 6 }}
+ {{- end }}
+{{- end }}
diff --git a/helm/core/values.yaml b/helm/core/values.yaml
index 215e3cf2bf..ce08899207 100644
--- a/helm/core/values.yaml
+++ b/helm/core/values.yaml
@@ -136,7 +136,6 @@ global:
excludeInboundPorts: ""
includeInboundPorts: "*"
-
# istio egress capture allowlist
# https://istio.io/docs/tasks/traffic-management/egress.html#calling-external-services-directly
# example: includeIPRanges: "172.30.0.0/16,172.20.0.0/16"
@@ -322,8 +321,8 @@ global:
# Host:Port for submitting traces to the Datadog agent.
address: "$(HOST_IP):8126"
lightstep:
- address: "" # example: lightstep-satellite:443
- accessToken: "" # example: abcdefg1234567
+ address: "" # example: lightstep-satellite:443
+ accessToken: "" # example: abcdefg1234567
stackdriver:
# enables trace output to stdout.
debug: false
@@ -449,25 +448,25 @@ gateway:
prometheus.io/scrape: "true"
prometheus.io/path: "/stats/prometheus"
sidecar.istio.io/inject: "false"
-
+
# Define the security context for the pod.
# If unset, this will be automatically set to the minimum privileges required to bind to port 80 and 443.
# On Kubernetes 1.22+, this only requires the `net.ipv4.ip_unprivileged_port_start` sysctl.
securityContext: ~
containerSecurityContext: ~
-
+
service:
# Type of service. Set to "None" to disable the service entirely
type: LoadBalancer
ports:
- - name: http2
- port: 80
- protocol: TCP
- targetPort: 80
- - name: https
- port: 443
- protocol: TCP
- targetPort: 443
+ - name: http2
+ port: 80
+ protocol: TCP
+ targetPort: 80
+ - name: https
+ port: 443
+ protocol: TCP
+ targetPort: 443
annotations: {}
loadBalancerIP: ""
loadBalancerClass: ""
@@ -476,7 +475,7 @@ gateway:
rollingMaxSurge: 100%
rollingMaxUnavailable: 25%
-
+
resources:
requests:
cpu: 2000m
@@ -484,22 +483,39 @@ gateway:
limits:
cpu: 2000m
memory: 2048Mi
-
+
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 80
-
+
nodeSelector: {}
-
+
tolerations: []
-
+
affinity: {}
-
+
# If specified, the gateway will act as a network gateway for the given network.
networkGateway: ""
-
+
+ metrics:
+ # If true, create PodMonitor or VMPodScrape for gateway
+ enabled: false
+ # provider group name for CustomResourceDefinition, can be monitoring.coreos.com or operator.victoriametrics.com
+ provider: monitoring.coreos.com
+ interval: ""
+ scrapeTimeout: ""
+ honorLabels: false
+ # for monitoring.coreos.com/v1.PodMonitor
+ metricRelabelings: []
+ relabelings: []
+ # for operator.victoriametrics.com/v1beta1.VMPodScrape
+ metricRelabelConfigs: []
+ relabelConfigs: []
+ # some more raw podMetricsEndpoints spec
+ rawSpec: {}
+
controller:
name: "higress-controller"
replicas: 1
@@ -510,22 +526,20 @@ controller:
env: {}
labels: {}
-
- probe: {
- httpGet: {
- path: /ready,
- port: 8888,
- },
- initialDelaySeconds: 1,
- periodSeconds: 3,
- timeoutSeconds: 5
- }
-
+
+ probe:
+ {
+ httpGet: { path: /ready, port: 8888 },
+ initialDelaySeconds: 1,
+ periodSeconds: 3,
+ timeoutSeconds: 5,
+ }
+
imagePullSecrets: []
rbac:
create: true
-
+
serviceAccount:
# Specifies whether a service account should be created
create: true
@@ -534,37 +548,30 @@ controller:
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
-
+
podAnnotations: {}
-
- podSecurityContext: {}
+
+ podSecurityContext:
+ {}
# fsGroup: 2000
-
- ports: [
- {
- "name": "http",
- "protocol": "TCP",
- "port": 8888,
- "targetPort": 8888,
- },
- {
- "name": "http-solver",
- "protocol": "TCP",
- "port": 8889,
- "targetPort": 8889,
- },
- {
- "name": "grpc",
- "protocol": "TCP",
- "port": 15051,
- "targetPort": 15051,
- }
- ]
-
+
+ ports:
+ [
+ { "name": "http", "protocol": "TCP", "port": 8888, "targetPort": 8888 },
+ {
+ "name": "http-solver",
+ "protocol": "TCP",
+ "port": 8889,
+ "targetPort": 8889,
+ },
+ { "name": "grpc", "protocol": "TCP", "port": 15051, "targetPort": 15051 },
+ ]
+
service:
type: ClusterIP
-
- securityContext: {}
+
+ securityContext:
+ {}
# capabilities:
# drop:
# - ALL
@@ -579,11 +586,11 @@ controller:
limits:
cpu: 1000m
memory: 2048Mi
-
+
nodeSelector: {}
-
+
tolerations: []
-
+
affinity: {}
autoscaling:
@@ -594,7 +601,7 @@ controller:
automaticHttps:
enabled: true
email: ""
-
+
## Discovery Settings
pilot:
autoscaleEnabled: false
@@ -656,7 +663,6 @@ pilot:
# Additional labels to apply to the deployment.
deploymentLabels: {}
-
## Mesh config settings
# Install the mesh config map, generated from values.yaml.
@@ -666,16 +672,15 @@ pilot:
# Additional labels to apply on the pod level for monitoring and logging configuration.
podLabels: {}
-
# Tracing config settings
tracing:
enable: false
sampling: 100
timeout: 500
skywalking:
- # access_token: ""
- service: ""
- port: 11800
+ # access_token: ""
+ service: ""
+ port: 11800
# zipkin:
- # service: ""
- # port: 9411
+ # service: ""
+ # port: 9411
From e7761a2ecc8d30ae1e6ebb5cb7ed6d1997f81d96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?=
Date: Mon, 23 Sep 2024 20:24:55 +0800
Subject: [PATCH 04/16] Update README.md
---
plugins/wasm-go/extensions/ai-agent/README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/plugins/wasm-go/extensions/ai-agent/README.md b/plugins/wasm-go/extensions/ai-agent/README.md
index 1057d35d3d..76a83cec00 100644
--- a/plugins/wasm-go/extensions/ai-agent/README.md
+++ b/plugins/wasm-go/extensions/ai-agent/README.md
@@ -7,7 +7,7 @@ description: AI Agent插件配置参考
## 功能说明
一个可定制化的 API AI Agent,支持配置 http method 类型为 GET 与 POST 的 API,支持多轮对话,支持流式与非流式模式。
agent流程图如下:
-
+
## 配置字段
@@ -360,4 +360,4 @@ curl 'http://<这里换成网关公网IP>/api/openai/v1/chat/completions' \
```json
{"id":"65dcf12c-61ff-9e68-bffa-44fc9e6070d5","choices":[{"index":0,"message":{"role":"assistant","content":" “九头蛇万岁!”的德语翻译为“Hoch lebe Hydra!”。"},"finish_reason":"stop"}],"created":1724043865,"model":"qwen-max-0403","object":"chat.completion","usage":{"prompt_tokens":908,"completion_tokens":52,"total_tokens":960}}
-```
\ No newline at end of file
+```
From b24731593f04b51c4d469d603d0d2c5c118ee144 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?=
Date: Mon, 23 Sep 2024 20:26:25 +0800
Subject: [PATCH 05/16] Update README.md
---
plugins/wasm-go/extensions/ai-agent/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/plugins/wasm-go/extensions/ai-agent/README.md b/plugins/wasm-go/extensions/ai-agent/README.md
index 76a83cec00..77a0c022e5 100644
--- a/plugins/wasm-go/extensions/ai-agent/README.md
+++ b/plugins/wasm-go/extensions/ai-agent/README.md
@@ -7,7 +7,7 @@ description: AI Agent插件配置参考
## 功能说明
一个可定制化的 API AI Agent,支持配置 http method 类型为 GET 与 POST 的 API,支持多轮对话,支持流式与非流式模式。
agent流程图如下:
-
+
## 配置字段
From dc61bfc5c543b6cfc32b2c9717b6ee1aaf12e20e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?=
Date: Tue, 24 Sep 2024 17:26:25 +0800
Subject: [PATCH 06/16] add istio workload sds (#1332)
---
helm/core/templates/_pod.tpl | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/helm/core/templates/_pod.tpl b/helm/core/templates/_pod.tpl
index 657a4f29d4..432f9d3d4e 100644
--- a/helm/core/templates/_pod.tpl
+++ b/helm/core/templates/_pod.tpl
@@ -167,6 +167,12 @@ template:
{{- toYaml .Values.gateway.resources | nindent 10 }}
{{- end }}
volumeMounts:
+ - mountPath: /var/run/secrets/workload-spiffe-uds
+ name: workload-socket
+ - mountPath: /var/run/secrets/credential-uds
+ name: credential-socket
+ - mountPath: /var/run/secrets/workload-spiffe-credentials
+ name: workload-certs
{{- if eq (include "controller.jwtPolicy" .) "third-party-jwt" }}
- name: istio-token
mountPath: /var/run/secrets/tokens
@@ -245,6 +251,12 @@ template:
{{- toYaml . | nindent 6 }}
{{- end }}
volumes:
+ - emptyDir: {}
+ name: workload-socket
+ - emptyDir: {}
+ name: credential-socket
+ - emptyDir: {}
+ name: workload-certs
{{- if eq (include "controller.jwtPolicy" .) "third-party-jwt" }}
- name: istio-token
projected:
From bef91397533f55fbfa03ca713375a168775b73a5 Mon Sep 17 00:00:00 2001
From: rinfx <893383980@qq.com>
Date: Tue, 24 Sep 2024 18:45:40 +0800
Subject: [PATCH 07/16] Ai proxy support doubao (#1337)
---
plugins/wasm-go/extensions/ai-proxy/README.md | 13 +++
.../extensions/ai-proxy/provider/doubao.go | 102 ++++++++++++++++++
.../extensions/ai-proxy/provider/provider.go | 2 +
3 files changed, 117 insertions(+)
create mode 100644 plugins/wasm-go/extensions/ai-proxy/provider/doubao.go
diff --git a/plugins/wasm-go/extensions/ai-proxy/README.md b/plugins/wasm-go/extensions/ai-proxy/README.md
index 1196f10710..59019894c9 100644
--- a/plugins/wasm-go/extensions/ai-proxy/README.md
+++ b/plugins/wasm-go/extensions/ai-proxy/README.md
@@ -650,6 +650,19 @@ provider:
}
```
+### 使用 OpenAI 协议代理豆包大模型服务
+
+**配置信息**
+
+```yaml
+provider:
+ type: doubao
+ apiTokens:
+ - YOUR_DOUBAO_API_KEY
+ modelMapping:
+ '*': YOUR_DOUBAO_ENDPOINT
+ timeout: 1200000
+```
### 使用月之暗面配合其原生的文件上下文
diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/doubao.go b/plugins/wasm-go/extensions/ai-proxy/provider/doubao.go
new file mode 100644
index 0000000000..0ca349a773
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-proxy/provider/doubao.go
@@ -0,0 +1,102 @@
+package provider
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/alibaba/higress/plugins/wasm-go/extensions/ai-proxy/util"
+ "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
+ "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
+ "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
+)
+
+const (
+ doubaoDomain = "ark.cn-beijing.volces.com"
+ doubaoChatCompletionPath = "/api/v3/chat/completions"
+)
+
+type doubaoProviderInitializer struct{}
+
+func (m *doubaoProviderInitializer) ValidateConfig(config ProviderConfig) error {
+ if config.apiTokens == nil || len(config.apiTokens) == 0 {
+ return errors.New("no apiToken found in provider config")
+ }
+ return nil
+}
+
+func (m *doubaoProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) {
+ return &doubaoProvider{
+ config: config,
+ contextCache: createContextCache(&config),
+ }, nil
+}
+
+type doubaoProvider struct {
+ config ProviderConfig
+ contextCache *contextCache
+}
+
+func (m *doubaoProvider) GetProviderType() string {
+ return providerTypeDoubao
+}
+
+func (m *doubaoProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, log wrapper.Log) (types.Action, error) {
+ _ = util.OverwriteRequestHost(doubaoDomain)
+ _ = util.OverwriteRequestAuthorization("Bearer " + m.config.GetRandomToken())
+ _ = proxywasm.RemoveHttpRequestHeader("Content-Length")
+ if m.config.protocol == protocolOriginal {
+ ctx.DontReadRequestBody()
+ return types.ActionContinue, nil
+ }
+ if apiName != ApiNameChatCompletion {
+ return types.ActionContinue, errUnsupportedApiName
+ }
+ _ = util.OverwriteRequestPath(doubaoChatCompletionPath)
+ return types.ActionContinue, nil
+}
+
+func (m *doubaoProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte, log wrapper.Log) (types.Action, error) {
+ if apiName != ApiNameChatCompletion {
+ return types.ActionContinue, errUnsupportedApiName
+ }
+ request := &chatCompletionRequest{}
+ if err := decodeChatCompletionRequest(body, request); err != nil {
+ return types.ActionContinue, err
+ }
+ model := request.Model
+ if model == "" {
+ return types.ActionContinue, errors.New("missing model in chat completion request")
+ }
+ mappedModel := getMappedModel(model, m.config.modelMapping, log)
+ if mappedModel == "" {
+ return types.ActionContinue, errors.New("model becomes empty after applying the configured mapping")
+ }
+ request.Model = mappedModel
+ if m.contextCache != nil {
+ err := m.contextCache.GetContent(func(content string, err error) {
+ defer func() {
+ _ = proxywasm.ResumeHttpRequest()
+ }()
+ if err != nil {
+ log.Errorf("failed to load context file: %v", err)
+ _ = util.SendResponse(500, "ai-proxy.doubao.load_ctx_failed", util.MimeTypeTextPlain, fmt.Sprintf("failed to load context file: %v", err))
+ }
+ insertContextMessage(request, content)
+ if err := replaceJsonRequestBody(request, log); err != nil {
+ _ = util.SendResponse(500, "ai-proxy.doubao.insert_ctx_failed", util.MimeTypeTextPlain, fmt.Sprintf("failed to replace request body: %v", err))
+ }
+ }, log)
+ if err == nil {
+ return types.ActionPause, nil
+ } else {
+ return types.ActionContinue, err
+ }
+ } else {
+ if err := replaceJsonRequestBody(request, log); err != nil {
+ _ = util.SendResponse(500, "ai-proxy.doubao.transform_body_failed", util.MimeTypeTextPlain, fmt.Sprintf("failed to replace request body: %v", err))
+ return types.ActionContinue, err
+ }
+ _ = proxywasm.ResumeHttpRequest()
+ return types.ActionPause, nil
+ }
+}
diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go
index 0fb38eb338..c6ab5ef74b 100644
--- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go
+++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go
@@ -39,6 +39,7 @@ const (
providerTypeDeepl = "deepl"
providerTypeMistral = "mistral"
providerTypeCohere = "cohere"
+ providerTypeDoubao = "doubao"
protocolOpenAI = "openai"
protocolOriginal = "original"
@@ -96,6 +97,7 @@ var (
providerTypeDeepl: &deeplProviderInitializer{},
providerTypeMistral: &mistralProviderInitializer{},
providerTypeCohere: &cohereProviderInitializer{},
+ providerTypeDoubao: &doubaoProviderInitializer{},
}
)
From b82853c653c698f3960294de8177df203a7feeb0 Mon Sep 17 00:00:00 2001
From: rinfx <893383980@qq.com>
Date: Tue, 24 Sep 2024 19:42:10 +0800
Subject: [PATCH 08/16] Update ai statistics (#1303)
---
.../extensions/ai-statistics/README.md | 207 +++++--
.../extensions/ai-statistics/README_EN.md | 145 +++++
.../wasm-go/extensions/ai-statistics/go.mod | 3 -
.../wasm-go/extensions/ai-statistics/go.sum | 9 +-
.../wasm-go/extensions/ai-statistics/main.go | 532 ++++++++++++------
5 files changed, 676 insertions(+), 220 deletions(-)
create mode 100644 plugins/wasm-go/extensions/ai-statistics/README_EN.md
diff --git a/plugins/wasm-go/extensions/ai-statistics/README.md b/plugins/wasm-go/extensions/ai-statistics/README.md
index 211201be26..31fb207f9e 100644
--- a/plugins/wasm-go/extensions/ai-statistics/README.md
+++ b/plugins/wasm-go/extensions/ai-statistics/README.md
@@ -1,69 +1,178 @@
-# 介绍
-提供AI可观测基础能力,其后需接ai-proxy插件,如果不接ai-proxy插件的话,则只支持openai协议。
+---
+title: AI可观测
+keywords: [higress, AI, observability]
+description: AI可观测配置参考
+---
-# 配置说明
+## 介绍
+提供AI可观测基础能力,包括 metric, log, trace,其后需接ai-proxy插件,如果不接ai-proxy插件的话,则需要用户进行相应配置才可生效。
+
+## 运行属性
+
+插件执行阶段:`默认阶段`
+插件执行优先级:`200`
+
+## 配置说明
+插件默认请求符合openai协议格式,并提供了以下基础可观测值,用户无需特殊配置:
+
+- metric:提供了输入token、输出token、首个token的rt(流式请求)、请求总rt等指标,支持在网关、路由、服务、模型四个维度上进行观测
+- log:提供了 input_token, output_token, model, llm_service_duration, llm_first_token_duration 等字段
+
+用户还可以通过配置的方式对可观测的值进行扩展:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|-------|------|-----|------------------------|
-| `enable` | bool | 必填 | - | 是否开启ai统计功能 |
-| `tracing_span` | array | 非必填 | - | 自定义tracing span tag 配置 |
+| `attributes` | []Attribute | 非必填 | - | 用户希望记录在log/span中的信息 |
+
+Attribute 配置说明:
-## tracing_span 配置说明
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|-------|-----|-----|------------------------|
-| `key` | string | 必填 | - | tracing tag 名称 |
-| `value_source` | string | 必填 | - | tag 取值来源 |
-| `value` | string | 必填 | - | tag 取值 key value/path |
+| `key` | string | 必填 | - | attrribute 名称 |
+| `value_source` | string | 必填 | - | attrribute 取值来源,可选值为 `fixed_value`, `request_header`, `request_body`, `response_header`, `response_body`, `response_streaming_body` |
+| `value` | string | 必填 | - | attrribute 取值 key value/path |
+| `rule` | string | 非必填 | - | 从流式响应中提取 attrribute 的规则,可选值为 `first`, `replace`, `append`|
+| `apply_to_log` | bool | 非必填 | false | 是否将提取的信息记录在日志中 |
+| `apply_to_span` | bool | 非必填 | false | 是否将提取的信息记录在链路追踪span中 |
-value_source为 tag 值的取值来源,可选配置值有 4 个:
-- property : tag 值通过proxywasm.GetProperty()方法获取,value配置GetProperty()方法要提取的key名
-- requeset_header : tag 值通过http请求头获取,value配置为header key
-- request_body :tag 值通过请求body获取,value配置格式为 gjson的 GJSON PATH 语法
-- response_header : tag 值通过http响应头获取,value配置为header key
+`value_source` 的各种取值含义如下:
+
+- `fixed_value`:固定值
+- `requeset_header` : attrribute 值通过 http 请求头获取,value 配置为 header key
+- `request_body` :attrribute 值通过请求 body 获取,value 配置格式为 gjson 的 jsonpath
+- `response_header` :attrribute 值通过 http 响应头获取,value 配置为header key
+- `response_body` :attrribute 值通过响应 body 获取,value 配置格式为 gjson 的 jsonpath
+- `response_streaming_body` :attrribute 值通过流式响应 body 获取,value 配置格式为 gjson 的 jsonpath
+
+
+当 `value_source` 为 `response_streaming_body` 时,应当配置 `rule`,用于指定如何从流式body中获取指定值,取值含义如下:
+
+- `first`:多个chunk中取第一个有效chunk的值
+- `replace`:多个chunk中取最后一个有效chunk的值
+- `append`:拼接多个有效chunk中的值,可用于获取回答内容
+
+## 配置示例
+如果希望在网关访问日志中记录ai-statistic相关的统计值,需要修改log_format,在原log_format基础上添加一个新字段,示例如下:
-举例如下:
```yaml
-tracing_label:
-- key: "session_id"
- value_source: "requeset_header"
- value: "session_id"
-- key: "user_content"
- value_source: "request_body"
- value: "input.messages.1.content"
+'{"ai_log":"%FILTER_STATE(wasm.ai_log:PLAIN)%"}'
```
-开启后 metrics 示例:
+### 空配置
+#### 监控
```
-route_upstream_model_input_token{ai_route="openai",ai_cluster="qwen",ai_model="qwen-max"} 21
-route_upstream_model_output_token{ai_route="openai",ai_cluster="qwen",ai_model="qwen-max"} 17
+route_upstream_model_metric_input_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 10
+route_upstream_model_metric_llm_duration_count{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1
+route_upstream_model_metric_llm_first_token_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 309
+route_upstream_model_metric_llm_service_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1955
+route_upstream_model_metric_output_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 69
```
-日志示例:
+#### 日志
+```json
+{
+ "ai_log":"{\"model\":\"qwen-turbo\",\"input_token\":\"10\",\"output_token\":\"69\",\"llm_first_token_duration\":\"309\",\"llm_service_duration\":\"1955\"}"
+}
+```
+
+#### 链路追踪
+配置为空时,不会在span中添加额外的attribute
+
+### 从非openai协议提取token使用信息
+在ai-proxy中设置协议为original时,以百炼为例,可作如下配置指定如何提取model, input_token, output_token
+```yaml
+attributes:
+ - key: model
+ value_source: response_body
+ value: usage.models.0.model_id
+ apply_to_log: true
+ apply_to_span: false
+ - key: input_token
+ value_source: response_body
+ value: usage.models.0.input_tokens
+ apply_to_log: true
+ apply_to_span: false
+ - key: output_token
+ value_source: response_body
+ value: usage.models.0.output_tokens
+ apply_to_log: true
+ apply_to_span: false
+```
+#### 监控
+```
+route_upstream_model_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343
+route_upstream_model_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153
+route_upstream_model_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725
+route_upstream_model_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1
+```
+
+#### 日志
+此配置下日志效果如下:
```json
{
- "model": "qwen-max",
- "input_token": "21",
- "output_token": "17",
- "authority": "dashscope.aliyuncs.com",
- "bytes_received": "336",
- "bytes_sent": "1675",
- "duration": "1590",
- "istio_policy_status": "-",
- "method": "POST",
- "path": "/v1/chat/completions",
- "protocol": "HTTP/1.1",
- "request_id": "5895f5a9-e4e3-425b-98db-6c6a926195b7",
- "requested_server_name": "-",
- "response_code": "200",
- "response_flags": "-",
- "route_name": "openai",
- "start_time": "2024-06-18T09:37:14.078Z",
- "trace_id": "-",
- "upstream_cluster": "qwen",
- "upstream_service_time": "496",
- "upstream_transport_failure_reason": "-",
- "user_agent": "PostmanRuntime/7.37.3",
- "x_forwarded_for": "-"
+ "ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
}
+```
+
+#### 链路追踪
+链路追踪的 span 中可以看到 model, input_token, output_token 三个额外的 attribute
+
+### 配合认证鉴权记录consumer
+举例如下:
+```yaml
+attributes:
+ - key: consumer # 配合认证鉴权记录consumer
+ value_source: request_header
+ value: x-mse-consumer
+ apply_to_log: true
+```
+
+### 记录问题与回答
+```yaml
+attributes:
+ - key: question # 记录问题
+ value_source: request_body
+ value: messages.@reverse.0.content
+ apply_to_log: true
+ - key: answer # 在流式响应中提取大模型的回答
+ value_source: response_streaming_body
+ value: choices.0.delta.content
+ rule: append
+ apply_to_log: true
+ - key: answer # 在非流式响应中提取大模型的回答
+ value_source: response_body
+ value: choices.0.message.content
+ apply_to_log: true
+```
+
+## 进阶
+配合阿里云SLS数据加工,可以将ai相关的字段进行提取加工,例如原始日志为:
+
+```
+ai_log:{"question":"用python计算2的3次方","answer":"你可以使用 Python 的乘方运算符 `**` 来计算一个数的次方。计算2的3次方,即2乘以自己2次,可以用以下代码表示:\n\n```python\nresult = 2 ** 3\nprint(result)\n```\n\n运行这段代码,你会得到输出结果为8,因为2乘以自己两次等于8。","model":"qwen-max","input_token":"16","output_token":"76","llm_service_duration":"5913"}
+```
+
+使用如下数据加工脚本,可以提取出question和answer:
+
+```
+e_regex("ai_log", grok("%{EXTRACTJSON}"))
+e_set("question", json_select(v("json"), "question", default="-"))
+e_set("answer", json_select(v("json"), "answer", default="-"))
+```
+
+提取后,SLS中会添加question和answer两个字段,示例如下:
+
+```
+ai_log:{"question":"用python计算2的3次方","answer":"你可以使用 Python 的乘方运算符 `**` 来计算一个数的次方。计算2的3次方,即2乘以自己2次,可以用以下代码表示:\n\n```python\nresult = 2 ** 3\nprint(result)\n```\n\n运行这段代码,你会得到输出结果为8,因为2乘以自己两次等于8。","model":"qwen-max","input_token":"16","output_token":"76","llm_service_duration":"5913"}
+
+question:用python计算2的3次方
+
+answer:你可以使用 Python 的乘方运算符 `**` 来计算一个数的次方。计算2的3次方,即2乘以自己2次,可以用以下代码表示:
+
+result = 2 ** 3
+print(result)
+
+运行这段代码,你会得到输出结果为8,因为2乘以自己两次等于8。
+
```
\ No newline at end of file
diff --git a/plugins/wasm-go/extensions/ai-statistics/README_EN.md b/plugins/wasm-go/extensions/ai-statistics/README_EN.md
new file mode 100644
index 0000000000..e94544a510
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-statistics/README_EN.md
@@ -0,0 +1,145 @@
+---
+title: AI Statistics
+keywords: [higress, AI, observability]
+description: AI Statistics plugin configuration reference
+---
+
+## Introduction
+Provides basic AI observability capabilities, including metric, log, and trace. The ai-proxy plug-in needs to be connected afterwards. If the ai-proxy plug-in is not connected, the user needs to configure it accordingly to take effect.
+
+## Runtime Properties
+
+Plugin Phase: `CUSTOM`
+Plugin Priority: `200`
+
+## Configuration instructions
+The default request of the plug-in conforms to the openai protocol format and provides the following basic observable values. Users do not need special configuration:
+
+- metric: It provides indicators such as input token, output token, rt of the first token (streaming request), total request rt, etc., and supports observation in the four dimensions of gateway, routing, service, and model.
+- log: Provides input_token, output_token, model, llm_service_duration, llm_first_token_duration and other fields
+
+Users can also expand observable values through configuration:
+
+| Name | Type | Required | Default | Description |
+|----------------|-------|------|-----|------------------------|
+| `attributes` | []Attribute | required | - | Information that the user wants to record in log/span |
+
+Attribute Configuration instructions:
+
+| Name | Type | Required | Default | Description |
+|----------------|-------|-----|-----|------------------------|
+| `key` | string | required | - | attrribute key |
+| `value_source` | string | required | - | attrribute value source, optional values are `fixed_value`, `request_header`, `request_body`, `response_header`, `response_body`, `response_streaming_body` |
+| `value` | string | required | - | how to get attrribute value |
+| `rule` | string | optional | - | Rule to extract attribute from streaming response, optional values are `first`, `replace`, `append`|
+| `apply_to_log` | bool | optional | false | Whether to record the extracted information in the log |
+| `apply_to_span` | bool | optional | false | Whether to record the extracted information in the link tracking span |
+
+The meanings of various values for `value_source` are as follows:
+
+- `fixed_value`: fixed value
+- `requeset_header`: The attrribute is obtained through the http request header
+- `request_body`: The attrribute is obtained through the http request body
+- `response_header`: The attrribute is obtained through the http response header
+- `response_body`: The attrribute is obtained through the http response body
+- `response_streaming_body`: The attrribute is obtained through the http streaming response body
+
+
+When `value_source` is `response_streaming_body`, `rule` should be configured to specify how to obtain the specified value from the streaming body. The meaning of the value is as follows:
+
+- `first`: extract value from the first valid chunk
+- `replace`: extract value from the last valid chunk
+- `append`: join value pieces from all valid chunks
+
+## Configuration example
+If you want to record ai-statistic related statistical values in the gateway access log, you need to modify log_format and add a new field based on the original log_format. The example is as follows:
+
+```yaml
+'{"ai_log":"%FILTER_STATE(wasm.ai_log:PLAIN)%"}'
+```
+
+### Empty
+#### Metric
+```
+route_upstream_model_metric_input_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 10
+route_upstream_model_metric_llm_duration_count{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1
+route_upstream_model_metric_llm_first_token_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 309
+route_upstream_model_metric_llm_service_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1955
+route_upstream_model_metric_output_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 69
+```
+
+#### Log
+```json
+{
+ "ai_log":"{\"model\":\"qwen-turbo\",\"input_token\":\"10\",\"output_token\":\"69\",\"llm_first_token_duration\":\"309\",\"llm_service_duration\":\"1955\"}"
+}
+```
+
+#### Trace
+When the configuration is empty, no additional attributes will be added to the span.
+
+### Extract token usage information from non-openai protocols
+When setting the protocol to original in ai-proxy, taking Alibaba Cloud Bailian as an example, you can make the following configuration to specify how to extract `model`, `input_token`, `output_token`
+
+```yaml
+attributes:
+ - key: model
+ value_source: response_body
+ value: usage.models.0.model_id
+ apply_to_log: true
+ apply_to_span: false
+ - key: input_token
+ value_source: response_body
+ value: usage.models.0.input_tokens
+ apply_to_log: true
+ apply_to_span: false
+ - key: output_token
+ value_source: response_body
+ value: usage.models.0.output_tokens
+ apply_to_log: true
+ apply_to_span: false
+```
+#### Metric
+```
+route_upstream_model_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343
+route_upstream_model_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153
+route_upstream_model_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725
+route_upstream_model_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1
+```
+
+#### Log
+```json
+{
+ "ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
+}
+```
+
+#### Trace
+Three additional attributes `model`, `input_token`, and `output_token` can be seen in the trace spans.
+
+### Cooperate with authentication and authentication record consumer
+```yaml
+attributes:
+ - key: consumer
+ value_source: request_header
+ value: x-mse-consumer
+ apply_to_log: true
+```
+
+### Record questions and answers
+```yaml
+attributes:
+ - key: question
+ value_source: request_body
+ value: messages.@reverse.0.content
+ apply_to_log: true
+ - key: answer
+ value_source: response_streaming_body
+ value: choices.0.delta.content
+ rule: append
+ apply_to_log: true
+ - key: answer
+ value_source: response_body
+ value: choices.0.message.content
+ apply_to_log: true
+```
\ No newline at end of file
diff --git a/plugins/wasm-go/extensions/ai-statistics/go.mod b/plugins/wasm-go/extensions/ai-statistics/go.mod
index 8d0f87c062..a5c87ef617 100644
--- a/plugins/wasm-go/extensions/ai-statistics/go.mod
+++ b/plugins/wasm-go/extensions/ai-statistics/go.mod
@@ -10,8 +10,6 @@ require (
github.com/tidwall/gjson v1.14.3
)
-require github.com/tetratelabs/wazero v1.7.1 // indirect
-
require (
github.com/google/uuid v1.3.0 // indirect
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect
@@ -19,5 +17,4 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/resp v0.1.1 // indirect
- github.com/wasilibs/go-re2 v1.5.3
)
diff --git a/plugins/wasm-go/extensions/ai-statistics/go.sum b/plugins/wasm-go/extensions/ai-statistics/go.sum
index b0732f4e65..f473e12b2d 100644
--- a/plugins/wasm-go/extensions/ai-statistics/go.sum
+++ b/plugins/wasm-go/extensions/ai-statistics/go.sum
@@ -1,19 +1,14 @@
-github.com/alibaba/higress/plugins/wasm-go v1.3.6-0.20240522012622-fc6a6aad8906 h1:RhEmB+ApLKsClZD7joTC4ifmsVgOVz4pFLdPR3xhNaE=
-github.com/alibaba/higress/plugins/wasm-go v1.3.6-0.20240522012622-fc6a6aad8906/go.mod h1:10jQXKsYFUF7djs+Oy7t82f4dbie9pISfP9FJwpPLuk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
-github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc h1:t2AT8zb6N/59Y78lyRWedVoVWHNRSCBh0oWCC+bluTQ=
-github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
+github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/tetratelabs/wazero v1.7.1 h1:QtSfd6KLc41DIMpDYlJdoMc6k7QTN246DM2+n2Y/Dx8=
-github.com/tetratelabs/wazero v1.7.1/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -22,6 +17,4 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
-github.com/wasilibs/go-re2 v1.5.3 h1:wiuTcgDZdLhu8NG8oqF5sF5Q3yIU14lPAvXqeYzDK3g=
-github.com/wasilibs/go-re2 v1.5.3/go.mod h1:PzpVPsBdFC7vM8QJbbEnOeTmwA0DGE783d/Gex8eCV8=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/plugins/wasm-go/extensions/ai-statistics/main.go b/plugins/wasm-go/extensions/ai-statistics/main.go
index e7396160f4..14fcc4d2ab 100644
--- a/plugins/wasm-go/extensions/ai-statistics/main.go
+++ b/plugins/wasm-go/extensions/ai-statistics/main.go
@@ -3,19 +3,16 @@ package main
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
- "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
- "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
- "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
- "github.com/tidwall/gjson"
"strconv"
"strings"
"time"
-)
-const (
- StatisticsRequestStartTime = "ai-statistics-request-start-time"
- StatisticsFirstTokenTime = "ai-statistics-first-token-time"
+ "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
+ "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
+ "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
+ "github.com/tidwall/gjson"
)
func main() {
@@ -30,146 +27,243 @@ func main() {
)
}
+const (
+ // Trace span prefix
+ TracePrefix = "trace_span_tag."
+ // Context consts
+ StatisticsRequestStartTime = "ai-statistics-request-start-time"
+ StatisticsFirstTokenTime = "ai-statistics-first-token-time"
+ CtxGeneralAtrribute = "attributes"
+ CtxLogAtrribute = "logAttributes"
+ CtxStreamingBodyBuffer = "streamingBodyBuffer"
+
+ // Source Type
+ FixedValue = "fixed_value"
+ RequestHeader = "request_header"
+ RequestBody = "request_body"
+ ResponseHeader = "response_header"
+ ResponseStreamingBody = "response_streaming_body"
+ ResponseBody = "response_body"
+
+ // Inner metric & log attributes name
+ Model = "model"
+ InputToken = "input_token"
+ OutputToken = "output_token"
+ LLMFirstTokenDuration = "llm_first_token_duration"
+ LLMServiceDuration = "llm_service_duration"
+ LLMDurationCount = "llm_duration_count"
+
+ // Extract Rule
+ RuleFirst = "first"
+ RuleReplace = "replace"
+ RuleAppend = "append"
+)
+
// TracingSpan is the tracing span configuration.
-type TracingSpan struct {
- Key string `required:"true" yaml:"key" json:"key"`
- ValueSource string `required:"true" yaml:"valueSource" json:"valueSource"`
- Value string `required:"true" yaml:"value" json:"value"`
+type Attribute struct {
+ Key string `json:"key"`
+ ValueSource string `json:"value_source"`
+ Value string `json:"value"`
+ Rule string `json:"rule,omitempty"`
+ ApplyToLog bool `json:"apply_to_log,omitempty"`
+ ApplyToSpan bool `json:"apply_to_span,omitempty"`
}
type AIStatisticsConfig struct {
- Enable bool `required:"true" yaml:"enable" json:"enable"`
- // TracingSpan array define the tracing span.
- TracingSpan []TracingSpan `required:"true" yaml:"tracingSpan" json:"tracingSpan"`
- Metrics map[string]proxywasm.MetricCounter `required:"true" yaml:"metrics" json:"metrics"`
+ // Metrics
+ // TODO: add more metrics in Gauge and Histogram format
+ counterMetrics map[string]proxywasm.MetricCounter
+ // Attributes to be recorded in log & span
+ attributes []Attribute
+ // If there exist attributes extracted from streaming body, chunks should be buffered
+ shouldBufferStreamingBody bool
+}
+
+func generateMetricName(route, cluster, model, metricName string) string {
+ return fmt.Sprintf("route.%s.upstream.%s.model.%s.metric.%s", route, cluster, model, metricName)
+}
+
+func getRouteName() (string, error) {
+ if raw, err := proxywasm.GetProperty([]string{"route_name"}); err != nil {
+ return "-", err
+ } else {
+ return string(raw), nil
+ }
}
-func (config *AIStatisticsConfig) incrementCounter(metricName string, inc uint64, log wrapper.Log) {
- counter, ok := config.Metrics[metricName]
+func getClusterName() (string, error) {
+ if raw, err := proxywasm.GetProperty([]string{"cluster_name"}); err != nil {
+ return "-", err
+ } else {
+ return string(raw), nil
+ }
+}
+
+func (config *AIStatisticsConfig) incrementCounter(metricName string, inc uint64) {
+ counter, ok := config.counterMetrics[metricName]
if !ok {
counter = proxywasm.DefineCounterMetric(metricName)
- config.Metrics[metricName] = counter
+ config.counterMetrics[metricName] = counter
}
counter.Increment(inc)
}
func parseConfig(configJson gjson.Result, config *AIStatisticsConfig, log wrapper.Log) error {
- config.Enable = configJson.Get("enable").Bool()
-
- // Parse tracing span.
- tracingSpanConfigArray := configJson.Get("tracing_span").Array()
- config.TracingSpan = make([]TracingSpan, len(tracingSpanConfigArray))
- for i, tracingSpanConfig := range tracingSpanConfigArray {
- tracingSpan := TracingSpan{
- Key: tracingSpanConfig.Get("key").String(),
- ValueSource: tracingSpanConfig.Get("value_source").String(),
- Value: tracingSpanConfig.Get("value").String(),
+ // Parse tracing span attributes setting.
+ attributeConfigs := configJson.Get("attributes").Array()
+ config.attributes = make([]Attribute, len(attributeConfigs))
+ for i, attributeConfig := range attributeConfigs {
+ attribute := Attribute{}
+ err := json.Unmarshal([]byte(attributeConfig.Raw), &attribute)
+ if err != nil {
+ log.Errorf("parse config failed, %v", err)
+ return err
}
- config.TracingSpan[i] = tracingSpan
+ if attribute.ValueSource == ResponseStreamingBody {
+ config.shouldBufferStreamingBody = true
+ }
+ if attribute.Rule != "" && attribute.Rule != RuleFirst && attribute.Rule != RuleReplace && attribute.Rule != RuleAppend {
+ return errors.New("value of rule must be one of [nil, first, replace, append]")
+ }
+ config.attributes[i] = attribute
}
-
- config.Metrics = make(map[string]proxywasm.MetricCounter)
-
- configStr, _ := json.Marshal(config)
- log.Infof("Init ai-statistics config success, config: %s.", configStr)
+ // Metric settings
+ config.counterMetrics = make(map[string]proxywasm.MetricCounter)
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config AIStatisticsConfig, log wrapper.Log) types.Action {
-
- if !config.Enable {
- ctx.DontReadRequestBody()
- return types.ActionContinue
- }
-
- // Fetch request header tracing span value.
- setTracingSpanValueBySource(config, "request_header", nil, log)
- // Fetch request process proxy wasm property.
- // Warn: The property may be modified by response process , so the value of the property may be overwritten.
- setTracingSpanValueBySource(config, "property", nil, log)
-
+ ctx.SetContext(CtxGeneralAtrribute, map[string]string{})
+ ctx.SetContext(CtxLogAtrribute, map[string]string{})
+ ctx.SetContext(StatisticsRequestStartTime, time.Now().UnixMilli())
+
+ // Set user defined log & span attributes which type is fixed_value
+ setAttributeBySource(ctx, config, FixedValue, nil, log)
+ // Set user defined log & span attributes which type is request_header
+ setAttributeBySource(ctx, config, RequestHeader, nil, log)
// Set request start time.
- ctx.SetContext(StatisticsRequestStartTime, strconv.FormatUint(uint64(time.Now().UnixMilli()), 10))
- // The request has a body and requires delaying the header transmission until a cache miss occurs,
- // at which point the header should be sent.
return types.ActionContinue
}
func onHttpRequestBody(ctx wrapper.HttpContext, config AIStatisticsConfig, body []byte, log wrapper.Log) types.Action {
- // Set request body tracing span value.
- setTracingSpanValueBySource(config, "request_body", body, log)
+ // Set user defined log & span attributes.
+ setAttributeBySource(ctx, config, RequestBody, body, log)
return types.ActionContinue
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config AIStatisticsConfig, log wrapper.Log) types.Action {
- if !config.Enable {
- ctx.DontReadResponseBody()
- return types.ActionContinue
- }
contentType, _ := proxywasm.GetHttpResponseHeader("content-type")
if !strings.Contains(contentType, "text/event-stream") {
ctx.BufferResponseBody()
}
- // Set response header tracing span value.
- setTracingSpanValueBySource(config, "response_header", nil, log)
+ // Set user defined log & span attributes.
+ setAttributeBySource(ctx, config, ResponseHeader, nil, log)
return types.ActionContinue
}
func onHttpStreamingBody(ctx wrapper.HttpContext, config AIStatisticsConfig, data []byte, endOfStream bool, log wrapper.Log) []byte {
-
- // If the end of the stream is reached, calculate the total time and set tracing span tag total_time.
- // Otherwise, set tracing span tag first_token_time.
- if endOfStream {
- requestStartTimeStr := ctx.GetContext(StatisticsRequestStartTime).(string)
- requestStartTime, _ := strconv.ParseInt(requestStartTimeStr, 10, 64)
- responseEndTime := time.Now().UnixMilli()
- setTracingSpanValue("total_time", fmt.Sprintf("%d", responseEndTime-requestStartTime), log)
- } else {
- firstTokenTime := ctx.GetContext(StatisticsFirstTokenTime)
- if firstTokenTime == nil {
- firstTokenTimeStr := strconv.FormatInt(time.Now().UnixMilli(), 10)
- ctx.SetContext(StatisticsFirstTokenTime, firstTokenTimeStr)
- setTracingSpanValue("first_token_time", firstTokenTimeStr, log)
+ // Buffer stream body for record log & span attributes
+ if config.shouldBufferStreamingBody {
+ var streamingBodyBuffer []byte
+ streamingBodyBuffer, ok := ctx.GetContext(CtxStreamingBodyBuffer).([]byte)
+ if !ok {
+ streamingBodyBuffer = data
+ } else {
+ streamingBodyBuffer = append(streamingBodyBuffer, data...)
}
+ ctx.SetContext(CtxStreamingBodyBuffer, streamingBodyBuffer)
}
- model, inputToken, outputToken, ok := getUsage(data)
+ // Get requestStartTime from http context
+ requestStartTime, ok := ctx.GetContext(StatisticsRequestStartTime).(int64)
if !ok {
+ log.Error("failed to get requestStartTime from http context")
return data
}
- setFilterStateData(model, inputToken, outputToken, log)
- incrementCounter(config, model, inputToken, outputToken, log)
- // Set tracing span tag input_tokens and output_tokens.
- setTracingSpanValue("input_tokens", strconv.FormatInt(inputToken, 10), log)
- setTracingSpanValue("output_tokens", strconv.FormatInt(outputToken, 10), log)
- // Set response process proxy wasm property.
- setTracingSpanValueBySource(config, "property", nil, log)
+ // If this is the first chunk, record first token duration metric and span attribute
+ if ctx.GetContext(StatisticsFirstTokenTime) == nil {
+ firstTokenTime := time.Now().UnixMilli()
+ ctx.SetContext(StatisticsFirstTokenTime, firstTokenTime)
+ attributes, _ := ctx.GetContext(CtxGeneralAtrribute).(map[string]string)
+ attributes[LLMFirstTokenDuration] = fmt.Sprint(firstTokenTime - requestStartTime)
+ ctx.SetContext(CtxGeneralAtrribute, attributes)
+ }
+
+ // Set information about this request
+
+ if model, inputToken, outputToken, ok := getUsage(data); ok {
+ attributes, _ := ctx.GetContext(CtxGeneralAtrribute).(map[string]string)
+ // Record Log Attributes
+ attributes[Model] = model
+ attributes[InputToken] = fmt.Sprint(inputToken)
+ attributes[OutputToken] = fmt.Sprint(outputToken)
+ // Set attributes to http context
+ ctx.SetContext(CtxGeneralAtrribute, attributes)
+ }
+ // If the end of the stream is reached, record metrics/logs/spans.
+ if endOfStream {
+ responseEndTime := time.Now().UnixMilli()
+ attributes, _ := ctx.GetContext(CtxGeneralAtrribute).(map[string]string)
+ attributes[LLMServiceDuration] = fmt.Sprint(responseEndTime - requestStartTime)
+ ctx.SetContext(CtxGeneralAtrribute, attributes)
+
+ // Set user defined log & span attributes.
+ if config.shouldBufferStreamingBody {
+ streamingBodyBuffer, ok := ctx.GetContext(CtxStreamingBodyBuffer).([]byte)
+ if !ok {
+ return data
+ }
+ setAttributeBySource(ctx, config, ResponseStreamingBody, streamingBodyBuffer, log)
+ }
+
+ // Write inner filter states which can be used by other plugins such as ai-token-ratelimit
+ writeFilterStates(ctx, log)
+
+ // Write log
+ writeLog(ctx, log)
+
+ // Write metrics
+ writeMetric(ctx, config, log)
+ }
return data
}
func onHttpResponseBody(ctx wrapper.HttpContext, config AIStatisticsConfig, body []byte, log wrapper.Log) types.Action {
+ // Get attributes from http context
+ attributes, _ := ctx.GetContext(CtxGeneralAtrribute).(map[string]string)
+
+ // Get requestStartTime from http context
+ requestStartTime, _ := ctx.GetContext(StatisticsRequestStartTime).(int64)
- // Calculate the total time and set tracing span tag total_time.
- requestStartTimeStr := ctx.GetContext(StatisticsRequestStartTime).(string)
- requestStartTime, _ := strconv.ParseInt(requestStartTimeStr, 10, 64)
responseEndTime := time.Now().UnixMilli()
- setTracingSpanValue("total_time", fmt.Sprintf("%d", responseEndTime-requestStartTime), log)
+ attributes[LLMServiceDuration] = fmt.Sprint(responseEndTime - requestStartTime)
+ // Set information about this request
model, inputToken, outputToken, ok := getUsage(body)
- if !ok {
- return types.ActionContinue
+ if ok {
+ attributes[Model] = model
+ attributes[InputToken] = fmt.Sprint(inputToken)
+ attributes[OutputToken] = fmt.Sprint(outputToken)
+ // Update attributes
+ ctx.SetContext(CtxGeneralAtrribute, attributes)
}
- setFilterStateData(model, inputToken, outputToken, log)
- incrementCounter(config, model, inputToken, outputToken, log)
- // Set tracing span tag input_tokens and output_tokens.
- setTracingSpanValue("input_tokens", strconv.FormatInt(inputToken, 10), log)
- setTracingSpanValue("output_tokens", strconv.FormatInt(outputToken, 10), log)
- // Set response process proxy wasm property.
- setTracingSpanValueBySource(config, "property", nil, log)
+
+ // Set user defined log & span attributes.
+ setAttributeBySource(ctx, config, ResponseBody, body, log)
+
+ // Write inner filter states which can be used by other plugins such as ai-token-ratelimit
+ writeFilterStates(ctx, log)
+
+ // Write log
+ writeLog(ctx, log)
+
+ // Write metrics
+ writeMetric(ctx, config, log)
+
return types.ActionContinue
}
@@ -198,92 +292,210 @@ func getUsage(data []byte) (model string, inputTokenUsage int64, outputTokenUsag
return
}
-// setFilterData sets the input_token and output_token in the filter state.
-// ai-token-ratelimit will use these values to calculate the total token usage.
-func setFilterStateData(model string, inputToken int64, outputToken int64, log wrapper.Log) {
- if e := proxywasm.SetProperty([]string{"model"}, []byte(model)); e != nil {
- log.Errorf("failed to set model in filter state: %v", e)
- }
- if e := proxywasm.SetProperty([]string{"input_token"}, []byte(fmt.Sprintf("%d", inputToken))); e != nil {
- log.Errorf("failed to set input_token in filter state: %v", e)
- }
- if e := proxywasm.SetProperty([]string{"output_token"}, []byte(fmt.Sprintf("%d", outputToken))); e != nil {
- log.Errorf("failed to set output_token in filter state: %v", e)
- }
-}
-
-func incrementCounter(config AIStatisticsConfig, model string, inputToken int64, outputToken int64, log wrapper.Log) {
- var route, cluster string
- if raw, err := proxywasm.GetProperty([]string{"route_name"}); err == nil {
- route = string(raw)
- }
- if raw, err := proxywasm.GetProperty([]string{"cluster_name"}); err == nil {
- cluster = string(raw)
- }
- config.incrementCounter("route."+route+".upstream."+cluster+".model."+model+".input_token", uint64(inputToken), log)
- config.incrementCounter("route."+route+".upstream."+cluster+".model."+model+".output_token", uint64(outputToken), log)
-}
-
// fetches the tracing span value from the specified source.
-func setTracingSpanValueBySource(config AIStatisticsConfig, tracingSource string, body []byte, log wrapper.Log) {
- for _, tracingSpanEle := range config.TracingSpan {
- if tracingSource == tracingSpanEle.ValueSource {
- switch tracingSource {
- case "response_header":
- if value, err := proxywasm.GetHttpResponseHeader(tracingSpanEle.Value); err == nil {
- setTracingSpanValue(tracingSpanEle.Key, value, log)
+func setAttributeBySource(ctx wrapper.HttpContext, config AIStatisticsConfig, source string, body []byte, log wrapper.Log) {
+ attributes, ok := ctx.GetContext(CtxGeneralAtrribute).(map[string]string)
+ if !ok {
+ log.Error("failed to get attributes from http context")
+ return
+ }
+ for _, attribute := range config.attributes {
+ if source == attribute.ValueSource {
+ switch source {
+ case FixedValue:
+ log.Debugf("[attribute] source type: %s, key: %s, value: %s", source, attribute.Key, attribute.Value)
+ attributes[attribute.Key] = attribute.Value
+ case RequestHeader:
+ if value, err := proxywasm.GetHttpRequestHeader(attribute.Value); err == nil {
+ log.Debugf("[attribute] source type: %s, key: %s, value: %s", source, attribute.Key, value)
+ attributes[attribute.Key] = value
}
- case "request_body":
- bodyJson := gjson.ParseBytes(body)
- value := trimQuote(bodyJson.Get(tracingSpanEle.Value).String())
- setTracingSpanValue(tracingSpanEle.Key, value, log)
- case "request_header":
- if value, err := proxywasm.GetHttpRequestHeader(tracingSpanEle.Value); err == nil {
- setTracingSpanValue(tracingSpanEle.Key, value, log)
+ case RequestBody:
+ raw := gjson.GetBytes(body, attribute.Value).Raw
+ var value string
+ if len(raw) > 2 {
+ value = raw[1 : len(raw)-1]
}
- case "property":
- if raw, err := proxywasm.GetProperty([]string{tracingSpanEle.Value}); err == nil {
- setTracingSpanValue(tracingSpanEle.Key, string(raw), log)
+ log.Debugf("[attribute] source type: %s, key: %s, value: %s", source, attribute.Key, value)
+ attributes[attribute.Key] = value
+ case ResponseHeader:
+ if value, err := proxywasm.GetHttpResponseHeader(attribute.Value); err == nil {
+ log.Debugf("[log attribute] source type: %s, key: %s, value: %s", source, attribute.Key, value)
+ attributes[attribute.Key] = value
}
+ case ResponseStreamingBody:
+ value := extractStreamingBodyByJsonPath(body, attribute.Value, attribute.Rule, log)
+ log.Debugf("[log attribute] source type: %s, key: %s, value: %s", source, attribute.Key, value)
+ attributes[attribute.Key] = value
+ case ResponseBody:
+ value := gjson.GetBytes(body, attribute.Value).Raw
+ if len(value) > 2 && value[0] == '"' && value[len(value)-1] == '"' {
+ value = value[1 : len(value)-1]
+ }
+ log.Debugf("[log attribute] source type: %s, key: %s, value: %s", source, attribute.Key, value)
+ attributes[attribute.Key] = value
default:
-
}
}
+ if attribute.ApplyToLog {
+ setLogAttribute(ctx, attribute.Key, attributes[attribute.Key], log)
+ }
+ if attribute.ApplyToSpan {
+ setSpanAttribute(attribute.Key, attributes[attribute.Key], log)
+ }
}
+ ctx.SetContext(CtxGeneralAtrribute, attributes)
}
-// Set the tracing span with value.
-func setTracingSpanValue(tracingKey, tracingValue string, log wrapper.Log) {
- log.Debugf("try to set trace span [%s] with value [%s].", tracingKey, tracingValue)
-
- if tracingValue != "" {
- traceSpanTag := "trace_span_tag." + tracingKey
-
- if raw, err := proxywasm.GetProperty([]string{traceSpanTag}); err == nil {
- if raw != nil {
- log.Warnf("trace span [%s] already exists, value will be overwrite, orign value: %s.", traceSpanTag, string(raw))
+func extractStreamingBodyByJsonPath(data []byte, jsonPath string, rule string, log wrapper.Log) string {
+ chunks := bytes.Split(bytes.TrimSpace(data), []byte("\n\n"))
+ var value string
+ if rule == RuleFirst {
+ for _, chunk := range chunks {
+ jsonObj := gjson.GetBytes(chunk, jsonPath)
+ if jsonObj.Exists() {
+ value = jsonObj.String()
+ break
+ }
+ }
+ } else if rule == RuleReplace {
+ for _, chunk := range chunks {
+ jsonObj := gjson.GetBytes(chunk, jsonPath)
+ if jsonObj.Exists() {
+ value = jsonObj.String()
+ }
+ }
+ } else if rule == RuleAppend {
+ // extract llm response
+ for _, chunk := range chunks {
+ raw := gjson.GetBytes(chunk, jsonPath).Raw
+ if len(raw) > 2 && raw[0] == '"' && raw[len(raw)-1] == '"' {
+ value += raw[1 : len(raw)-1]
}
}
+ } else {
+ log.Errorf("unsupported rule type: %s", rule)
+ }
+ return value
+}
+
+func setFilterState(key, value string, log wrapper.Log) {
+ if value != "" {
+ if e := proxywasm.SetProperty([]string{key}, []byte(fmt.Sprint(value))); e != nil {
+ log.Errorf("failed to set %s in filter state: %v", key, e)
+ }
+ } else {
+ log.Debugf("failed to write filter state [%s], because it's value is empty")
+ }
+}
- if e := proxywasm.SetProperty([]string{traceSpanTag}, []byte(tracingValue)); e != nil {
+// Set the tracing span with value.
+func setSpanAttribute(key, value string, log wrapper.Log) {
+ if value != "" {
+ traceSpanTag := TracePrefix + key
+ if e := proxywasm.SetProperty([]string{traceSpanTag}, []byte(value)); e != nil {
log.Errorf("failed to set %s in filter state: %v", traceSpanTag, e)
}
- log.Debugf("successed to set trace span [%s] with value [%s].", traceSpanTag, tracingValue)
+ } else {
+ log.Debugf("failed to write span attribute [%s], because it's value is empty")
+ }
+}
+
+// fetches the tracing span value from the specified source.
+func setLogAttribute(ctx wrapper.HttpContext, key string, value interface{}, log wrapper.Log) {
+ logAttributes, ok := ctx.GetContext(CtxLogAtrribute).(map[string]string)
+ if !ok {
+ log.Error("failed to get logAttributes from http context")
+ return
}
+ logAttributes[key] = fmt.Sprint(value)
+ ctx.SetContext(CtxLogAtrribute, logAttributes)
}
-// trims the quote from the source string.
-func trimQuote(source string) string {
- TempKey := strings.Trim(source, `"`)
- Key, _ := zhToUnicode([]byte(TempKey))
- return string(Key)
+func writeFilterStates(ctx wrapper.HttpContext, log wrapper.Log) {
+ attributes, _ := ctx.GetContext(CtxGeneralAtrribute).(map[string]string)
+ setFilterState(Model, attributes[Model], log)
+ setFilterState(InputToken, attributes[InputToken], log)
+ setFilterState(OutputToken, attributes[OutputToken], log)
+}
+
+func writeMetric(ctx wrapper.HttpContext, config AIStatisticsConfig, log wrapper.Log) {
+ attributes, _ := ctx.GetContext(CtxGeneralAtrribute).(map[string]string)
+ route, _ := getRouteName()
+ cluster, _ := getClusterName()
+ model, ok := attributes["model"]
+ if !ok {
+ log.Errorf("Get model failed")
+ return
+ }
+ if inputToken, ok := attributes[InputToken]; ok {
+ inputTokenUint64, err := strconv.ParseUint(inputToken, 10, 0)
+ if err != nil || inputTokenUint64 == 0 {
+ log.Errorf("inputToken convert failed, value is %d, err msg is [%v]", inputTokenUint64, err)
+ return
+ }
+ config.incrementCounter(generateMetricName(route, cluster, model, InputToken), inputTokenUint64)
+ }
+ if outputToken, ok := attributes[OutputToken]; ok {
+ outputTokenUint64, err := strconv.ParseUint(outputToken, 10, 0)
+ if err != nil || outputTokenUint64 == 0 {
+ log.Errorf("outputToken convert failed, value is %d, err msg is [%v]", outputTokenUint64, err)
+ return
+ }
+ config.incrementCounter(generateMetricName(route, cluster, model, OutputToken), outputTokenUint64)
+ }
+ if llmFirstTokenDuration, ok := attributes[LLMFirstTokenDuration]; ok {
+ llmFirstTokenDurationUint64, err := strconv.ParseUint(llmFirstTokenDuration, 10, 0)
+ if err != nil || llmFirstTokenDurationUint64 == 0 {
+ log.Errorf("llmFirstTokenDuration convert failed, value is %d, err msg is [%v]", llmFirstTokenDurationUint64, err)
+ return
+ }
+ config.incrementCounter(generateMetricName(route, cluster, model, LLMFirstTokenDuration), llmFirstTokenDurationUint64)
+ }
+ if llmServiceDuration, ok := attributes[LLMServiceDuration]; ok {
+ llmServiceDurationUint64, err := strconv.ParseUint(llmServiceDuration, 10, 0)
+ if err != nil || llmServiceDurationUint64 == 0 {
+ log.Errorf("llmServiceDuration convert failed, value is %d, err msg is [%v]", llmServiceDurationUint64, err)
+ return
+ }
+ config.incrementCounter(generateMetricName(route, cluster, model, LLMServiceDuration), llmServiceDurationUint64)
+ }
+ config.incrementCounter(generateMetricName(route, cluster, model, LLMDurationCount), 1)
}
-// converts the zh string to Unicode.
-func zhToUnicode(raw []byte) ([]byte, error) {
- str, err := strconv.Unquote(strings.Replace(strconv.Quote(string(raw)), `\\u`, `\u`, -1))
- if err != nil {
- return nil, err
+func writeLog(ctx wrapper.HttpContext, log wrapper.Log) {
+ attributes, _ := ctx.GetContext(CtxGeneralAtrribute).(map[string]string)
+ logAttributes, _ := ctx.GetContext(CtxLogAtrribute).(map[string]string)
+ // Set inner log fields
+ if attributes[Model] != "" {
+ logAttributes[Model] = attributes[Model]
+ }
+ if attributes[InputToken] != "" {
+ logAttributes[InputToken] = attributes[InputToken]
+ }
+ if attributes[OutputToken] != "" {
+ logAttributes[OutputToken] = attributes[OutputToken]
+ }
+ if attributes[LLMFirstTokenDuration] != "" {
+ logAttributes[LLMFirstTokenDuration] = attributes[LLMFirstTokenDuration]
+ }
+ if attributes[LLMServiceDuration] != "" {
+ logAttributes[LLMServiceDuration] = attributes[LLMServiceDuration]
+ }
+ // Traverse log fields
+ items := []string{}
+ for k, v := range logAttributes {
+ items = append(items, fmt.Sprintf(`"%s":"%s"`, k, v))
+ }
+ aiLogField := fmt.Sprintf(`{%s}`, strings.Join(items, ","))
+ // log.Infof("ai request json log: %s", aiLogField)
+ jsonMap := map[string]string{
+ "ai_log": aiLogField,
+ }
+ serialized, _ := json.Marshal(jsonMap)
+ jsonLogRaw := gjson.GetBytes(serialized, "ai_log").Raw
+ jsonLog := jsonLogRaw[1 : len(jsonLogRaw)-1]
+ if err := proxywasm.SetProperty([]string{"ai_log"}, []byte(jsonLog)); err != nil {
+ log.Errorf("failed to set ai_log in filter state: %v", err)
}
- return []byte(str), nil
}
From e004321cb0e14fd8fa5040f901fae87e934668d4 Mon Sep 17 00:00:00 2001
From: rinfx <893383980@qq.com>
Date: Tue, 24 Sep 2024 19:42:34 +0800
Subject: [PATCH 09/16] Update ai security guard (#1261)
---
.../extensions/ai-security-guard/README.md | 145 +++++++++++-
.../extensions/ai-security-guard/README_EN.md | 69 ++++++
.../extensions/ai-security-guard/go.mod | 2 +-
.../extensions/ai-security-guard/go.sum | 7 +-
.../extensions/ai-security-guard/main.go | 215 ++++++++++++------
5 files changed, 350 insertions(+), 88 deletions(-)
create mode 100644 plugins/wasm-go/extensions/ai-security-guard/README_EN.md
diff --git a/plugins/wasm-go/extensions/ai-security-guard/README.md b/plugins/wasm-go/extensions/ai-security-guard/README.md
index 4961d527b3..5a8f753f3b 100644
--- a/plugins/wasm-go/extensions/ai-security-guard/README.md
+++ b/plugins/wasm-go/extensions/ai-security-guard/README.md
@@ -1,22 +1,143 @@
+---
+title: AI内容安全
+keywords: [higress, AI, security]
+description: 阿里云内容安全检测
+---
+
## 功能说明
+通过对接阿里云内容安全检测大模型的输入输出,保障AI应用内容合法合规。
+
+## 运行属性
+
+插件执行阶段:`默认阶段`
+插件执行优先级:`300`
## 配置说明
| Name | Type | Requirement | Default | Description |
-| :-: | :-: | :-: | :-: | :-: |
-| serviceSource | string | requried | - | 服务来源,填dns |
-| serviceName | string | requried | - | 服务名 |
-| servicePort | string | requried | - | 服务端口 |
-| domain | string | requried | - | 阿里云内容安全endpoint |
-| ak | string | requried | - | 阿里云AK |
-| sk | string | requried | - | 阿里云SK |
+| ------------ | ------------ | ------------ | ------------ | ------------ |
+| `serviceName` | string | requried | - | 服务名 |
+| `servicePort` | string | requried | - | 服务端口 |
+| `serviceHost` | string | requried | - | 阿里云内容安全endpoint的域名 |
+| `accessKey` | string | requried | - | 阿里云AK |
+| `secretKey` | string | requried | - | 阿里云SK |
+| `checkRequest` | bool | optional | false | 检查提问内容是否合规 |
+| `checkResponse` | bool | optional | false | 检查大模型的回答内容是否合规,生效时会使流式响应变为非流式 |
+| `requestCheckService` | string | optional | llm_query_moderation | 指定阿里云内容安全用于检测输入内容的服务 |
+| `responseCheckService` | string | optional | llm_response_moderation | 指定阿里云内容安全用于检测输出内容的服务 |
+| `requestContentJsonPath` | string | optional | `messages.@reverse.0.content` | 指定要检测内容在请求body中的jsonpath |
+| `responseContentJsonPath` | string | optional | `choices.0.message.content` | 指定要检测内容在响应body中的jsonpath |
+| `responseStreamContentJsonPath` | string | optional | `choices.0.delta.content` | 指定要检测内容在流式响应body中的jsonpath |
+| `denyCode` | int | optional | 200 | 指定内容非法时的响应状态码 |
+| `denyMessage` | string | optional | openai格式的流失/非流式响应,回答内容为阿里云内容安全的建议回答 | 指定内容非法时的响应内容 |
## 配置示例
+### 前提条件
+由于插件中需要调用阿里云内容安全服务,所以需要先创建一个DNS类型的服务,例如:
+
+
+
+### 检测输入内容是否合规
+
+```yaml
+serviceName: safecheck.dns
+servicePort: 443
+serviceHost: "green-cip.cn-shanghai.aliyuncs.com"
+accessKey: "XXXXXXXXX"
+secretKey: "XXXXXXXXXXXXXXX"
+checkRequest: true
+```
+
+### 检测输入与输出是否合规
+
+```yaml
+serviceName: safecheck.dns
+servicePort: 443
+serviceHost: green-cip.cn-shanghai.aliyuncs.com
+accessKey: "XXXXXXXXX"
+secretKey: "XXXXXXXXXXXXXXX"
+checkRequest: true
+checkResponse: true
+```
+
+### 指定自定义内容安全检测服务
+用户可能需要根据不同的场景配置不同的检测规则,该问题可通过为不同域名/路由/服务配置不同的内容安全检测服务实现。如下图所示,我们创建了一个名为 llm_query_moderation_01 的检测服务,其中的检测规则在 llm_query_moderation 之上做了一些改动:
+
+
+
+接下来在目标域名/路由/服务级别进行以下配置,指定使用我们自定义的 llm_query_moderation_01 中的规则进行检测:
+
+```yaml
+serviceName: safecheck.dns
+servicePort: 443
+serviceHost: "green-cip.cn-shanghai.aliyuncs.com"
+accessKey: "XXXXXXXXX"
+secretKey: "XXXXXXXXXXXXXXX"
+checkRequest: true
+requestCheckService: llm_query_moderation_01
+```
+
+### 配置非openai协议(例如百炼App)
+
```yaml
-serviceSource: "dns"
-serviceName: "safecheck"
+serviceName: safecheck.dns
servicePort: 443
-domain: "green-cip.cn-shanghai.aliyuncs.com"
-ak: "XXXXXXXXX"
-sk: "XXXXXXXXXXXXXXX"
+serviceHost: "green-cip.cn-shanghai.aliyuncs.com"
+accessKey: "XXXXXXXXX"
+secretKey: "XXXXXXXXXXXXXXX"
+checkRequest: true
+checkResponse: true
+requestContentJsonPath: "input.prompt"
+responseContentJsonPath: "output.text"
+denyCode: 200
+denyMessage: "很抱歉,我无法回答您的问题"
+```
+
+## 可观测
+### Metric
+ai-security-guard 插件提供了以下监控指标:
+- `ai_sec_request_deny`: 请求内容安全检测失败请求数
+- `ai_sec_response_deny`: 模型回答安全检测失败请求数
+
+### Trace
+如果开启了链路追踪,ai-security-guard 插件会在请求 span 中添加以下 attributes:
+- `ai_sec_risklabel`: 表示请求命中的风险类型
+- `ai_sec_deny_phase`: 表示请求被检测到风险的阶段(取值为request或者response)
+
+## 请求示例
+```bash
+curl http://localhost/v1/chat/completions \
+-H "Content-Type: application/json" \
+-d '{
+ "model": "gpt-4o-mini",
+ "messages": [
+ {
+ "role": "user",
+ "content": "这是一段非法内容"
+ }
+ ]
+}'
+```
+
+请求内容会被发送到阿里云内容安全服务进行检测,如果请求内容检测结果为非法,网关将返回形如以下的回答:
+
+```json
+{
+ "id": "chatcmpl-123",
+ "object": "chat.completion",
+ "created": 1677652288,
+ "model": "gpt-4o-mini",
+ "system_fingerprint": "fp_44709d6fcb",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "作为一名人工智能助手,我不能提供涉及色情、暴力、政治等敏感话题的内容。如果您有其他相关问题,欢迎您提问。",
+ },
+ "logprobs": null,
+ "finish_reason": "stop"
+ }
+ ]
+}
```
diff --git a/plugins/wasm-go/extensions/ai-security-guard/README_EN.md b/plugins/wasm-go/extensions/ai-security-guard/README_EN.md
new file mode 100644
index 0000000000..450b554179
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-security-guard/README_EN.md
@@ -0,0 +1,69 @@
+---
+title: AI Content Security
+keywords: [higress, AI, security]
+description: Alibaba Cloud content security
+---
+
+
+## Introduction
+Integrate with Aliyun content security service for detections of input and output of LLMs, ensuring that application content is legal and compliant.
+
+## Runtime Properties
+
+Plugin Phase: `CUSTOM`
+Plugin Priority: `300`
+
+## Configuration
+| Name | Type | Requirement | Default | Description |
+| ------------ | ------------ | ------------ | ------------ | ------------ |
+| `serviceName` | string | requried | - | service name |
+| `servicePort` | string | requried | - | service port |
+| `serviceHost` | string | requried | - | Host of Aliyun content security service endpoint |
+| `accessKey` | string | requried | - | Aliyun accesskey |
+| `secretKey` | string | requried | - | Aliyun secretkey |
+| `checkRequest` | bool | optional | false | check if the input is legal |
+| `checkResponse` | bool | optional | false | check if the output is legal |
+| `requestCheckService` | string | optional | llm_query_moderation | Aliyun yundun service name for input check |
+| `responseCheckService` | string | optional | llm_response_moderation | Aliyun yundun service name for output check |
+| `requestContentJsonPath` | string | optional | `messages.@reverse.0.content` | Specify the jsonpath of the content to be detected in the request body |
+| `responseContentJsonPath` | string | optional | `choices.0.message.content` | Specify the jsonpath of the content to be detected in the response body |
+| `responseStreamContentJsonPath` | string | optional | `choices.0.delta.content` | Specify the jsonpath of the content to be detected in the streaming response body |
+| `denyCode` | int | optional | 200 | Response status code when the specified content is illegal |
+| `denyMessage` | string | optional | Drainage/non-streaming response in openai format, the answer content is the suggested answer from Alibaba Cloud content security
+ | Response content when the specified content is illegal |
+
+
+## Examples of configuration
+### Check if the input is legal
+
+```yaml
+serviceName: safecheck.dns
+servicePort: 443
+serviceHost: "green-cip.cn-shanghai.aliyuncs.com"
+accessKey: "XXXXXXXXX"
+secretKey: "XXXXXXXXXXXXXXX"
+checkRequest: true
+```
+
+### Check if both the input and output are legal
+
+```yaml
+serviceName: safecheck.dns
+servicePort: 443
+serviceHost: green-cip.cn-shanghai.aliyuncs.com
+accessKey: "XXXXXXXXX"
+secretKey: "XXXXXXXXXXXXXXX"
+checkRequest: true
+checkResponse: true
+```
+
+## Observability
+### Metric
+ai-security-guard plugin provides following metrics:
+- `ai_sec_request_deny`: count of requests denied at request phase
+- `ai_sec_response_deny`: count of requests denied at response phase
+
+### Trace
+ai-security-guard plugin provides following span attributes:
+- `ai_sec_risklabel`: risk type of this request
+- `ai_sec_deny_phase`: denied phase of this request, value can be request/response
\ No newline at end of file
diff --git a/plugins/wasm-go/extensions/ai-security-guard/go.mod b/plugins/wasm-go/extensions/ai-security-guard/go.mod
index cd70355982..f2bc5a1436 100644
--- a/plugins/wasm-go/extensions/ai-security-guard/go.mod
+++ b/plugins/wasm-go/extensions/ai-security-guard/go.mod
@@ -1,4 +1,4 @@
-module myplugin
+module github.com/alibaba/higress/plugins/wasm-go/extensions/ai-security-guard
go 1.18
diff --git a/plugins/wasm-go/extensions/ai-security-guard/go.sum b/plugins/wasm-go/extensions/ai-security-guard/go.sum
index 1924b268fc..f473e12b2d 100644
--- a/plugins/wasm-go/extensions/ai-security-guard/go.sum
+++ b/plugins/wasm-go/extensions/ai-security-guard/go.sum
@@ -1,14 +1,9 @@
-github.com/alibaba/higress/plugins/wasm-go v1.3.5 h1:VOLL3m442IHCSu8mR5AZ4sc6LVT9X0w1hdqDI7oB9jY=
-github.com/alibaba/higress/plugins/wasm-go v1.3.5/go.mod h1:kr3V9Ntbspj1eSrX8rgjBsdMXkGupYEf+LM72caGPQc=
-github.com/alibaba/higress/plugins/wasm-go v1.3.6-0.20240522012622-fc6a6aad8906 h1:RhEmB+ApLKsClZD7joTC4ifmsVgOVz4pFLdPR3xhNaE=
-github.com/alibaba/higress/plugins/wasm-go v1.3.6-0.20240522012622-fc6a6aad8906/go.mod h1:10jQXKsYFUF7djs+Oy7t82f4dbie9pISfP9FJwpPLuk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
-github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc h1:t2AT8zb6N/59Y78lyRWedVoVWHNRSCBh0oWCC+bluTQ=
-github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
+github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
diff --git a/plugins/wasm-go/extensions/ai-security-guard/main.go b/plugins/wasm-go/extensions/ai-security-guard/main.go
index a97fda5770..e1ccfeb572 100644
--- a/plugins/wasm-go/extensions/ai-security-guard/main.go
+++ b/plugins/wasm-go/extensions/ai-security-guard/main.go
@@ -1,12 +1,12 @@
package main
import (
+ "bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
- "encoding/json"
"errors"
"fmt"
"net/http"
@@ -32,16 +32,47 @@ func main() {
)
}
+const (
+ OpenAIResponseFormat = `{"id": "chatcmpl-123","object": "chat.completion","model": "gpt-4o-mini","choices": [{"index": 0,"message": {"role": "assistant","content": "%s"},"logprobs": null,"finish_reason": "stop"}]}`
+ OpenAIStreamResponseChunk = `data:{"id":"chatcmpl-123","object":"chat.completion.chunk","model":"gpt-4o-mini", "choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"logprobs":null,"finish_reason":null}]}`
+ OpenAIStreamResponseEnd = `data:{"id":"chatcmpl-123","object":"chat.completion.chunk","model":"gpt-4o-mini", "choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]}`
+ OpenAIStreamResponseFormat = OpenAIStreamResponseChunk + "\n\n" + OpenAIStreamResponseEnd
+
+ TracingPrefix = "trace_span_tag."
+
+ DefaultRequestCheckService = "llm_query_moderation"
+ DefaultResponseCheckService = "llm_response_moderation"
+ DefaultRequestJsonPath = "messages.@reverse.0.content"
+ DefaultResponseJsonPath = "choices.0.message.content"
+ DefaultStreamingResponseJsonPath = "choices.0.delta.content"
+ DefaultDenyCode = 200
+
+ AliyunUserAgent = "CIPFrom/AIGateway"
+)
+
type AISecurityConfig struct {
- client wrapper.HttpClient
- ak string
- sk string
+ client wrapper.HttpClient
+ ak string
+ sk string
+ checkRequest bool
+ requestCheckService string
+ requestContentJsonPath string
+ checkResponse bool
+ responseCheckService string
+ responseContentJsonPath string
+ responseStreamContentJsonPath string
+ denyCode int64
+ denyMessage string
+ metrics map[string]proxywasm.MetricCounter
}
-type StandardResponse struct {
- Code int `json:"Code"`
- Phase string `json:"BlockPhase"`
- Message string `json:"Message"`
+func (config *AISecurityConfig) incrementCounter(metricName string, inc uint64) {
+ counter, ok := config.metrics[metricName]
+ if !ok {
+ counter = proxywasm.DefineCounterMetric(metricName)
+ config.metrics[metricName] = counter
+ }
+ counter.Increment(inc)
}
func urlEncoding(rawStr string) string {
@@ -71,7 +102,7 @@ func getSign(params map[string]string, secret string) string {
})
canonicalStr := strings.Join(paramArray, "&")
signStr := "POST&%2F&" + urlEncoding(canonicalStr)
- fmt.Println(signStr)
+ // proxywasm.LogInfo(signStr)
return hmacSha1(signStr, secret)
}
@@ -86,32 +117,70 @@ func generateHexID(length int) (string, error) {
func parseConfig(json gjson.Result, config *AISecurityConfig, log wrapper.Log) error {
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
- domain := json.Get("domain").String()
- config.ak = json.Get("ak").String()
- config.sk = json.Get("sk").String()
- if serviceName == "" || servicePort == 0 || domain == "" {
+ serviceHost := json.Get("serviceHost").String()
+ if serviceName == "" || servicePort == 0 || serviceHost == "" {
return errors.New("invalid service config")
}
- config.client = wrapper.NewClusterClient(wrapper.DnsCluster{
- ServiceName: serviceName,
- Port: servicePort,
- Domain: domain,
+ config.ak = json.Get("accessKey").String()
+ config.sk = json.Get("secretKey").String()
+ if config.ak == "" || config.sk == "" {
+ return errors.New("invalid AK/SK config")
+ }
+ config.checkRequest = json.Get("checkRequest").Bool()
+ config.checkResponse = json.Get("checkResponse").Bool()
+ config.denyMessage = json.Get("denyMessage").String()
+ if obj := json.Get("denyCode"); obj.Exists() {
+ config.denyCode = obj.Int()
+ } else {
+ config.denyCode = DefaultDenyCode
+ }
+ if obj := json.Get("requestCheckService"); obj.Exists() {
+ config.requestCheckService = obj.String()
+ } else {
+ config.requestCheckService = DefaultRequestCheckService
+ }
+ if obj := json.Get("responseCheckService"); obj.Exists() {
+ config.responseCheckService = obj.String()
+ } else {
+ config.responseCheckService = DefaultResponseCheckService
+ }
+ if obj := json.Get("requestContentJsonPath"); obj.Exists() {
+ config.requestContentJsonPath = obj.String()
+ } else {
+ config.requestContentJsonPath = DefaultRequestJsonPath
+ }
+ if obj := json.Get("responseContentJsonPath"); obj.Exists() {
+ config.responseContentJsonPath = obj.String()
+ } else {
+ config.responseContentJsonPath = DefaultResponseJsonPath
+ }
+ if obj := json.Get("responseStreamContentJsonPath"); obj.Exists() {
+ config.responseStreamContentJsonPath = obj.String()
+ } else {
+ config.responseStreamContentJsonPath = DefaultStreamingResponseJsonPath
+ }
+ config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{
+ FQDN: serviceName,
+ Port: servicePort,
+ Host: serviceHost,
})
+ config.metrics = make(map[string]proxywasm.MetricCounter)
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log wrapper.Log) types.Action {
+ if !config.checkRequest {
+ ctx.DontReadRequestBody()
+ }
+ if !config.checkResponse {
+ ctx.DontReadResponseBody()
+ }
return types.ActionContinue
}
func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte, log wrapper.Log) types.Action {
- messages := gjson.GetBytes(body, "messages").Array()
- if len(messages) > 0 {
- role := messages[len(messages)-1].Get("role").String()
- content := messages[len(messages)-1].Get("content").String()
- if role != "user" {
- return types.ActionContinue
- }
+ content := gjson.GetBytes(body, config.requestContentJsonPath).String()
+ if content != "" {
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
randomID, _ := generateHexID(16)
params := map[string]string{
@@ -123,7 +192,7 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []
"Action": "TextModerationPlus",
"AccessKeyId": config.ak,
"Timestamp": timestamp,
- "Service": "llm_query_moderation",
+ "Service": config.requestCheckService,
"ServiceParameters": `{"content": "` + content + `"}`,
}
signature := getSign(params, config.sk+"&")
@@ -132,31 +201,27 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []
reqParams.Add(k, v)
}
reqParams.Add("Signature", signature)
- config.client.Post(fmt.Sprintf("/?%s", reqParams.Encode()), nil, nil,
+ config.client.Post(fmt.Sprintf("/?%s", reqParams.Encode()), [][2]string{{"User-Agent", AliyunUserAgent}}, nil,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
respData := gjson.GetBytes(responseBody, "Data")
if respData.Exists() {
respAdvice := respData.Get("Advice")
respResult := respData.Get("Result")
if respAdvice.Exists() {
- sr := StandardResponse{
- Code: 403,
- Phase: "Request",
- Message: respAdvice.Array()[0].Get("Answer").String(),
- }
- jsonData, _ := json.MarshalIndent(sr, "", " ")
- label := respResult.Array()[0].Get("Label").String()
- proxywasm.SetProperty([]string{"risklabel"}, []byte(label))
- proxywasm.SendHttpResponseWithDetail(403, "ai-security-guard.label."+label, [][2]string{{"content-type", "application/json"}}, jsonData, -1)
- } else if respResult.Array()[0].Get("Label").String() != "nonLabel" {
- sr := StandardResponse{
- Code: 403,
- Phase: "Request",
- Message: "risk detected",
+ proxywasm.SetProperty([]string{TracingPrefix, "ai_sec_risklabel"}, []byte(respResult.Array()[0].Get("Label").String()))
+ proxywasm.SetProperty([]string{TracingPrefix, "ai_sec_deny_phase"}, []byte("request"))
+ config.incrementCounter("ai_sec_request_deny", 1)
+ if config.denyMessage != "" {
+ proxywasm.SendHttpResponse(uint32(config.denyCode), [][2]string{{"content-type", "application/json"}}, []byte(config.denyMessage), -1)
+ } else {
+ if gjson.GetBytes(body, "stream").Bool() {
+ jsonData := []byte(fmt.Sprintf(OpenAIStreamResponseFormat, respAdvice.Array()[0].Get("Answer").String()))
+ proxywasm.SendHttpResponse(uint32(config.denyCode), [][2]string{{"content-type", "text/event-stream;charset=UTF-8"}}, jsonData, -1)
+ } else {
+ jsonData := []byte(fmt.Sprintf(OpenAIResponseFormat, respAdvice.Array()[0].Get("Answer").String()))
+ proxywasm.SendHttpResponse(uint32(config.denyCode), [][2]string{{"content-type", "application/json"}}, jsonData, -1)
+ }
}
- jsonData, _ := json.MarshalIndent(sr, "", " ")
- proxywasm.SetProperty([]string{"risklabel"}, []byte(respResult.Array()[0].Get("Label").String()))
- proxywasm.SendHttpResponseWithDetail(403, "ai-security-guard.risk_detected", [][2]string{{"content-type", "application/json"}}, jsonData, -1)
} else {
proxywasm.ResumeHttpRequest()
}
@@ -206,9 +271,16 @@ func onHttpResponseHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log
}
func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte, log wrapper.Log) types.Action {
- messages := gjson.GetBytes(body, "choices").Array()
- if len(messages) > 0 {
- content := messages[0].Get("message").Get("content").String()
+ hdsMap := ctx.GetContext("headers").(map[string][]string)
+ isStreamingResponse := strings.Contains(strings.Join(hdsMap["content-type"], ";"), "event-stream")
+ var content string
+ if isStreamingResponse {
+ content = extractMessageFromStreamingBody(body, config.responseStreamContentJsonPath)
+ } else {
+ content = gjson.GetBytes(body, config.responseContentJsonPath).String()
+ }
+ log.Debugf("Raw response content is: %s", content)
+ if len(content) > 0 {
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
randomID, _ := generateHexID(16)
params := map[string]string{
@@ -220,7 +292,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
"Action": "TextModerationPlus",
"AccessKeyId": config.ak,
"Timestamp": timestamp,
- "Service": "llm_response_moderation",
+ "Service": config.responseCheckService,
"ServiceParameters": `{"content": "` + content + `"}`,
}
signature := getSign(params, config.sk+"&")
@@ -229,7 +301,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
reqParams.Add(k, v)
}
reqParams.Add("Signature", signature)
- config.client.Post(fmt.Sprintf("/?%s", reqParams.Encode()), nil, nil,
+ config.client.Post(fmt.Sprintf("/?%s", reqParams.Encode()), [][2]string{{"User-Agent", AliyunUserAgent}}, nil,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
defer proxywasm.ResumeHttpResponse()
respData := gjson.GetBytes(responseBody, "Data")
@@ -237,31 +309,23 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
respAdvice := respData.Get("Advice")
respResult := respData.Get("Result")
if respAdvice.Exists() {
- sr := StandardResponse{
- Code: 403,
- Phase: "Response",
- Message: respAdvice.Array()[0].Get("Answer").String(),
+ var jsonData []byte
+ if config.denyMessage != "" {
+ jsonData = []byte(config.denyMessage)
+ } else {
+ if strings.Contains(strings.Join(hdsMap["content-type"], ";"), "event-stream") {
+ jsonData = []byte(fmt.Sprintf(OpenAIStreamResponseFormat, respAdvice.Array()[0].Get("Answer").String()))
+ } else {
+ jsonData = []byte(fmt.Sprintf(OpenAIResponseFormat, respAdvice.Array()[0].Get("Answer").String()))
+ }
}
- jsonData, _ := json.MarshalIndent(sr, "", " ")
- hdsMap := ctx.GetContext("headers").(map[string][]string)
delete(hdsMap, "content-length")
- hdsMap[":status"] = []string{"403"}
+ hdsMap[":status"] = []string{fmt.Sprint(config.denyCode)}
proxywasm.ReplaceHttpResponseHeaders(reconvertHeaders(hdsMap))
proxywasm.ReplaceHttpResponseBody(jsonData)
- proxywasm.SetProperty([]string{"risklabel"}, []byte(respResult.Array()[0].Get("Label").String()))
- } else if respResult.Array()[0].Get("Label").String() != "nonLabel" {
- sr := StandardResponse{
- Code: 403,
- Phase: "Response",
- Message: "risk detected",
- }
- jsonData, _ := json.MarshalIndent(sr, "", " ")
- hdsMap := ctx.GetContext("headers").(map[string][]string)
- delete(hdsMap, "content-length")
- hdsMap[":status"] = []string{"403"}
- proxywasm.ReplaceHttpResponseHeaders(reconvertHeaders(hdsMap))
- proxywasm.ReplaceHttpResponseBody(jsonData)
- proxywasm.SetProperty([]string{"risklabel"}, []byte(respResult.Array()[0].Get("Label").String()))
+ proxywasm.SetProperty([]string{TracingPrefix, "ai_sec_risklabel"}, []byte(respResult.Array()[0].Get("Label").String()))
+ proxywasm.SetProperty([]string{TracingPrefix, "ai_sec_deny_phase"}, []byte("response"))
+ config.incrementCounter("ai_sec_response_deny", 1)
}
}
},
@@ -271,3 +335,16 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
return types.ActionContinue
}
}
+
+func extractMessageFromStreamingBody(data []byte, jsonPath string) string {
+ chunks := bytes.Split(bytes.TrimSpace(data), []byte("\n\n"))
+ strChunks := []string{}
+ for _, chunk := range chunks {
+ // Example: "choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"logprobs":null,"finish_reason":null}]
+ jsonObj := gjson.GetBytes(chunk, jsonPath)
+ if jsonObj.Exists() {
+ strChunks = append(strChunks, jsonObj.String())
+ }
+ }
+ return strings.Join(strChunks, "")
+}
From 1acaaea222836c3b1f3ab1e5c4b17772ec0654e5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?=
Date: Wed, 25 Sep 2024 10:54:37 +0800
Subject: [PATCH 10/16] Update README.md
---
plugins/wasm-go/extensions/ai-agent/README.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/plugins/wasm-go/extensions/ai-agent/README.md b/plugins/wasm-go/extensions/ai-agent/README.md
index 77a0c022e5..4869365b59 100644
--- a/plugins/wasm-go/extensions/ai-agent/README.md
+++ b/plugins/wasm-go/extensions/ai-agent/README.md
@@ -9,6 +9,9 @@ description: AI Agent插件配置参考
agent流程图如下:

+## 运行属性
+插件执行阶段:`默认阶段`
+插件执行优先级:`200`
## 配置字段
From 8293042c25226238f4529b566236f73396ffc101 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?=
Date: Wed, 25 Sep 2024 10:55:01 +0800
Subject: [PATCH 11/16] Update README_EN.md
---
plugins/wasm-go/extensions/ai-agent/README_EN.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/plugins/wasm-go/extensions/ai-agent/README_EN.md b/plugins/wasm-go/extensions/ai-agent/README_EN.md
index b594b102ab..ae2b596556 100644
--- a/plugins/wasm-go/extensions/ai-agent/README_EN.md
+++ b/plugins/wasm-go/extensions/ai-agent/README_EN.md
@@ -10,7 +10,7 @@ The agent flow chart is as follows:
## Runtime Properties
Plugin execution phase: `Default Phase`
-Plugin execution priority: `20`
+Plugin execution priority: `200`
## Configuration Fields
From af4e34b7edae36abdf18cef5f3c03429f5c494cb Mon Sep 17 00:00:00 2001
From: mamba <371510756@qq.com>
Date: Thu, 26 Sep 2024 09:16:00 +0800
Subject: [PATCH 12/16] =?UTF-8?q?chore:=20=F0=9F=A4=96=20[frontend-gray]?=
=?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=B3=E4=BA=8E=E5=A4=84=E7=90=86index=20p?=
=?UTF-8?q?age=20=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=20(#1345)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Kent Dong
---
.../extensions/frontend-gray/README.md | 22 ++++----
.../extensions/frontend-gray/envoy.yaml | 19 ++++---
.../wasm-go/extensions/frontend-gray/main.go | 50 ++++++++++---------
3 files changed, 46 insertions(+), 45 deletions(-)
diff --git a/plugins/wasm-go/extensions/frontend-gray/README.md b/plugins/wasm-go/extensions/frontend-gray/README.md
index 1711969c90..8dee32008b 100644
--- a/plugins/wasm-go/extensions/frontend-gray/README.md
+++ b/plugins/wasm-go/extensions/frontend-gray/README.md
@@ -130,7 +130,7 @@ grayDeployments:
- name: beta-user
version: gray
enabled: true
- weight: 80
+ weight: 80
```
总的灰度规则为100%,其中灰度版本的权重为`80%`,基线版本为`20%`。一旦用户命中了灰度规则,会根据IP固定这个用户的灰度版本(否则会在下次请求时随机选择一个灰度版本)。
@@ -229,16 +229,16 @@ grayDeployments:
- name: beta-user
version: gray
enabled: true
- weight: 80
+ weight: 80
injection:
- head:
- -
- body:
- first:
- -
- -
- last:
- -
- -
+ head:
+ -
+ body:
+ first:
+ -
+ -
+ last:
+ -
+ -
```
通过 `injection`往HTML首页注入代码,可以在`head`标签注入代码,也可以在`body`标签的`first`和`last`位置注入代码。
\ No newline at end of file
diff --git a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml
index f454a7f072..e859c29a5c 100644
--- a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml
+++ b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml
@@ -73,25 +73,24 @@ static_resources:
],
"rewrite": {
"host": "frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com",
- "notFoundUri": "/mfe/app1/{version}/333.html",
+ "notFoundUri": "/cygtapi/{version}/333.html",
"indexRouting": {
- "/app1": "/mfe/app1/{version}/index.html",
- "/": "/mfe/app1/{version}/index.html"
+ "/app1": "/cygtapi/{version}/index.html",
+ "/": "/cygtapi/{version}/index.html"
},
"fileRouting": {
- "/": "/mfe/app1/{version}",
- "/app1": "/mfe/app1/{version}"
+ "/": "/cygtapi/{version}",
+ "/app1": "/cygtapi/{version}"
}
},
"baseDeployment": {
- "version": "dev"
+ "version": "base"
},
"grayDeployments": [
{
"name": "beta-user",
- "version": "0.0.1",
- "enabled": true,
- "weight": 50
+ "version": "gray",
+ "enabled": true
}
],
"injection": {
@@ -128,4 +127,4 @@ static_resources:
address:
socket_address:
address: frontend-gray-cn-shanghai.oss-cn-shanghai.aliyuncs.com
- port_value: 80
\ No newline at end of file
+ port_value: 80
diff --git a/plugins/wasm-go/extensions/frontend-gray/main.go b/plugins/wasm-go/extensions/frontend-gray/main.go
index e1fc7a8b83..148751cc3c 100644
--- a/plugins/wasm-go/extensions/frontend-gray/main.go
+++ b/plugins/wasm-go/extensions/frontend-gray/main.go
@@ -108,19 +108,26 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
if !util.IsGrayEnabled(grayConfig) {
return types.ActionContinue
}
- status, err := proxywasm.GetHttpResponseHeader(":status")
- contentType, _ := proxywasm.GetHttpResponseHeader("Content-Type")
+ isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool)
+ if !ok {
+ isPageRequest = false // 默认值
+ }
+ // response 不处理非首页的请求
+ if !isPageRequest {
+ ctx.DontReadResponseBody()
+ return types.ActionContinue
+ }
+ status, err := proxywasm.GetHttpResponseHeader(":status")
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
// 删除Content-Disposition,避免自动下载文件
proxywasm.RemoveHttpResponseHeader("Content-Disposition")
}
- isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool)
- if !ok {
- isPageRequest = false // 默认值
- }
+ // 删除content-length,可能要修改Response返回值
+ proxywasm.RemoveHttpResponseHeader("Content-Length")
+ // 处理code为 200的情况
if err != nil || status != "200" {
if status == "404" {
if grayConfig.Rewrite.NotFound != "" && isPageRequest {
@@ -143,6 +150,7 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
ctx.BufferResponseBody()
return types.ActionContinue
} else {
+ // 直接返回400
ctx.DontReadResponseBody()
}
}
@@ -150,25 +158,19 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
return types.ActionContinue
}
- // 删除content-length,可能要修改Response返回值
- proxywasm.RemoveHttpResponseHeader("Content-Length")
-
- if strings.HasPrefix(contentType, "text/html") || isPageRequest {
- // 不会进去Streaming 的Body处理
- ctx.BufferResponseBody()
-
- proxywasm.ReplaceHttpResponseHeader("Cache-Control", "no-cache, no-store")
-
- frontendVersion := ctx.GetContext(config.XPreHigressTag).(string)
- xUniqueClient := ctx.GetContext(config.XUniqueClientId).(string)
+ // 不会进去Streaming 的Body处理
+ ctx.BufferResponseBody()
+ proxywasm.ReplaceHttpResponseHeader("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")
- // 设置前端的版本
- proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s,%s; Max-Age=%s; Path=/;", config.XPreHigressTag, frontendVersion, xUniqueClient, grayConfig.UserStickyMaxAge))
- // 设置后端的版本
- if util.IsBackendGrayEnabled(grayConfig) {
- backendVersion := ctx.GetContext(grayConfig.BackendGrayTag).(string)
- proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%s; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.UserStickyMaxAge))
- }
+ frontendVersion := ctx.GetContext(config.XPreHigressTag).(string)
+ xUniqueClient := ctx.GetContext(config.XUniqueClientId).(string)
+
+ // 设置前端的版本
+ proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s,%s; Max-Age=%s; Path=/;", config.XPreHigressTag, frontendVersion, xUniqueClient, grayConfig.UserStickyMaxAge))
+ // 设置后端的版本
+ if util.IsBackendGrayEnabled(grayConfig) {
+ backendVersion := ctx.GetContext(grayConfig.BackendGrayTag).(string)
+ proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%s; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.UserStickyMaxAge))
}
return types.ActionContinue
}
From 260772926c996f0b584e5e8da6bf7a699ebc25d5 Mon Sep 17 00:00:00 2001
From: Benny
Date: Thu, 26 Sep 2024 11:07:44 +0800
Subject: [PATCH 13/16] =?UTF-8?q?Standardize=20the=20data=20structure=20re?=
=?UTF-8?q?turned=20by=20the=20AI=20security=20security=20a=E2=80=A6=20(#1?=
=?UTF-8?q?344)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Kent Dong
---
.../extensions/ai-security-guard/README.md | 3 +-
.../extensions/ai-security-guard/main.go | 53 +++++++++++++++----
2 files changed, 44 insertions(+), 12 deletions(-)
diff --git a/plugins/wasm-go/extensions/ai-security-guard/README.md b/plugins/wasm-go/extensions/ai-security-guard/README.md
index 5a8f753f3b..8b63213d5e 100644
--- a/plugins/wasm-go/extensions/ai-security-guard/README.md
+++ b/plugins/wasm-go/extensions/ai-security-guard/README.md
@@ -30,7 +30,6 @@ description: 阿里云内容安全检测
| `denyCode` | int | optional | 200 | 指定内容非法时的响应状态码 |
| `denyMessage` | string | optional | openai格式的流失/非流式响应,回答内容为阿里云内容安全的建议回答 | 指定内容非法时的响应内容 |
-
## 配置示例
### 前提条件
由于插件中需要调用阿里云内容安全服务,所以需要先创建一个DNS类型的服务,例如:
@@ -123,7 +122,7 @@ curl http://localhost/v1/chat/completions \
```json
{
- "id": "chatcmpl-123",
+ "id": "chatcmpl-AAy3hK1dE4ODaegbGOMoC9VY4Sizv",
"object": "chat.completion",
"created": 1677652288,
"model": "gpt-4o-mini",
diff --git a/plugins/wasm-go/extensions/ai-security-guard/main.go b/plugins/wasm-go/extensions/ai-security-guard/main.go
index e1ccfeb572..4640596c84 100644
--- a/plugins/wasm-go/extensions/ai-security-guard/main.go
+++ b/plugins/wasm-go/extensions/ai-security-guard/main.go
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"errors"
"fmt"
+ mrand "math/rand"
"net/http"
"net/url"
"sort"
@@ -33,10 +34,10 @@ func main() {
}
const (
- OpenAIResponseFormat = `{"id": "chatcmpl-123","object": "chat.completion","model": "gpt-4o-mini","choices": [{"index": 0,"message": {"role": "assistant","content": "%s"},"logprobs": null,"finish_reason": "stop"}]}`
- OpenAIStreamResponseChunk = `data:{"id":"chatcmpl-123","object":"chat.completion.chunk","model":"gpt-4o-mini", "choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"logprobs":null,"finish_reason":null}]}`
- OpenAIStreamResponseEnd = `data:{"id":"chatcmpl-123","object":"chat.completion.chunk","model":"gpt-4o-mini", "choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]}`
- OpenAIStreamResponseFormat = OpenAIStreamResponseChunk + "\n\n" + OpenAIStreamResponseEnd
+ OpenAIResponseFormat = `{"id": "%s","object":"chat.completion","model":%s,"choices":[{"index":0,"message":{"role":"assistant","content":%s},"logprobs":null,"finish_reason":"stop"}]}`
+ OpenAIStreamResponseChunk = `data:{"id":"%s","object":"chat.completion.chunk","model":%s,"choices":[{"index":0,"delta":{"role":"assistant","content":%s},"logprobs":null,"finish_reason":null}]}`
+ OpenAIStreamResponseEnd = `data:{"id":"%s","object":"chat.completion.chunk","model": %s,"choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]}`
+ OpenAIStreamResponseFormat = OpenAIStreamResponseChunk + "\n\n" + OpenAIStreamResponseEnd + "\n\n" + `data: [DONE]`
TracingPrefix = "trace_span_tag."
@@ -168,18 +169,32 @@ func parseConfig(json gjson.Result, config *AISecurityConfig, log wrapper.Log) e
return nil
}
+func generateRandomID() string {
+ const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ b := make([]byte, 29)
+ for i := range b {
+ b[i] = charset[mrand.Intn(len(charset))]
+ }
+ return "chatcmpl-" + string(b)
+}
+
func onHttpRequestHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log wrapper.Log) types.Action {
if !config.checkRequest {
+ log.Debugf("request checking is disabled")
ctx.DontReadRequestBody()
}
if !config.checkResponse {
+ log.Debugf("response checking is disabled")
ctx.DontReadResponseBody()
}
return types.ActionContinue
}
func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte, log wrapper.Log) types.Action {
+ proxywasm.LogDebugf("checking request body...")
content := gjson.GetBytes(body, config.requestContentJsonPath).String()
+ model := gjson.GetBytes(body, "model").Raw
+ ctx.SetContext("requestModel", model)
if content != "" {
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
randomID, _ := generateHexID(16)
@@ -201,7 +216,7 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []
reqParams.Add(k, v)
}
reqParams.Add("Signature", signature)
- config.client.Post(fmt.Sprintf("/?%s", reqParams.Encode()), [][2]string{{"User-Agent", AliyunUserAgent}}, nil,
+ err := config.client.Post(fmt.Sprintf("/?%s", reqParams.Encode()), [][2]string{{"User-Agent", AliyunUserAgent}}, nil,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
respData := gjson.GetBytes(responseBody, "Data")
if respData.Exists() {
@@ -214,13 +229,17 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []
if config.denyMessage != "" {
proxywasm.SendHttpResponse(uint32(config.denyCode), [][2]string{{"content-type", "application/json"}}, []byte(config.denyMessage), -1)
} else {
+ answer := respAdvice.Array()[0].Get("Answer").Raw
if gjson.GetBytes(body, "stream").Bool() {
- jsonData := []byte(fmt.Sprintf(OpenAIStreamResponseFormat, respAdvice.Array()[0].Get("Answer").String()))
+ randomID := generateRandomID()
+ jsonData := []byte(fmt.Sprintf(OpenAIStreamResponseFormat, randomID, model, answer))
proxywasm.SendHttpResponse(uint32(config.denyCode), [][2]string{{"content-type", "text/event-stream;charset=UTF-8"}}, jsonData, -1)
} else {
- jsonData := []byte(fmt.Sprintf(OpenAIResponseFormat, respAdvice.Array()[0].Get("Answer").String()))
+ randomID := generateRandomID()
+ jsonData := []byte(fmt.Sprintf(OpenAIResponseFormat, randomID, model, answer))
proxywasm.SendHttpResponse(uint32(config.denyCode), [][2]string{{"content-type", "application/json"}}, jsonData, -1)
}
+ ctx.DontReadResponseBody()
}
} else {
proxywasm.ResumeHttpRequest()
@@ -230,8 +249,13 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []
}
},
)
+ if err != nil {
+ log.Errorf("failed call the safe check service: %v", err)
+ return types.ActionContinue
+ }
return types.ActionPause
} else {
+ proxywasm.LogDebugf("request content is empty. skip")
return types.ActionContinue
}
}
@@ -271,8 +295,10 @@ func onHttpResponseHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log
}
func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte, log wrapper.Log) types.Action {
+ proxywasm.LogDebugf("checking response body...")
hdsMap := ctx.GetContext("headers").(map[string][]string)
isStreamingResponse := strings.Contains(strings.Join(hdsMap["content-type"], ";"), "event-stream")
+ model := ctx.GetStringContext("requestModel", "unknown")
var content string
if isStreamingResponse {
content = extractMessageFromStreamingBody(body, config.responseStreamContentJsonPath)
@@ -301,7 +327,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
reqParams.Add(k, v)
}
reqParams.Add("Signature", signature)
- config.client.Post(fmt.Sprintf("/?%s", reqParams.Encode()), [][2]string{{"User-Agent", AliyunUserAgent}}, nil,
+ err := config.client.Post(fmt.Sprintf("/?%s", reqParams.Encode()), [][2]string{{"User-Agent", AliyunUserAgent}}, nil,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
defer proxywasm.ResumeHttpResponse()
respData := gjson.GetBytes(responseBody, "Data")
@@ -314,9 +340,11 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
jsonData = []byte(config.denyMessage)
} else {
if strings.Contains(strings.Join(hdsMap["content-type"], ";"), "event-stream") {
- jsonData = []byte(fmt.Sprintf(OpenAIStreamResponseFormat, respAdvice.Array()[0].Get("Answer").String()))
+ randomID := generateRandomID()
+ jsonData = []byte(fmt.Sprintf(OpenAIStreamResponseFormat, randomID, model, respAdvice.Array()[0].Get("Answer").String()))
} else {
- jsonData = []byte(fmt.Sprintf(OpenAIResponseFormat, respAdvice.Array()[0].Get("Answer").String()))
+ randomID := generateRandomID()
+ jsonData = []byte(fmt.Sprintf(OpenAIResponseFormat, randomID, model, respAdvice.Array()[0].Get("Answer").String()))
}
}
delete(hdsMap, "content-length")
@@ -330,8 +358,13 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
}
},
)
+ if err != nil {
+ log.Errorf("failed call the safe check service: %v", err)
+ return types.ActionContinue
+ }
return types.ActionPause
} else {
+ proxywasm.LogDebugf("request content is empty. skip")
return types.ActionContinue
}
}
From 708e7af79a366a7cc71a05717a24275f3dab7ba4 Mon Sep 17 00:00:00 2001
From: Kent Dong
Date: Thu, 26 Sep 2024 11:27:22 +0800
Subject: [PATCH 14/16] feat: Support configuring a global provider list in
ai-proxy plugin (#1334)
---
plugins/wasm-go/extensions/ai-proxy/README.md | 5 +-
.../wasm-go/extensions/ai-proxy/README_EN.md | 73 +++++++++++++++++--
.../extensions/ai-proxy/config/config.go | 54 ++++++++++++--
.../wasm-go/extensions/ai-proxy/envoy.yaml | 33 +++++----
plugins/wasm-go/extensions/ai-proxy/main.go | 23 +++++-
.../extensions/ai-proxy/provider/provider.go | 26 +++++--
6 files changed, 176 insertions(+), 38 deletions(-)
diff --git a/plugins/wasm-go/extensions/ai-proxy/README.md b/plugins/wasm-go/extensions/ai-proxy/README.md
index 59019894c9..c63d485ca7 100644
--- a/plugins/wasm-go/extensions/ai-proxy/README.md
+++ b/plugins/wasm-go/extensions/ai-proxy/README.md
@@ -607,6 +607,7 @@ provider:
```
### 使用original协议代理百炼智能体应用
+
**配置信息**
```yaml
@@ -832,6 +833,7 @@ provider:
}
}
```
+
### 使用 OpenAI 协议代理混元服务
**配置信息**
@@ -849,9 +851,10 @@ provider:
```
**请求示例**
+
请求脚本:
-```sh
+```shell
curl --location 'http:///v1/chat/completions' \
--header 'Content-Type: application/json' \
--data '{
diff --git a/plugins/wasm-go/extensions/ai-proxy/README_EN.md b/plugins/wasm-go/extensions/ai-proxy/README_EN.md
index a2b756a99a..57ef01193f 100644
--- a/plugins/wasm-go/extensions/ai-proxy/README_EN.md
+++ b/plugins/wasm-go/extensions/ai-proxy/README_EN.md
@@ -139,9 +139,9 @@ For 360 Brain, the corresponding `type` is `ai360`. It has no unique configurati
For Mistral, the corresponding `type` is `mistral`. It has no unique configuration fields.
-#### Minimax
+#### MiniMax
-For Minimax, the corresponding `type` is `minimax`. Its unique configuration field is:
+For MiniMax, the corresponding `type` is `minimax`. Its unique configuration field is:
| Name | Data Type | Filling Requirements | Default Value | Description |
| ---------------- | -------- | --------------------- |---------------|------------------------------------------------------------------------------------------------------------|
@@ -593,6 +593,69 @@ provider:
"request_id": "187e99ba-5b64-9ffe-8f69-01dafbaf6ed7"
}
```
+
+### Forwards requests to AliCloud Bailian with the "original" protocol
+
+**Configuration Information**
+
+```yaml
+activeProviderId: my-qwen
+providers:
+ - id: my-qwen
+ type: qwen
+ apiTokens:
+ - "YOUR_DASHSCOPE_API_TOKEN"
+ protocol: original
+```
+
+**Example Request**
+
+```json
+{
+ "input": {
+ "prompt": "What is Dubbo?"
+ },
+ "parameters": {},
+ "debug": {}
+}
+```
+
+**Example Response**
+
+```json
+{
+ "output": {
+ "finish_reason": "stop",
+ "session_id": "677e7e8fbb874e1b84792b65042e1599",
+ "text": "Apache Dubbo is a..."
+ },
+ "usage": {
+ "models": [
+ {
+ "output_tokens": 449,
+ "model_id": "qwen-max",
+ "input_tokens": 282
+ }
+ ]
+ },
+ "request_id": "b59e45e3-5af4-91df-b7c6-9d746fd3297c"
+}
+```
+
+### Using OpenAI Protocol Proxy for Doubao Service
+
+```yaml
+activeProviderId: my-doubao
+providers:
+- id: my-doubao
+ type: doubao
+ apiTokens:
+ - YOUR_DOUBAO_API_KEY
+ modelMapping:
+ '*': YOUR_DOUBAO_ENDPOINT
+ timeout: 1200000
+```
+
### Utilizing Moonshot with its Native File Context
Upload files to Moonshot in advance and use its AI services based on file content.
@@ -782,8 +845,7 @@ provider:
Request script:
-```sh
-
+```shell
curl --location 'http:///v1/chat/completions' \
--header 'Content-Type: application/json' \
--data '{
@@ -955,7 +1017,7 @@ provider:
provider:
type: ai360
apiTokens:
- - "YOUR_MINIMAX_API_TOKEN"
+ - "YOUR_AI360_API_TOKEN"
modelMapping:
"gpt-4o": "360gpt-turbo-responsibility-8k"
"gpt-4": "360gpt2-pro"
@@ -1264,6 +1326,7 @@ Here, `model` denotes the service tier of DeepL and can only be either `Free` or
```
**Response Example**
+
```json
{
"choices": [
diff --git a/plugins/wasm-go/extensions/ai-proxy/config/config.go b/plugins/wasm-go/extensions/ai-proxy/config/config.go
index e1bba64027..b545271a70 100644
--- a/plugins/wasm-go/extensions/ai-proxy/config/config.go
+++ b/plugins/wasm-go/extensions/ai-proxy/config/config.go
@@ -25,32 +25,70 @@ import (
type PluginConfig struct {
// @Title zh-CN AI服务提供商配置
// @Description zh-CN AI服务提供商配置,包含API接口、模型和知识库文件等信息
- providerConfig provider.ProviderConfig `required:"true" yaml:"provider"`
+ providerConfigs []provider.ProviderConfig `required:"true" yaml:"providers"`
- provider provider.Provider `yaml:"-"`
+ activeProviderConfig *provider.ProviderConfig `yaml:"-"`
+ activeProvider provider.Provider `yaml:"-"`
}
func (c *PluginConfig) FromJson(json gjson.Result) {
- c.providerConfig.FromJson(json.Get("provider"))
+ if providersJson := json.Get("providers"); providersJson.Exists() && providersJson.IsArray() {
+ c.providerConfigs = make([]provider.ProviderConfig, 0)
+ for _, providerJson := range providersJson.Array() {
+ providerConfig := provider.ProviderConfig{}
+ providerConfig.FromJson(providerJson)
+ c.providerConfigs = append(c.providerConfigs, providerConfig)
+ }
+ }
+
+ if providerJson := json.Get("provider"); providerJson.Exists() && providerJson.IsObject() {
+ // TODO: For legacy config support. To be removed later.
+ providerConfig := provider.ProviderConfig{}
+ providerConfig.FromJson(providerJson)
+ c.providerConfigs = []provider.ProviderConfig{providerConfig}
+ c.activeProviderConfig = &providerConfig
+ // Legacy configuration is used and the active provider is determined.
+ // We don't need to continue with the new configuration style.
+ return
+ }
+
+ c.activeProviderConfig = nil
+
+ activeProviderId := json.Get("activeProviderId").String()
+ if activeProviderId != "" {
+ for _, providerConfig := range c.providerConfigs {
+ if providerConfig.GetId() == activeProviderId {
+ c.activeProviderConfig = &providerConfig
+ break
+ }
+ }
+ }
}
func (c *PluginConfig) Validate() error {
- if err := c.providerConfig.Validate(); err != nil {
+ if c.activeProviderConfig == nil {
+ return nil
+ }
+ if err := c.activeProviderConfig.Validate(); err != nil {
return err
}
return nil
}
func (c *PluginConfig) Complete() error {
+ if c.activeProviderConfig == nil {
+ c.activeProvider = nil
+ return nil
+ }
var err error
- c.provider, err = provider.CreateProvider(c.providerConfig)
+ c.activeProvider, err = provider.CreateProvider(*c.activeProviderConfig)
return err
}
func (c *PluginConfig) GetProvider() provider.Provider {
- return c.provider
+ return c.activeProvider
}
-func (c *PluginConfig) GetProviderConfig() provider.ProviderConfig {
- return c.providerConfig
+func (c *PluginConfig) GetProviderConfig() *provider.ProviderConfig {
+ return c.activeProviderConfig
}
diff --git a/plugins/wasm-go/extensions/ai-proxy/envoy.yaml b/plugins/wasm-go/extensions/ai-proxy/envoy.yaml
index 9f6448060c..6f703668b8 100644
--- a/plugins/wasm-go/extensions/ai-proxy/envoy.yaml
+++ b/plugins/wasm-go/extensions/ai-proxy/envoy.yaml
@@ -56,21 +56,24 @@ static_resources:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
{
- "provider": {
- "type": "moonshot",
- "domain": "api.moonshot.cn",
- "apiTokens": [
- "****",
- "****"
- ],
- "timeout": 1200000,
- "modelMapping": {
- "gpt-3": "moonshot-v1-8k",
- "gpt-35-turbo": "moonshot-v1-32k",
- "gpt-4-turbo": "moonshot-v1-128k",
- "*": "moonshot-v1-8k"
- },
- }
+ "activeProviderId": "moonshot",
+ "providers": [
+ {
+ "type": "moonshot",
+ "domain": "api.moonshot.cn",
+ "apiTokens": [
+ "****",
+ "****"
+ ],
+ "timeout": 1200000,
+ "modelMapping": {
+ "gpt-3": "moonshot-v1-8k",
+ "gpt-35-turbo": "moonshot-v1-32k",
+ "gpt-4-turbo": "moonshot-v1-128k",
+ "*": "moonshot-v1-8k"
+ },
+ }
+ ]
}
- name: envoy.filters.http.router
clusters:
diff --git a/plugins/wasm-go/extensions/ai-proxy/main.go b/plugins/wasm-go/extensions/ai-proxy/main.go
index b01986c7e2..9e0fafe179 100644
--- a/plugins/wasm-go/extensions/ai-proxy/main.go
+++ b/plugins/wasm-go/extensions/ai-proxy/main.go
@@ -20,7 +20,7 @@ import (
const (
pluginName = "ai-proxy"
- ctxKeyApiName = "apiKey"
+ ctxKeyApiName = "apiName"
defaultMaxBodyBytes uint32 = 10 * 1024 * 1024
)
@@ -28,7 +28,7 @@ const (
func main() {
wrapper.SetCtx(
pluginName,
- wrapper.ParseConfigBy(parseConfig),
+ wrapper.ParseOverrideConfigBy(parseGlobalConfig, parseOverrideRuleConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeader),
wrapper.ProcessRequestBodyBy(onHttpRequestBody),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
@@ -37,8 +37,23 @@ func main() {
)
}
-func parseConfig(json gjson.Result, pluginConfig *config.PluginConfig, log wrapper.Log) error {
- // log.Debugf("loading config: %s", json.String())
+func parseGlobalConfig(json gjson.Result, pluginConfig *config.PluginConfig, log wrapper.Log) error {
+ //log.Debugf("loading global config: %s", json.String())
+
+ pluginConfig.FromJson(json)
+ if err := pluginConfig.Validate(); err != nil {
+ return err
+ }
+ if err := pluginConfig.Complete(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func parseOverrideRuleConfig(json gjson.Result, global config.PluginConfig, pluginConfig *config.PluginConfig, log wrapper.Log) error {
+ //log.Debugf("loading override rule config: %s", json.String())
+
+ *pluginConfig = global
pluginConfig.FromJson(json)
if err := pluginConfig.Validate(); err != nil {
diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go
index c6ab5ef74b..b74eba97ac 100644
--- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go
+++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go
@@ -126,7 +126,10 @@ type ResponseBodyHandler interface {
}
type ProviderConfig struct {
- // @Title zh-CN AI服务提供商
+ // @Title zh-CN ID
+ // @Description zh-CN AI服务提供商标识
+ id string `required:"true" yaml:"id" json:"id"`
+ // @Title zh-CN 类型
// @Description zh-CN AI服务提供商类型
typ string `required:"true" yaml:"type" json:"type"`
// @Title zh-CN API Tokens
@@ -197,7 +200,20 @@ type ProviderConfig struct {
customSettings []CustomSetting
}
+func (c *ProviderConfig) GetId() string {
+ return c.id
+}
+
+func (c *ProviderConfig) GetType() string {
+ return c.typ
+}
+
+func (c *ProviderConfig) GetProtocol() string {
+ return c.protocol
+}
+
func (c *ProviderConfig) FromJson(json gjson.Result) {
+ c.id = json.Get("id").String()
c.typ = json.Get("type").String()
c.apiTokens = make([]string, 0)
for _, token := range json.Get("apiTokens").Array() {
@@ -322,6 +338,10 @@ func (c *ProviderConfig) IsOriginal() bool {
return c.protocol == protocolOriginal
}
+func (c *ProviderConfig) ReplaceByCustomSettings(body []byte) ([]byte, error) {
+ return ReplaceByCustomSettings(body, c.customSettings)
+}
+
func CreateProvider(pc ProviderConfig) (Provider, error) {
initializer, has := providerInitializers[pc.typ]
if !has {
@@ -366,7 +386,3 @@ func doGetMappedModel(model string, modelMapping map[string]string, log wrapper.
return ""
}
-
-func (c ProviderConfig) ReplaceByCustomSettings(body []byte) ([]byte, error) {
- return ReplaceByCustomSettings(body, c.customSettings)
-}
From 567d7c25f3169f0bc7fb6f41f1d792c33ed79aa3 Mon Sep 17 00:00:00 2001
From: Jun <108045855+2456868764@users.noreply.github.com>
Date: Thu, 26 Sep 2024 21:39:45 +0800
Subject: [PATCH 15/16] add buildrc (#1348)
---
plugins/wasm-go/extensions/ai-quota/.buildrc | 1 +
1 file changed, 1 insertion(+)
create mode 100644 plugins/wasm-go/extensions/ai-quota/.buildrc
diff --git a/plugins/wasm-go/extensions/ai-quota/.buildrc b/plugins/wasm-go/extensions/ai-quota/.buildrc
new file mode 100644
index 0000000000..f76a2883ac
--- /dev/null
+++ b/plugins/wasm-go/extensions/ai-quota/.buildrc
@@ -0,0 +1 @@
+EXTRA_TAGS=proxy_wasm_version_0_2_100
\ No newline at end of file
From ea99159d51639f5b7d3e996a44a77d03b42c97dc Mon Sep 17 00:00:00 2001
From: Hazel0928 <55099364+Hazel0928@users.noreply.github.com>
Date: Thu, 26 Sep 2024 22:38:33 +0800
Subject: [PATCH 16/16] feat: support frontend-gray plugin's envoy.yaml file to
host HTML (#1343)
Co-authored-by: Kent Dong
---
.../extensions/frontend-gray/config/config.go | 2 +
.../extensions/frontend-gray/envoy.yaml | 4 +-
.../wasm-go/extensions/frontend-gray/main.go | 44 ++++++++++---------
.../extensions/frontend-gray/util/utils.go | 25 +++++++++++
.../frontend-gray/util/utils_test.go | 19 ++++++++
5 files changed, 72 insertions(+), 22 deletions(-)
diff --git a/plugins/wasm-go/extensions/frontend-gray/config/config.go b/plugins/wasm-go/extensions/frontend-gray/config/config.go
index fd1c26b154..de689aad2a 100644
--- a/plugins/wasm-go/extensions/frontend-gray/config/config.go
+++ b/plugins/wasm-go/extensions/frontend-gray/config/config.go
@@ -55,6 +55,7 @@ type GrayConfig struct {
GraySubKey string
Rules []*GrayRule
Rewrite *Rewrite
+ Html string
BaseDeployment *Deployment
GrayDeployments []*Deployment
BackendGrayTag string
@@ -84,6 +85,7 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
grayConfig.GraySubKey = json.Get("graySubKey").String()
grayConfig.BackendGrayTag = json.Get("backendGrayTag").String()
grayConfig.UserStickyMaxAge = json.Get("userStickyMaxAge").String()
+ grayConfig.Html = json.Get("html").String()
if grayConfig.UserStickyMaxAge == "" {
// 默认值2天
diff --git a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml
index e859c29a5c..6dabed21de 100644
--- a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml
+++ b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml
@@ -107,7 +107,8 @@ static_resources:
""
]
}
- }
+ },
+ "html": "\n \n\napp1\n\n\n\n\t测试替换html版本\n\t
\n\t版本: {version}\n\t
\n\t\n\n"
}
- name: envoy.filters.http.router
typed_config:
@@ -116,7 +117,6 @@ static_resources:
- name: httpbin
connect_timeout: 30s
type: LOGICAL_DNS
- # Comment out the following line to test on v6 networks
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment:
diff --git a/plugins/wasm-go/extensions/frontend-gray/main.go b/plugins/wasm-go/extensions/frontend-gray/main.go
index 148751cc3c..a6207e7922 100644
--- a/plugins/wasm-go/extensions/frontend-gray/main.go
+++ b/plugins/wasm-go/extensions/frontend-gray/main.go
@@ -184,12 +184,33 @@ func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, b
isPageRequest = false // 默认值
}
frontendVersion := ctx.GetContext(config.XPreHigressTag).(string)
-
isNotFound, ok := ctx.GetContext(config.IsNotFound).(bool)
if !ok {
isNotFound = false // 默认值
}
+ // 检查是否存在自定义 HTML, 如有则省略 rewrite.indexRouting 的内容
+ if grayConfig.Html != "" {
+ log.Debugf("Returning custom HTML from config.")
+ // 替换响应体为 config.Html 内容
+ if err := proxywasm.ReplaceHttpResponseBody([]byte(grayConfig.Html)); err != nil {
+ log.Errorf("Error replacing response body: %v", err)
+ return types.ActionContinue
+ }
+
+ newHtml := util.InjectContent(grayConfig.Html, grayConfig.Injection)
+ // 替换当前html加载的动态文件版本
+ newHtml = strings.ReplaceAll(newHtml, "{version}", frontendVersion)
+
+ // 最终替换响应体
+ if err := proxywasm.ReplaceHttpResponseBody([]byte(newHtml)); err != nil {
+ log.Errorf("Error replacing injected response body: %v", err)
+ return types.ActionContinue
+ }
+
+ return types.ActionContinue
+ }
+
if isPageRequest && isNotFound && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" {
client := wrapper.NewClusterClient(wrapper.RouteCluster{Host: grayConfig.Rewrite.Host})
@@ -204,30 +225,13 @@ func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, b
// 将原始字节转换为字符串
newBody := string(body)
- // 收集需要插入的内容
- headInjection := strings.Join(grayConfig.Injection.Head, "\n")
- bodyFirstInjection := strings.Join(grayConfig.Injection.Body.First, "\n")
- bodyLastInjection := strings.Join(grayConfig.Injection.Body.Last, "\n")
-
- // 使用 strings.Builder 来提高性能
- var sb strings.Builder
- // 预分配内存,避免多次内存分配
- sb.Grow(len(newBody) + len(headInjection) + len(bodyFirstInjection) + len(bodyLastInjection))
- sb.WriteString(newBody)
-
- // 进行替换
- content := sb.String()
- content = strings.ReplaceAll(content, "", fmt.Sprintf("%s\n", headInjection))
- content = strings.ReplaceAll(content, "", fmt.Sprintf("\n%s", bodyFirstInjection))
- content = strings.ReplaceAll(content, "", fmt.Sprintf("%s\n", bodyLastInjection))
-
- // 最终结果
- newBody = content
+ newBody = util.InjectContent(newBody, grayConfig.Injection)
if err := proxywasm.ReplaceHttpResponseBody([]byte(newBody)); err != nil {
return types.ActionContinue
}
}
+
return types.ActionContinue
}
diff --git a/plugins/wasm-go/extensions/frontend-gray/util/utils.go b/plugins/wasm-go/extensions/frontend-gray/util/utils.go
index a8c096816e..ab7b2d4752 100644
--- a/plugins/wasm-go/extensions/frontend-gray/util/utils.go
+++ b/plugins/wasm-go/extensions/frontend-gray/util/utils.go
@@ -278,3 +278,28 @@ func FilterGrayWeight(grayConfig *config.GrayConfig, preVersion string, preUniqu
}
return nil
}
+
+// InjectContent 用于将内容注入到 HTML 文档的指定位置
+func InjectContent(originalHtml string, injectionConfig *config.Injection) string {
+
+ headInjection := strings.Join(injectionConfig.Head, "\n")
+ bodyFirstInjection := strings.Join(injectionConfig.Body.First, "\n")
+ bodyLastInjection := strings.Join(injectionConfig.Body.Last, "\n")
+
+ // 使用 strings.Builder 来提高性能
+ var sb strings.Builder
+ // 预分配内存,避免多次内存分配
+ sb.Grow(len(originalHtml) + len(headInjection) + len(bodyFirstInjection) + len(bodyLastInjection))
+ sb.WriteString(originalHtml)
+
+ modifiedHtml := sb.String()
+
+ // 注入到头部
+ modifiedHtml = strings.ReplaceAll(modifiedHtml, "", headInjection + "\n")
+ // 注入到body头
+ modifiedHtml = strings.ReplaceAll(modifiedHtml, "", "\n" + bodyFirstInjection)
+ // 注入到body尾
+ modifiedHtml = strings.ReplaceAll(modifiedHtml, "", bodyLastInjection + "\n")
+
+ return modifiedHtml
+}
diff --git a/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go b/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go
index 7ba014225f..d51e17f323 100644
--- a/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go
+++ b/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go
@@ -122,3 +122,22 @@ func TestFilterGrayWeight(t *testing.T) {
})
}
}
+
+func TestReplaceHtml(t *testing.T) {
+ var tests = []struct {
+ name string
+ input string
+ }{
+ {"demo", `{"injection":{"head":[""],"body":{"first":[""],"last":[""]},"last":[""]},"html": "\n \n\napp1\n\n\n\n\t测试替换html版本\n\t
\n\t版本: {version}\n\t
\n\t\n\n"}`},
+ {"demo-noBody", `{"injection":{"head":[""],"body":{"first":[""],"last":[""]},"last":[""]},"html": "\n \n\napp1\n\n\n"}`},
+ }
+ for _, test := range tests {
+ testName := test.name
+ t.Run(testName, func(t *testing.T) {
+ grayConfig := &config.GrayConfig{}
+ config.JsonToGrayConfig(gjson.Parse(test.input), grayConfig)
+ result := InjectContent(grayConfig.Html, grayConfig.Injection)
+ t.Logf("result-----: %v", result)
+ })
+ }
+}