From 1f8d50c0b10b9f96b162b6d6164fe6e8095842b9 Mon Sep 17 00:00:00 2001 From: Kent Dong Date: Tue, 8 Oct 2024 10:42:42 +0800 Subject: [PATCH 01/32] feat: Update the latest tag when building a new plugin image (#1354) --- .github/workflows/build-and-push-wasm-plugin-image.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-push-wasm-plugin-image.yaml b/.github/workflows/build-and-push-wasm-plugin-image.yaml index d716d5e39b..518dfeca75 100644 --- a/.github/workflows/build-and-push-wasm-plugin-image.yaml +++ b/.github/workflows/build-and-push-wasm-plugin-image.yaml @@ -89,7 +89,9 @@ jobs: push_command=${push_command%\"} # 删除PUSH_COMMAND中的双引号,确保oras push正常解析 target_image="${{ env.IMAGE_REGISTRY_SERVICE }}/${{ env.IMAGE_REPOSITORY}}/${{ env.PLUGIN_NAME }}:${{ env.VERSION }}" + target_image_latest="${{ env.IMAGE_REGISTRY_SERVICE }}/${{ env.IMAGE_REPOSITORY}}/${{ env.PLUGIN_NAME }}:latest" echo "TargetImage=${target_image}" + echo "TargetImageLatest=${target_image_latest}" cd ${{ github.workspace }}/plugins/wasm-go/extensions/${PLUGIN_NAME} if [ -f ./.buildrc ]; then @@ -108,7 +110,6 @@ jobs: tar czvf plugin.tar.gz plugin.wasm echo ${{ secrets.REGISTRY_PASSWORD }} | oras login -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin ${{ env.IMAGE_REGISTRY_SERVICE }} oras push ${target_image} ${push_command} + oras push ${target_image_latest} ${push_command} " docker exec builder bash -c "$command" - - From 4d0d8a7f502f438db913289b9658381040f899e1 Mon Sep 17 00:00:00 2001 From: mamba <371510756@qq.com> Date: Tue, 8 Oct 2024 13:15:58 +0800 Subject: [PATCH 02/32] =?UTF-8?q?fix:=20=F0=9F=90=9B=20[frontend-gray]=20?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=20=E8=AF=B7=E6=B1=82=E9=9D=9E=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E8=B5=84=E6=BA=90=E6=97=B6=E5=80=99=EF=BC=8C=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E9=85=8D=E7=BD=AE=20(#1353)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wasm-go/extensions/frontend-gray/main.go | 5 +++-- .../extensions/frontend-gray/util/utils.go | 18 +++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/plugins/wasm-go/extensions/frontend-gray/main.go b/plugins/wasm-go/extensions/frontend-gray/main.go index a6207e7922..81eb3034da 100644 --- a/plugins/wasm-go/extensions/frontend-gray/main.go +++ b/plugins/wasm-go/extensions/frontend-gray/main.go @@ -68,15 +68,16 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, // 如果没有配置比例,则进行灰度规则匹配 if isPageRequest { - log.Infof("grayConfig.TotalGrayWeight==== %v", grayConfig.TotalGrayWeight) if grayConfig.TotalGrayWeight > 0 { + log.Infof("grayConfig.TotalGrayWeight: %v", grayConfig.TotalGrayWeight) 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) + grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue) + deployment = util.GetVersion(grayConfig, grayDeployment, preVersion, isPageRequest) } proxywasm.AddHttpRequestHeader(config.XHigressTag, deployment.Version) diff --git a/plugins/wasm-go/extensions/frontend-gray/util/utils.go b/plugins/wasm-go/extensions/frontend-gray/util/utils.go index ab7b2d4752..80291a2c3c 100644 --- a/plugins/wasm-go/extensions/frontend-gray/util/utils.go +++ b/plugins/wasm-go/extensions/frontend-gray/util/utils.go @@ -184,7 +184,7 @@ func GetVersion(grayConfig config.GrayConfig, deployment *config.Deployment, xPr } } } - return grayConfig.BaseDeployment + return deployment } // 从cookie中解析出灰度信息 @@ -294,12 +294,12 @@ func InjectContent(originalHtml string, injectionConfig *config.Injection) strin 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 + // 注入到头部 + modifiedHtml = strings.ReplaceAll(modifiedHtml, "", headInjection+"\n") + // 注入到body头 + modifiedHtml = strings.ReplaceAll(modifiedHtml, "", "\n"+bodyFirstInjection) + // 注入到body尾 + modifiedHtml = strings.ReplaceAll(modifiedHtml, "", bodyLastInjection+"\n") + + return modifiedHtml } From 3ed28f2a6614962a85424d8c8d72792653071dd7 Mon Sep 17 00:00:00 2001 From: Iris Date: Tue, 8 Oct 2024 14:00:16 +0800 Subject: [PATCH 03/32] fix: when there is a non-ip IPAddr in Eureka, delete it to avoid a failure in EDS (#1322) --- registry/eureka/client/http_client.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/registry/eureka/client/http_client.go b/registry/eureka/client/http_client.go index 8203130073..bfec44cfe6 100644 --- a/registry/eureka/client/http_client.go +++ b/registry/eureka/client/http_client.go @@ -18,6 +18,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "time" @@ -125,13 +126,25 @@ func (c *eurekaHttpClient) getApplications(path string) (*Applications, error) { apps := map[string]*fargo.Application{} for idx := range rj.Response.Applications { + ignore := false app := rj.Response.Applications[idx] + for _, instance := range app.Instances { + if ip := net.ParseIP(instance.IPAddr); ip == nil { + log.Warnf("the Non-IP IPAddr %s is not allowed, please check your app: %s", instance.IPAddr, app.Name) + ignore = true + break + } + } + if ignore { + continue + } apps[app.Name] = app } for name, app := range apps { log.Debugf("Parsing metadata for app %v", name) if err := app.ParseAllMetadata(); err != nil { + log.Errorf("Failed to parse metadata for app %v: %v", name, err) return nil, err } } From ecf52aecfc1cd04663b991243a42a6475f212aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Wed, 9 Oct 2024 15:54:19 +0800 Subject: [PATCH 04/32] Supports MCP service configuration protocol and SNI, along with various other fixes. (#1369) --- .../customresourcedefinitions.gen.yaml | 4 + api/networking/v1/mcp_bridge.pb.go | 29 +++++-- api/networking/v1/mcp_bridge.proto | 2 + envoy/envoy | 2 +- .../crds/customresourcedefinitions.gen.yaml | 5 ++ helm/core/templates/_pod.tpl | 4 +- istio/istio | 2 +- pkg/common/protocol.go | 22 +++++- pkg/config/constants/constants.go | 4 + pkg/ingress/config/ingress_config.go | 55 ++++++++++++-- pkg/ingress/kube/common/controller.go | 9 +++ pkg/ingress/kube/ingress/controller.go | 7 +- pkg/ingress/kube/ingressv1/controller.go | 7 +- pkg/ingress/mcp/generator.go | 10 +-- registry/consul/watcher.go | 7 +- registry/direct/watcher.go | 75 ++++++++++++++++--- registry/eureka/watcher.go | 7 +- registry/memory/cache.go | 57 +++++++++----- registry/memory/model.go | 34 ++++++--- registry/nacos/v2/watcher.go | 9 ++- registry/nacos/watcher.go | 7 +- registry/reconcile/reconcile.go | 2 + registry/zookeeper/watcher.go | 16 ++-- 23 files changed, 282 insertions(+), 94 deletions(-) diff --git a/api/kubernetes/customresourcedefinitions.gen.yaml b/api/kubernetes/customresourcedefinitions.gen.yaml index 1398b462f4..de7e1b9345 100644 --- a/api/kubernetes/customresourcedefinitions.gen.yaml +++ b/api/kubernetes/customresourcedefinitions.gen.yaml @@ -284,6 +284,10 @@ spec: type: string port: type: integer + protocol: + type: string + sni: + type: string type: type: string zkServicesPath: diff --git a/api/networking/v1/mcp_bridge.pb.go b/api/networking/v1/mcp_bridge.pb.go index 7aa8fbf28b..d71ea55025 100644 --- a/api/networking/v1/mcp_bridge.pb.go +++ b/api/networking/v1/mcp_bridge.pb.go @@ -126,6 +126,8 @@ type RegistryConfig struct { ConsulServiceTag string `protobuf:"bytes,15,opt,name=consulServiceTag,proto3" json:"consulServiceTag,omitempty"` ConsulRefreshInterval int64 `protobuf:"varint,16,opt,name=consulRefreshInterval,proto3" json:"consulRefreshInterval,omitempty"` AuthSecretName string `protobuf:"bytes,17,opt,name=authSecretName,proto3" json:"authSecretName,omitempty"` + Protocol string `protobuf:"bytes,18,opt,name=protocol,proto3" json:"protocol,omitempty"` + Sni string `protobuf:"bytes,19,opt,name=sni,proto3" json:"sni,omitempty"` } func (x *RegistryConfig) Reset() { @@ -279,6 +281,20 @@ func (x *RegistryConfig) GetAuthSecretName() string { return "" } +func (x *RegistryConfig) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *RegistryConfig) GetSni() string { + if x != nil { + return x.Sni + } + return "" +} + var File_networking_v1_mcp_bridge_proto protoreflect.FileDescriptor var file_networking_v1_mcp_bridge_proto_rawDesc = []byte{ @@ -292,7 +308,7 @@ var file_networking_v1_mcp_bridge_proto_rawDesc = []byte{ 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x68, 0x69, 0x67, 0x72, 0x65, 0x73, 0x73, 0x2e, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x0a, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x69, 0x65, 0x73, 0x22, 0xa5, 0x05, 0x0a, + 0x52, 0x0a, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x69, 0x65, 0x73, 0x22, 0xd3, 0x05, 0x0a, 0x0e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x17, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, @@ -335,10 +351,13 @@ var file_networking_v1_mcp_bridge_proto_rawDesc = []byte{ 0x72, 0x65, 0x73, 0x68, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, - 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x61, 0x6c, 0x69, 0x62, 0x61, 0x62, 0x61, 0x2f, 0x68, 0x69, 0x67, 0x72, 0x65, - 0x73, 0x73, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, - 0x67, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6e, 0x69, 0x18, 0x13, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, + 0x6e, 0x69, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x61, 0x6c, 0x69, 0x62, 0x61, 0x62, 0x61, 0x2f, 0x68, 0x69, 0x67, 0x72, 0x65, 0x73, 0x73, + 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x2f, + 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/api/networking/v1/mcp_bridge.proto b/api/networking/v1/mcp_bridge.proto index 8c2c6159d9..53c5fad004 100644 --- a/api/networking/v1/mcp_bridge.proto +++ b/api/networking/v1/mcp_bridge.proto @@ -64,4 +64,6 @@ message RegistryConfig { string consulServiceTag = 15; int64 consulRefreshInterval = 16; string authSecretName = 17; + string protocol = 18; + string sni = 19; } diff --git a/envoy/envoy b/envoy/envoy index 9c9c3b717c..b3541845c1 160000 --- a/envoy/envoy +++ b/envoy/envoy @@ -1 +1 @@ -Subproject commit 9c9c3b717c9a3dd8cb8772ef5de86938aa1c93a8 +Subproject commit b3541845c1a78d817c73806299415439c23488d2 diff --git a/helm/core/crds/customresourcedefinitions.gen.yaml b/helm/core/crds/customresourcedefinitions.gen.yaml index c6cc49c25b..de7e1b9345 100644 --- a/helm/core/crds/customresourcedefinitions.gen.yaml +++ b/helm/core/crds/customresourcedefinitions.gen.yaml @@ -284,6 +284,10 @@ spec: type: string port: type: integer + protocol: + type: string + sni: + type: string type: type: string zkServicesPath: @@ -302,3 +306,4 @@ spec: subresources: status: {} +--- diff --git a/helm/core/templates/_pod.tpl b/helm/core/templates/_pod.tpl index 432f9d3d4e..4e7e0a6ac7 100644 --- a/helm/core/templates/_pod.tpl +++ b/helm/core/templates/_pod.tpl @@ -180,7 +180,7 @@ template: {{- end }} - name: config mountPath: /etc/istio/config - - name: istio-ca-root-cert + - name: higress-ca-root-cert mountPath: /var/run/secrets/istio - name: istio-data mountPath: /var/lib/istio/data @@ -266,7 +266,7 @@ template: expirationSeconds: 43200 path: istio-token {{- end }} - - name: istio-ca-root-cert + - name: higress-ca-root-cert configMap: {{- if .Values.global.enableHigressIstio }} name: istio-ca-root-cert diff --git a/istio/istio b/istio/istio index 8918eb802a..dae7ac29f4 160000 --- a/istio/istio +++ b/istio/istio @@ -1 +1 @@ -Subproject commit 8918eb802a2ab7aafe91ea5010c0642258d94669 +Subproject commit dae7ac29f4a86aaeca72c60400abe01bbefe8fb0 diff --git a/pkg/common/protocol.go b/pkg/common/protocol.go index f3b51626b2..a8af22c43c 100644 --- a/pkg/common/protocol.go +++ b/pkg/common/protocol.go @@ -21,7 +21,10 @@ type Protocol string const ( TCP Protocol = "TCP" HTTP Protocol = "HTTP" + HTTP2 Protocol = "HTTP2" + HTTPS Protocol = "HTTPS" GRPC Protocol = "GRPC" + GRPCS Protocol = "GRPCS" Dubbo Protocol = "Dubbo" Unsupported Protocol = "UnsupportedProtocol" ) @@ -32,8 +35,14 @@ func ParseProtocol(s string) Protocol { return TCP case "http": return HTTP + case "https": + return HTTPS + case "http2": + return HTTP2 case "grpc", "triple", "tri": return GRPC + case "grpcs": + return GRPCS case "dubbo": return Dubbo } @@ -51,7 +60,7 @@ func (p Protocol) IsTCP() bool { func (p Protocol) IsHTTP() bool { switch p { - case HTTP, GRPC: + case HTTP, GRPC, GRPCS, HTTP2, HTTPS: return true default: return false @@ -60,7 +69,16 @@ func (p Protocol) IsHTTP() bool { func (p Protocol) IsGRPC() bool { switch p { - case GRPC: + case GRPC, GRPCS: + return true + default: + return false + } +} + +func (i Protocol) IsHTTPS() bool { + switch i { + case HTTPS, GRPCS: return true default: return false diff --git a/pkg/config/constants/constants.go b/pkg/config/constants/constants.go index 6a24952276..0bde512325 100644 --- a/pkg/config/constants/constants.go +++ b/pkg/config/constants/constants.go @@ -23,3 +23,7 @@ const KnativeIngressCRDName = "ingresses.networking.internal.knative.dev" const KnativeServicesCRDName = "services.serving.knative.dev" const ManagedGatewayController = "higress.io/gateway-controller" + +const RegistryTypeLabelKey = "higress-registry-type" + +const RegistryNameLabelKey = "higress-registry-name" diff --git a/pkg/ingress/config/ingress_config.go b/pkg/ingress/config/ingress_config.go index 2d8b344212..e41181676a 100644 --- a/pkg/ingress/config/ingress_config.go +++ b/pkg/ingress/config/ingress_config.go @@ -53,6 +53,7 @@ import ( extlisterv1 "github.com/alibaba/higress/client/pkg/listers/extensions/v1alpha1" netlisterv1 "github.com/alibaba/higress/client/pkg/listers/networking/v1" "github.com/alibaba/higress/pkg/cert" + higressconst "github.com/alibaba/higress/pkg/config/constants" "github.com/alibaba/higress/pkg/ingress/kube/annotations" "github.com/alibaba/higress/pkg/ingress/kube/common" "github.com/alibaba/higress/pkg/ingress/kube/configmap" @@ -628,8 +629,8 @@ func (m *IngressConfig) convertServiceEntry([]common.WrapperConfig) []config.Con if m.RegistryReconciler == nil { return nil } - serviceEntries := m.RegistryReconciler.GetAllServiceEntryWrapper() - IngressLog.Infof("Found http2rpc serviceEntries %s", serviceEntries) + serviceEntries := m.RegistryReconciler.GetAllServiceWrapper() + IngressLog.Infof("Found mcp serviceEntries %v", serviceEntries) out := make([]config.Config, 0, len(serviceEntries)) for _, se := range serviceEntries { out = append(out, config.Config{ @@ -638,6 +639,10 @@ func (m *IngressConfig) convertServiceEntry([]common.WrapperConfig) []config.Con Name: se.ServiceEntry.Hosts[0], Namespace: "mcp", CreationTimestamp: se.GetCreateTime(), + Labels: map[string]string{ + higressconst.RegistryTypeLabelKey: se.RegistryType, + higressconst.RegistryNameLabelKey: se.RegistryName, + }, }, Spec: se.ServiceEntry, }) @@ -703,6 +708,32 @@ func (m *IngressConfig) convertDestinationRule(configs []common.WrapperConfig) [ destinationRules[serviceName] = dr } + if m.RegistryReconciler != nil { + drws := m.RegistryReconciler.GetAllDestinationRuleWrapper() + IngressLog.Infof("Found mcp destinationRules: %v", drws) + for _, destinationRuleWrapper := range drws { + serviceName := destinationRuleWrapper.ServiceKey.ServiceFQDN + dr, exist := destinationRules[serviceName] + if !exist { + destinationRules[serviceName] = destinationRuleWrapper + } else if dr.DestinationRule.TrafficPolicy != nil { + portTrafficPolicy := destinationRuleWrapper.DestinationRule.TrafficPolicy.PortLevelSettings[0] + portUpdated := false + for _, portTrafficPolicy := range dr.DestinationRule.TrafficPolicy.PortLevelSettings { + if portTrafficPolicy.Port.Number == portTrafficPolicy.Port.Number { + portTrafficPolicy.Tls = portTrafficPolicy.Tls + portUpdated = true + break + } + } + if portUpdated { + continue + } + dr.DestinationRule.TrafficPolicy.PortLevelSettings = append(dr.DestinationRule.TrafficPolicy.PortLevelSettings, portTrafficPolicy) + } + } + } + out := make([]config.Config, 0, len(destinationRules)) for _, dr := range destinationRules { sort.SliceStable(dr.DestinationRule.TrafficPolicy.PortLevelSettings, func(i, j int) bool { @@ -727,6 +758,7 @@ func (m *IngressConfig) convertDestinationRule(configs []common.WrapperConfig) [ Spec: dr.DestinationRule, }) } + return out } @@ -1034,16 +1066,27 @@ func (m *IngressConfig) AddOrUpdateMcpBridge(clusterNamespacedName util.ClusterN } if m.RegistryReconciler == nil { m.RegistryReconciler = reconcile.NewReconciler(func() { - metadata := config.Meta{ + seMetadata := config.Meta{ Name: "mcpbridge-serviceentry", Namespace: m.namespace, GroupVersionKind: gvk.ServiceEntry, // Set this label so that we do not compare configs and just push. Labels: map[string]string{constants.AlwaysPushLabel: "true"}, } + drMetadata := config.Meta{ + Name: "mcpbridge-destinationrule", + Namespace: m.namespace, + GroupVersionKind: gvk.DestinationRule, + // Set this label so that we do not compare configs and just push. + Labels: map[string]string{constants.AlwaysPushLabel: "true"}, + } for _, f := range m.serviceEntryHandlers { IngressLog.Debug("McpBridge triggerd serviceEntry update") - f(config.Config{Meta: metadata}, config.Config{Meta: metadata}, istiomodel.EventUpdate) + f(config.Config{Meta: seMetadata}, config.Config{Meta: seMetadata}, istiomodel.EventUpdate) + } + for _, f := range m.destinationRuleHandlers { + IngressLog.Debug("McpBridge triggerd destinationRule update") + f(config.Config{Meta: drMetadata}, config.Config{Meta: drMetadata}, istiomodel.EventUpdate) } }, m.localKubeClient, m.namespace) } @@ -1489,7 +1532,7 @@ func constructBasicAuthEnvoyFilter(rules *common.BasicAuthRules, namespace strin }, nil } -func QueryByName(serviceEntries []*memory.ServiceEntryWrapper, serviceName string) (*memory.ServiceEntryWrapper, error) { +func QueryByName(serviceEntries []*memory.ServiceWrapper, serviceName string) (*memory.ServiceWrapper, error) { IngressLog.Infof("Found http2rpc serviceEntries %s", serviceEntries) for _, se := range serviceEntries { if se.ServiceName == serviceName { @@ -1499,7 +1542,7 @@ func QueryByName(serviceEntries []*memory.ServiceEntryWrapper, serviceName strin return nil, fmt.Errorf("can't find ServiceEntry by serviceName:%v", serviceName) } -func QueryRpcServiceVersion(serviceEntry *memory.ServiceEntryWrapper, serviceName string) (string, error) { +func QueryRpcServiceVersion(serviceEntry *memory.ServiceWrapper, serviceName string) (string, error) { IngressLog.Infof("Found http2rpc serviceEntry %s", serviceEntry) IngressLog.Infof("Found http2rpc ServiceEntry %s", serviceEntry.ServiceEntry) IngressLog.Infof("Found http2rpc WorkloadSelector %s", serviceEntry.ServiceEntry.WorkloadSelector) diff --git a/pkg/ingress/kube/common/controller.go b/pkg/ingress/kube/common/controller.go index 7db8880f88..be61d9803c 100644 --- a/pkg/ingress/kube/common/controller.go +++ b/pkg/ingress/kube/common/controller.go @@ -52,6 +52,15 @@ type WrapperGateway struct { Host string } +func CreateMcpServiceKey(host string, portNumber int32) ServiceKey { + return ServiceKey{ + Namespace: "mcp", + Name: host, + ServiceFQDN: host, + Port: portNumber, + } +} + func (w *WrapperGateway) IsHTTPS() bool { if w.Gateway == nil || len(w.Gateway.Servers) == 0 { return false diff --git a/pkg/ingress/kube/ingress/controller.go b/pkg/ingress/kube/ingress/controller.go index f55d64c3c0..ddfa2b0054 100644 --- a/pkg/ingress/kube/ingress/controller.go +++ b/pkg/ingress/kube/ingress/controller.go @@ -920,12 +920,7 @@ func (c *controller) storeBackendTrafficPolicy(wrapper *common.WrapperConfig, ba if common.ValidateBackendResource(backend.Resource) && wrapper.AnnotationsConfig.Destination != nil { for _, dest := range wrapper.AnnotationsConfig.Destination.McpDestination { portNumber := dest.Destination.GetPort().GetNumber() - serviceKey := common.ServiceKey{ - Namespace: "mcp", - Name: dest.Destination.Host, - Port: int32(portNumber), - ServiceFQDN: dest.Destination.Host, - } + serviceKey := common.CreateMcpServiceKey(dest.Destination.Host, int32(portNumber)) if _, exist := store[serviceKey]; !exist { if serviceKey.Port != 0 { store[serviceKey] = &common.WrapperTrafficPolicy{ diff --git a/pkg/ingress/kube/ingressv1/controller.go b/pkg/ingress/kube/ingressv1/controller.go index 7e493e9f57..847f2eb852 100644 --- a/pkg/ingress/kube/ingressv1/controller.go +++ b/pkg/ingress/kube/ingressv1/controller.go @@ -900,12 +900,7 @@ func (c *controller) storeBackendTrafficPolicy(wrapper *common.WrapperConfig, ba if common.ValidateBackendResource(backend.Resource) && wrapper.AnnotationsConfig.Destination != nil { for _, dest := range wrapper.AnnotationsConfig.Destination.McpDestination { portNumber := dest.Destination.GetPort().GetNumber() - serviceKey := common.ServiceKey{ - Namespace: "mcp", - Name: dest.Destination.Host, - Port: int32(portNumber), - ServiceFQDN: dest.Destination.Host, - } + serviceKey := common.CreateMcpServiceKey(dest.Destination.Host, int32(portNumber)) if _, exist := store[serviceKey]; !exist { if serviceKey.Port != 0 { store[serviceKey] = &common.WrapperTrafficPolicy{ diff --git a/pkg/ingress/mcp/generator.go b/pkg/ingress/mcp/generator.go index 0fb6f7aad4..693a8c923f 100644 --- a/pkg/ingress/mcp/generator.go +++ b/pkg/ingress/mcp/generator.go @@ -64,7 +64,7 @@ func (c ServiceEntryGenerator) Generate(proxy *model.Proxy, w *model.WatchedReso return serviceEntries[i].CreationTimestamp.Before(serviceEntries[j].CreationTimestamp) }) } - return generate(proxy, serviceEntries, w, updates, false, false) + return generate(proxy, serviceEntries, w, updates, c.GeneratorOptions.KeepConfigLabels, c.GeneratorOptions.KeepConfigAnnotations) } func (c ServiceEntryGenerator) GenerateDeltas(proxy *model.Proxy, updates *model.PushRequest, @@ -82,7 +82,7 @@ type VirtualServiceGenerator struct { func (c VirtualServiceGenerator) Generate(proxy *model.Proxy, w *model.WatchedResource, updates *model.PushRequest) (model.Resources, model.XdsLogDetails, error) { virtualServices := c.Environment.List(gvk.VirtualService, model.NamespaceAll) - return generate(proxy, virtualServices, w, updates, false, false) + return generate(proxy, virtualServices, w, updates, c.GeneratorOptions.KeepConfigLabels, c.GeneratorOptions.KeepConfigAnnotations) } func (c VirtualServiceGenerator) GenerateDeltas(proxy *model.Proxy, updates *model.PushRequest, @@ -100,7 +100,7 @@ type DestinationRuleGenerator struct { func (c DestinationRuleGenerator) Generate(proxy *model.Proxy, w *model.WatchedResource, updates *model.PushRequest) (model.Resources, model.XdsLogDetails, error) { rules := c.Environment.List(gvk.DestinationRule, model.NamespaceAll) - return generate(proxy, rules, w, updates, false, false) + return generate(proxy, rules, w, updates, c.GeneratorOptions.KeepConfigLabels, c.GeneratorOptions.KeepConfigAnnotations) } func (c DestinationRuleGenerator) GenerateDeltas(proxy *model.Proxy, updates *model.PushRequest, @@ -118,7 +118,7 @@ type EnvoyFilterGenerator struct { func (c EnvoyFilterGenerator) Generate(proxy *model.Proxy, w *model.WatchedResource, updates *model.PushRequest) (model.Resources, model.XdsLogDetails, error) { filters := c.Environment.List(gvk.EnvoyFilter, model.NamespaceAll) - return generate(proxy, filters, w, updates, false, false) + return generate(proxy, filters, w, updates, c.GeneratorOptions.KeepConfigLabels, c.GeneratorOptions.KeepConfigAnnotations) } func (c EnvoyFilterGenerator) GenerateDeltas(proxy *model.Proxy, updates *model.PushRequest, @@ -154,7 +154,7 @@ type WasmPluginGenerator struct { func (c WasmPluginGenerator) Generate(proxy *model.Proxy, w *model.WatchedResource, updates *model.PushRequest) (model.Resources, model.XdsLogDetails, error) { wasmPlugins := c.Environment.List(gvk.WasmPlugin, model.NamespaceAll) - return generate(proxy, wasmPlugins, w, updates, false, false) + return generate(proxy, wasmPlugins, w, updates, c.GeneratorOptions.KeepConfigLabels, c.GeneratorOptions.KeepConfigAnnotations) } func (c WasmPluginGenerator) GenerateDeltas(proxy *model.Proxy, push *model.PushContext, updates *model.PushRequest, diff --git a/registry/consul/watcher.go b/registry/consul/watcher.go index f88cb88d52..157d278329 100644 --- a/registry/consul/watcher.go +++ b/registry/consul/watcher.go @@ -237,7 +237,7 @@ func (w *watcher) Stop() { // clean the cache suffix := strings.Join([]string{serviceName, w.ConsulDatacenter, w.Type}, common.DotSeparator) host := strings.ReplaceAll(suffix, common.Underscore, common.Hyphen) - w.cache.DeleteServiceEntryWrapper(host) + w.cache.DeleteServiceWrapper(host) } w.isStop = true close(w.stop) @@ -295,15 +295,16 @@ func (w *watcher) getSubscribeCallback(serviceName string) func(idx uint64, data serviceEntry := w.generateServiceEntry(host, services) if serviceEntry != nil { log.Infof("consul update serviceEntry %s cache", host) - w.cache.UpdateServiceEntryWrapper(host, &memory.ServiceEntryWrapper{ + w.cache.UpdateServiceWrapper(host, &memory.ServiceWrapper{ ServiceEntry: serviceEntry, ServiceName: serviceName, Suffix: suffix, RegistryType: w.Type, + RegistryName: w.Name, }) } else { log.Infof("consul serviceEntry %s is nil", host) - //w.cache.DeleteServiceEntryWrapper(host) + //w.cache.DeleteServiceWrapper(host) } } } diff --git a/registry/direct/watcher.go b/registry/direct/watcher.go index 6bc22597ac..f523f1f06a 100644 --- a/registry/direct/watcher.go +++ b/registry/direct/watcher.go @@ -22,14 +22,15 @@ import ( "sync" "istio.io/api/networking/v1alpha3" - "istio.io/istio/pkg/config/protocol" "istio.io/pkg/log" apiv1 "github.com/alibaba/higress/api/networking/v1" "github.com/alibaba/higress/pkg/common" + ingress "github.com/alibaba/higress/pkg/ingress/kube/common" "github.com/alibaba/higress/registry" provider "github.com/alibaba/higress/registry" "github.com/alibaba/higress/registry/memory" + "github.com/go-errors/errors" ) type watcher struct { @@ -48,6 +49,9 @@ func NewWatcher(cache memory.Cache, opts ...WatcherOption) (provider.Watcher, er for _, opt := range opts { opt(w) } + if common.ParseProtocol(w.Protocol) == common.Unsupported { + return nil, errors.Errorf("invalid protocol:%s", w.Protocol) + } return w, nil } @@ -75,17 +79,42 @@ func WithPort(port uint32) WatcherOption { } } +func WithProtocol(protocol string) WatcherOption { + return func(w *watcher) { + w.Protocol = protocol + if w.Protocol == "" { + w.Protocol = string(common.HTTP) + } + } +} + +func WithSNI(sni string) WatcherOption { + return func(w *watcher) { + w.Sni = sni + } +} + func (w *watcher) Run() { w.mutex.Lock() defer w.mutex.Unlock() host := strings.Join([]string{w.Name, w.Type}, common.DotSeparator) serviceEntry := w.generateServiceEntry(host) if serviceEntry != nil { - w.cache.UpdateServiceEntryWrapper(host, &memory.ServiceEntryWrapper{ - ServiceName: w.Name, - ServiceEntry: serviceEntry, - Suffix: w.Type, - RegistryType: w.Type, + var destinationRuleWrapper *ingress.WrapperDestinationRule + destinationRule := w.generateDestinationRule(serviceEntry) + if destinationRule != nil { + destinationRuleWrapper = &ingress.WrapperDestinationRule{ + DestinationRule: destinationRule, + ServiceKey: ingress.CreateMcpServiceKey(host, int32(w.Port)), + } + } + w.cache.UpdateServiceWrapper(host, &memory.ServiceWrapper{ + ServiceName: w.Name, + ServiceEntry: serviceEntry, + Suffix: w.Type, + RegistryType: w.Type, + RegistryName: w.Name, + DestinationRuleWrapper: destinationRuleWrapper, }) w.UpdateService() } @@ -96,7 +125,7 @@ func (w *watcher) Stop() { w.mutex.Lock() defer w.mutex.Unlock() host := strings.Join([]string{w.Name, w.Type}, common.DotSeparator) - w.cache.DeleteServiceEntryWrapper(host) + w.cache.DeleteServiceWrapper(host) w.Ready(false) } @@ -146,8 +175,8 @@ func (w *watcher) generateServiceEntry(host string) *v1alpha3.ServiceEntry { var ports []*v1alpha3.ServicePort ports = append(ports, &v1alpha3.ServicePort{ Number: w.Port, - Name: "http", - Protocol: string(protocol.HTTP), + Name: w.Protocol, + Protocol: string(common.ParseProtocol(w.Protocol)), }) se := &v1alpha3.ServiceEntry{ Hosts: []string{host}, @@ -163,6 +192,34 @@ func (w *watcher) generateServiceEntry(host string) *v1alpha3.ServiceEntry { return se } +func (w *watcher) generateDestinationRule(se *v1alpha3.ServiceEntry) *v1alpha3.DestinationRule { + if !common.Protocol(se.Ports[0].Protocol).IsHTTPS() { + return nil + } + sni := w.Sni + // DNS type, automatically sets SNI based on domain name. + if sni == "" && w.Type == string(registry.DNS) && len(se.Endpoints) == 1 { + sni = w.Domain + } + return &v1alpha3.DestinationRule{ + Host: se.Hosts[0], + TrafficPolicy: &v1alpha3.TrafficPolicy{ + PortLevelSettings: []*v1alpha3.TrafficPolicy_PortTrafficPolicy{ + &v1alpha3.TrafficPolicy_PortTrafficPolicy{ + Port: &v1alpha3.PortSelector{ + Number: se.Ports[0].Number, + }, + Tls: &v1alpha3.ClientTLSSettings{ + Mode: v1alpha3.ClientTLSSettings_SIMPLE, + Sni: sni, + }, + }, + }, + }, + } + +} + func (w *watcher) GetRegistryType() string { return w.RegistryConfig.Type } diff --git a/registry/eureka/watcher.go b/registry/eureka/watcher.go index 7c6d5c27ca..280c4c27e3 100644 --- a/registry/eureka/watcher.go +++ b/registry/eureka/watcher.go @@ -147,7 +147,7 @@ func (w *watcher) Stop() { log.Errorf("Failed to unsubscribe service : %v", serviceName) continue } - w.cache.DeleteServiceEntryWrapper(makeHost(serviceName)) + w.cache.DeleteServiceWrapper(makeHost(serviceName)) } w.UpdateService() } @@ -203,17 +203,18 @@ func (w *watcher) subscribe(service *fargo.Application) error { if err != nil { return err } - w.cache.UpdateServiceEntryWrapper(makeHost(service.Name), &memory.ServiceEntryWrapper{ + w.cache.UpdateServiceWrapper(makeHost(service.Name), &memory.ServiceWrapper{ ServiceName: service.Name, ServiceEntry: se, Suffix: suffix, RegistryType: w.Type, + RegistryName: w.Name, }) return nil } if w.updateCacheWhenEmpty { - w.cache.DeleteServiceEntryWrapper(makeHost(service.Name)) + w.cache.DeleteServiceWrapper(makeHost(service.Name)) } return nil diff --git a/registry/memory/cache.go b/registry/memory/cache.go index c0cacf7497..9e72a99778 100644 --- a/registry/memory/cache.go +++ b/registry/memory/cache.go @@ -24,26 +24,28 @@ import ( "istio.io/pkg/log" "github.com/alibaba/higress/pkg/common" + ingress "github.com/alibaba/higress/pkg/ingress/kube/common" ) type Cache interface { - UpdateServiceEntryWrapper(service string, data *ServiceEntryWrapper) - DeleteServiceEntryWrapper(service string) + UpdateServiceWrapper(service string, data *ServiceWrapper) + DeleteServiceWrapper(service string) PurgeStaleService() UpdateServiceEntryEndpointWrapper(service, ip, regionId, zoneId, protocol string, labels map[string]string) GetServiceByEndpoints(requestVersions, endpoints map[string]bool, versionKey string, protocol common.Protocol) map[string][]string GetAllServiceEntry() []*v1alpha3.ServiceEntry - GetAllServiceEntryWrapper() []*ServiceEntryWrapper - GetIncrementalServiceEntryWrapper() (updatedList []*ServiceEntryWrapper, deletedList []*ServiceEntryWrapper) + GetAllServiceWrapper() []*ServiceWrapper + GetAllDestinationRuleWrapper() []*ingress.WrapperDestinationRule + GetIncrementalServiceWrapper() (updatedList []*ServiceWrapper, deletedList []*ServiceWrapper) RemoveEndpointByIp(ip string) } func NewCache() Cache { return &store{ mux: &sync.RWMutex{}, - sew: make(map[string]*ServiceEntryWrapper), - toBeUpdated: make([]*ServiceEntryWrapper, 0), - toBeDeleted: make([]*ServiceEntryWrapper, 0), + sew: make(map[string]*ServiceWrapper), + toBeUpdated: make([]*ServiceWrapper, 0), + toBeDeleted: make([]*ServiceWrapper, 0), ip2services: make(map[string]map[string]bool), deferedDelete: make(map[string]struct{}), } @@ -51,9 +53,9 @@ func NewCache() Cache { type store struct { mux *sync.RWMutex - sew map[string]*ServiceEntryWrapper - toBeUpdated []*ServiceEntryWrapper - toBeDeleted []*ServiceEntryWrapper + sew map[string]*ServiceWrapper + toBeUpdated []*ServiceWrapper + toBeDeleted []*ServiceWrapper ip2services map[string]map[string]bool deferedDelete map[string]struct{} } @@ -94,7 +96,7 @@ func (s *store) UpdateServiceEntryEndpointWrapper(service, ip, regionId, zoneId, return } -func (s *store) UpdateServiceEntryWrapper(service string, data *ServiceEntryWrapper) { +func (s *store) UpdateServiceWrapper(service string, data *ServiceWrapper) { s.mux.Lock() defer s.mux.Unlock() @@ -116,7 +118,7 @@ func (s *store) UpdateServiceEntryWrapper(service string, data *ServiceEntryWrap log.Infof("ServiceEntry updated, host:%s", service) } -func (s *store) DeleteServiceEntryWrapper(service string) { +func (s *store) DeleteServiceWrapper(service string) { s.mux.Lock() defer s.mux.Unlock() @@ -199,31 +201,46 @@ func (s *store) GetAllServiceEntry() []*v1alpha3.ServiceEntry { return seList } -// GetAllServiceEntryWrapper get all ServiceEntryWrapper in the store for xds push -func (s *store) GetAllServiceEntryWrapper() []*ServiceEntryWrapper { +// GetAllServiceWrapper get all ServiceWrapper in the store for xds push +func (s *store) GetAllServiceWrapper() []*ServiceWrapper { s.mux.RLock() defer s.mux.RUnlock() defer s.cleanUpdateAndDeleteArray() - sewList := make([]*ServiceEntryWrapper, 0) + sewList := make([]*ServiceWrapper, 0) for _, serviceEntryWrapper := range s.sew { sewList = append(sewList, serviceEntryWrapper.DeepCopy()) } return sewList } -// GetIncrementalServiceEntryWrapper get incremental ServiceEntryWrapper in the store for xds push -func (s *store) GetIncrementalServiceEntryWrapper() ([]*ServiceEntryWrapper, []*ServiceEntryWrapper) { +// GetAllDestinationRuleWrapper get all DestinationRuleWrapper in the store for xds push +func (s *store) GetAllDestinationRuleWrapper() []*ingress.WrapperDestinationRule { s.mux.RLock() defer s.mux.RUnlock() defer s.cleanUpdateAndDeleteArray() - updatedList := make([]*ServiceEntryWrapper, 0) + drwList := make([]*ingress.WrapperDestinationRule, 0) + for _, serviceEntryWrapper := range s.sew { + if serviceEntryWrapper.DestinationRuleWrapper != nil { + drwList = append(drwList, serviceEntryWrapper.DeepCopy().DestinationRuleWrapper) + } + } + return drwList +} + +// GetIncrementalServiceWrapper get incremental ServiceWrapper in the store for xds push +func (s *store) GetIncrementalServiceWrapper() ([]*ServiceWrapper, []*ServiceWrapper) { + s.mux.RLock() + defer s.mux.RUnlock() + defer s.cleanUpdateAndDeleteArray() + + updatedList := make([]*ServiceWrapper, 0) for _, serviceEntryWrapper := range s.toBeUpdated { updatedList = append(updatedList, serviceEntryWrapper.DeepCopy()) } - deletedList := make([]*ServiceEntryWrapper, 0) + deletedList := make([]*ServiceWrapper, 0) for _, serviceEntryWrapper := range s.toBeDeleted { deletedList = append(deletedList, serviceEntryWrapper.DeepCopy()) } @@ -236,7 +253,7 @@ func (s *store) cleanUpdateAndDeleteArray() { s.toBeDeleted = nil } -func (s *store) updateIpMap(service string, data *ServiceEntryWrapper) { +func (s *store) updateIpMap(service string, data *ServiceWrapper) { for _, ep := range data.ServiceEntry.Endpoints { if s.ip2services[ep.Address] == nil { s.ip2services[ep.Address] = make(map[string]bool) diff --git a/registry/memory/model.go b/registry/memory/model.go index 3a3c3f7380..3d452209e0 100644 --- a/registry/memory/model.go +++ b/registry/memory/model.go @@ -18,27 +18,37 @@ import ( "time" "istio.io/api/networking/v1alpha3" + + "github.com/alibaba/higress/pkg/ingress/kube/common" ) -type ServiceEntryWrapper struct { - ServiceName string - ServiceEntry *v1alpha3.ServiceEntry - Suffix string - RegistryType string - createTime time.Time +type ServiceWrapper struct { + ServiceName string + ServiceEntry *v1alpha3.ServiceEntry + DestinationRuleWrapper *common.WrapperDestinationRule + Suffix string + RegistryType string + RegistryName string + createTime time.Time } -func (sew *ServiceEntryWrapper) DeepCopy() *ServiceEntryWrapper { - return &ServiceEntryWrapper{ - ServiceEntry: sew.ServiceEntry.DeepCopy(), - createTime: sew.GetCreateTime(), +func (sew *ServiceWrapper) DeepCopy() *ServiceWrapper { + res := &ServiceWrapper{} + res = sew + res.ServiceEntry = sew.ServiceEntry.DeepCopy() + res.createTime = sew.GetCreateTime() + + if sew.DestinationRuleWrapper != nil { + res.DestinationRuleWrapper = sew.DestinationRuleWrapper + res.DestinationRuleWrapper.DestinationRule = sew.DestinationRuleWrapper.DestinationRule.DeepCopy() } + return res } -func (sew *ServiceEntryWrapper) SetCreateTime(createTime time.Time) { +func (sew *ServiceWrapper) SetCreateTime(createTime time.Time) { sew.createTime = createTime } -func (sew *ServiceEntryWrapper) GetCreateTime() time.Time { +func (sew *ServiceWrapper) GetCreateTime() time.Time { return sew.createTime } diff --git a/registry/nacos/v2/watcher.go b/registry/nacos/v2/watcher.go index b51ed7d6e1..c58a092042 100644 --- a/registry/nacos/v2/watcher.go +++ b/registry/nacos/v2/watcher.go @@ -66,7 +66,7 @@ type watcher struct { isStop bool addrProvider *address.NacosAddressProvider updateCacheWhenEmpty bool - nacosClientConfig *constant.ClientConfig + nacosClientConfig *constant.ClientConfig authOption provider.AuthOption } @@ -413,7 +413,7 @@ func (w *watcher) getSubscribeCallback(groupName string, serviceName string) fun if err != nil { if strings.Contains(err.Error(), "hosts is empty") { if w.updateCacheWhenEmpty { - w.cache.DeleteServiceEntryWrapper(host) + w.cache.DeleteServiceWrapper(host) } } else { log.Errorf("callback error:%v", err) @@ -425,11 +425,12 @@ func (w *watcher) getSubscribeCallback(groupName string, serviceName string) fun return } serviceEntry := w.generateServiceEntry(host, services) - w.cache.UpdateServiceEntryWrapper(host, &memory.ServiceEntryWrapper{ + w.cache.UpdateServiceWrapper(host, &memory.ServiceWrapper{ ServiceName: serviceName, ServiceEntry: serviceEntry, Suffix: suffix, RegistryType: w.Type, + RegistryName: w.Name, }) } } @@ -487,7 +488,7 @@ func (w *watcher) Stop() { suffix := strings.Join([]string{s[0], w.NacosNamespace, "nacos"}, common.DotSeparator) suffix = strings.ReplaceAll(suffix, common.Underscore, common.Hyphen) host := strings.Join([]string{s[1], suffix}, common.DotSeparator) - w.cache.DeleteServiceEntryWrapper(host) + w.cache.DeleteServiceWrapper(host) } w.isStop = true diff --git a/registry/nacos/watcher.go b/registry/nacos/watcher.go index 08e30e82bd..132bcc0db9 100644 --- a/registry/nacos/watcher.go +++ b/registry/nacos/watcher.go @@ -301,7 +301,7 @@ func (w *watcher) getSubscribeCallback(groupName string, serviceName string) fun if err != nil { if strings.Contains(err.Error(), "hosts is empty") { if w.updateCacheWhenEmpty { - w.cache.DeleteServiceEntryWrapper(host) + w.cache.DeleteServiceWrapper(host) } } else { log.Errorf("callback error:%v", err) @@ -312,11 +312,12 @@ func (w *watcher) getSubscribeCallback(groupName string, serviceName string) fun return } serviceEntry := w.generateServiceEntry(host, services) - w.cache.UpdateServiceEntryWrapper(host, &memory.ServiceEntryWrapper{ + w.cache.UpdateServiceWrapper(host, &memory.ServiceWrapper{ ServiceName: serviceName, ServiceEntry: serviceEntry, Suffix: suffix, RegistryType: w.Type, + RegistryName: w.Name, }) } } @@ -374,7 +375,7 @@ func (w *watcher) Stop() { suffix := strings.Join([]string{s[0], w.NacosNamespace, w.Type}, common.DotSeparator) suffix = strings.ReplaceAll(suffix, common.Underscore, common.Hyphen) host := strings.Join([]string{s[1], suffix}, common.DotSeparator) - w.cache.DeleteServiceEntryWrapper(host) + w.cache.DeleteServiceWrapper(host) } w.isStop = true close(w.stop) diff --git a/registry/reconcile/reconcile.go b/registry/reconcile/reconcile.go index 84e304c381..21806d9e80 100644 --- a/registry/reconcile/reconcile.go +++ b/registry/reconcile/reconcile.go @@ -211,6 +211,8 @@ func (r *Reconciler) generateWatcherFromRegistryConfig(registry *apiv1.RegistryC direct.WithName(registry.Name), direct.WithDomain(registry.Domain), direct.WithPort(registry.Port), + direct.WithProtocol(registry.Protocol), + direct.WithSNI(registry.Sni), ) case string(Eureka): watcher, err = eureka.NewWatcher( diff --git a/registry/zookeeper/watcher.go b/registry/zookeeper/watcher.go index d90cd8385d..27bf3110b5 100644 --- a/registry/zookeeper/watcher.go +++ b/registry/zookeeper/watcher.go @@ -331,11 +331,12 @@ func (w *watcher) DataChange(eventType Event) bool { se := w.generateServiceEntry(w.serviceEntry[host]) w.seMux.Unlock() - w.cache.UpdateServiceEntryWrapper(host, &memory.ServiceEntryWrapper{ + w.cache.UpdateServiceWrapper(host, &memory.ServiceWrapper{ ServiceName: host, ServiceEntry: se, Suffix: "zookeeper", RegistryType: w.Type, + RegistryName: w.Name, }) w.UpdateService() } else if eventType.Action == EventTypeDel { @@ -358,14 +359,15 @@ func (w *watcher) DataChange(eventType Event) bool { //todo update if len(se.Endpoints) == 0 { if !w.keepStaleWhenEmpty { - w.cache.DeleteServiceEntryWrapper(host) + w.cache.DeleteServiceWrapper(host) } } else { - w.cache.UpdateServiceEntryWrapper(host, &memory.ServiceEntryWrapper{ + w.cache.UpdateServiceWrapper(host, &memory.ServiceWrapper{ ServiceName: host, ServiceEntry: se, Suffix: "zookeeper", RegistryType: w.Type, + RegistryName: w.Name, }) } w.UpdateService() @@ -560,20 +562,22 @@ func (w *watcher) ChildToServiceEntry(children []string, interfaceName, zkPath s if !reflect.DeepEqual(value, config) { w.serviceEntry[host] = config //todo update or create serviceentry - w.cache.UpdateServiceEntryWrapper(host, &memory.ServiceEntryWrapper{ + w.cache.UpdateServiceWrapper(host, &memory.ServiceWrapper{ ServiceName: host, ServiceEntry: se, Suffix: "zookeeper", RegistryType: w.Type, + RegistryName: w.Name, }) } } else { w.serviceEntry[host] = config - w.cache.UpdateServiceEntryWrapper(host, &memory.ServiceEntryWrapper{ + w.cache.UpdateServiceWrapper(host, &memory.ServiceWrapper{ ServiceName: host, ServiceEntry: se, Suffix: "zookeeper", RegistryType: w.Type, + RegistryName: w.Name, }) } } @@ -708,7 +712,7 @@ func (w *watcher) Stop() { w.seMux.Lock() for key := range w.serviceEntry { - w.cache.DeleteServiceEntryWrapper(key) + w.cache.DeleteServiceWrapper(key) } w.UpdateService() w.seMux.Unlock() From 93317adbc78f81b5457348c9d070feba448f1bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E8=B4=A4=E6=B6=9B?= <601803023@qq.com> Date: Wed, 9 Oct 2024 17:22:31 +0800 Subject: [PATCH 05/32] feat: Support status sync for Gateway API resources (#1315) --- pkg/ingress/kube/gateway/controller.go | 11 +- pkg/ingress/kube/gateway/istio/context.go | 132 +++++-- pkg/ingress/kube/gateway/istio/controller.go | 4 +- pkg/ingress/kube/gateway/istio/conversion.go | 6 +- .../kube/gateway/istio/conversion_test.go | 346 +++++++++++------- .../istio/testdata/invalid.status.yaml.golden | 50 +-- .../kube/gateway/istio/testdata/invalid.yaml | 33 +- .../istio/testdata/invalid.yaml.golden | 19 - 8 files changed, 358 insertions(+), 243 deletions(-) diff --git a/pkg/ingress/kube/gateway/controller.go b/pkg/ingress/kube/gateway/controller.go index 9eaa15ddcc..b813053256 100644 --- a/pkg/ingress/kube/gateway/controller.go +++ b/pkg/ingress/kube/gateway/controller.go @@ -22,6 +22,7 @@ import ( kubecredentials "istio.io/istio/pilot/pkg/credentials/kube" "istio.io/istio/pilot/pkg/model" kubecontroller "istio.io/istio/pilot/pkg/serviceregistry/kube/controller" + "istio.io/istio/pilot/pkg/status" "istio.io/istio/pkg/config" "istio.io/istio/pkg/config/constants" "istio.io/istio/pkg/config/schema/collection" @@ -48,6 +49,7 @@ type gatewayController struct { store model.ConfigStoreController credsController credentials.MulticlusterController istioController *istiogateway.Controller + statusManager *status.Manager resourceUpToDate atomic.Bool } @@ -76,9 +78,10 @@ func NewController(client kube.Client, options common.Options) common.GatewayCon istioController.DefaultGatewaySelector = map[string]string{options.GatewaySelectorKey: options.GatewaySelectorValue} } + var statusManager *status.Manager = nil if options.EnableStatus { - // TODO: Add status sync support - //istioController.SetStatusWrite(true,) + statusManager = status.NewManager(store) + istioController.SetStatusWrite(true, statusManager) } else { IngressLog.Infof("Disable status update for cluster %s", clusterId) } @@ -87,6 +90,7 @@ func NewController(client kube.Client, options common.Options) common.GatewayCon store: store, credsController: credsController, istioController: istioController, + statusManager: statusManager, } } @@ -148,6 +152,9 @@ func (g *gatewayController) Run(stop <-chan struct{}) { }) go g.store.Run(stop) go g.istioController.Run(stop) + if g.statusManager != nil { + g.statusManager.Start(stop) + } } func (g *gatewayController) SetWatchErrorHandler(f func(r *cache.Reflector, err error)) error { diff --git a/pkg/ingress/kube/gateway/istio/context.go b/pkg/ingress/kube/gateway/istio/context.go index 53bc97f4d4..86d7230fd3 100644 --- a/pkg/ingress/kube/gateway/istio/context.go +++ b/pkg/ingress/kube/gateway/istio/context.go @@ -15,26 +15,37 @@ package istio import ( + "context" "fmt" "sort" - "strconv" "strings" networking "istio.io/api/networking/v1alpha3" "istio.io/istio/pilot/pkg/model" + serviceRegistryKube "istio.io/istio/pilot/pkg/serviceregistry/kube" "istio.io/istio/pkg/cluster" - "istio.io/istio/pkg/config/host" + "istio.io/istio/pkg/config/schema/gvk" + "istio.io/istio/pkg/kube" "istio.io/istio/pkg/util/sets" corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // GatewayContext contains a minimal subset of push context functionality to be exposed to GatewayAPIControllers type GatewayContext struct { ps *model.PushContext + // Start - Updated by Higress + client kube.Client + domainSuffix string + clusterID cluster.ID + // End - Updated by Higress } -func NewGatewayContext(ps *model.PushContext) GatewayContext { - return GatewayContext{ps} +// Start - Updated by Higress + +func NewGatewayContext(ps *model.PushContext, client kube.Client, domainSuffix string, clusterID cluster.ID) GatewayContext { + return GatewayContext{ps, client, domainSuffix, clusterID} } // ResolveGatewayInstances attempts to resolve all instances that a gateway will be exposed on. @@ -59,26 +70,20 @@ func (gc GatewayContext) ResolveGatewayInstances( foundExternal := sets.New[string]() foundPending := sets.New[string]() warnings := []string{} + + // Cache endpoints to reduce redundant queries + endpointsCache := make(map[string]*corev1.Endpoints) + for _, g := range gwsvcs { - svc, f := gc.ps.ServiceIndex.HostnameAndNamespace[host.Name(g)][namespace] - if !f { - otherNamespaces := []string{} - for ns := range gc.ps.ServiceIndex.HostnameAndNamespace[host.Name(g)] { - otherNamespaces = append(otherNamespaces, `"`+ns+`"`) // Wrap in quotes for output - } - if len(otherNamespaces) > 0 { - sort.Strings(otherNamespaces) - warnings = append(warnings, fmt.Sprintf("hostname %q not found in namespace %q, but it was found in namespace(s) %v", - g, namespace, strings.Join(otherNamespaces, ", "))) - } else { - warnings = append(warnings, fmt.Sprintf("hostname %q not found", g)) - } + svc := gc.GetService(g, namespace, gvk.Service.Kind) + if svc == nil { + warnings = append(warnings, fmt.Sprintf("hostname %q not found", g)) continue } - svcKey := svc.Key() + for port := range ports { - instances := gc.ps.ServiceInstancesByPort(svc, port, nil) - if len(instances) > 0 { + exists := checkServicePortExists(svc, port) + if exists { foundInternal.Insert(fmt.Sprintf("%s:%d", g, port)) if svc.Attributes.ClusterExternalAddresses.Len() > 0 { // Fetch external IPs from all clusters @@ -92,22 +97,30 @@ func (gc GatewayContext) ResolveGatewayInstances( } } } else { - instancesByPort := gc.ps.ServiceInstances(svcKey) - if instancesEmpty(instancesByPort) { + endpoints, ok := endpointsCache[g] + if !ok { + endpoints = gc.GetEndpoints(g, namespace) + endpointsCache[g] = endpoints + } + + if endpoints == nil { warnings = append(warnings, fmt.Sprintf("no instances found for hostname %q", g)) } else { - hintPort := sets.New[string]() - for _, instances := range instancesByPort { - for _, i := range instances { - if i.Endpoint.EndpointPort == uint32(port) { - hintPort.Insert(strconv.Itoa(i.ServicePort.Port)) + hintWorkloadPort := false + for _, subset := range endpoints.Subsets { + for _, subSetPort := range subset.Ports { + if subSetPort.Port == int32(port) { + hintWorkloadPort = true + break } } + if hintWorkloadPort { + break + } } - if hintPort.Len() > 0 { + if hintWorkloadPort { warnings = append(warnings, fmt.Sprintf( - "port %d not found for hostname %q (hint: the service port should be specified, not the workload port. Did you mean one of these ports: %v?)", - port, g, sets.SortedList(hintPort))) + "port %d not found for hostname %q (hint: the service port should be specified, not the workload port", port, g)) } else { warnings = append(warnings, fmt.Sprintf("port %d not found for hostname %q", port, g)) } @@ -119,15 +132,60 @@ func (gc GatewayContext) ResolveGatewayInstances( return sets.SortedList(foundInternal), sets.SortedList(foundExternal), sets.SortedList(foundPending), warnings } -func (gc GatewayContext) GetService(hostname, namespace string) *model.Service { - return gc.ps.ServiceIndex.HostnameAndNamespace[host.Name(hostname)][namespace] +func (gc GatewayContext) GetService(hostname, namespace, kind string) *model.Service { + // Currently only supports type Kubernetes Service + if kind != gvk.Service.Kind { + log.Warnf("Unsupported kind: expected 'Service', but got '%s'", kind) + return nil + } + serviceName := extractServiceName(hostname) + + svc, err := gc.client.Kube().CoreV1().Services(namespace).Get(context.TODO(), serviceName, metav1.GetOptions{}) + if err != nil { + if kerrors.IsNotFound(err) { + return nil + } + log.Errorf("failed to get service (serviceName: %s, namespace: %s): %v", serviceName, namespace, err) + return nil + } + + return serviceRegistryKube.ConvertService(*svc, gc.domainSuffix, gc.clusterID) } -func instancesEmpty(m map[int][]*model.ServiceInstance) bool { - for _, instances := range m { - if len(instances) > 0 { - return false +func (gc GatewayContext) GetEndpoints(hostname, namespace string) *corev1.Endpoints { + serviceName := extractServiceName(hostname) + + endpoints, err := gc.client.Kube().CoreV1().Endpoints(namespace).Get(context.TODO(), serviceName, metav1.GetOptions{}) + + if err != nil { + if kerrors.IsNotFound(err) { + return nil } + log.Errorf("failed to get endpoints (serviceName: %s, namespace: %s): %v", serviceName, namespace, err) + return nil } - return true + + return endpoints } + +func checkServicePortExists(svc *model.Service, port int) bool { + if svc == nil { + return false + } + for _, svcPort := range svc.Ports { + if port == svcPort.Port { + return true + } + } + return false +} + +func extractServiceName(hostName string) string { + parts := strings.Split(hostName, ".") + if len(parts) >= 4 { + return parts[0] + } + return "" +} + +// End - Updated by Higress diff --git a/pkg/ingress/kube/gateway/istio/controller.go b/pkg/ingress/kube/gateway/istio/controller.go index 5592701ad4..1b09776871 100644 --- a/pkg/ingress/kube/gateway/istio/controller.go +++ b/pkg/ingress/kube/gateway/istio/controller.go @@ -201,7 +201,9 @@ func (c *Controller) Reconcile(ps *model.PushContext) error { ReferenceGrant: referenceGrant, DefaultGatewaySelector: c.DefaultGatewaySelector, Domain: c.domain, - Context: NewGatewayContext(ps), + // Start - Updated by Higress + Context: NewGatewayContext(ps, c.client, c.domain, c.cluster), + // End - Updated by Higress } if !input.hasResources() { diff --git a/pkg/ingress/kube/gateway/istio/conversion.go b/pkg/ingress/kube/gateway/istio/conversion.go index 1e7bc31597..83c817a717 100644 --- a/pkg/ingress/kube/gateway/istio/conversion.go +++ b/pkg/ingress/kube/gateway/istio/conversion.go @@ -1168,7 +1168,7 @@ func buildDestination(ctx configContext, to k8s.BackendRef, ns string, enforceRe return nil, &ConfigError{Reason: InvalidDestination, Message: "serviceName invalid; the name of the Service must be used, not the hostname."} } hostname := fmt.Sprintf("%s.%s.svc.%s", to.Name, namespace, ctx.Domain) - if ctx.Context.GetService(hostname, namespace) == nil { + if ctx.Context.GetService(hostname, namespace, gvk.Service.Kind) == nil { invalidBackendErr = &ConfigError{Reason: InvalidDestinationNotFound, Message: fmt.Sprintf("backend(%s) not found", hostname)} } return &istio.Destination{ @@ -1192,7 +1192,7 @@ func buildDestination(ctx configContext, to k8s.BackendRef, ns string, enforceRe if strings.Contains(string(to.Name), ".") { return nil, &ConfigError{Reason: InvalidDestination, Message: "serviceName invalid; the name of the Service must be used, not the hostname."} } - if ctx.Context.GetService(hostname, namespace) == nil { + if ctx.Context.GetService(hostname, namespace, "ServiceImport") == nil { invalidBackendErr = &ConfigError{Reason: InvalidDestinationNotFound, Message: fmt.Sprintf("backend(%s) not found", hostname)} } return &istio.Destination{ @@ -1210,7 +1210,7 @@ func buildDestination(ctx configContext, to k8s.BackendRef, ns string, enforceRe return nil, &ConfigError{Reason: InvalidDestination, Message: "namespace may not be set with Hostname type"} } hostname := string(to.Name) - if ctx.Context.GetService(hostname, namespace) == nil { + if ctx.Context.GetService(hostname, namespace, "Hostname") == nil { invalidBackendErr = &ConfigError{Reason: InvalidDestinationNotFound, Message: fmt.Sprintf("backend(%s) not found", hostname)} } return &istio.Destination{ diff --git a/pkg/ingress/kube/gateway/istio/conversion_test.go b/pkg/ingress/kube/gateway/istio/conversion_test.go index 661295a6e1..986aecbc69 100644 --- a/pkg/ingress/kube/gateway/istio/conversion_test.go +++ b/pkg/ingress/kube/gateway/istio/conversion_test.go @@ -17,6 +17,7 @@ package istio import ( + "context" "fmt" "os" "reflect" @@ -25,6 +26,7 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "istio.io/istio/pilot/pkg/config/kube/crd" credentials "istio.io/istio/pilot/pkg/credentials/kube" "istio.io/istio/pilot/pkg/features" @@ -47,7 +49,8 @@ import ( "sigs.k8s.io/yaml" ) -var ports = []*model.Port{ +// Start - Updated by Higress +var ports = []corev1.ServicePort{ { Name: "http", Port: 80, @@ -64,232 +67,291 @@ var defaultGatewaySelector = map[string]string{ "higress": "higress-system-higress-gateway", } -var services = []*model.Service{ +var services = []corev1.Service{ { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ Name: "higress-gateway", Namespace: "higress-system", - ClusterExternalAddresses: &model.AddressMap{ - Addresses: map[cluster.ID][]string{ - "Kubernetes": {"1.2.3.4"}, - }, - }, }, - Ports: ports, - Hostname: "higress-gateway.higress-system.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + ExternalIPs: []string{"1.2.3.4"}, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example.com", Namespace: "higress-system", }, - Ports: ports, - Hostname: "example.com", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin", Namespace: "default", }, - Ports: ports, - Hostname: "httpbin.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-apple", Namespace: "apple", }, - Ports: ports, - Hostname: "httpbin-apple.apple.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-banana", Namespace: "banana", }, - Ports: ports, - Hostname: "httpbin-banana.banana.svc.domain.suffix", - }, - { - Attributes: model.ServiceAttributes{ - Namespace: "default", + Spec: corev1.ServiceSpec{ + Ports: ports, }, - Ports: ports, - Hostname: "httpbin-second.default.svc.domain.suffix", }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-second", Namespace: "default", }, - Ports: ports, - Hostname: "httpbin-wildcard.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-wildcard", Namespace: "default", }, - Ports: ports, - Hostname: "foo-svc.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-svc", Namespace: "default", }, - Ports: ports, - Hostname: "httpbin-other.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-other", Namespace: "default", }, - Ports: ports, - Hostname: "example.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", Namespace: "default", }, - Ports: ports, - Hostname: "echo.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "echo", Namespace: "default", }, - Ports: ports, - Hostname: "echo.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin", Namespace: "cert", }, - Ports: ports, - Hostname: "httpbin.cert.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-svc", Namespace: "service", }, - Ports: ports, - Hostname: "my-svc.service.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "google.com", Namespace: "default", }, - Ports: ports, - Hostname: "google.com", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc2", Namespace: "allowed-1", }, - Ports: ports, - Hostname: "svc2.allowed-1.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc2", Namespace: "allowed-2", }, - Ports: ports, - Hostname: "svc2.allowed-2.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc1", Namespace: "allowed-1", }, - Ports: ports, - Hostname: "svc1.allowed-1.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc3", Namespace: "allowed-2", }, - Ports: ports, - Hostname: "svc3.allowed-2.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc4", Namespace: "default", }, - Ports: ports, - Hostname: "svc4.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin", Namespace: "group-namespace1", }, - Ports: ports, - Hostname: "httpbin.group-namespace1.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin", Namespace: "group-namespace2", }, - Ports: ports, - Hostname: "httpbin.group-namespace2.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-zero", Namespace: "default", }, - Ports: ports, - Hostname: "httpbin-zero.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin", Namespace: "higress-system", }, - Ports: ports, - Hostname: "httpbin.higress-system.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-mirror", Namespace: "default", }, - Ports: ports, - Hostname: "httpbin-mirror.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-foo", Namespace: "default", }, - Ports: ports, - Hostname: "httpbin-foo.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-alt", Namespace: "default", }, - Ports: ports, - Hostname: "httpbin-alt.default.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "higress-controller", Namespace: "higress-system", }, - Ports: ports, - Hostname: "higress-controller.higress-system.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ + ObjectMeta: metav1.ObjectMeta{ + Name: "echo", Namespace: "higress-system", }, - Ports: ports, - Hostname: "higress-controller.higress-system.svc.domain.suffix", + Spec: corev1.ServiceSpec{ + Ports: ports, + }, }, { - Attributes: model.ServiceAttributes{ - Namespace: "higress-system", + ObjectMeta: metav1.ObjectMeta{ + Name: "httpbin-bad", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: ports, }, - Ports: ports, - Hostname: "echo.higress-system.svc.domain.suffix", }, +} + +var endpoints = []corev1.Endpoints{ { - Attributes: model.ServiceAttributes{ - Namespace: "default", + ObjectMeta: metav1.ObjectMeta{ + Name: "higress-gateway", + Namespace: "higress-system", + }, + Subsets: []corev1.EndpointSubset{ + { + Ports: []corev1.EndpointPort{ + { + Port: 8080, + }, + }, + }, }, - Ports: ports, - Hostname: "httpbin-bad.default.svc.domain.suffix", }, } +// End - Updated by Higress + var ( // https://github.com/kubernetes/kubernetes/blob/v1.25.4/staging/src/k8s.io/kubectl/pkg/cmd/create/create_secret_tls_test.go#L31 rsaCertPEM = `-----BEGIN CERTIFICATE----- @@ -364,6 +426,21 @@ func init() { func TestConvertResources(t *testing.T) { validator := crdvalidation.NewIstioValidator(t) + + // Start - Updated by Higress + client := kube.NewFakeClient() + for _, svc := range services { + if _, err := client.Kube().CoreV1().Services(svc.Namespace).Create(context.TODO(), &svc, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + for _, endpoint := range endpoints { + if _, err := client.Kube().CoreV1().Endpoints(endpoint.Namespace).Create(context.TODO(), &endpoint, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + // End - Updated by Higress + cases := []struct { name string }{ @@ -374,38 +451,23 @@ func TestConvertResources(t *testing.T) { {"weighted"}, {"zero"}, {"invalid"}, - {"multi-gateway"}, + // 目前仅支持 type 为 Hostname 和 ServiceImport + //{"multi-gateway"}, {"delegated"}, {"route-binding"}, {"reference-policy-tls"}, {"reference-policy-service"}, - {"serviceentry"}, + //{"serviceentry"}, {"alias"}, - {"mcs"}, + //{"mcs"}, {"route-precedence"}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { input := readConfig(t, fmt.Sprintf("testdata/%s.yaml", tt.name), validator) - // Setup a few preconfigured services - instances := []*model.ServiceInstance{} - for _, svc := range services { - instances = append(instances, &model.ServiceInstance{ - Service: svc, - ServicePort: ports[0], - Endpoint: &model.IstioEndpoint{EndpointPort: 8080}, - }, &model.ServiceInstance{ - Service: svc, - ServicePort: ports[1], - Endpoint: &model.IstioEndpoint{}, - }) - } - cg := v1alpha3.NewConfigGenTest(t, v1alpha3.TestOptions{ - Services: services, - Instances: instances, - }) + cg := v1alpha3.NewConfigGenTest(t, v1alpha3.TestOptions{}) kr := splitInput(t, input) - kr.Context = NewGatewayContext(cg.PushContext()) + kr.Context = NewGatewayContext(cg.PushContext(), client, "domain.suffix", "") output := convertResources(kr) output.AllowedReferences = AllowedReferences{} // Not tested here output.ReferencedNamespaceKeys = nil // Not tested here @@ -427,20 +489,20 @@ func TestConvertResources(t *testing.T) { assert.Equal(t, golden, output) - //outputStatus := getStatus(t, kr.GatewayClass, kr.Gateway, kr.HTTPRoute, kr.TLSRoute, kr.TCPRoute) - //goldenStatusFile := fmt.Sprintf("testdata/%s.status.yaml.golden", tt.name) - //if util.Refresh() { - // if err := os.WriteFile(goldenStatusFile, outputStatus, 0o644); err != nil { - // t.Fatal(err) - // } - //} - //goldenStatus, err := os.ReadFile(goldenStatusFile) - //if err != nil { - // t.Fatal(err) - //} - //if diff := cmp.Diff(string(goldenStatus), string(outputStatus)); diff != "" { - // t.Fatalf("Diff:\n%s", diff) - //} + outputStatus := getStatus(t, kr.GatewayClass, kr.Gateway, kr.HTTPRoute, kr.TLSRoute, kr.TCPRoute) + goldenStatusFile := fmt.Sprintf("testdata/%s.status.yaml.golden", tt.name) + if util.Refresh() { + if err := os.WriteFile(goldenStatusFile, outputStatus, 0o644); err != nil { + t.Fatal(err) + } + } + goldenStatus, err := os.ReadFile(goldenStatusFile) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(string(goldenStatus), string(outputStatus)); diff != "" { + t.Fatalf("Diff:\n%s", diff) + } }) } } @@ -593,7 +655,7 @@ spec: input := readConfigString(t, tt.config, validator) cg := v1alpha3.NewConfigGenTest(t, v1alpha3.TestOptions{}) kr := splitInput(t, input) - kr.Context = NewGatewayContext(cg.PushContext()) + kr.Context = NewGatewayContext(cg.PushContext(), nil, "", "") output := convertResources(kr) c := &Controller{ state: output, @@ -814,7 +876,7 @@ func BenchmarkBuildHTTPVirtualServices(b *testing.B) { validator := crdvalidation.NewIstioValidator(b) input := readConfig(b, "testdata/benchmark-httproute.yaml", validator) kr := splitInput(b, input) - kr.Context = NewGatewayContext(cg.PushContext()) + kr.Context = NewGatewayContext(cg.PushContext(), nil, "", "") ctx := configContext{ GatewayResources: kr, AllowedReferences: convertReferencePolicies(kr), diff --git a/pkg/ingress/kube/gateway/istio/testdata/invalid.status.yaml.golden b/pkg/ingress/kube/gateway/istio/testdata/invalid.status.yaml.golden index 1642b5f13e..632e80d567 100644 --- a/pkg/ingress/kube/gateway/istio/testdata/invalid.status.yaml.golden +++ b/pkg/ingress/kube/gateway/istio/testdata/invalid.status.yaml.golden @@ -128,8 +128,7 @@ status: - lastTransitionTime: fake message: 'Failed to assign to any requested addresses: port 8080 not found for hostname "higress-gateway.higress-system.svc.domain.suffix" (hint: the service - port should be specified, not the workload port. Did you mean one of these ports: - [80]?)' + port should be specified, not the workload port' reason: Invalid status: "False" type: Programmed @@ -163,26 +162,6 @@ status: --- apiVersion: gateway.networking.k8s.io/v1beta1 kind: Gateway -metadata: - creationTimestamp: null - name: invalid-gateway-address - namespace: invalid-gateway-address -spec: null -status: - conditions: - - lastTransitionTime: fake - message: only Hostname is supported, ignoring [1.2.3.4] - reason: UnsupportedAddress - status: "False" - type: Accepted - - lastTransitionTime: fake - message: Failed to assign to any requested addresses - reason: UnsupportedAddress - status: "False" - type: Programmed ---- -apiVersion: gateway.networking.k8s.io/v1beta1 -kind: Gateway metadata: creationTimestamp: null name: invalid-cert-kind @@ -477,4 +456,29 @@ status: namespace: higress-system sectionName: fake --- - +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: HTTPRoute +metadata: + creationTimestamp: null + name: no-backend + namespace: default +spec: null +status: + parents: + - conditions: + - lastTransitionTime: fake + message: Route was valid + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: fake + message: All references resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: higress.io/gateway-controller + parentRef: + group: "" + kind: Service + name: httpbin +--- diff --git a/pkg/ingress/kube/gateway/istio/testdata/invalid.yaml b/pkg/ingress/kube/gateway/istio/testdata/invalid.yaml index cddda93a2f..ac6793640d 100644 --- a/pkg/ingress/kube/gateway/istio/testdata/invalid.yaml +++ b/pkg/ingress/kube/gateway/istio/testdata/invalid.yaml @@ -55,22 +55,23 @@ spec: hostname: "*.example" port: 8080 # Test service has port 80 with targetPort 8080 protocol: HTTP ---- -apiVersion: gateway.networking.k8s.io/v1alpha2 -kind: Gateway -metadata: - name: invalid-gateway-address - namespace: invalid-gateway-address -spec: - gatewayClassName: higress - addresses: - - value: 1.2.3.4 - type: istio.io/FakeType - listeners: - - name: default - hostname: "*.domain.example" - port: 80 - protocol: HTTP +#--- +# Higress 仅支持 addresses type 为 Hostname +#apiVersion: gateway.networking.k8s.io/v1alpha2 +#kind: Gateway +#metadata: +# name: invalid-gateway-address +# namespace: invalid-gateway-address +#spec: +# gatewayClassName: higress +# addresses: +# - value: 1.2.3.4 +# type: istio.io/FakeType +# listeners: +# - name: default +# hostname: "*.domain.example" +# port: 80 +# protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1alpha2 kind: Gateway diff --git a/pkg/ingress/kube/gateway/istio/testdata/invalid.yaml.golden b/pkg/ingress/kube/gateway/istio/testdata/invalid.yaml.golden index f9b31f8ed0..466e230fdc 100644 --- a/pkg/ingress/kube/gateway/istio/testdata/invalid.yaml.golden +++ b/pkg/ingress/kube/gateway/istio/testdata/invalid.yaml.golden @@ -53,25 +53,6 @@ spec: protocol: HTTP --- apiVersion: networking.istio.io/v1alpha3 -kind: Gateway -metadata: - annotations: - internal.istio.io/parents: Gateway/invalid-gateway-address/default.invalid-gateway-address - creationTimestamp: null - name: invalid-gateway-address-istio-autogenerated-k8s-gateway-default - namespace: invalid-gateway-address -spec: - selector: - higress: higress-system-higress-gateway - servers: - - hosts: - - invalid-gateway-address/*.domain.example - port: - name: default - number: 80 - protocol: HTTP ---- -apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: annotations: From e126f3a888ffc46e07b4d19d8f0c711bd5236279 Mon Sep 17 00:00:00 2001 From: 007gzs <007gzs@gmail.com> Date: Wed, 9 Oct 2024 17:58:43 +0800 Subject: [PATCH 06/32] Rust wrappers (#1367) --- plugins/wasm-rust/Cargo.lock | 31 +++ plugins/wasm-rust/Cargo.toml | 2 + .../wasm-rust/extensions/demo-wasm/Cargo.lock | 263 ++++++++++++++++++ .../wasm-rust/extensions/demo-wasm/Cargo.toml | 15 + .../wasm-rust/extensions/demo-wasm/src/lib.rs | 203 ++++++++++++++ plugins/wasm-rust/src/cluster_wrapper.rs | 259 +++++++++++++++++ plugins/wasm-rust/src/lib.rs | 2 + plugins/wasm-rust/src/plugin_wrapper.rs | 208 +++++++++++--- plugins/wasm-rust/src/request_wrapper.rs | 82 ++++++ 9 files changed, 1033 insertions(+), 32 deletions(-) create mode 100644 plugins/wasm-rust/extensions/demo-wasm/Cargo.lock create mode 100644 plugins/wasm-rust/extensions/demo-wasm/Cargo.toml create mode 100644 plugins/wasm-rust/extensions/demo-wasm/src/lib.rs create mode 100644 plugins/wasm-rust/src/cluster_wrapper.rs create mode 100644 plugins/wasm-rust/src/request_wrapper.rs diff --git a/plugins/wasm-rust/Cargo.lock b/plugins/wasm-rust/Cargo.lock index 06dc3d05ea..899d559b0d 100644 --- a/plugins/wasm-rust/Cargo.lock +++ b/plugins/wasm-rust/Cargo.lock @@ -20,12 +20,24 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "getrandom" version = "0.2.15" @@ -51,6 +63,8 @@ dependencies = [ name = "higress-wasm-rust" version = "0.1.0" dependencies = [ + "http", + "lazy_static", "multimap", "proxy-wasm", "serde", @@ -58,12 +72,29 @@ dependencies = [ "uuid", ] +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.155" diff --git a/plugins/wasm-rust/Cargo.toml b/plugins/wasm-rust/Cargo.toml index ff7ab504ce..a1e5472c63 100644 --- a/plugins/wasm-rust/Cargo.toml +++ b/plugins/wasm-rust/Cargo.toml @@ -11,3 +11,5 @@ serde = "1.0" serde_json = "1.0" uuid = { version = "1.3.3", features = ["v4"] } multimap = "0" +http = "1" +lazy_static = "1" diff --git a/plugins/wasm-rust/extensions/demo-wasm/Cargo.lock b/plugins/wasm-rust/extensions/demo-wasm/Cargo.lock new file mode 100644 index 0000000000..85e2edaea3 --- /dev/null +++ b/plugins/wasm-rust/extensions/demo-wasm/Cargo.lock @@ -0,0 +1,263 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "demo-wasm" +version = "0.1.0" +dependencies = [ + "higress-wasm-rust", + "http", + "multimap", + "proxy-wasm", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "higress-wasm-rust" +version = "0.1.0" +dependencies = [ + "http", + "lazy_static", + "multimap", + "proxy-wasm", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.157" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374af5f94e54fa97cf75e945cce8a6b201e88a1a07e688b47dfd2a59c66dbd86" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "multimap" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +dependencies = [ + "serde", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proxy-wasm" +version = "0.2.2" +source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b" +dependencies = [ + "hashbrown", + "log", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.208" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.208" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6af063034fc1935ede7be0122941bafa9bacb949334d090b77ca98b5817c7d9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "uuid" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +dependencies = [ + "getrandom", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/plugins/wasm-rust/extensions/demo-wasm/Cargo.toml b/plugins/wasm-rust/extensions/demo-wasm/Cargo.toml new file mode 100644 index 0000000000..a517c2b531 --- /dev/null +++ b/plugins/wasm-rust/extensions/demo-wasm/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "demo-wasm" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib"] + +[dependencies] +higress-wasm-rust = { path = "../../", version = "0.1.0" } +proxy-wasm = { git="https://github.com/higress-group/proxy-wasm-rust-sdk", branch="main", version="0.2.2" } +serde = { version = "1.0", features = ["derive"] } +multimap = "*" +http = "*" \ No newline at end of file diff --git a/plugins/wasm-rust/extensions/demo-wasm/src/lib.rs b/plugins/wasm-rust/extensions/demo-wasm/src/lib.rs new file mode 100644 index 0000000000..55647a83cc --- /dev/null +++ b/plugins/wasm-rust/extensions/demo-wasm/src/lib.rs @@ -0,0 +1,203 @@ +use higress_wasm_rust::cluster_wrapper::DnsCluster; +use higress_wasm_rust::log::Log; +use higress_wasm_rust::plugin_wrapper::{ + HttpCallArgStorage, HttpCallbackFn, HttpContextWrapper, RootContextWrapper, +}; +use higress_wasm_rust::rule_matcher::{on_configure, RuleMatcher, SharedRuleMatcher}; +use http::Method; +use multimap::MultiMap; +use proxy_wasm::traits::{Context, HttpContext, RootContext}; +use proxy_wasm::types::{Bytes, ContextType, DataAction, HeaderAction, LogLevel}; + +use serde::Deserialize; +use std::cell::RefCell; +use std::ops::DerefMut; +use std::rc::Rc; +use std::time::Duration; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Trace); + proxy_wasm::set_root_context(|_|Box::new(DemoWasmRoot::new())); +}} + +const PLUGIN_NAME: &str = "demo-wasm"; + +#[derive(Default, Debug, Deserialize, Clone)] +struct DemoWasmConfig { + // 配置文件结构体 + test: String, +} + +fn format_body(body: Option>) -> String { + if let Some(bd) = &body { + if let Ok(b) = std::str::from_utf8(bd) { + return b.to_string(); + } + } + format!("{:?}", body) +} + +fn test_callback( + this: &mut DemoWasm, + status_code: u16, + headers: &MultiMap, + body: Option>, +) { + this.log.info(&format!( + "test_callback status_code:{}, headers: {:?}, body: {}", + status_code, + headers, + format_body(body) + )); + this.reset_http_request(); +} +struct DemoWasm { + // 每个请求对应的插件实例 + log: Log, + config: Option, + + arg_storage: HttpCallArgStorage>>, +} + +impl Context for DemoWasm {} +impl HttpContext for DemoWasm {} +impl HttpContextWrapper>> for DemoWasm { + fn log(&self) -> &Log { + &self.log + } + fn get_http_call_storage( + &mut self, + ) -> Option<&mut HttpCallArgStorage>>> { + Some(&mut self.arg_storage) + } + fn on_config(&mut self, config: &DemoWasmConfig) { + // 获取config + self.log.info(&format!("on_config {}", config.test)); + self.config = Some(config.clone()) + } + fn on_http_request_complete_headers( + &mut self, + headers: &MultiMap, + ) -> HeaderAction { + // 请求header获取完成回调 + self.log + .info(&format!("on_http_request_complete_headers {:?}", headers)); + HeaderAction::Continue + } + fn on_http_response_complete_headers( + &mut self, + headers: &MultiMap, + ) -> HeaderAction { + // 返回header获取完成回调 + self.log + .info(&format!("on_http_response_complete_headers {:?}", headers)); + HeaderAction::Continue + } + fn cache_request_body(&self) -> bool { + // 是否缓存请求body + true + } + fn cache_response_body(&self) -> bool { + // 是否缓存返回body + true + } + fn on_http_call_response_detail( + &mut self, + _token_id: u32, + arg: Box>, + status_code: u16, + headers: &MultiMap, + body: Option>, + ) { + arg(self, status_code, headers, body) + } + fn on_http_request_complete_body(&mut self, req_body: &Bytes) -> DataAction { + // 请求body获取完成回调 + self.log.info(&format!( + "on_http_request_complete_body {}", + String::from_utf8(req_body.clone()).unwrap_or("".to_string()) + )); + let cluster = DnsCluster::new("httpbin", "httpbin.org", 80); + if self + .http_call( + &cluster, + &Method::POST, + "http://httpbin.org/post", + MultiMap::new(), + Some("test_body".as_bytes()), + // Box::new(move |this, _status_code, _headers, _body| this.resume_http_request()), + Box::new(test_callback), + Duration::from_secs(5), + ) + .is_ok() + { + DataAction::StopIterationAndBuffer + } else { + self.log.info("http_call fail"); + DataAction::Continue + } + } + fn on_http_response_complete_body(&mut self, res_body: &Bytes) -> DataAction { + // 返回body获取完成回调 + self.log.info(&format!( + "on_http_response_complete_body {}", + String::from_utf8(res_body.clone()).unwrap_or("".to_string()) + )); + DataAction::Continue + } +} +struct DemoWasmRoot { + log: Log, + rule_matcher: SharedRuleMatcher, +} +impl DemoWasmRoot { + fn new() -> Self { + let log = Log::new(PLUGIN_NAME.to_string()); + log.info("DemoWasmRoot::new"); + DemoWasmRoot { + log, + rule_matcher: Rc::new(RefCell::new(RuleMatcher::default())), + } + } +} + +impl Context for DemoWasmRoot {} + +impl RootContext for DemoWasmRoot { + fn on_configure(&mut self, _plugin_configuration_size: usize) -> bool { + self.log.info("DemoWasmRoot::on_configure"); + on_configure( + self, + _plugin_configuration_size, + self.rule_matcher.borrow_mut().deref_mut(), + &self.log, + ) + } + fn create_http_context(&self, context_id: u32) -> Option> { + self.log.info(&format!( + "DemoWasmRoot::create_http_context({})", + context_id + )); + self.create_http_context_use_wrapper(context_id) + } + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } +} + +impl RootContextWrapper>> for DemoWasmRoot { + fn rule_matcher(&self) -> &SharedRuleMatcher { + &self.rule_matcher + } + + fn create_http_context_wrapper( + &self, + _context_id: u32, + ) -> Option>>>> { + Some(Box::new(DemoWasm { + config: None, + log: Log::new(PLUGIN_NAME.to_string()), + arg_storage: HttpCallArgStorage::new(), + })) + } +} diff --git a/plugins/wasm-rust/src/cluster_wrapper.rs b/plugins/wasm-rust/src/cluster_wrapper.rs new file mode 100644 index 0000000000..2891293fb3 --- /dev/null +++ b/plugins/wasm-rust/src/cluster_wrapper.rs @@ -0,0 +1,259 @@ +use crate::{internal::get_property, request_wrapper::get_request_host}; + +pub trait Cluster { + fn cluster_name(&self) -> String; + fn host_name(&self) -> String; +} +#[derive(Debug, Clone)] +pub struct RouteCluster { + host: String, +} +impl RouteCluster { + pub fn new(host: &str) -> Self { + RouteCluster { + host: host.to_string(), + } + } +} +impl Cluster for RouteCluster { + fn cluster_name(&self) -> String { + if let Some(res) = get_property(vec!["cluster_name"]) { + if let Ok(r) = String::from_utf8(res) { + return r; + } + } + String::new() + } + + fn host_name(&self) -> String { + if !self.host.is_empty() { + return self.host.clone(); + } + + get_request_host() + } +} + +#[derive(Debug, Clone)] +pub struct K8sCluster { + service_name: String, + namespace: String, + port: String, + version: String, + host: String, +} + +impl K8sCluster { + pub fn new(service_name: &str, namespace: &str, port: &str, version: &str, host: &str) -> Self { + K8sCluster { + service_name: service_name.to_string(), + namespace: namespace.to_string(), + port: port.to_string(), + version: version.to_string(), + host: host.to_string(), + } + } +} + +impl Cluster for K8sCluster { + fn cluster_name(&self) -> String { + format!( + "outbound|{}|{}|{}.{}.svc.cluster.local", + self.port, + self.version, + self.service_name, + if self.namespace.is_empty() { + "default" + } else { + &self.namespace + } + ) + } + + fn host_name(&self) -> String { + if self.host.is_empty() { + format!("{}.{}.svc.cluster.local", self.service_name, self.namespace) + } else { + self.host.clone() + } + } +} + +#[derive(Debug, Clone)] +pub struct NacosCluster { + service_name: String, + group: String, + namespace_id: String, + port: u16, + is_ext_registry: bool, + version: String, + host: String, +} + +impl NacosCluster { + pub fn new( + service_name: &str, + group: &str, + namespace_id: &str, + port: u16, + is_ext_registry: bool, + version: &str, + host: &str, + ) -> Self { + NacosCluster { + service_name: service_name.to_string(), + group: group.to_string(), + namespace_id: namespace_id.to_string(), + port, + is_ext_registry, + version: version.to_string(), + host: host.to_string(), + } + } +} +impl Cluster for NacosCluster { + fn cluster_name(&self) -> String { + let group = if self.group.is_empty() { + "DEFAULT-GROUP".to_string() + } else { + self.group.replace('_', "-") + }; + let tail = if self.is_ext_registry { + "nacos-ext" + } else { + "nacos" + }; + format!( + "outbound|{}|{}|{}.{}.{}.{}", + self.port, self.version, self.service_name, group, self.namespace_id, tail + ) + } + + fn host_name(&self) -> String { + if self.host.is_empty() { + self.service_name.clone() + } else { + self.host.clone() + } + } +} + +#[derive(Debug, Clone)] +pub struct StaticIpCluster { + service_name: String, + port: u16, + host: String, +} + +impl StaticIpCluster { + pub fn new(service_name: &str, port: u16, host: &str) -> Self { + StaticIpCluster { + service_name: service_name.to_string(), + port, + host: host.to_string(), + } + } +} +impl Cluster for StaticIpCluster { + fn cluster_name(&self) -> String { + format!("outbound|{}||{}.static", self.port, self.service_name) + } + + fn host_name(&self) -> String { + if self.host.is_empty() { + self.service_name.clone() + } else { + self.host.clone() + } + } +} + +#[derive(Debug, Clone)] +pub struct DnsCluster { + service_name: String, + domain: String, + port: u16, +} + +impl DnsCluster { + pub fn new(service_name: &str, domain: &str, port: u16) -> Self { + DnsCluster { + service_name: service_name.to_string(), + domain: domain.to_string(), + port, + } + } +} +impl Cluster for DnsCluster { + fn cluster_name(&self) -> String { + format!("outbound|{}||{}.dns", self.port, self.service_name) + } + + fn host_name(&self) -> String { + self.domain.clone() + } +} + +#[derive(Debug, Clone)] +pub struct ConsulCluster { + service_name: String, + datacenter: String, + port: u16, + host: String, +} + +impl ConsulCluster { + pub fn new(service_name: &str, datacenter: &str, port: u16, host: &str) -> Self { + ConsulCluster { + service_name: service_name.to_string(), + datacenter: datacenter.to_string(), + port, + host: host.to_string(), + } + } +} +impl Cluster for ConsulCluster { + fn cluster_name(&self) -> String { + format!( + "outbound|{}||{}.{}.consul", + self.port, self.service_name, self.datacenter + ) + } + + fn host_name(&self) -> String { + if self.host.is_empty() { + self.service_name.clone() + } else { + self.host.clone() + } + } +} + +#[derive(Debug, Clone)] +pub struct FQDNCluster { + fqdn: String, + host: String, + port: u16, +} + +impl FQDNCluster { + pub fn new(fqdn: &str, host: &str, port: u16) -> Self { + FQDNCluster { + fqdn: fqdn.to_string(), + host: host.to_string(), + port, + } + } +} +impl Cluster for FQDNCluster { + fn cluster_name(&self) -> String { + format!("outbound|{}||{}", self.port, self.fqdn) + } + fn host_name(&self) -> String { + if self.host.is_empty() { + self.fqdn.clone() + } else { + self.host.clone() + } + } +} diff --git a/plugins/wasm-rust/src/lib.rs b/plugins/wasm-rust/src/lib.rs index 1e38d0cb0b..3296ff648a 100644 --- a/plugins/wasm-rust/src/lib.rs +++ b/plugins/wasm-rust/src/lib.rs @@ -12,8 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod cluster_wrapper; pub mod error; mod internal; pub mod log; pub mod plugin_wrapper; +pub mod request_wrapper; pub mod rule_matcher; diff --git a/plugins/wasm-rust/src/plugin_wrapper.rs b/plugins/wasm-rust/src/plugin_wrapper.rs index ba4e0a2580..25d445f22f 100644 --- a/plugins/wasm-rust/src/plugin_wrapper.rs +++ b/plugins/wasm-rust/src/plugin_wrapper.rs @@ -12,15 +12,24 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::HashMap; +use std::time::Duration; + +use crate::cluster_wrapper::Cluster; +use crate::log::Log; use crate::rule_matcher::SharedRuleMatcher; +use http::{method::Method, Uri}; +use lazy_static::lazy_static; use multimap::MultiMap; -use proxy_wasm::hostcalls::log; use proxy_wasm::traits::{Context, HttpContext, RootContext}; -use proxy_wasm::types::LogLevel; -use proxy_wasm::types::{Action, Bytes, DataAction, HeaderAction}; +use proxy_wasm::types::{Action, Bytes, DataAction, HeaderAction, Status}; use serde::de::DeserializeOwned; -pub trait RootContextWrapper: RootContext +lazy_static! { + static ref LOG: Log = Log::new("plugin_wrapper".to_string()); +} + +pub trait RootContextWrapper: RootContext where PluginConfig: Default + DeserializeOwned + 'static + Clone, { @@ -39,11 +48,37 @@ where fn create_http_context_wrapper( &self, _context_id: u32, - ) -> Option>> { + ) -> Option>> { None } } -pub trait HttpContextWrapper: HttpContext { +pub type HttpCallbackFn = dyn FnOnce(&mut T, u16, &MultiMap, Option>); + +pub struct HttpCallArgStorage { + args: HashMap, +} +impl Default for HttpCallArgStorage { + fn default() -> Self { + Self::new() + } +} +impl HttpCallArgStorage { + pub fn new() -> Self { + HttpCallArgStorage { + args: HashMap::new(), + } + } + pub fn set(&mut self, token_id: u32, arg: HttpCallArg) { + self.args.insert(token_id, arg); + } + pub fn pop(&mut self, token_id: u32) -> Option { + self.args.remove(&token_id) + } +} +pub trait HttpContextWrapper: HttpContext { + fn log(&self) -> &Log { + &LOG + } fn on_config(&mut self, _config: &PluginConfig) {} fn on_http_request_complete_headers( &mut self, @@ -69,26 +104,96 @@ pub trait HttpContextWrapper: HttpContext { fn on_http_response_complete_body(&mut self, _res_body: &Bytes) -> DataAction { DataAction::Continue } + + #[allow(clippy::too_many_arguments)] + fn on_http_call_response_detail( + &mut self, + _token_id: u32, + _arg: HttpCallArg, + _status_code: u16, + _headers: &MultiMap, + _body: Option>, + ) { + } fn replace_http_request_body(&mut self, body: &[u8]) { self.set_http_request_body(0, i32::MAX as usize, body) } fn replace_http_response_body(&mut self, body: &[u8]) { self.set_http_response_body(0, i32::MAX as usize, body) } + + fn get_http_call_storage(&mut self) -> Option<&mut HttpCallArgStorage> { + None + } + + #[allow(clippy::too_many_arguments)] + fn http_call( + &mut self, + cluster: &dyn Cluster, + method: &Method, + raw_url: &str, + headers: MultiMap, + body: Option<&[u8]>, + arg: HttpCallArg, + timeout: Duration, + ) -> Result { + if let Ok(uri) = raw_url.parse::() { + let mut authority = cluster.host_name(); + if let Some(host) = uri.host() { + authority = host.to_string(); + } + let mut path = uri.path().to_string(); + if let Some(query) = uri.query() { + path = format!("{}?{}", path, query); + } + let mut headers_vec = Vec::new(); + for (k, v) in headers.iter() { + headers_vec.push((k.as_str(), v.as_str())); + } + headers_vec.push((":method", method.as_str())); + headers_vec.push((":path", &path)); + headers_vec.push((":authority", &authority)); + let ret = self.dispatch_http_call( + &cluster.cluster_name(), + headers_vec, + body, + Vec::new(), + timeout, + ); + + if let Ok(token_id) = ret { + if let Some(storage) = self.get_http_call_storage() { + storage.set(token_id, arg); + self.log().debug( + &format!( + "http call start, id: {}, cluster: {}, method: {}, url: {}, body: {:?}, timeout: {:?}", + token_id, cluster.cluster_name(), method.as_str(), raw_url, body, timeout + ) + ); + } else { + return Err(Status::InternalFailure); + } + } + ret + } else { + self.log().critical(&format!("invalid raw_url:{}", raw_url)); + Err(Status::ParseFailure) + } + } } -pub struct PluginHttpWrapper { +pub struct PluginHttpWrapper { req_headers: MultiMap, res_headers: MultiMap, req_body_len: usize, res_body_len: usize, config: Option, rule_matcher: SharedRuleMatcher, - http_content: Box>, + http_content: Box>, } -impl PluginHttpWrapper { +impl PluginHttpWrapper { pub fn new( rule_matcher: &SharedRuleMatcher, - http_content: Box>, + http_content: Box>, ) -> Self { PluginHttpWrapper { req_headers: MultiMap::new(), @@ -100,8 +205,15 @@ impl PluginHttpWrapper { http_content, } } + fn get_http_call_arg(&mut self, token_id: u32) -> Option { + if let Some(storage) = self.http_content.get_http_call_storage() { + storage.pop(token_id) + } else { + None + } + } } -impl Context for PluginHttpWrapper { +impl Context for PluginHttpWrapper { fn on_http_call_response( &mut self, token_id: u32, @@ -109,8 +221,50 @@ impl Context for PluginHttpWrapper { body_size: usize, num_trailers: usize, ) { - self.http_content - .on_http_call_response(token_id, num_headers, body_size, num_trailers) + if let Some(arg) = self.get_http_call_arg(token_id) { + let body = self.get_http_call_response_body(0, body_size); + let mut headers = MultiMap::new(); + let mut status_code = 502; + let mut normal_response = false; + for (k, v) in self.get_http_call_response_headers_bytes() { + match String::from_utf8(v) { + Ok(header_value) => { + if k == ":status" { + if let Ok(code) = header_value.parse::() { + status_code = code; + normal_response = true; + } else { + self.http_content + .log() + .error(&format!("failed to parse status: {}", header_value)); + status_code = 500; + } + } + headers.insert(k, header_value); + } + Err(_) => { + self.http_content.log().warn(&format!( + "http call response header contains non-ASCII characters header: {}", + k + )); + } + } + } + self.http_content.log().warn(&format!( + "http call end, id: {}, code: {}, normal: {}, body: {:?}", + token_id, status_code, normal_response, body + )); + self.http_content.on_http_call_response_detail( + token_id, + arg, + status_code, + &headers, + body, + ) + } else { + self.http_content + .on_http_call_response(token_id, num_headers, body_size, num_trailers) + } } fn on_grpc_call_response(&mut self, token_id: u32, status_code: u32, response_size: usize) { @@ -138,7 +292,7 @@ impl Context for PluginHttpWrapper { self.http_content.on_done() } } -impl HttpContext for PluginHttpWrapper +impl HttpContext for PluginHttpWrapper where PluginConfig: Default + DeserializeOwned + Clone, { @@ -152,15 +306,10 @@ where self.req_headers.insert(k, header_value); } Err(_) => { - log( - LogLevel::Warn, - format!( - "request http header contains non-ASCII characters header: {}", - k - ) - .as_str(), - ) - .unwrap(); + self.http_content.log().warn(&format!( + "request http header contains non-ASCII characters header: {}", + k + )); } } } @@ -212,15 +361,10 @@ where self.res_headers.insert(k, header_value); } Err(_) => { - log( - LogLevel::Warn, - format!( - "response http header contains non-ASCII characters header: {}", - k - ) - .as_str(), - ) - .unwrap(); + self.http_content.log().warn(&format!( + "response http header contains non-ASCII characters header: {}", + k + )); } } } diff --git a/plugins/wasm-rust/src/request_wrapper.rs b/plugins/wasm-rust/src/request_wrapper.rs new file mode 100644 index 0000000000..bc9624f6a9 --- /dev/null +++ b/plugins/wasm-rust/src/request_wrapper.rs @@ -0,0 +1,82 @@ +use proxy_wasm::hostcalls; + +use crate::internal; + +fn get_request_head(head: &str, log_flag: &str) -> String { + if let Some(value) = internal::get_http_request_header(head) { + value + } else { + hostcalls::log( + proxy_wasm::types::LogLevel::Error, + &format!("get request {} failed", log_flag), + ) + .unwrap(); + String::new() + } +} +pub fn get_request_scheme() -> String { + get_request_head(":scheme", "head") +} + +pub fn get_request_host() -> String { + get_request_head(":authority", "host") +} + +pub fn get_request_path() -> String { + get_request_head(":path", "path") +} + +pub fn get_request_method() -> String { + get_request_head(":method", "method") +} + +pub fn is_binary_request_body() -> bool { + if let Some(content_type) = internal::get_http_request_header("content-type") { + if content_type.contains("octet-stream") || content_type.contains("grpc") { + return true; + } + } + if let Some(encoding) = internal::get_http_request_header("content-encoding") { + if !encoding.is_empty() { + return true; + } + } + false +} + +pub fn is_binary_response_body() -> bool { + if let Some(content_type) = internal::get_http_response_header("content-type") { + if content_type.contains("octet-stream") || content_type.contains("grpc") { + return true; + } + } + if let Some(encoding) = internal::get_http_response_header("content-encoding") { + if !encoding.is_empty() { + return true; + } + } + false +} +pub fn has_request_body() -> bool { + let content_type = internal::get_http_request_header("content-type"); + let content_length_str = internal::get_http_request_header("content-length"); + let transfer_encoding = internal::get_http_request_header("transfer-encoding"); + hostcalls::log( + proxy_wasm::types::LogLevel::Debug, + &format!( + "check has request body: content_type:{:?}, content_length_str:{:?}, transfer_encoding:{:?}", + content_type, content_length_str, transfer_encoding + ) + ).unwrap(); + if !content_type.is_some_and(|x| !x.is_empty()) { + return true; + } + if let Some(cl) = content_length_str { + if let Ok(content_length) = cl.parse::() { + if content_length > 0 { + return true; + } + } + } + transfer_encoding.is_some_and(|x| x == "chunked") +} From f20c48e960d3ad2319c6687aec507a1cd6a76210 Mon Sep 17 00:00:00 2001 From: Kent Dong Date: Wed, 9 Oct 2024 18:00:44 +0800 Subject: [PATCH 07/32] fix: Update the envoy.yaml template used by hgctl (#1370) --- hgctl/pkg/plugin/test/templates.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hgctl/pkg/plugin/test/templates.go b/hgctl/pkg/plugin/test/templates.go index 8e1b06ef85..21e596e263 100644 --- a/hgctl/pkg/plugin/test/templates.go +++ b/hgctl/pkg/plugin/test/templates.go @@ -114,6 +114,8 @@ static_resources: value: | {{ .JSONExample }} - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: httpbin connect_timeout: 30s From e26a2a37d7ea1d55eaed9a342a70de359f0d7df0 Mon Sep 17 00:00:00 2001 From: lixf311 Date: Wed, 9 Oct 2024 19:52:16 +0800 Subject: [PATCH 08/32] feat: add api-workflow plugin (#1229) --- .../extensions/api-workflow/Dockerfile | 2 + .../wasm-go/extensions/api-workflow/README.md | 384 ++++++++++++++++++ .../wasm-go/extensions/api-workflow/go.mod | 21 + .../wasm-go/extensions/api-workflow/go.sum | 23 ++ .../extensions/api-workflow/img/dag.png | Bin 0 -> 55721 bytes .../extensions/api-workflow/img/img.png | Bin 0 -> 97642 bytes .../wasm-go/extensions/api-workflow/main.go | 307 ++++++++++++++ .../api-workflow/utils/conditional.go | 116 ++++++ .../api-workflow/utils/conditional_test.go | 100 +++++ .../extensions/api-workflow/utils/http.go | 45 ++ .../extensions/api-workflow/utils/tools.go | 7 + .../api-workflow/workflow/workflow.go | 325 +++++++++++++++ 12 files changed, 1330 insertions(+) create mode 100644 plugins/wasm-go/extensions/api-workflow/Dockerfile create mode 100644 plugins/wasm-go/extensions/api-workflow/README.md create mode 100644 plugins/wasm-go/extensions/api-workflow/go.mod create mode 100644 plugins/wasm-go/extensions/api-workflow/go.sum create mode 100644 plugins/wasm-go/extensions/api-workflow/img/dag.png create mode 100644 plugins/wasm-go/extensions/api-workflow/img/img.png create mode 100644 plugins/wasm-go/extensions/api-workflow/main.go create mode 100644 plugins/wasm-go/extensions/api-workflow/utils/conditional.go create mode 100644 plugins/wasm-go/extensions/api-workflow/utils/conditional_test.go create mode 100644 plugins/wasm-go/extensions/api-workflow/utils/http.go create mode 100644 plugins/wasm-go/extensions/api-workflow/utils/tools.go create mode 100644 plugins/wasm-go/extensions/api-workflow/workflow/workflow.go diff --git a/plugins/wasm-go/extensions/api-workflow/Dockerfile b/plugins/wasm-go/extensions/api-workflow/Dockerfile new file mode 100644 index 0000000000..9b084e0596 --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/Dockerfile @@ -0,0 +1,2 @@ +FROM scratch +COPY main.wasm plugin.wasm \ No newline at end of file diff --git a/plugins/wasm-go/extensions/api-workflow/README.md b/plugins/wasm-go/extensions/api-workflow/README.md new file mode 100644 index 0000000000..9819b36884 --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/README.md @@ -0,0 +1,384 @@ +--- +title: API 工作流 +keywords: [ API工作流 ] +description: API 工作流插件配置参考 +--- +## 功能说明 +`api工作流 `实现了可编排的API workflow 插件,支持根据配置定义生成DAG并执行工作流 + +## 配置说明 + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 备注 | +|----------|--------|------| --- |--------|----| +| workflow | object | 必填 | | DAG的定义 | | +| env | object | 选填 | | 一些环境变量 | | + +`env`object的配置字段说明如下: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 备注 | +|----------|--------|------|------|-----------|--| +| timeout | int | 选填 | 5000 | 每次请求的过期时间 | 单位是毫秒(ms) | +| max_depth | int | 选填 | 100 | 工作流最大迭代次数 | | + + +`workflow`object的配置字段说明如下: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 备注 | +|-------|----------------------| ---- | --- |-----------|----| +| nodes | array of node object | 选填 | | DAG的定义的节点 | | +| edges | array of edge object | 必填 | | DAG的定义的边 | | + +`edge` object的配置字段说明如下: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|-------------| ------ | ---- | --- |------------------------------------------------| +| source | string | 必填 | - | 上一步的操作,必须是定义的node的name,或者初始化工作流的start | +| target | string | 必填 | - | 当前的操作,必须是定义的node的name,或者结束工作流的关键字 end continue | | +| conditional | string | 选填 | - | 这一步是否执行的判断条件 | + +`node` object的配置字段说明如下: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 备注 | +| --------------- |------------------------------------|---| --- |-------------------------------|-------------------------------| +| name | string | 必填 | - | node名称 | 全局唯一 | +| service_name | string | 必填 | - | higress配置的服务名称 | | +| service_port | int | 选填 | 80 | higress配置的服务端口 | | +| service_domain | string | 选填 | | higress配置的服务domain | | +| service_path | string | 必填 | | 请求的path | | +| service_headers | array of header object | 选填 | | 请求的头 | | +| service_body_replace_keys| array of bodyReplaceKeyPair object | 选填| 请求body模板替换键值对 | 用来构造请求| 如果为空,则直接使用service_body_tmpl请求 | +| service_body_tmpl | string | 选填 | | 请求的body模板 | | +| service_method | string | 必填 | | 请求的方法 | GET,POST | + +`header` object 的配置字段说明如下: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 备注 | +|-------|------------------------|---| --- |-----------| --------- | +| key | string | 必填 | - | 头文件的key | | +| value | string | 必填 | - | 头文件的value | | + +`bodyReplaceKeyPair` object 配置说明 + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 备注 | +|------|------------------------|---| --- |-----------|--| +| from | string | 必填 | - | 描述数据从哪获得 | | +| to | string | 必填 | - | 描述数据最后放到那 | | + + + +## 用法示例 + +我们把工作流抽象成DAG配置文件,加上控制流和数据流更方便的控制流程和构造请求。 + +![img](img/img.png) + + + +### DAG的定义 + +#### 边edge +描述操作如何编排 + +样例 +```yaml + edges: + - source: start + target: A + - source: start + target: B + - source: start + target: C + - source: A + target: D + - source: B + target: D + - source: C + target: D + - source: D + target: end + conditional: "gt {{D||check}} 0.9" + - source: D + target: E + conditional: "lt {{D||check}} 0.9" + - source: E + target: end +``` +#### 控制流 conditional 和 target +##### 分支 conditional +插件执行到conditional的定义不为空的步骤`edge`时,会根据表达式定义判断这步是否执行,如果判断为否,会跳过这个分支。 +表达式可使用参数,用{{xxx}}标注,具体定义见数据流`模板和变量` +支持比较表达式和例子如下: +`eq arg1 arg2`: arg1 == arg2时为true 不只是数字,支持string +`lt arg1 arg2`: arg1 < arg2时为true +`le arg1 arg2`: arg1 <= arg2时为true +`gt arg1 arg2`: arg1 > arg2时为true +`ge arg1 arg2`: arg1 >= arg2时为true +`and arg1 arg2`: arg1 && arg2 +`or arg1 arg2`: arg1 || arg2 +`contain arg1 arg2`: arg1 包含 arg2时为true +支持and 和 or的嵌套 比如 `and (eq 1 1) (or (contain hello hi) (lt 1 2))` + +##### 结束和执行工作流 target +当target为`name`,执行name的操作 +当target 为`end`,直接返回source的结果,结束工作流 +当target 为`continue`,结束工作流,将请求放行到下一个plugin + +#### 数据流 + +进入plugin的数据(request body),会根据构造模板json`node.service_body_tmpl`和`node.service_body_replace_keys`构造请求body,并把执行后结果存在key为`nodeName`的上下文里,只支持json格式的数据。 + +##### 模板和变量 +在工作流的配置文件中 +###### edge.conditional +配置文件的定义中,`edge.conditional` 支持模板和变量,方便根据数据流的数据来构建请求数据 +在模板里使用变量来代表数据和过滤。变量使用`{{str1||str2}}`包裹,使用`||`分隔,str1代表使用那个node的输出数据,str2代表如何取数据,过滤表达式基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串,`@all`代表全都要 + +例子 +```yaml +conditional: "lt {{D||check}} 0.9" +``` +node D 的返回值是 +```json +{"check": 0.99} +``` +解析后的表达式 `lt 0.99 0.9` + +###### node.service_body_tmpl 和 node.service_body_replace_keys +这组配置用来构造请求body,`node.service_body_tmpl`是模板json ,`node.service_body_replace_keys`用来描述如何填充模板json,是一个object的数组,from标识数据从哪里来,to表示填充的位置 +`from`是使用`str1||str2`的字符串,str1代表使用那个node的执行返回数据,str2代表如何取数据,表达式基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 +`to`标识数据放哪,表达式基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法来描述填充位置,使用的是sjson来拼接json,填充到`tool.service_body_tmpl` 的模板json里 +当`node.service_body_replace_keys`为空时,代表直接发送`node.service_body_tmpl` + +例子 +```yaml + service_body_tmpl: + embeddings: + result: "" + msg: "" + sk: "sk-xxxxxx" + service_body_replace_keys: + - to "embeddings.result" + from "A||output.embeddings.0.embedding" + - to "msg" + from "B||@all" +``` +`A`节点的输出是 +```json +{"embeddings": {"output":{"embeddings":[{"embedding":[0.014398524595686043],"text_index":0}]},"usage":{"total_tokens":12},"request_id":"2a5229bc-53d9-91ca-bce2-00ae5e01a1d3"}} +``` +`B`节点的输出是 +```json +["higress项目主仓库的github地址是什么"] +``` +根据 service_body_tmpl 和 service_body_replace_keys 构造的request body如下 +```json +{"embeddings":{"result":"[0.014398524595686043,......]"},"msg":["higress项目主仓库的github地址是什么"],"sk":"sk-xxxxxx"} +``` + + + +### node的定义 + +具体执行的单元,封装了httpCall,提供http的访问能力,获取各种api的能力。request body支持自主构建。 + +样例 +```yaml + nodes: + - name: "A" + service_domain: "dashscope.aliyuncs.com" + service_name: "dashscope" + service_port: 443 + service_path: "/api/v1/services/embeddings/text-embedding/text-embedding" + service_method: "POST" + service_body_tmpl: + model: "text-embedding-v2" + input: + texts: "" + parameters: + text_type: "query" + service_body_replace_keys: + - from: "start||messages.#(role==user)#.content" + to: "input.texts" + service_headers: + - key: "Authorization" + value: "Bearer sk-b98f462xxxxxxxx" + - key: "Content-Type" + value: "application/json" +``` +这是请求官方 text-embedding-v2模型的请求样例 具体请求可以看 https://help.aliyun.com/zh/dashscope/developer-reference/text-embedding-api-details?spm=a2c22.12281978.0.0.4d596ea2lRn8xW +### 一个工作流的例子 +从三个节点ABC获取信息,等到数据都就位了,再执行D。 并根据D的输出判断是否需要执行E还是直接结束 +![dag.png](img/dag.png) +start的返回值(请求plugin的body) +```json +{ + "model":"qwen-7b-chat-xft", + "frequency_penalty":0, + "max_tokens":800, + "stream":false, + "messages": [{"role":"user","content":"higress项目主仓库的github地址是什么"}], + "presence_penalty":0,"temperature":0.7,"top_p":0.95 +} +``` +A的返回值是 +```json +{ + "output":{ + "embeddings": [ + { + "text_index": 0, + "embedding": [-0.006929283495992422,-0.005336422007530928] + }, + { + "text_index": 1, + "embedding": [-0.006929283495992422,-0.005336422007530928] + }, + { + "text_index": 2, + "embedding": [-0.006929283495992422,-0.005336422007530928] + }, + { + "text_index": 3, + "embedding": [-0.006929283495992422,-0.005336422007530928] + } + ] + }, + "usage":{ + "total_tokens":12 + }, + "request_id":"d89c06fb-46a1-47b6-acb9-bfb17f814969" +} +``` +B的返回值是 +```json +{"llm":"this is b"} +``` +C的返回值是 +```json +{ + "get": "this is c" +} +``` +D的返回值是 +```json +{"check": 0.99, "llm":{}} +``` +E的返回值是 +```json +{"save": "ok", "date":{}} +``` +这个工作流的配置文件如下: +```yaml +env: + max_depth: 100 + timeout: 3000 +workflow: + edges: + - source: start + target: A + - source: start + target: B + - source: start + target: C + - source: A + target: D + - source: B + target: D + - source: C + target: D + - source: D + target: end + conditional: "lt {{D||check}} 0.9" + - source: D + target: E + conditional: "gt {{D||check}} 0.9" + - source: E + target: end + nodes: + - name: "A" + service_domain: "dashscope.aliyuncs.com" + service_name: "dashscope" + service_port: 443 + service_path: "/api/v1/services/embeddings/text-embedding/text-embedding" + service_method: "POST" + service_body_tmpl: + model: "text-embedding-v2" + input: + texts: "" + parameters: + text_type: "query" + service_body_replace_keys: + - from: "start||messages.#(role==user)#.content" + to: "input.texts" + service_headers: + - key: "Authorization" + value: "Bearer sk-b98f462xxxxxxxx" + - key: "Content-Type" + value: "application/json" + - name: "B" + service_body_tmpl: + embeddings: "default" + msg: "default request body" + sk: "sk-xxxxxx" + service_body_replace_keys: + service_headers: + - key: "AK" + value: "ak-xxxxxxxxxxxxxxxxxxxx" + - key: "Content-Type" + value: "application/json" + service_method: "POST" + service_name: "whoai.static" + service_path: "/llm" + service_port: 80 + - name: "C" + service_method: "GET" + service_name: "whoai.static" + service_path: "/get" + service_port: 80 + - name: "D" + service_headers: + service_method: "POST" + service_name: "whoai.static" + service_path: "/check_cache" + service_port: 80 + service_body_tmpl: + A_result: "" + B_result: "" + C_result: "" + service_body_replace_keys: + - from: "A||output.embeddings.0.embedding.0" + to: "A_result" + - from: "B||llm" + to: "B_result" + - from: "C||get" + to: "C_result" + - name: "E" + service_method: "POST" + service_name: "whoai.static" + service_path: "/save_cache" + service_port: 80 + service_body_tmpl: + save: "" + service_body_replace_keys: + - from: "D||llm" + to: "save" +``` +执行请求 +```bash +curl -v '127.0.0.1:8080' -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json'--data-raw '{"model":"qwen-7b-chat-xft","frequency_penalty":0,"max_tokens":800,"stream":false,"messages":[{"role":"user","content":"higress项目主仓库的github地址是什么"}],"presence_penalty":0,"temperature":0.7,"top_p":0.95}' +``` + +执行后的简略debug日志,可以看到工作流等到前置的ABC流程执行完毕后,根据返回值构建了D的body` {"A_result":0.007155838584362588,"B_result":"this is b","C_result":"this is c"}`;执行D后,根据D的返回值`{"check": 0.99, "llm":{}}`进行条件判断,最终继续执行了E`gt 0.99 0.9`,然后结束流程 +```bash +[api-workflow] workflow exec task,source is start,target is A, body is {"input":{"texts":["higress项目主仓库的github地址是什么"]},"model":"text-embedding-v2","parameters":{"text_type":"query"}},header is [[Authorization Bearer sk-b98f4628125xxxxxxxxxxxxxxxx] [Content-Type application/json]] +[api-workflow] workflow exec task,source is start,target is B, body is {"embeddings":"default","msg":"default request body","sk":"sk-xxxxxx"},header is [[AK ak-xxxxxxxxxxxxxxxxxxxx] [Content-Type application/json]] +[api-workflow] workflow exec task,source is start,target is C, body is ,header is [] +[api-workflow] source is B,target is D,stauts is map[A:0 B:0 C:0 D:2 E:1] +[api-workflow] source is C,target is D,stauts is map[A:0 B:0 C:0 D:1 E:1] +[api-workflow] source is A,target is D,stauts is map[A:0 B:0 C:0 D:0 E:1] +[api-workflow] workflow exec task,source is A,target is D, body is,header is [] +[api-workflow] source is D,target is end,workflow is pass +[api-workflow] source is D,target is E,stauts is map[A:0 B:0 C:0 D:0 E:0] +[api-workflow] workflow exec task,source is D,target is E, body is {"save":"{\"A_result\":0.007155838584362588,\"B_result\":\"this is b\",\"C_result\":\"this is c\"}"},header is [] +[api-workflow] source is E,target is end,workflow is end +``` diff --git a/plugins/wasm-go/extensions/api-workflow/go.mod b/plugins/wasm-go/extensions/api-workflow/go.mod new file mode 100644 index 0000000000..c3073a8c8b --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/go.mod @@ -0,0 +1,21 @@ +module api-workflow + +go 1.19 + +replace github.com/alibaba/higress/plugins/wasm-go => ../.. + +require ( + github.com/alibaba/higress/plugins/wasm-go v0.0.0 + github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f + github.com/tidwall/gjson v1.14.3 + github.com/tidwall/sjson v1.2.5 +) + +require ( + github.com/google/uuid v1.3.0 // indirect + github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect + github.com/magefile/mage v1.14.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/resp v0.1.1 // indirect +) diff --git a/plugins/wasm-go/extensions/api-workflow/go.sum b/plugins/wasm-go/extensions/api-workflow/go.sum new file mode 100644 index 0000000000..2995e01db7 --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/go.sum @@ -0,0 +1,23 @@ +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-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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +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= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +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/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/plugins/wasm-go/extensions/api-workflow/img/dag.png b/plugins/wasm-go/extensions/api-workflow/img/dag.png new file mode 100644 index 0000000000000000000000000000000000000000..92a36a9f7e3fbce75b5d8fe47d53575614618815 GIT binary patch literal 55721 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 z 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 literal 0 HcmV?d00001 diff --git a/plugins/wasm-go/extensions/api-workflow/img/img.png b/plugins/wasm-go/extensions/api-workflow/img/img.png new file mode 100644 index 0000000000000000000000000000000000000000..fcb2c659d54671abec777af7f6236a3a46161dc4 GIT binary patch literal 97642 zcma&OWk6M17dE;T0VP!g*`%U$r+}1zpmYm}vQ;`n8fgVVDMjh-5D-LCTDns}K)M_0 z^p3@O&->nczds*8&cV&zYt1$1m}5NSd7io7sVK=3;!)tCP$^$Cv;ao5-MZ7hoEZ(XwXr~H*~SN6@gnIk2CE#F~E z$$Qz}k=3R6;`76g6&9)?=T+5MZvG0d95DXU8QEza+mR8Yl-|3v?gdVO|6{r@vu@{;ywBp*JuxdR*x$bp85udU|?x zc6L_QrF#FXR-@&?Q)fcb4|mrZqdtU(GdA{@x$J(SdYEgWUg5g07L*en9DKd8-(`C- zlyy0i?N^>D-)ARcM^7>O77zdnLrAD7>@l? zKlN<~JA3<;-(|ZoLblWA@SIKc^~cU&;Sf*?ShPiOPc^=e7qEwB-9J=|NN|(#bn!Z;mVsgZ=!Emg^|oti@9nmB^0|IItUewCQA8E z58~^GYXicw8&lJ6fYDJ3NA&P-3=Z0xsnbPOk9dj9)6y(~Y**qD(~MP>9u zvb(=PM$D{>dUsSX36qIslPY0El-yzAy{~sRwibGAM6lQ&7KM;76@-7V7%oy&!a`x( zKGdKRY+UyL%zSxN)(}K(KKOC;yM5M#;ICi5Z~_aSs?>J8a1D!zVQW0pa(4b6la?{x z63S{*ZdKv&_py??hV(CmpxvCO55I=APdni#6h<(owWwBUF2BQ>2Xq^z>q=)*jlNt&hF!IPEm{5k|Mm)Z$maN;#$| zSYOGxk~v>X$Eae$NTsfBS{2q&H=0#Zp<7^b9bV(Sj=uFgCI$ypeQ?d|jD%sK+u;r@ zT@you(m;vz)ZU&GH&5YqCtP{5?wuDdvCQ4O-33pcJXspZ)i1S4&9oJ~$-`swE8ptsHpQd8w*h@D=VQ_bj-}}!eoPT2EIO%g@3WK z%1^nf9`3BnoWon$#1v@3|D#%3TjhgtiZL~3;T_`Q@o6kO1O_T9UCo~uXU;i?49>h1 z6%E`B`+Kzcp6cORTO==B{F}bNwgr_Tg?i|Uu5@4~-{Y*odz~!~>~x&hv4@O1%$qy4qMN|4!MJarLe+fx8vu} z(6g>`a(>xoP#Wh9xKJ4(XV0Exa#k&4xp`B1%GUwq33Ix=8#U7!-pYgWtiSGc1~OFO z=I-u;XWG#MmI0g(a8P+S;gcv-o?A%SP?2RTceeBPB0TV?XDF059uGN6yKrPgZ^VEY zHG7$yJR|NR33bL4fqjz^Q}4Z*$X%5XP{eJR-Atf3!2 zs#UqWJ!cek-BSrWgKA;9etlvt-XraqyopH$3_L^}Zl5PpT3X7kTQH6)a#+#T!9neD zL(svRhD)76v7JQ|pt6I@%5LN4T_9m-qJYs#82Uh>(=z^ha!gfyu)Fqmf72K~hC*eu z!49eZeua!oz;Y<$thN^p;o}Nt`c@<TGx>_&G_oo$Cmx@gb(=z(=8s-oOm> zPc{Tn+FV3=Vzm)F2rWt4__$ z%`r76$GiGE$n@l~!;GqIJRd2kK`-}Zs>8=II>pa_fAsp#ys$jBuwXOJgOGy=@WfV( z7mE$L3uyouyV&Vx`v}OcUS)*Qlht*yEDz+yBU3>PQ=w5VAI@QoQ)V-RY!&UUggajI z+I{KDw6Ky~w5Nl)%Aq$-;IO@@34_T?sPV$>{h9%Z{(l$mh!^ifFAo)(BLlkwxh3^k z_gd9oWF<}~i*#-I*SDCO$*C!1eqYN3kl$&fd+^}F|88O25d#|)|GvjlGl4wp)2AE0u=m1>i*NlmkdO<& z^c@W#Rn04qoLds2wGH<)kj6cr;%u+}zAbd+pCyOdaR`-Vduae456=KTSIvUNp#=8f z(f(#A>jNHMUc;IuD2ht4!iDa~2eAJ24Gr#l>#uMsoi?6ADTjr%v4Hz!c_ToBboqCg z<2R#r-7<$9HrFMXwhHI%$wu1KxuGwV6q2u@GGtCW`}u4d=Q=P$jQL4PNz2R2UmjPm;J5JmLB;783xwyvh@Qsq@!?ukOwH<86{ooh zk(m2YiAgt|?gUh%j;5v$hW>{K+vbq&>)w&zr*5`DeCQROym9@o5ZYaopWsfZ1I}*mrBqWB3f`FWoEsys$rs!t$ z^wiXP+M{ocmA_Qi(8FnMX%Tim5)3?oJxfe0)^#3|vd7_0j_~W2W%M!>)NoFHF~?P^ zLw-eK#d5evwf`f3{mJp+%JOpG63P^P77J^E)YN=uX+XI=gCfjC%EifP zU1si!AOSCVyTL+FD&ND;IOon~l=((SQyL0P5zU(ts_=7gC{at6291_{`^HPS20QM> zi@b^2MyBrW+q&P32ph$p@ku%354m}J=I8=yT@m=1^&lJ@h1x{QrJrn|&2xkF)YN8* zh;@kbhzRl^w8dcF6R7=~vI!^w$5p*TIi|mQRql_dud}kU-nte3_vFuvyn};)Vaio! zh%i1bZtiaQ>C=48hwJF*=qD4BX)+CHW8+U()s?R%f`1iTn;An&u%yhUl%n`DU3OP- ziKr5`n$qi;yh^`+?>QSI?hz+k1xm|jaUl4oYjD1yss(y*s9_UZ1PaDK8gkp@ePmcYOluz3o&3p`Y@rSFbcu zJ+IKxuBDBC3LGI#JkXn3Uw`3Nz0cXg9+D~ez1UXWXbN2&UkUFk&N=6Pj98T!YCwE3eXX9 z|Kd-(qdm43u8=6FCP)Y-=LsLAe|OrTo1rp$sz#K!V<;iv>FoOx_Jy0PYnnpjAI_>6vi z=3Da9kB58>Ys+TwmyncezJ}`#B=BYW(rMDXJlt&&zYFsS`2A%60CCU z!0^R{DLztlHL~imtomyd z6cjD!03-R*5Qecr^Q$>Bco`*lU;T#jo?v-|@WpB2qSn1m(;;*_fx05FnHq^Z>7`YD z{}9IuqzFp?wSVzK`|DF|!s*saLMvUmE3gkGC0|dhXq$;m43f~&avE`{j3$>zmbU)> z9Xs0(iCi;FOSL*Va_x871JT&}2kdHGyduOh@{+%N9=}m`-B3f1y{&&2>;p;6cCjy$ zZ|?pl215e_SB-VSf*8K|r!GBn1PyLB>-IiZ_HArz`Y!D~=Er*wqOl*wC1&q%ux09e z{Q)++wzTxQigWc`>eL@3G%sGh`~#gmhLex(1xgA~$X z`nuGa03J0P8=mx7sABIa`28BTwznTaz8Pa8B_nI7XMb~!4jV{LR1L?LpaM)R!% z?Zr{_<0^M{fCfgdq428gyB=&Y-@NI4nEJxn`d5{Qc>MSKii-BOw)n)va~e`is%#I7 z0;Du{SAV~!5=x4jPNbS#U43jZ=IK)sFHtCkAC>t2J#qUcREMVu6#3783l|bg>6T}z zr>kAlP$xOAahqYJr$;L_?JHJW!%aVibm> z2?KbX^bO(aoyTNb#$kP}M9j~wxVZg>OeWvcl@ghfno4>oS)atFp`xO)9Al~l8G`#U zd6e{syVTT3nPca=M)XALyB~mO)z#GMSbL~UxE0t1G#3=nV@@~{Wf>dF1Ta|$O2K$Z z1MEVfmR_CyL(q#|C%@w zfR>)Mj<|4&iUHnPC(l84JbeS;(}`MDSJ(3Yy@8NY;6*a-tr{$edSQjUMD;om!8>W9CGh6^V5+Vdq!jI?7 zKOp%3|B|QXJtZX$HntGxBB8BA-mXzTKW`GNG>|BT3&^(xV8qsWYbdvpm@y{XH^09;js?Z4nhaMk=3fl%C(}6)qt^eM57n*G~|GD&d z_m9ZMVTh#0d3kwU|GRi_aB$z`!cbur(&TA9d2-!2@56_S|NcD>5BjvetzeiBwk4=U z$aIZDe8SI3V|RBj&*0Z*jR;g#_|J=TuuTvS@ZSkU-dSz)&mSRJ{^?BqkC#{;KYl!_ z?{OUZ;lnj}66nm4H?lki?5C8zH25_c1_zxZ)oI`Zx=APKhgLo12^Sg>0ae^rY6uPC825qY|$F zPw2u*5D^ibh5~dkX*VuYhyVL{36t%SyvVq+&r$o&zm;yJN4mRzHY3mjX=KWu7!;8= zPf}4+FTCZmomKdMy}_tGiA=dSP0BtEGGiV`OhC(O!5+Go|uK97f|n{RO8SoB##0I)g%XkMfFuEb-=|G&$M zqoW_f9%L>DN~RQA?T(I)oQK5@HZ}({;rdYmmU7I#7j&}Gm1%PT281{wu=zn5hypB3)k5557pY98szHHc`%T%o*AXt_h|cE$+ZX!P`R znD4soUTy_FH*{kLaA66gs%cH^tPBID{UlXW4Kpc-a9O2!c`9Kw!q6%o{b}Vah=fPH zZVs!0ce;gYYHIq7sJWkdvw4mJl_=;5{CbS;em+6}O(CJkTU!5+b0}CrPyPr#)64q$ zjQ9z~7Qc9&_tV~XQ0T8d*xTRlZZI1u;qF+~k|CxMNjQp$i#sKz5QIW%b+ot9hp~5X z2*d80Y8@7KSl-)SdZ2@~v%ysXY zIVNXk^>^z-!re7ZVSv?%gacI`9{;<8m>5t-6u`kqFQvo!!X=jh-xiwvOd>2c?xcZX z_UG!0553sRWs{iMnymL992|stRpoZ5)$ivA-FK$*?&>3*y;1L5R--A45VnSQ7HC7| z2EIJ{gnif5RYa4QhsPi>gNc*#z1)K@dN2Q;RAiHq(%-zPvJLZ{zg}?{n-u{kq@BaV z(YhilLxrEPRrJc8g8P+$hci5rO{w1l>m?O*ewr!ZRjjZ&dlh8 ztq+uq|K(JQxUSY#|NK`OK$Qn7U&6(~@lylf9IjhrVS02fD2V#DjXjJi=?XRVBTzS3 z_)UAfM{_d-Ou9T7k!-+pe7rlRN7oR1KlVk_hwDiNM(q^#lDeussjpw3E#$Lb)Tq52 zdCMwJm(>4Ywwzg#IdDS_Uj$$S&28S(5a1^=F;$ z-o4{4%r!DJG+myqmwBqYPhmZ9_7q0J?Sg`)ATx$ztHg-8MfJaj{)o-f{$a_B>y{wRiBQ0MN*^?vnAl^DpLdZNzWeS+ zsPF_dsD$!BacRjnL_eEti!|GYcEcyNpn$JXdTiW>p2xhOt&pXk<*s?5^hRf%bQqgX zl9qCe;1g{gJ)wrGdlakFK==r-k(LQDg=S(guc3qVQ*H{k>~FO{TrQRSrxk4+prh~z z#+sT_EHvqsB&+@ov{SE-t-XCHcHVOu8k&L$01x!|VfGODl@Iz$96!LMX0bQHlTipT z8Mj4TmqAp-@F+f0$v}D4nQw*PCzS@{gzZ1aKZO7UhCk`M^R}5-^d&T!S+3Y>tY0wt zj^$E9!nGDI*tkg{adDNq@=N6LJ`a;hOPP07ndKOKe`HgQ(G zRo}lUH{)x@G_-0-TaZrx$ac59d;9hxz2x;@eX|MlK<|iZ{@tIWRiy*oTO%p^&-#Rv zY7&3LT#X5?fIW>xwda|J3I*7Y1k}PIyD>*cuBy;IO}T|8MKN{P6hYn4u(7jC*)`W@ z5%B63T$BKHsCL7@k}$qutwZlLu(}gtg5tLLIq?&m_W#hkkqrL&)qGa_*1?~C=7GOW z@SB-fkIzzfvM-yqmiSB&AEJZk^o~(zd^Epw=Ch#Rd_!=AI1gkp{-83m_j1z^JV|E% zG)5>7bW+o$`u1cLl6e8adQVhZd$ibPEMARl7ccPgg#skO1S-M>oqbfgp;rJV@6F}b6nj;>J?208@D(pCdssA=!8#6q~*&v(@{ zHX1`|fmp~$lVW_p&B!nM?sd2Ot!xVShy)?jnC9eSanC0`zG zEv zM9>YOyv_`e(4!>KUEK-~Ms7&AGu^s1P%^gIa{v$>Bs*)XKKeWAgft@BG$Djkw;kG? z&QT;)jsVqqS92JclFCQtc-3hWpB;5+qkbmKz7M$l5EnJ9wL`GeNbBKx_-IET7_d}= z%^RKktZZy;ot>Snt;zkUc4SKcyQL^6_x)8WE zoyQ&TmB7L4Y1|uIy}PKaKRY{VS3g2atl>?@uG?k3Fj8upk70-xbDQ*y`CeLz$W%cU z`c>|sH$to5;9mvuoC&{KlDV6T>00-t(c0SDJ?mR66tV_+I^T>kub$*+t&j12kTaW~ zEPpVH4jAe`dZV_y=?btPN5x{HKZlY&LGAaqM_~jqxi>G%L;iy~qei1mk3&(FX8PrcmH#66`x;|^N#@f)`BWq*i<+bfexxd(L1%Y`bgDJXs zLm_#PjEYL3*UXrUB9MoMz5@1!Fi45FZ&Nx}SW=>wp>~$@v~HhcfJeP{@NrVAqO7be zJ5p_)g@tPK?aWe+;JQO5@t(LG zAag!OD<%knLALwtdv`z?LLMhakY}ee>YF<|cel1uGJ%w2FvpAX0Ah(=w2`yG05QmyX)KXKC@iU8VD#}&7#T395!ba{mg(&0QkT7OCah4acKj= zw=g+_yr-cCF*cI=bmi-iMDua_SKwJ^`l4sR*)gx)m2&?Rtw#eZN}CuqJMca&iD{v9 zUa4tme+VxCY~F{))8LnbS~9qd~i*r`!Q^0%_y&m3eolzP$qmdrTDJv zD=>9&7HfACod`PQC~a*nBC>kUcA z^cGn@taV?94n|@4-VGmEZ)QQk64-`dQTT4fOOBlfVgn;1V^mC1h{FTgm>MSajf#g2 z=(atx+Na+l6Y+rRH2rjyhl%N4f3+Xz0dI;ZRtoZwZj*>_6HsXLS(FAneG6ubUDEP~ zuz^OF)(SLS`Fi{#m2PE5?NJ4nUESOu`MVQ{+XG3s3PZbMWkPB>Qlfe~k1g_(9NB

EfQ5LQ-Z-Y%KF*4(&Y8c(xyCHn3$R4>mBh-& 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

FJ4YxS7LR!CWlE-czP|Dg2QUuTnRX6^)rW>) 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 literal 0 HcmV?d00001 diff --git a/plugins/wasm-go/extensions/api-workflow/main.go b/plugins/wasm-go/extensions/api-workflow/main.go new file mode 100644 index 0000000000..5a4254b8d5 --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/main.go @@ -0,0 +1,307 @@ +// 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 main + +import ( + ejson "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "api-workflow/utils" + . "api-workflow/workflow" + + "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" +) + +const ( + DefaultMaxDepth uint32 = 100 + WorkflowExecStatus string = "workflowExecStatus" + DefaultTimeout uint32 = 5000 +) + +func main() { + wrapper.SetCtx( + "api-workflow", + wrapper.ParseConfigBy(parseConfig), + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + ) +} + +func parseConfig(json gjson.Result, c *PluginConfig, log wrapper.Log) error { + + edges := make([]Edge, 0) + nodes := make(map[string]Node) + var err error + // env + env := json.Get("env") + // timeout + c.Env.Timeout = uint32(env.Get("timeout").Int()) + if c.Env.Timeout == 0 { + c.Env.Timeout = DefaultTimeout + } + // max_depth + c.Env.MaxDepth = uint32(env.Get("max_depth").Int()) + if c.Env.MaxDepth == 0 { + c.Env.MaxDepth = DefaultMaxDepth + } + // workflow + workflow := json.Get("workflow") + if !workflow.Exists() { + return errors.New("workflow is empty") + } + // workflow.edges + edges_ := workflow.Get("edges") + if edges_.Exists() && edges_.IsArray() { + for _, w := range edges_.Array() { + task := Task{} + edge := Edge{} + edge.Source = w.Get("source").String() + if edge.Source == "" { + return errors.New("source is empty") + } + edge.Target = w.Get("target").String() + if edge.Target == "" { + return errors.New("target is empty") + } + edge.Task = &task + + edge.Conditional = w.Get("conditional").String() + edges = append(edges, edge) + } + } + c.Workflow.Edges = edges + // workflow.nodes + nodes_ := workflow.Get("nodes") + if nodes_.Exists() && nodes_.IsArray() { + for _, value := range nodes_.Array() { + node := Node{} + node.Name = value.Get("name").String() + if node.Name == "" { + return errors.New("tool name is empty") + } + node.ServiceName = value.Get("service_name").String() + if node.ServiceName == "" { + return errors.New("tool service name is empty") + } + node.ServicePort = value.Get("service_port").Int() + if node.ServicePort == 0 { + if strings.HasSuffix(node.ServiceName, ".static") { + // use default logic port which is 80 for static service + node.ServicePort = 80 + } else { + return errors.New("tool service port is empty") + } + + } + node.ServiceDomain = value.Get("service_domain").String() + node.ServicePath = value.Get("service_path").String() + if node.ServicePath == "" { + node.ServicePath = "/" + } + node.ServiceMethod = value.Get("service_method").String() + if node.ServiceMethod == "" { + return errors.New("service_method is empty") + } + serviceHeaders := value.Get("service_headers") + if serviceHeaders.Exists() && serviceHeaders.IsArray() { + serviceHeaders_ := []ServiceHeader{} + err = ejson.Unmarshal([]byte(serviceHeaders.Raw), &serviceHeaders_) + node.ServiceHeaders = serviceHeaders_ + } + + node.ServiceBodyTmpl = value.Get("service_body_tmpl").String() + serviceBodyReplaceKeys := value.Get("service_body_replace_keys") + if serviceBodyReplaceKeys.Exists() && serviceBodyReplaceKeys.IsArray() { + serviceBodyReplaceKeys_ := []BodyReplaceKeyPair{} + err = ejson.Unmarshal([]byte(serviceBodyReplaceKeys.Raw), &serviceBodyReplaceKeys_) + node.ServiceBodyReplaceKeys = serviceBodyReplaceKeys_ + if err != nil { + return fmt.Errorf("unmarshal service body replace keys failed, err:%v", err) + } + } + + nodes[node.Name] = node + } + c.Workflow.Nodes = nodes + // workflow.WorkflowExecStatus + c.Workflow.WorkflowExecStatus, err = initWorkflowExecStatus(c) + log.Debugf("init status : %v", c.Workflow.WorkflowExecStatus) + if err != nil { + log.Errorf("init workflow exec status failed, err:%v", err) + return fmt.Errorf("init workflow exec status failed, err:%v", err) + } + } + log.Debugf("config : %v", c) + return nil +} + +func initWorkflowExecStatus(config *PluginConfig) (map[string]int, error) { + result := make(map[string]int) + + for name, _ := range config.Workflow.Nodes { + result[name] = 0 + } + for _, edge := range config.Workflow.Edges { + + if edge.Source == TaskStart || edge.Target == TaskContinue || edge.Target == TaskEnd { + continue + } + + count, ok := result[edge.Target] + if !ok { + return nil, fmt.Errorf("Target %s is not exist in nodes", edge.Target) + } + result[edge.Target] = count + 1 + + } + return result, nil +} + +func onHttpRequestBody(ctx wrapper.HttpContext, config PluginConfig, body []byte, log wrapper.Log) types.Action { + + initHeader := make([][2]string, 0) + // 初始化运行状态 + ctx.SetContext(WorkflowExecStatus, config.Workflow.WorkflowExecStatus) + + // 执行工作流 + for _, edge := range config.Workflow.Edges { + + if edge.Source == TaskStart { + ctx.SetContext(fmt.Sprintf("%s", TaskStart), body) + err := recursive(edge, initHeader, body, 1, config, log, ctx) + if err != nil { + // 工作流处理错误,返回500给用户 + log.Errorf("recursive failed: %v", err) + _ = utils.SendResponse(500, "api-workflow.recursive_failed", utils.MimeTypeTextPlain, fmt.Sprintf("workflow plugin recursive failed: %v", err)) + + } + } + } + + return types.ActionPause +} + +// 放入符合条件的edge +func recursive(edge Edge, headers [][2]string, body []byte, depth uint32, config PluginConfig, log wrapper.Log, ctx wrapper.HttpContext) error { + + var err error + // 防止递归次数太多 + if depth > config.Env.MaxDepth { + return fmt.Errorf("maximum recursion depth reached") + } + + // 判断是不是end + if edge.IsEnd() { + log.Debugf("source is %s,target is %s,workflow is end", edge.Source, edge.Target) + log.Debugf("body is %s", string(body)) + _ = proxywasm.SendHttpResponse(200, headers, body, -1) + return nil + } + // 判断是不是continue + if edge.IsContinue() { + log.Debugf("source is %s,target is %s,workflow is continue", edge.Source, edge.Target) + _ = proxywasm.ResumeHttpRequest() + return nil + } + + // 封装task + err = edge.WrapperTask(config, ctx) + if err != nil { + log.Errorf("workflow exec wrapperTask find error,source is %s,target is %s,error is %v ", edge.Source, edge.Target, err) + return fmt.Errorf("workflow exec wrapperTask find error,source is %s,target is %s,error is %v ", edge.Source, edge.Target, err) + } + + // 执行task + log.Debugf("workflow exec task,source is %s,target is %s, body is %s,header is %v", edge.Source, edge.Target, string(edge.Task.Body), edge.Task.Headers) + err = wrapper.HttpCall(edge.Task.Cluster, edge.Task.Method, edge.Task.ServicePath, edge.Task.Headers, edge.Task.Body, func(statusCode int, responseHeaders http.Header, responseBody []byte) { + log.Debugf("code:%d", statusCode) + // 判断response code + if statusCode < 400 { + + // 存入这轮返回的body + ctx.SetContext(fmt.Sprintf("%s", edge.Target), responseBody) + + headers_ := make([][2]string, len(responseHeaders)) + for key, value := range responseHeaders { + headers_ = append(headers_, [2]string{key, value[0]}) + } + // 判断是否进入下一步 + nextStatus := ctx.GetContext(WorkflowExecStatus).(map[string]int) + + // 进入下一步 + for _, next := range config.Workflow.Edges { + if next.Source == edge.Target { + // 更新workflow status + if next.Target != TaskContinue && next.Target != TaskEnd { + + nextStatus[next.Target] = nextStatus[next.Target] - 1 + log.Debugf("source is %s,target is %s,stauts is %v", next.Source, next.Target, nextStatus) + // 还有没执行完的边 + if nextStatus[next.Target] > 0 { + ctx.SetContext(WorkflowExecStatus, nextStatus) + return + } + // 执行出了问题 + if nextStatus[next.Target] < 0 { + log.Errorf("workflow exec status find error %v", nextStatus) + _ = utils.SendResponse(500, "api-workflow.exec_task_failed", utils.MimeTypeTextPlain, fmt.Sprintf("workflow exec status find error %v", nextStatus)) + return + } + } + // 判断是否执行 + isPass, err2 := next.IsPass(ctx) + if err2 != nil { + log.Errorf("check pass find error:%v", err2) + _ = utils.SendResponse(500, "api-workflow.task_check_paas_failed", utils.MimeTypeTextPlain, fmt.Sprintf("check pass find error:%v", err2)) + return + } + if isPass { + log.Debugf("source is %s,target is %s,workflow is pass ", next.Source, next.Target) + nextStatus = ctx.GetContext(WorkflowExecStatus).(map[string]int) + nextStatus[next.Target] = nextStatus[next.Target] - 1 + ctx.SetContext(WorkflowExecStatus, nextStatus) + continue + + } + + // 执行下一步 + err = recursive(next, headers_, responseBody, depth+1, config, log, ctx) + if err != nil { + log.Errorf("recursive error:%v", err) + _ = utils.SendResponse(500, "api-workflow.recursive_failed", utils.MimeTypeTextPlain, fmt.Sprintf("recursive error:%v", err)) + return + } + } + } + + } else { + // statusCode >= 400 ,task httpCall执行失败,放行请求,打印错误,结束workflow + log.Errorf("workflow exec task find error,code is %d,body is %s", statusCode, string(responseBody)) + _ = utils.SendResponse(500, "api-workflow.httpCall_failed", utils.MimeTypeTextPlain, fmt.Sprintf("workflow exec task find error,code is %d,body is %s", statusCode, string(responseBody))) + } + return + + }, config.Env.MaxDepth*config.Env.Timeout) + if err != nil { + log.Errorf("httpcall error:%v", err) + } + + return err +} diff --git a/plugins/wasm-go/extensions/api-workflow/utils/conditional.go b/plugins/wasm-go/extensions/api-workflow/utils/conditional.go new file mode 100644 index 0000000000..03bf0c9954 --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/utils/conditional.go @@ -0,0 +1,116 @@ +package utils + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// 原子表达式描述: +// eq arg1 arg2: arg1 == arg2时为true +// ne arg1 arg2: arg1 != arg2时为true +// lt arg1 arg2: arg1 < arg2时为true +// le arg1 arg2: arg1 <= arg2时为true +// gt arg1 arg2: arg1 > arg2时为true +// ge arg1 arg2: arg1 >= arg2时为true +// and arg1 arg2: arg1 && arg2 +// or arg1 arg2: arg1 || arg2 +// contain arg1 arg2: arg1 包含 arg2时为true +var operators = map[string]interface{}{ + "eq": func(a, b interface{}) bool { + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) + }, + "ge": func(a, b float64) bool { return a >= b }, + "le": func(a, b float64) bool { return a <= b }, + "gt": func(a, b float64) bool { return a > b }, + "lt": func(a, b float64) bool { return a < b }, + "and": func(a, b bool) bool { return a && b }, + "or": func(a, b bool) bool { return a || b }, + "contain": func(a, b string) bool { return strings.Contains(a, b) }, +} + +// 执行判断条件 +func ExecConditionalStr(conditionalStr string) (bool, error) { + // 正则表达式匹配括号内的表达式 + re := regexp.MustCompile(`\(([^()]*)\)`) + matches := re.FindAllStringSubmatch(conditionalStr, -1) + // 找到最里面的(原子表达式) + for _, match := range matches { + subCondition := match[1] + result, err := ExecConditionalStr(subCondition) + if err != nil { + return false, err + } + // 用结果替换原子表达式 + conditionalStr = strings.ReplaceAll(conditionalStr, match[0], fmt.Sprintf("%t", result)) + } + + fields := strings.Fields(conditionalStr) + // 执行原子表达式 + if len(fields) == 3 { + compareFunc := operators[fields[0]] + switch fc := compareFunc.(type) { + default: + return false, fmt.Errorf("invalid conditional func %v", compareFunc) + case func(a, b float64) bool: + a, err := strconv.ParseFloat(fields[1], 64) + if err != nil { + return false, fmt.Errorf("invalid conditional str %s", conditionalStr) + } + b, err := strconv.ParseFloat(fields[2], 64) + if err != nil { + return false, fmt.Errorf("invalid conditional str %s", conditionalStr) + } + return fc(a, b), nil + case func(a, b bool) bool: + a, err := strconv.ParseBool(fields[1]) + if err != nil { + return false, fmt.Errorf("invalid conditional str %s", conditionalStr) + } + b, err := strconv.ParseBool(fields[2]) + if err != nil { + return false, fmt.Errorf("invalid conditional str %s", conditionalStr) + } + return fc(a, b), nil + case func(a, b string) bool: + a := fields[1] + b := fields[2] + return fc(a, b), nil + case func(a, b interface{}) bool: + a := fields[1] + b := fields[2] + return fc(a, b), nil + } + // 继续获取上一层的(原子表达式) + } else if strings.Contains(conditionalStr, "(") || strings.Contains(conditionalStr, ")") { + return ExecConditionalStr(conditionalStr) + // 原子表达式有问题,返回 + } else { + return false, fmt.Errorf("invalid conditional str %s", conditionalStr) + } + +} + +// 通过正则表达式寻找模板中的 {{foo}} 字符串foo +// 返回 {{foo}} : foo +func ParseTmplStr(tmpl string) map[string]string { + result := make(map[string]string) + re := regexp.MustCompile(`\{\{(.*?)\}\}`) + matches := re.FindAllStringSubmatch(tmpl, -1) + for _, match := range matches { + result[match[0]] = match[1] + } + return result +} + +// 使用kv替换模板中的字符 +// 例如 模板是`hello,{{foo}}` 使用{"{{foo}}":"bot"} 替换后为`hello,bot` +func ReplacedStr(tmpl string, kvs map[string]string) string { + + for k, v := range kvs { + tmpl = strings.Replace(tmpl, k, v, -1) + } + + return tmpl +} diff --git a/plugins/wasm-go/extensions/api-workflow/utils/conditional_test.go b/plugins/wasm-go/extensions/api-workflow/utils/conditional_test.go new file mode 100644 index 0000000000..b167dc1549 --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/utils/conditional_test.go @@ -0,0 +1,100 @@ +package utils + +import ( + "reflect" + "testing" +) + +func TestExecConditionalStr(t *testing.T) { + + tests := []struct { + name string + args string + want bool + wantErr bool + }{ + {"eq int true", "eq 1 1", true, false}, + {"eq int false", "eq 1 2", false, false}, + {"eq str true", "eq foo foo", true, false}, + {"eq str false", "eq foo boo", false, false}, + {"eq float true", "eq 0.99 0.99", true, false}, + {"eq float false", "eq 1.1 2.2", false, false}, + {"eq float int false", "eq 1.0 1", false, false}, + {"eq float str false", "eq 1.0 foo", false, false}, + {"lt true", "lt 1.1 2", true, false}, + {"lt false", "lt 2 1", false, false}, + {"le true", "le 1 2", true, false}, + {"le false", "le 2 1", false, false}, + {"gt true", "gt 2 1", true, false}, + {"gt false", "gt 1 2", false, false}, + {"ge true", "ge 2 1", true, false}, + {"ge false", "ge 1 2", false, false}, + {"and true", "and true true", true, false}, + {"and false", "and true false", false, false}, + {"or true", "or true false", true, false}, + {"or false", "or false false", false, false}, + {"contain true", "contain helloworld world", true, false}, + {"contain false", "contain helloworld moon", false, false}, + {"invalid input", "invalid", false, true}, + {"nested expression 1", "and (eq 1 1) (lt 2 3)", true, false}, + {"nested expression 2", "or (eq 1 2) (and (eq 1 1) (gt 2 3))", false, false}, + {"nested expression error", "or (eq 1 2) (and (eq 1 1) (gt 2 3)))", false, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ExecConditionalStr(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("ExecConditionalStr() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ExecConditionalStr() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseTmplStr(t *testing.T) { + type args struct { + tmpl string + } + tests := []struct { + name string + args string + want map[string]string + }{ + {"normal", "{{foo}}", map[string]string{"{{foo}}": "foo"}}, + {"single", "{foo}", map[string]string{}}, + {"empty", "foo", map[string]string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseTmplStr(tt.args) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseTmplStr() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReplacedStr(t *testing.T) { + type args struct { + tmpl string + kvs map[string]string + } + tests := []struct { + name string + args args + want string + }{ + {"normal", args{tmpl: "hello,{{foo}}", kvs: map[string]string{"{{foo}}": "bot"}}, "hello,bot"}, + {"empty", args{tmpl: "hello,foo", kvs: map[string]string{"{{foo}}": "bot"}}, "hello,foo"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ReplacedStr(tt.args.tmpl, tt.args.kvs); got != tt.want { + t.Errorf("ReplacedStr() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/wasm-go/extensions/api-workflow/utils/http.go b/plugins/wasm-go/extensions/api-workflow/utils/http.go new file mode 100644 index 0000000000..8a9ebb1911 --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/utils/http.go @@ -0,0 +1,45 @@ +package utils + +import "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + +const ( + HeaderContentType = "Content-Type" + + MimeTypeTextPlain = "text/plain" + MimeTypeApplicationJson = "application/json" +) + +func SendResponse(statusCode uint32, statusCodeDetails string, contentType, body string) error { + return proxywasm.SendHttpResponseWithDetail(statusCode, statusCodeDetails, CreateHeaders(HeaderContentType, contentType), []byte(body), -1) +} + +func CreateHeaders(kvs ...string) [][2]string { + headers := make([][2]string, 0, len(kvs)/2) + for i := 0; i < len(kvs); i += 2 { + headers = append(headers, [2]string{kvs[i], kvs[i+1]}) + } + return headers +} + +func OverwriteRequestHost(host string) error { + if originHost, err := proxywasm.GetHttpRequestHeader(":authority"); err == nil { + _ = proxywasm.ReplaceHttpRequestHeader("X-ENVOY-ORIGINAL-HOST", originHost) + } + return proxywasm.ReplaceHttpRequestHeader(":authority", host) +} + +func OverwriteRequestPath(path string) error { + if originPath, err := proxywasm.GetHttpRequestHeader(":path"); err == nil { + _ = proxywasm.ReplaceHttpRequestHeader("X-ENVOY-ORIGINAL-PATH", originPath) + } + return proxywasm.ReplaceHttpRequestHeader(":path", path) +} + +func OverwriteRequestAuthorization(credential string) error { + if exist, _ := proxywasm.GetHttpRequestHeader("X-HI-ORIGINAL-AUTH"); exist == "" { + if originAuth, err := proxywasm.GetHttpRequestHeader("Authorization"); err == nil { + _ = proxywasm.AddHttpRequestHeader("X-HI-ORIGINAL-AUTH", originAuth) + } + } + return proxywasm.ReplaceHttpRequestHeader("Authorization", credential) +} diff --git a/plugins/wasm-go/extensions/api-workflow/utils/tools.go b/plugins/wasm-go/extensions/api-workflow/utils/tools.go new file mode 100644 index 0000000000..2f62e82e9d --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/utils/tools.go @@ -0,0 +1,7 @@ +package utils + +import "strings" + +func TrimQuote(source string) string { + return strings.Trim(source, `"`) +} diff --git a/plugins/wasm-go/extensions/api-workflow/workflow/workflow.go b/plugins/wasm-go/extensions/api-workflow/workflow/workflow.go new file mode 100644 index 0000000000..3b827c68ed --- /dev/null +++ b/plugins/wasm-go/extensions/api-workflow/workflow/workflow.go @@ -0,0 +1,325 @@ +package workflow + +import ( + "fmt" + "strings" + + "api-workflow/utils" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + TaskTypeHTTP string = "http" + TaskStart string = "start" + TaskEnd string = "end" + TaskContinue string = "continue" + UseContextFlag string = "||" + AllFlag string = "@all" +) + +type PluginConfig struct { + + // @Title zh-CN 工作流 + // @Description zh-CN 工作流的具体描述 + Workflow Workflow `json:"workflow" yaml:"workflow"` + // @Title zh-CN 环境变量 + // @Description zh-CN 用来定义整个工作流的环境变量 + Env Env `json:"env" yaml:"env"` +} + +type Env struct { + // @Title zh-CN 超时时间 + // @Description zh-CN 用来定义工作流的超时时间,单位是毫秒 + Timeout uint32 `json:"timeout" yaml:"timeout"` + // @Title zh-CN 最大迭代深度 + // @Description zh-CN 用来定义工作流最大的迭代深度,默认是100 + MaxDepth uint32 `json:"max_depth" yaml:"max_depth"` +} +type Workflow struct { + // @Title zh-CN 边的列表 + // @Description zh-CN 边的列表 + Edges []Edge `json:"edges" yaml:"edges"` + // @Title zh-CN 节点的列表 + // @Description zh-CN 节点的列表 + Nodes map[string]Node `json:"nodes" yaml:"nodes"` + // @Title zh-CN 工作流的状态 + // @Description zh-CN 工作流的执行状态,用于记录node之间的相互依赖和执行情况 + WorkflowExecStatus map[string]int `json:"-" yaml:"-"` +} + +type Edge struct { + // @Title zh-CN 上一步节点 + // @Description zh-CN 上一步节点,必须是定义node的name,或者初始化工作流的start + Source string `json:"source" yaml:"source"` + // @Title zh-CN 当前执行的节点 + // @Description zh-CN 当前执行节点,必须是定义的node的name,或者结束工作流的关键字 end continue + Target string `json:"target" yaml:"target"` + // @Title zh-CN 执行操作 + // @Description zh-CN 执行单元,里面实时封装需要的数据 + Task *Task + // @Title zh-CN 判断表达式 + // @Description zh-CN 是否执行下一步的判断条件 + Conditional string `json:"conditional" yaml:"conditional"` +} + +type Task struct { + Cluster wrapper.Cluster `json:"-" yaml:"-"` + ServicePath string `json:"service_path" yaml:"service_path"` + ServicePort int64 `json:"service_port" yaml:"service_port"` + ServiceKey string `json:"service_key" yaml:"service_key"` + Body []byte `json:"-" yaml:"-"` + Headers [][2]string `json:"headers" yaml:"headers"` + Method string `json:"method" yaml:"method"` + TaskType string `json:"task_type" yaml:"task_type"` +} + +type Node struct { + // @Title zh-CN 节点名称 + // @Description zh-CN 节点名称全局唯一 + Name string `json:"name" yaml:"name"` + // @Title zh-CN 服务名称 + // @Description zh-CN 带服务类型的完整名称,例如 my.dns or foo.static + ServiceName string `json:"service_name" yaml:"service_name"` + // @Title zh-CN 服务端口 + // @Description zh-CN static类型默认是80 + ServicePort int64 `json:"service_port" yaml:"service_port"` + // @Title zh-CN 服务域名 + // @Description zh-CN 服务域名,例如 dashscope.aliyuncs.com + ServiceDomain string `json:"service_domain" yaml:"service_domain"` + // @Title zh-CN http访问路径 + // @Description zh-CN http访问路径,默认是 / + ServicePath string `json:"service_path" yaml:"service_path"` + // @Title zh-CN http 方法 + // @Description zh-CN http方法,支持所有可用方法 GET,POST等 + ServiceMethod string `json:"service_method" yaml:"service_method"` + // @Title zh-CN http 请求头文件 + // @Description zh-CN 请求头文件 + ServiceHeaders []ServiceHeader `json:"service_headers" yaml:"service_headers"` + // @Title zh-CN http 请求body模板 + // @Description zh-CN 请求body模板,用来构造请求 + ServiceBodyTmpl string `json:"service_body_tmpl" yaml:"service_body_tmpl"` + // @Title zh-CN http 请求body模板替换键值对 + // @Description zh-CN 请求body模板替换键值对,用来构造请求。to表示填充的位置,from表示数据从哪里, + // 标识表达式基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 + ServiceBodyReplaceKeys []BodyReplaceKeyPair `json:"service_body_replace_keys" yaml:"service_body_replace_keys"` +} +type BodyReplaceKeyPair struct { + // @Title zh-CN from表示数据从哪里, + // @Description zh-CN from表示数据从哪里 + // 标识表达式基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 + From string `json:"from" yaml:"from"` + // @Title zh-CN to表示填充的位置 + // @Description zh-CN to表示填充的位置, + // 标识表达式基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 + To string `json:"to" yaml:"to"` +} +type ServiceHeader struct { + Key string `json:"key" yaml:"key"` + Value string `json:"value" yaml:"value"` +} + +func (w *Edge) IsEnd() bool { + if w.Target == TaskEnd { + return true + } + return false +} +func (w *Edge) IsContinue() bool { + if w.Target == TaskContinue { + return true + } + return false +} +func (e *Edge) IsPass(ctx wrapper.HttpContext) (bool, error) { + // 执行判断Conditional + if e.Conditional != "" { + + var err error + // 获取模板里的表达式 + + e.Conditional, err = e.WrapperDataByTmplStr(e.Conditional, ctx) + if err != nil { + return false, fmt.Errorf("workflow WrapperDateByTmplStr %s failed: %v", e.Conditional, err) + } + ok, err := e.ExecConditional() + if err != nil { + + return false, fmt.Errorf("wl exec conditional %s failed: %v", e.Conditional, err) + } + return !ok, nil + + } + return false, nil +} + +func (w *Edge) WrapperTask(config PluginConfig, ctx wrapper.HttpContext) error { + + // 判断 node 是否存在 + node, isTool := config.Workflow.Nodes[w.Target] + + if isTool { + w.Task.TaskType = TaskTypeHTTP + } else { + return fmt.Errorf("do not find target :%s", w.Target) + } + + switch w.Task.TaskType { + default: + return fmt.Errorf("unknown node type :%s", w.Task.TaskType) + case TaskTypeHTTP: + err := w.wrapperNodeTask(node, ctx) + if err != nil { + return err + } + + } + return nil + +} + +func (w *Edge) wrapperBody(requestBodyTemplate string, keyPairs []BodyReplaceKeyPair, ctx wrapper.HttpContext) error { + + requestBody, err := w.WrapperDataByTmplStrAndKeys(requestBodyTemplate, keyPairs, ctx) + if err != nil { + return fmt.Errorf("wrapper date by tmpl str is %s ,find err: %v", requestBodyTemplate, err) + } + + w.Task.Body = requestBody + return nil +} + +func (w *Edge) wrapperNodeTask(node Node, ctx wrapper.HttpContext) error { + // 封装cluster + w.Task.Cluster = wrapper.FQDNCluster{ + Host: node.ServiceDomain, + FQDN: node.ServiceName, + Port: node.ServicePort, + } + + // 封装请求body + err := w.wrapperBody(node.ServiceBodyTmpl, node.ServiceBodyReplaceKeys, ctx) + if err != nil { + return fmt.Errorf("wrapper body parse failed: %v", err) + } + + // 封装请求Method path headers + w.Task.Method = node.ServiceMethod + w.Task.ServicePath = node.ServicePath + w.Task.Headers = make([][2]string, 0) + if len(node.ServiceHeaders) > 0 { + for _, header := range node.ServiceHeaders { + w.Task.Headers = append(w.Task.Headers, [2]string{header.Key, header.Value}) + } + } + + return nil +} + +// 利用模板和替换键值对构造请求,使用`||`分隔,str1代表使用node是执行结果。tr2代表如何取数据,使用gjson的表达式,`@all`代表全都要 +func (w *Edge) WrapperDataByTmplStrAndKeys(tmpl string, keyPairs []BodyReplaceKeyPair, ctx wrapper.HttpContext) ([]byte, error) { + var err error + // 不需要替换 node.service_body_replace_keys 为空 + if len(keyPairs) == 0 { + return []byte(tmpl), nil + } + + for _, keyPair := range keyPairs { + + jsonPath := keyPair.From + target := keyPair.To + var contextValueRaw []byte + // 获取上下文数据 + if strings.Contains(jsonPath, UseContextFlag) { + pathStr := strings.Split(jsonPath, UseContextFlag) + if len(pathStr) == 2 { + contextKey := pathStr[0] + contextBody := ctx.GetContext(contextKey) + if contextValue, ok := contextBody.([]byte); ok { + contextValueRaw = contextValue + jsonPath = pathStr[1] + } else { + return nil, fmt.Errorf("context value is not []byte,key is %s", contextKey) + } + } + } + + // 执行封装 , `@all`代表全都要 + requestBody := gjson.ParseBytes(contextValueRaw) + if jsonPath == AllFlag { + + tmpl, err = sjson.SetRaw(tmpl, target, requestBody.Raw) + if err != nil { + return nil, fmt.Errorf("wrapper body parse failed: %v", err) + } + continue + } + requestBodyJson := requestBody.Get(jsonPath) + if requestBodyJson.Exists() { + tmpl, err = sjson.SetRaw(tmpl, target, requestBodyJson.Raw) + if err != nil { + return nil, fmt.Errorf("wrapper body parse failed: %v", err) + } + + } else { + return nil, fmt.Errorf("wrapper body parse failed: not exists %s", jsonPath) + } + } + return []byte(tmpl), nil + +} + +// 变量使用`{{str1||str2}}`包裹,使用`||`分隔,str1代表使用node是执行结果。tr2代表如何取数据,使用gjson的表达式,`@all`代表全都要 +func (w *Edge) WrapperDataByTmplStr(tmpl string, ctx wrapper.HttpContext) (string, error) { + var body []byte + // 获取模板里的表达式 + TmplKeyAndPath := utils.ParseTmplStr(tmpl) + if len(TmplKeyAndPath) == 0 { + return tmpl, nil + } + // 解析表达式 { "{{str1||str2}}":"str1||str2" } + for k, path := range TmplKeyAndPath { + // 变量使用`{{str1||str2}}`包裹,使用`||`分隔,str1代表使用前面命名为name的数据()。 + if strings.Contains(path, UseContextFlag) { + pathStr := strings.Split(path, UseContextFlag) + if len(pathStr) == 2 { + contextKey := pathStr[0] + contextBody := ctx.GetContext(contextKey) + if contextValue, ok := contextBody.([]byte); ok { + body = contextValue + path = pathStr[1] + } else { + return tmpl, fmt.Errorf("context value is not []byte,key is %s", contextKey) + } + } + // 执行封装 , `@all`代表全都要 + requestBody := gjson.ParseBytes(body) + if path == AllFlag { + tmpl = strings.Replace(tmpl, k, utils.TrimQuote(requestBody.Raw), -1) + continue + } + requestBodyJson := requestBody.Get(path) + if requestBodyJson.Exists() { + tmpl = utils.ReplacedStr(tmpl, map[string]string{k: utils.TrimQuote(requestBodyJson.Raw)}) + } else { + return tmpl, fmt.Errorf("use path {{%s}} get value is not exists,json is:%s", path, requestBody.Raw) + } + } else { + return "", fmt.Errorf("tmpl parse find error: || is not exists %s", path) + } + + } + return tmpl, nil +} + +func (w *Edge) ExecConditional() (bool, error) { + + ConditionalResult, err := utils.ExecConditionalStr(w.Conditional) + if err != nil { + return false, fmt.Errorf("exec conditional failed: %v", err) + } + return ConditionalResult, nil + +} From c30ca5dd9ef416cb66d194abfac85e4b057e51d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Wed, 9 Oct 2024 20:09:48 +0800 Subject: [PATCH 09/32] fix static cluster of skywalking service (#1372) --- helm/core/templates/configmap.yaml | 36 ++---------------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/helm/core/templates/configmap.yaml b/helm/core/templates/configmap.yaml index 9a07c3392c..b7814f5bf7 100644 --- a/helm/core/templates/configmap.yaml +++ b/helm/core/templates/configmap.yaml @@ -155,44 +155,12 @@ data: "transport_api_version": "V3", "grpc_service": { "envoy_grpc": { - "cluster_name": "service_skywalking" + "cluster_name": "outbound|{{ .Values.tracing.skywalking.port }}||{{ .Values.tracing.skywalking.service }}" } } } } - ], - "static_resources": { - "clusters": [ - { - "name": "service_skywalking", - "type": "LOGICAL_DNS", - "connect_timeout": "5s", - "http2_protocol_options": { - }, - "dns_lookup_family": "V4_ONLY", - "lb_policy": "ROUND_ROBIN", - "load_assignment": { - "cluster_name": "service_skywalking", - "endpoints": [ - { - "lb_endpoints": [ - { - "endpoint": { - "address": { - "socket_address": { - "address": "{{ .Values.tracing.skywalking.service }}", - "port_value": "{{ .Values.tracing.skywalking.port }}" - } - } - } - } - ] - } - ] - } - } - ] - } + ] } --- {{- end }} From 9972e7611a5372545c0f1215d21220471cfdc6a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Wed, 9 Oct 2024 20:10:00 +0800 Subject: [PATCH 10/32] rel: Release 2.0.1 (#1375) --- VERSION | 2 +- helm/core/Chart.yaml | 4 ++-- helm/higress/Chart.lock | 8 ++++---- helm/higress/Chart.yaml | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/VERSION b/VERSION index 46b105a30d..0ac852dded 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.0.0 +v2.0.1 diff --git a/helm/core/Chart.yaml b/helm/core/Chart.yaml index 1a969e7d22..1dca35d253 100644 --- a/helm/core/Chart.yaml +++ b/helm/core/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -appVersion: 2.0.0 +appVersion: 2.0.1 description: Helm chart for deploying higress gateways icon: https://higress.io/img/higress_logo_small.png home: http://higress.io/ @@ -10,4 +10,4 @@ name: higress-core sources: - http://github.com/alibaba/higress type: application -version: 2.0.0 +version: 2.0.1 diff --git a/helm/higress/Chart.lock b/helm/higress/Chart.lock index a8f1278488..e2397ea62a 100644 --- a/helm/higress/Chart.lock +++ b/helm/higress/Chart.lock @@ -1,9 +1,9 @@ dependencies: - name: higress-core repository: file://../core - version: 2.0.0 + version: 2.0.1 - name: higress-console repository: https://higress.io/helm-charts/ - version: 1.4.3 -digest: sha256:ebfedb7faee4973b6e1e3624a9fcc20790943aef76ec60921e0010d1e62ff92a -generated: "2024-09-13T10:36:29.963179+08:00" + version: 1.4.4 +digest: sha256:6e4d77c31c834a404a728ec5a8379dd5df27a7e9b998a08e6524dc6534b07c1d +generated: "2024-10-09T20:07:21.857942+08:00" diff --git a/helm/higress/Chart.yaml b/helm/higress/Chart.yaml index 20c63eee98..7d683863ba 100644 --- a/helm/higress/Chart.yaml +++ b/helm/higress/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -appVersion: 2.0.0 +appVersion: 2.0.1 description: Helm chart for deploying Higress gateways icon: https://higress.io/img/higress_logo_small.png home: http://higress.io/ @@ -12,9 +12,9 @@ sources: dependencies: - name: higress-core repository: "file://../core" - version: 2.0.0 + version: 2.0.1 - name: higress-console repository: "https://higress.io/helm-charts/" - version: 1.4.3 + version: 1.4.4 type: application -version: 2.0.0 +version: 2.0.1 From 601b205abcb75448d8f9709703b39b4bed6bf290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Thu, 10 Oct 2024 15:31:48 +0800 Subject: [PATCH 11/32] Update Makefile.core.mk --- Makefile.core.mk | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile.core.mk b/Makefile.core.mk index 1f99882b5c..95341d0f5f 100644 --- a/Makefile.core.mk +++ b/Makefile.core.mk @@ -187,8 +187,8 @@ install: pre-install cd helm/higress; helm dependency build helm install higress helm/higress -n higress-system --create-namespace --set 'global.local=true' -ENVOY_LATEST_IMAGE_TAG ?= 7722dc383cd5d566d1b10a738f79a4cba1a18304 -ISTIO_LATEST_IMAGE_TAG ?= 7722dc383cd5d566d1b10a738f79a4cba1a18304 +ENVOY_LATEST_IMAGE_TAG ?= 2.0.1 +ISTIO_LATEST_IMAGE_TAG ?= 2.0.1 install-dev: pre-install helm install higress helm/core -n higress-system --create-namespace --set 'controller.tag=$(TAG)' --set 'gateway.replicas=1' --set 'pilot.tag=$(ISTIO_LATEST_IMAGE_TAG)' --set 'gateway.tag=$(ENVOY_LATEST_IMAGE_TAG)' --set 'global.local=true' From ae6dab919d7dd9138591fbdd18bc297711603219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Thu, 10 Oct 2024 16:07:57 +0800 Subject: [PATCH 12/32] fix istio ns name (#1378) --- istio/istio | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/istio/istio b/istio/istio index dae7ac29f4..d380470e53 160000 --- a/istio/istio +++ b/istio/istio @@ -1 +1 @@ -Subproject commit dae7ac29f4a86aaeca72c60400abe01bbefe8fb0 +Subproject commit d380470e53b6aa45b7a8ab2bf26cbc6c147da06f From 1a53c7b4d384905b815d7cd7f01f98dd14cfe83d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Fri, 11 Oct 2024 11:39:46 +0800 Subject: [PATCH 13/32] fix mcpbridge endpoint port (#1382) --- registry/direct/watcher.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/direct/watcher.go b/registry/direct/watcher.go index f523f1f06a..8ad5f858c2 100644 --- a/registry/direct/watcher.go +++ b/registry/direct/watcher.go @@ -152,7 +152,7 @@ func (w *watcher) generateServiceEntry(host string) *v1alpha3.ServiceEntry { } endpoint = &v1alpha3.WorkloadEntry{ Address: pair[0], - Ports: map[string]uint32{"http": uint32(port)}, + Ports: map[string]uint32{w.Protocol: uint32(port)}, } } else if w.Type == string(registry.DNS) { if !domainRegex.MatchString(ep) { From 952c9ec5dc7f87db307bc02fca07a1a31787476a Mon Sep 17 00:00:00 2001 From: rinfx <893383980@qq.com> Date: Mon, 14 Oct 2024 12:45:53 +0800 Subject: [PATCH 14/32] Ai proxy support coze (#1387) --- plugins/wasm-go/extensions/ai-proxy/README.md | 12 +++++ .../wasm-go/extensions/ai-proxy/README_EN.md | 10 +++++ .../extensions/ai-proxy/provider/coze.go | 44 +++++++++++++++++++ .../extensions/ai-proxy/provider/provider.go | 2 + 4 files changed, 68 insertions(+) create mode 100644 plugins/wasm-go/extensions/ai-proxy/provider/coze.go diff --git a/plugins/wasm-go/extensions/ai-proxy/README.md b/plugins/wasm-go/extensions/ai-proxy/README.md index 51156a81d1..2a8a838b1d 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README.md +++ b/plugins/wasm-go/extensions/ai-proxy/README.md @@ -669,6 +669,18 @@ provider: timeout: 1200000 ``` +### 使用 original 协议代理 Coze 应用 + +**配置信息** + +```yaml +provider: + type: coze + apiTokens: + - YOUR_COZE_API_KEY + protocol: original +``` + ### 使用月之暗面配合其原生的文件上下文 提前上传文件至月之暗面,以文件内容作为上下文使用其 AI 服务。 diff --git a/plugins/wasm-go/extensions/ai-proxy/README_EN.md b/plugins/wasm-go/extensions/ai-proxy/README_EN.md index 57ef01193f..e34546a4e5 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README_EN.md +++ b/plugins/wasm-go/extensions/ai-proxy/README_EN.md @@ -656,6 +656,16 @@ providers: timeout: 1200000 ``` +### Using original Protocol Proxy for Coze applications + +```yaml +provider: + type: coze + apiTokens: + - YOUR_COZE_API_KEY + protocol: original +``` + ### Utilizing Moonshot with its Native File Context Upload files to Moonshot in advance and use its AI services based on file content. diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/coze.go b/plugins/wasm-go/extensions/ai-proxy/provider/coze.go new file mode 100644 index 0000000000..c163c95661 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/provider/coze.go @@ -0,0 +1,44 @@ +package provider + +import ( + "errors" + + "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/types" +) + +const ( + cozeDomain = "api.coze.cn" +) + +type cozeProviderInitializer struct{} + +func (m *cozeProviderInitializer) 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 *cozeProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) { + return &cozeProvider{ + config: config, + contextCache: createContextCache(&config), + }, nil +} + +type cozeProvider struct { + config ProviderConfig + contextCache *contextCache +} + +func (m *cozeProvider) GetProviderType() string { + return providerTypeCoze +} + +func (m *cozeProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, log wrapper.Log) (types.Action, error) { + _ = util.OverwriteRequestHost(cozeDomain) + _ = util.OverwriteRequestAuthorization("Bearer " + m.config.GetRandomToken()) + return types.ActionContinue, nil +} diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go index facd8bb283..c9d59fd035 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go @@ -42,6 +42,7 @@ const ( providerTypeMistral = "mistral" providerTypeCohere = "cohere" providerTypeDoubao = "doubao" + providerTypeCoze = "coze" protocolOpenAI = "openai" protocolOriginal = "original" @@ -101,6 +102,7 @@ var ( providerTypeMistral: &mistralProviderInitializer{}, providerTypeCohere: &cohereProviderInitializer{}, providerTypeDoubao: &doubaoProviderInitializer{}, + providerTypeCoze: &cozeProviderInitializer{}, } ) From 04ce776f14086008424f252bcbfe58eda5dab554 Mon Sep 17 00:00:00 2001 From: Kent Dong Date: Mon, 14 Oct 2024 18:50:45 +0800 Subject: [PATCH 15/32] feat: Support route fallback by default (#1381) --- helm/core/templates/fallback-envoyfilter.yaml | 22 +++++++++++++++++++ helm/core/values.yaml | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 helm/core/templates/fallback-envoyfilter.yaml diff --git a/helm/core/templates/fallback-envoyfilter.yaml b/helm/core/templates/fallback-envoyfilter.yaml new file mode 100644 index 0000000000..567ee09256 --- /dev/null +++ b/helm/core/templates/fallback-envoyfilter.yaml @@ -0,0 +1,22 @@ +apiVersion: networking.istio.io/v1alpha3 +kind: EnvoyFilter +metadata: + name: {{ include "gateway.name" . }}-global-custom-response + namespace: {{ .Release.Namespace }} + labels: + {{- include "gateway.labels" . | nindent 4}} +spec: + configPatches: + - applyTo: HTTP_FILTER + match: + context: GATEWAY + listener: + filterChain: + filter: + name: envoy.filters.network.http_connection_manager + patch: + operation: INSERT_FIRST + value: + name: envoy.filters.http.custom_response + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.custom_response.v3.CustomResponse \ No newline at end of file diff --git a/helm/core/values.yaml b/helm/core/values.yaml index ce08899207..39582f748d 100644 --- a/helm/core/values.yaml +++ b/helm/core/values.yaml @@ -26,7 +26,7 @@ global: autoscalingv2API: true local: false # When deploying to a local cluster (e.g.: kind cluster), set this to true. kind: false # Deprecated. Please use "global.local" instead. Will be removed later. - enableIstioAPI: false + enableIstioAPI: true enableGatewayAPI: false # Deprecated enableHigressIstio: false From 0a112d1a1e22a2b5122d65ab85c792f540c4cbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Tue, 15 Oct 2024 11:50:43 +0800 Subject: [PATCH 16/32] fix mcp service port protocol name (#1383) --- registry/direct/watcher.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/registry/direct/watcher.go b/registry/direct/watcher.go index 8ad5f858c2..533c80d63f 100644 --- a/registry/direct/watcher.go +++ b/registry/direct/watcher.go @@ -133,6 +133,7 @@ var domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA- func (w *watcher) generateServiceEntry(host string) *v1alpha3.ServiceEntry { endpoints := make([]*v1alpha3.WorkloadEntry, 0) + protocol := string(common.ParseProtocol(w.Protocol)) for _, ep := range strings.Split(w.Domain, common.CommaSeparator) { var endpoint *v1alpha3.WorkloadEntry if w.Type == string(registry.Static) { @@ -152,7 +153,7 @@ func (w *watcher) generateServiceEntry(host string) *v1alpha3.ServiceEntry { } endpoint = &v1alpha3.WorkloadEntry{ Address: pair[0], - Ports: map[string]uint32{w.Protocol: uint32(port)}, + Ports: map[string]uint32{protocol: uint32(port)}, } } else if w.Type == string(registry.DNS) { if !domainRegex.MatchString(ep) { @@ -175,8 +176,8 @@ func (w *watcher) generateServiceEntry(host string) *v1alpha3.ServiceEntry { var ports []*v1alpha3.ServicePort ports = append(ports, &v1alpha3.ServicePort{ Number: w.Port, - Name: w.Protocol, - Protocol: string(common.ParseProtocol(w.Protocol)), + Name: protocol, + Protocol: protocol, }) se := &v1alpha3.ServiceEntry{ Hosts: []string{host}, From 85f8eb51660343c9b740e66148d497a16d945c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Tue, 15 Oct 2024 20:52:03 +0800 Subject: [PATCH 17/32] key-auth consumer support set independent key source (#1392) --- .../wasm-cpp/extensions/key_auth/plugin.cc | 204 +++++++++++++----- plugins/wasm-cpp/extensions/key_auth/plugin.h | 12 +- .../extensions/key_auth/plugin_test.cc | 63 +++++- 3 files changed, 218 insertions(+), 61 deletions(-) diff --git a/plugins/wasm-cpp/extensions/key_auth/plugin.cc b/plugins/wasm-cpp/extensions/key_auth/plugin.cc index a88b6bda7a..ad111760e8 100644 --- a/plugins/wasm-cpp/extensions/key_auth/plugin.cc +++ b/plugins/wasm-cpp/extensions/key_auth/plugin.cc @@ -42,10 +42,7 @@ static RegisterContextFactory register_KeyAuth(CONTEXT_FACTORY(PluginContext), namespace { -void deniedNoKeyAuthData(const std::string& realm) { - sendLocalResponse(401, "No API key found in request", "", - {{"WWW-Authenticate", absl::StrCat("Key realm=", realm)}}); -} +const std::string OriginalAuthKey("X-HI-ORIGINAL-AUTH"); void deniedInvalidCredentials(const std::string& realm) { sendLocalResponse(401, "Request denied by Key Auth check. Invalid API key", @@ -84,6 +81,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration, } if (!JsonArrayIterate( configuration, "consumers", [&](const json& consumer) -> bool { + Consumer c; auto item = consumer.find("name"); if (item == consumer.end()) { LOG_WARN("can't find 'name' field in consumer."); @@ -94,6 +92,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration, !name.first) { return false; } + c.name = name.first.value(); item = consumer.find("credential"); if (item == consumer.end()) { LOG_WARN("can't find 'credential' field in consumer."); @@ -104,6 +103,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration, !credential.first) { return false; } + c.credential = credential.first.value(); if (rule.credential_to_name.find(credential.first.value()) != rule.credential_to_name.end()) { LOG_WARN(absl::StrCat("duplicate consumer credential: ", @@ -113,15 +113,59 @@ bool PluginRootContext::parsePluginConfig(const json& configuration, rule.credentials.insert(credential.first.value()); rule.credential_to_name.emplace( std::make_pair(credential.first.value(), name.first.value())); + item = consumer.find("keys"); + if (item != consumer.end()) { + c.keys = std::vector{OriginalAuthKey}; + if (!JsonArrayIterate( + consumer, "keys", [&](const json& key_json) -> bool { + auto key = JsonValueAs(key_json); + if (key.second != + Wasm::Common::JsonParserResultDetail::OK) { + return false; + } + c.keys->push_back(key.first.value()); + return true; + })) { + LOG_WARN("failed to parse configuration for consumer keys."); + return false; + } + item = consumer.find("in_query"); + if (item != consumer.end()) { + auto in_query = JsonValueAs(item.value()); + if (in_query.second != + Wasm::Common::JsonParserResultDetail::OK || + !in_query.first) { + LOG_WARN( + "failed to parse 'in_query' field in consumer " + "configuration."); + return false; + } + c.in_query = in_query.first; + } + item = consumer.find("in_header"); + if (item != consumer.end()) { + auto in_header = JsonValueAs(item.value()); + if (in_header.second != + Wasm::Common::JsonParserResultDetail::OK || + !in_header.first) { + LOG_WARN( + "failed to parse 'in_header' field in consumer " + "configuration."); + return false; + } + c.in_header = in_header.first; + } + } + rule.consumers.push_back(std::move(c)); return true; })) { LOG_WARN("failed to parse configuration for credentials."); return false; } - if (rule.credentials.empty()) { - LOG_INFO("at least one credential has to be configured for a rule."); - return false; - } + // if (rule.credentials.empty()) { + // LOG_INFO("at least one credential has to be configured for a rule."); + // return false; + // } if (!JsonArrayIterate(configuration, "keys", [&](const json& item) -> bool { auto key = JsonValueAs(item); if (key.second != Wasm::Common::JsonParserResultDetail::OK) { @@ -137,6 +181,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration, LOG_WARN("at least one key has to be configured for a rule."); return false; } + rule.keys.push_back(OriginalAuthKey); auto it = configuration.find("realm"); if (it != configuration.end()) { auto realm_string = JsonValueAs(it.value()); @@ -175,36 +220,102 @@ bool PluginRootContext::parsePluginConfig(const json& configuration, bool PluginRootContext::checkPlugin( const KeyAuthConfigRule& rule, const std::optional>& allow_set) { - auto credential = extractCredential(rule); - if (credential.empty()) { - LOG_DEBUG("empty credential"); - deniedNoKeyAuthData(rule.realm); - return false; + if (rule.consumers.empty()) { + for (const auto& key : rule.keys) { + auto credential = extractCredential(rule.in_header, rule.in_query, key); + if (credential.empty()) { + LOG_DEBUG("empty credential for key: " + key); + continue; + } + + auto auth_credential_iter = rule.credentials.find(credential); + if (auth_credential_iter == rule.credentials.end()) { + LOG_DEBUG("api key not found: " + credential); + continue; + } + + auto credential_to_name_iter = rule.credential_to_name.find(credential); + if (credential_to_name_iter != rule.credential_to_name.end()) { + if (allow_set && !allow_set->empty()) { + if (allow_set->find(credential_to_name_iter->second) == + allow_set->end()) { + deniedUnauthorizedConsumer(rule.realm); + LOG_DEBUG("unauthorized consumer: " + + credential_to_name_iter->second); + return false; + } + } + addRequestHeader("X-Mse-Consumer", credential_to_name_iter->second); + } + return true; + } + } else { + for (const auto& consumer : rule.consumers) { + std::vector keys_to_check = + consumer.keys.value_or(rule.keys); + bool in_query = consumer.in_query.value_or(rule.in_query); + bool in_header = consumer.in_header.value_or(rule.in_header); + + for (const auto& key : keys_to_check) { + auto credential = extractCredential(in_header, in_query, key); + if (credential.empty()) { + LOG_DEBUG("empty credential for key: " + key); + continue; + } + + if (credential != consumer.credential) { + LOG_DEBUG("credential does not match the consumer's credential: " + + credential); + continue; + } + + auto auth_credential_iter = rule.credentials.find(credential); + if (auth_credential_iter == rule.credentials.end()) { + LOG_DEBUG("api key not found: " + credential); + continue; + } + + auto credential_to_name_iter = rule.credential_to_name.find(credential); + if (credential_to_name_iter != rule.credential_to_name.end()) { + if (allow_set && !allow_set->empty()) { + if (allow_set->find(credential_to_name_iter->second) == + allow_set->end()) { + deniedUnauthorizedConsumer(rule.realm); + LOG_DEBUG("unauthorized consumer: " + + credential_to_name_iter->second); + return false; + } + } + addRequestHeader("X-Mse-Consumer", credential_to_name_iter->second); + } + return true; + } + } } - auto auth_credential_iter = rule.credentials.find(std::string(credential)); - // Check if the credential is part of the credentials - // set from our container to grant or deny access. - if (auth_credential_iter == rule.credentials.end()) { - LOG_DEBUG(absl::StrCat("api key not found: ", credential)); - deniedInvalidCredentials(rule.realm); - return false; + + LOG_DEBUG("No valid credentials were found after checking all consumers."); + deniedInvalidCredentials(rule.realm); + return false; +} + +std::string PluginRootContext::extractCredential(bool in_header, bool in_query, + const std::string& key) { + if (in_header) { + auto header = getRequestHeader(key); + if (header->size() != 0) { + return header->toString(); + } } - // Check if this credential has a consumer name. If so, check if this - // consumer is allowed to access. If allow_set is empty, allow all consumers. - auto credential_to_name_iter = - rule.credential_to_name.find(std::string(std::string(credential))); - if (credential_to_name_iter != rule.credential_to_name.end()) { - if (allow_set && !allow_set.value().empty()) { - if (allow_set.value().find(credential_to_name_iter->second) == - allow_set.value().end()) { - deniedUnauthorizedConsumer(rule.realm); - LOG_DEBUG(credential_to_name_iter->second); - return false; - } + if (in_query) { + auto request_path_header = getRequestHeader(":path"); + auto path = request_path_header->view(); + auto params = Wasm::Common::Http::parseAndDecodeQueryString(path); + auto it = params.find(key); + if (it != params.end()) { + return it->second; } - addRequestHeader("X-Mse-Consumer", credential_to_name_iter->second); } - return true; + return ""; } bool PluginRootContext::onConfigure(size_t size) { @@ -234,31 +345,6 @@ bool PluginRootContext::configure(size_t configuration_size) { return true; } -std::string PluginRootContext::extractCredential( - const KeyAuthConfigRule& rule) { - auto request_path_header = getRequestHeader(":path"); - auto path = request_path_header->view(); - LOG_DEBUG(std::string(path)); - if (rule.in_query) { - auto params = Wasm::Common::Http::parseAndDecodeQueryString(path); - for (const auto& key : rule.keys) { - auto it = params.find(key); - if (it != params.end()) { - return it->second; - } - } - } - if (rule.in_header) { - for (const auto& key : rule.keys) { - auto header = getRequestHeader(key); - if (header->size() != 0) { - return header->toString(); - } - } - } - return ""; -} - FilterHeadersStatus PluginContext::onRequestHeaders(uint32_t, bool) { auto* rootCtx = rootContext(); return rootCtx->checkAuthRule( diff --git a/plugins/wasm-cpp/extensions/key_auth/plugin.h b/plugins/wasm-cpp/extensions/key_auth/plugin.h index 4a2b7418d3..4cf329d90c 100644 --- a/plugins/wasm-cpp/extensions/key_auth/plugin.h +++ b/plugins/wasm-cpp/extensions/key_auth/plugin.h @@ -36,7 +36,16 @@ namespace key_auth { #endif +struct Consumer { + std::string name; + std::string credential; + std::optional> keys; + std::optional in_query = std::nullopt; + std::optional in_header = std::nullopt; +}; + struct KeyAuthConfigRule { + std::vector consumers; std::unordered_set credentials; std::unordered_map credential_to_name; std::string realm = "MSE Gateway"; @@ -61,7 +70,8 @@ class PluginRootContext : public RootContext, private: bool parsePluginConfig(const json&, KeyAuthConfigRule&) override; - std::string extractCredential(const KeyAuthConfigRule&); + std::string extractCredential(bool in_header, bool in_query, + const std::string& key); }; // Per-stream context. diff --git a/plugins/wasm-cpp/extensions/key_auth/plugin_test.cc b/plugins/wasm-cpp/extensions/key_auth/plugin_test.cc index fc70df1b00..2f60811e00 100644 --- a/plugins/wasm-cpp/extensions/key_auth/plugin_test.cc +++ b/plugins/wasm-cpp/extensions/key_auth/plugin_test.cc @@ -121,7 +121,7 @@ TEST_F(KeyAuthTest, InQuery) { "_rules_": [ { "_match_route_": ["test"], - "credentials":["abc"], + "credentials":["abc","def"], "keys": ["apiKey", "x-api-key"] } ] @@ -144,6 +144,10 @@ TEST_F(KeyAuthTest, InQuery) { path_ = "/test?hello=123&apiKey=123"; EXPECT_EQ(context_->onRequestHeaders(0, false), FilterHeadersStatus::StopIteration); + + path_ = "/test?hello=123&apiKey=123&x-api-key=def"; + EXPECT_EQ(context_->onRequestHeaders(0, false), + FilterHeadersStatus::Continue); } TEST_F(KeyAuthTest, InQueryWithConsumer) { @@ -173,6 +177,29 @@ TEST_F(KeyAuthTest, InQueryWithConsumer) { FilterHeadersStatus::StopIteration); } +TEST_F(KeyAuthTest, EmptyConsumer) { + std::string configuration = R"( +{ + "consumers" : [], + "keys" : [ "apiKey", "x-api-key" ], + "_rules_" : [ {"_match_route_" : ["test"], "allow" : []} ] +})"; + BufferBase buffer; + buffer.set(configuration); + EXPECT_CALL(*mock_context_, getBuffer(WasmBufferType::PluginConfiguration)) + .WillOnce([&buffer](WasmBufferType) { return &buffer; }); + EXPECT_TRUE(root_context_->configure(configuration.size())); + + route_name_ = "test"; + path_ = "/test?hello=1&apiKey=abc"; + EXPECT_EQ(context_->onRequestHeaders(0, false), + FilterHeadersStatus::StopIteration); + + route_name_ = "test2"; + EXPECT_EQ(context_->onRequestHeaders(0, false), + FilterHeadersStatus::Continue); +} + TEST_F(KeyAuthTest, InHeader) { std::string configuration = R"( { @@ -240,6 +267,40 @@ TEST_F(KeyAuthTest, InHeaderWithConsumer) { FilterHeadersStatus::StopIteration); } +TEST_F(KeyAuthTest, ConsumerDifferentKey) { + std::string configuration = R"( +{ + "consumers" : [ {"credential" : "abc", "name" : "consumer1", "keys" : [ "apiKey" ]}, {"credential" : "123", "name" : "consumer2"} ], + "keys" : [ "apiKey2" ], + "_rules_" : [ {"_match_route_" : ["test"], "allow" : ["consumer1"]}, {"_match_route_" : ["test2"], "allow" : ["consumer2"]} ] +})"; + BufferBase buffer; + buffer.set(configuration); + EXPECT_CALL(*mock_context_, getBuffer(WasmBufferType::PluginConfiguration)) + .WillOnce([&buffer](WasmBufferType) { return &buffer; }); + EXPECT_TRUE(root_context_->configure(configuration.size())); + + route_name_ = "test"; + path_ = "/test?hello=1&apiKey=abc"; + EXPECT_EQ(context_->onRequestHeaders(0, false), + FilterHeadersStatus::Continue); + + route_name_ = "test"; + path_ = "/test?hello=1&apiKey2=abc"; + EXPECT_EQ(context_->onRequestHeaders(0, false), + FilterHeadersStatus::StopIteration); + + route_name_ = "test"; + path_ = "/test?hello=123&apiKey2=123"; + EXPECT_EQ(context_->onRequestHeaders(0, false), + FilterHeadersStatus::StopIteration); + + route_name_ = "test2"; + path_ = "/test?hello=123&apiKey2=123"; + EXPECT_EQ(context_->onRequestHeaders(0, false), + FilterHeadersStatus::Continue); +} + } // namespace key_auth } // namespace null_plugin } // namespace proxy_wasm From e298078065db6550f8a6035c93676de139c75777 Mon Sep 17 00:00:00 2001 From: Jun <108045855+2456868764@users.noreply.github.com> Date: Wed, 16 Oct 2024 10:21:03 +0800 Subject: [PATCH 18/32] add dns&static registry e2e test (#1393) --- test/e2e/conformance/base/manifests.yaml | 52 +++++++++++++++++ .../tests/httproute-dns-registry.go | 57 +++++++++++++++++++ .../tests/httproute-dns-registry.yaml | 48 ++++++++++++++++ .../tests/httproute-static-registry.go | 57 +++++++++++++++++++ .../tests/httproute-static-registry.yaml | 48 ++++++++++++++++ 5 files changed, 262 insertions(+) create mode 100644 test/e2e/conformance/tests/httproute-dns-registry.go create mode 100644 test/e2e/conformance/tests/httproute-dns-registry.yaml create mode 100644 test/e2e/conformance/tests/httproute-static-registry.go create mode 100644 test/e2e/conformance/tests/httproute-static-registry.yaml diff --git a/test/e2e/conformance/base/manifests.yaml b/test/e2e/conformance/base/manifests.yaml index 235dce14dc..a8afe3b11c 100644 --- a/test/e2e/conformance/base/manifests.yaml +++ b/test/e2e/conformance/base/manifests.yaml @@ -80,6 +80,58 @@ spec: --- apiVersion: v1 kind: Service +metadata: + name: infra-backend-v1-ip + namespace: higress-conformance-infra +spec: + selector: + app: infra-backend-v1-ip + ports: + - protocol: TCP + port: 8080 + targetPort: 3000 + clusterIP: 10.96.254.254 + type: ClusterIP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infra-backend-v1-ip + namespace: higress-conformance-infra + labels: + app: infra-backend-v1-ip +spec: + replicas: 1 + selector: + matchLabels: + app: infra-backend-v1-ip + template: + metadata: + labels: + app: infra-backend-v1-ip + spec: + containers: + - name: infra-backend-v1-ip + # From https://github.com/kubernetes-sigs/ingress-controller-conformance/tree/master/images/echoserver + # 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-v2 namespace: higress-conformance-infra diff --git a/test/e2e/conformance/tests/httproute-dns-registry.go b/test/e2e/conformance/tests/httproute-dns-registry.go new file mode 100644 index 0000000000..1864b8d394 --- /dev/null +++ b/test/e2e/conformance/tests/httproute-dns-registry.go @@ -0,0 +1,57 @@ +// 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 ( + "testing" + + "github.com/alibaba/higress/test/e2e/conformance/utils/http" + "github.com/alibaba/higress/test/e2e/conformance/utils/suite" +) + +func init() { + Register(HTTPRouteDNSRegistry) +} + +var HTTPRouteDNSRegistry = suite.ConformanceTest{ + ShortName: "HTTPRouteDNSRegistry", + Description: "The Ingress in the higress-conformance-infra namespace uses the dns service registry.", + Manifests: []string{"tests/httproute-dns-registry.yaml"}, + Features: []suite.SupportedFeature{suite.HTTPConformanceFeature}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + testcases := []http.Assertion{ + { + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/", + Method: "GET", + }, + }, + Response: http.AssertionResponse{ + ExpectedResponseNoRequest: true, + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + } + t.Run("HTTPRoute DNS Registry", func(t *testing.T) { + for _, testcase := range testcases { + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase) + } + }) + }, +} diff --git a/test/e2e/conformance/tests/httproute-dns-registry.yaml b/test/e2e/conformance/tests/httproute-dns-registry.yaml new file mode 100644 index 0000000000..8f992ac9ea --- /dev/null +++ b/test/e2e/conformance/tests/httproute-dns-registry.yaml @@ -0,0 +1,48 @@ +# 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.higress.io/v1 +kind: McpBridge +metadata: + name: default + namespace: higress-system +spec: + registries: + - type: dns + domain: infra-backend-v1.higress-conformance-infra.svc.cluster.local + name: infra-backend-v1 + port: 8080 + protocol: http + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + higress.io/destination: infra-backend-v1.dns + name: httproute-infra-backend-v1-dns-ingress + namespace: higress-system +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Prefix + path: / + backend: + resource: + apiGroup: networking.higress.io + kind: McpBridge + name: default diff --git a/test/e2e/conformance/tests/httproute-static-registry.go b/test/e2e/conformance/tests/httproute-static-registry.go new file mode 100644 index 0000000000..b95acf4a6c --- /dev/null +++ b/test/e2e/conformance/tests/httproute-static-registry.go @@ -0,0 +1,57 @@ +// 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 ( + "testing" + + "github.com/alibaba/higress/test/e2e/conformance/utils/http" + "github.com/alibaba/higress/test/e2e/conformance/utils/suite" +) + +func init() { + Register(HTTPRouteStaticRegistry) +} + +var HTTPRouteStaticRegistry = suite.ConformanceTest{ + ShortName: "HTTPRouteStaticRegistry", + Description: "The Ingress in the higress-conformance-infra namespace uses the static service registry.", + Manifests: []string{"tests/httproute-static-registry.yaml"}, + Features: []suite.SupportedFeature{suite.HTTPConformanceFeature}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + testcases := []http.Assertion{ + { + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/", + Method: "GET", + }, + }, + Response: http.AssertionResponse{ + ExpectedResponseNoRequest: true, + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + } + t.Run("HTTPRoute Static Registry", func(t *testing.T) { + for _, testcase := range testcases { + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase) + } + }) + }, +} diff --git a/test/e2e/conformance/tests/httproute-static-registry.yaml b/test/e2e/conformance/tests/httproute-static-registry.yaml new file mode 100644 index 0000000000..310302fbb8 --- /dev/null +++ b/test/e2e/conformance/tests/httproute-static-registry.yaml @@ -0,0 +1,48 @@ +# 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.higress.io/v1 +kind: McpBridge +metadata: + name: default + namespace: higress-system +spec: + registries: + - type: static + domain: 10.96.254.254:8080 + name: infra-backend-v1-ip + port: 8080 + protocol: http + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + higress.io/destination: infra-backend-v1-ip.static + name: httproute-infra-backend-v1-ip-ingress + namespace: higress-system +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Prefix + path: / + backend: + resource: + apiGroup: networking.higress.io + kind: McpBridge + name: default From d0693d8c4bab0b429d6b58e90589b64bceaac042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Wed, 16 Oct 2024 11:17:44 +0800 Subject: [PATCH 19/32] Update SECURITY.md --- SECURITY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/SECURITY.md b/SECURITY.md index e4f906b953..d643ca19b9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,7 @@ | Version | Supported | | ------- | ------------------ | +| 2.x.x | :white_check_mark: | | 1.x.x | :white_check_mark: | | < 1.0.0 | :x: | From 51c956f0b3f5f0bdc8823295d11e5920d08d3caa Mon Sep 17 00:00:00 2001 From: Kent Dong Date: Wed, 16 Oct 2024 18:42:49 +0800 Subject: [PATCH 20/32] fix: Fix clean targets in Makefile (#1397) --- Makefile.core.mk | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile.core.mk b/Makefile.core.mk index 95341d0f5f..55a9662f70 100644 --- a/Makefile.core.mk +++ b/Makefile.core.mk @@ -221,11 +221,15 @@ clean-higress: ## Cleans all the intermediate files and folders previously gener rm -rf $(DIRS_TO_CLEAN) clean-istio: + rm -rf external/api + rm -rf external/client-go rm -rf external/istio + rm -rf external/pkg clean-gateway: clean-istio rm -rf external/envoy rm -rf external/proxy + rm -rf external/go-control-plane rm -rf external/package/envoy.tar.gz clean-env: From 6f86c31bac81b7604d4a45854a5b811bfa44a171 Mon Sep 17 00:00:00 2001 From: Kent Dong Date: Wed, 16 Oct 2024 19:00:18 +0800 Subject: [PATCH 21/32] feat: Update submodules: envoy/envoy, istio/isitio (#1398) --- envoy/envoy | 2 +- istio/istio | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/envoy/envoy b/envoy/envoy index b3541845c1..e9302f5574 160000 --- a/envoy/envoy +++ b/envoy/envoy @@ -1 +1 @@ -Subproject commit b3541845c1a78d817c73806299415439c23488d2 +Subproject commit e9302f55742b53b617984cf25872c193357b6159 diff --git a/istio/istio b/istio/istio index d380470e53..1dbd773596 160000 --- a/istio/istio +++ b/istio/istio @@ -1 +1 @@ -Subproject commit d380470e53b6aa45b7a8ab2bf26cbc6c147da06f +Subproject commit 1dbd77359624ab4af2953a4840927fbd7ea1d668 From e923cbaeccab033efff42f53c51248e547dfefee Mon Sep 17 00:00:00 2001 From: Smoothengineer <160827599+Smoothengineer@users.noreply.github.com> Date: Thu, 17 Oct 2024 10:23:04 +0530 Subject: [PATCH 22/32] Update README_EN.md (#1402) --- README_EN.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README_EN.md b/README_EN.md index 4413ab8c0c..8ce33aa069 100644 --- a/README_EN.md +++ b/README_EN.md @@ -47,7 +47,7 @@ Powered by [Istio](https://github.com/istio/istio) and [Envoy](https://github.co Higress can function as a microservice gateway, which can discovery microservices from various service registries, such as Nacos, ZooKeeper, Consul, Eureka, etc. - It deeply integrates of [Dubbo](https://github.com/apache/dubbo), [Nacos](https://github.com/alibaba/nacos), [Sentinel](https://github.com/alibaba/Sentinel) and other microservice technology stacks. + It deeply integrates with [Dubbo](https://github.com/apache/dubbo), [Nacos](https://github.com/alibaba/nacos), [Sentinel](https://github.com/alibaba/Sentinel) and other microservice technology stacks. - **Security gateway**: @@ -57,7 +57,7 @@ Powered by [Istio](https://github.com/istio/istio) and [Envoy](https://github.co - **Easy to use** - Provide one-stop gateway solutions for traffic scheduling, service management, and security protection, support Console, K8s Ingress, and Gateway API configuration methods, and also support HTTP to Dubbo protocol conversion, and easily complete protocol mapping configuration. + Provides one-stop gateway solutions for traffic scheduling, service management, and security protection, support Console, K8s Ingress, and Gateway API configuration methods, and also support HTTP to Dubbo protocol conversion, and easily complete protocol mapping configuration. - **Easy to expand** @@ -73,7 +73,7 @@ Powered by [Istio](https://github.com/istio/istio) and [Envoy](https://github.co - **Security** - Provides JWT, OIDC, custom authentication and authentication, deeply integrates open source web application firewall. + Provides JWT, OIDC, custom authentication and authentication, deeply integrates open-source web application firewall. ## Community @@ -81,9 +81,9 @@ Powered by [Istio](https://github.com/istio/istio) and [Envoy](https://github.co ### Thanks -Higress would not be possible without the valuable open-source work of projects in the community. We would like to extend a special thank-you to Envoy and Istio. +Higress would not be possible without the valuable open-source work of projects in the community. We would like to extend a special thank you to Envoy and Istio. ### Related Repositories - Higress Console: https://github.com/higress-group/higress-console -- Higress Standalone: https://github.com/higress-group/higress-standalone \ No newline at end of file +- Higress Standalone: https://github.com/higress-group/higress-standalone From 7e6168a644c0c9c625fbe29331d83e00a48da2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Thu, 17 Oct 2024 14:31:26 +0800 Subject: [PATCH 23/32] Update README.md --- README.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 834b787a5d..5ef491b5f3 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,15 @@

-Higress 是基于阿里内部多年的 Envoy Gateway 实践沉淀,以开源 [Istio](https://github.com/istio/istio) 与 [Envoy](https://github.com/envoyproxy/envoy) 为核心构建的云原生 API 网关。 +Higress 是一款云原生 API 网关,内核基于 Istio 和 Envoy,可以用 Go/Rust/JS 等编写 Wasm 插件,提供了数十个现成的通用插件,以及开箱即用的控制台(demo 点[这里](http://demo.higress.io/)) -Higress 在阿里内部作为 AI 网关,承载了通义千问 APP、百炼大模型 API、机器学习 PAI 平台等 AI 业务的流量。 +Higress 在阿里内部为解决 Tengine reload 对长连接业务有损,以及 gRPC/Dubbo 负载均衡能力不足而诞生。 -Higress 能够用统一的协议对接国内外所有 LLM 模型厂商,同时具备丰富的 AI 可观测、多模型负载均衡/fallback、AI token 流控、AI 缓存等能力: +阿里云基于 Higress 构建了云原生 API 网关产品,为大量企业客户提供 99.99% 的网关高可用保障服务能力。 -![](https://img.alicdn.com/imgextra/i1/O1CN01fNnhCp1cV8mYPRFeS_!!6000000003605-0-tps-1080-608.jpg) +Higress 基于 AI 网关能力,支撑了通义千问 APP、百炼大模型 API、机器学习 PAI 平台等 AI 业务。同时服务国内头部的 AIGC 企业(如零一万物),以及 AI 产品(如 FastGPT) +![](https://img.alicdn.com/imgextra/i2/O1CN011AbR8023V8R5N0HcA_!!6000000007260-2-tps-1080-606.png) ## Summary @@ -67,23 +68,32 @@ K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start - **AI 网关**: - Higress 提供了一站式的 AI 插件集,可以增强依赖 AI 能力业务的稳定性、灵活性、可观测性,使得业务与 AI 的集成更加便捷和高效。 + Higress 能够用统一的协议对接国内外所有 LLM 模型厂商,同时具备丰富的 AI 可观测、多模型负载均衡/fallback、AI token 流控、AI 缓存等能力: + + ![](https://img.alicdn.com/imgextra/i1/O1CN01fNnhCp1cV8mYPRFeS_!!6000000003605-0-tps-1080-608.jpg) - **Kubernetes Ingress 网关**: Higress 可以作为 K8s 集群的 Ingress 入口网关, 并且兼容了大量 K8s Nginx Ingress 的注解,可以从 K8s Nginx Ingress 快速平滑迁移到 Higress。 支持 [Gateway API](https://gateway-api.sigs.k8s.io/) 标准,支持用户从 Ingress API 平滑迁移到 Gateway API。 + + 相比 ingress-nginx,资源开销大幅下降,路由变更生效速度有十倍提升: + + ![](https://img.alicdn.com/imgextra/i1/O1CN01bhEtb229eeMNBWmdP_!!6000000008093-2-tps-750-547.png) + ![](https://img.alicdn.com/imgextra/i1/O1CN01bqRets1LsBGyitj4S_!!6000000001354-2-tps-887-489.png) - **微服务网关**: Higress 可以作为微服务网关, 能够对接多种类型的注册中心发现服务配置路由,例如 Nacos, ZooKeeper, Consul, Eureka 等。 并且深度集成了 [Dubbo](https://github.com/apache/dubbo), [Nacos](https://github.com/alibaba/nacos), [Sentinel](https://github.com/alibaba/Sentinel) 等微服务技术栈,基于 Envoy C++ 网关内核的出色性能,相比传统 Java 类微服务网关,可以显著降低资源使用率,减少成本。 + + ![](https://img.alicdn.com/imgextra/i4/O1CN01v4ZbCj1dBjePSMZ17_!!6000000003698-0-tps-1613-926.jpg) - **安全防护网关**: - Higress 可以作为安全防护网关, 提供 WAF 的能力,并且支持多种认证鉴权策略,例如 key-auth, hmac-auth, jwt-auth, basic-auth, oidc 等。 + Higress 可以作为安全防护网关, 提供 WAF 的能力,并且支持多种认证鉴权策略,例如 key-auth, hmac-auth, jwt-auth, basic-auth, oidc 等。 ## 核心优势 @@ -176,4 +186,4 @@ K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start ### 关联仓库 - Higress 控制台:https://github.com/higress-group/higress-console -- Higress(独立运行版):https://github.com/higress-group/higress-standalone \ No newline at end of file +- Higress(独立运行版):https://github.com/higress-group/higress-standalone From 299621476f61389f2819ffe0df260d7ab28c0c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Thu, 17 Oct 2024 14:33:08 +0800 Subject: [PATCH 24/32] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5ef491b5f3..6495691fe4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![license](https://img.shields.io/github/license/alibaba/higress.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) [**官网**](https://higress.cn/)   | -  [**文档**](https://higress.cn/docs/latest/user/quickstart/)   | +  [**文档**](https://higress.cn/docs/latest/overview/what-is-higress/)   |   [**博客**](https://higress.cn/blog/)   |   [**电子书**](https://higress.cn/docs/ebook/wasm14/)   |   [**开发指引**](https://higress.cn/docs/latest/dev/architecture/)   | From c67f494b497fc074a7eca398e9f8a7205f02c1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Fri, 18 Oct 2024 09:54:10 +0800 Subject: [PATCH 25/32] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6495691fe4..87468ed9ad 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start ### 交流群 -![image](https://img.alicdn.com/imgextra/i2/O1CN01qPd7Ix1uZPVEsWjWp_!!6000000006051-0-tps-720-405.jpg) +![image](https://img.alicdn.com/imgextra/i2/O1CN01BkopaB22ZsvamFftE_!!6000000007135-0-tps-720-405.jpg) ### 技术分享 From 11ff2d1d31594a73d5b870de039ff52a066ad58d Mon Sep 17 00:00:00 2001 From: mamba <371510756@qq.com> Date: Fri, 18 Oct 2024 13:58:52 +0800 Subject: [PATCH 26/32] [frontend-gray] support grayKey from localStorage (#1395) --- .../extensions/frontend-gray/README.md | 25 ++++++++++ .../extensions/frontend-gray/config/config.go | 27 ++++++---- .../extensions/frontend-gray/envoy.yaml | 16 +++--- .../wasm-go/extensions/frontend-gray/main.go | 50 +++++++++++-------- .../extensions/frontend-gray/util/utils.go | 16 +++++- .../frontend-gray/util/utils_test.go | 24 +++++++++ 6 files changed, 115 insertions(+), 43 deletions(-) diff --git a/plugins/wasm-go/extensions/frontend-gray/README.md b/plugins/wasm-go/extensions/frontend-gray/README.md index 8dee32008b..dba4b73c0f 100644 --- a/plugins/wasm-go/extensions/frontend-gray/README.md +++ b/plugins/wasm-go/extensions/frontend-gray/README.md @@ -17,6 +17,7 @@ description: 前端灰度插件配置参考 | 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | |----------------|--------------|----|-----|----------------------------------------------------------------------------------------------------| | `grayKey` | string | 非必填 | - | 用户ID的唯一标识,可以来自Cookie或者Header中,比如 userid,如果没有填写则使用`rules[].grayTagKey`和`rules[].grayTagValue`过滤灰度规则 | +| `localStorageGrayKey` | string | 非必填 | - | 使用JWT鉴权方式,用户ID的唯一标识来自`localStorage`中,如果配置了当前参数,则`grayKey`失效 | | `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出,比如:`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` | | `userStickyMaxAge` | int | 非必填 | 172800 | 用户粘滞的时长:单位为秒,默认为`172800`,2天时间 | | `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 | @@ -168,6 +169,30 @@ cookie存在`appInfo`的JSON数据,其中包含`userId`字段为当前的唯 否则使用`version: base`版本 +### 用户信息存储在LocalStorage +由于网关插件需要识别用户为唯一身份信息,HTTP协议进行信息传输,只能在Header中传递。如果用户信息存储在LocalStorage,在首页注入一段脚本将LocalStorage中的用户信息设置到cookie中。 +``` +(function() { + var grayKey = '@@X_GRAY_KEY'; + var cookies = document.cookie.split('; ').filter(function(row) { + return row.indexOf(grayKey + '=') === 0; + }); + + try { + if (typeof localStorage !== 'undefined' && localStorage !== null) { + var storageValue = localStorage.getItem(grayKey); + var cookieValue = cookies.length > 0 ? decodeURIComponent(cookies[0].split('=')[1]) : null; + if (storageValue && storageValue.indexOf('=') < 0 && cookieValue && cookieValue !== storageValue) { + document.cookie = grayKey + '=' + encodeURIComponent(storageValue) + '; path=/;'; + window.location.reload(); + } + } + } catch (error) { + // xx + } +})(); +``` + ### rewrite重写配置 > 一般用于CDN部署场景 ```yml diff --git a/plugins/wasm-go/extensions/frontend-gray/config/config.go b/plugins/wasm-go/extensions/frontend-gray/config/config.go index de689aad2a..ecfbb3a836 100644 --- a/plugins/wasm-go/extensions/frontend-gray/config/config.go +++ b/plugins/wasm-go/extensions/frontend-gray/config/config.go @@ -49,17 +49,18 @@ type BodyInjection struct { } type GrayConfig struct { - UserStickyMaxAge string - TotalGrayWeight int - GrayKey string - GraySubKey string - Rules []*GrayRule - Rewrite *Rewrite - Html string - BaseDeployment *Deployment - GrayDeployments []*Deployment - BackendGrayTag string - Injection *Injection + UserStickyMaxAge string + TotalGrayWeight int + GrayKey string + LocalStorageGrayKey string + GraySubKey string + Rules []*GrayRule + Rewrite *Rewrite + Html string + BaseDeployment *Deployment + GrayDeployments []*Deployment + BackendGrayTag string + Injection *Injection } func convertToStringList(results []gjson.Result) []string { @@ -81,7 +82,11 @@ func convertToStringMap(result gjson.Result) map[string]string { func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) { // 解析 GrayKey + grayConfig.LocalStorageGrayKey = json.Get("localStorageGrayKey").String() grayConfig.GrayKey = json.Get("grayKey").String() + if grayConfig.LocalStorageGrayKey != "" { + grayConfig.GrayKey = grayConfig.LocalStorageGrayKey + } grayConfig.GraySubKey = json.Get("graySubKey").String() grayConfig.BackendGrayTag = json.Get("backendGrayTag").String() grayConfig.UserStickyMaxAge = json.Get("userStickyMaxAge").String() diff --git a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml index 6dabed21de..239e221bd7 100644 --- a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml +++ b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml @@ -73,23 +73,22 @@ static_resources: ], "rewrite": { "host": "frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com", - "notFoundUri": "/cygtapi/{version}/333.html", "indexRouting": { - "/app1": "/cygtapi/{version}/index.html", - "/": "/cygtapi/{version}/index.html" + "/app1": "/mfe/app1/{version}/index.html", + "/": "/mfe/app1/{version}/index.html" }, "fileRouting": { - "/": "/cygtapi/{version}", - "/app1": "/cygtapi/{version}" + "/": "/mfe/app1/{version}", + "/app1": "/mfe/app1/{version}" } }, "baseDeployment": { - "version": "base" + "version": "dev" }, "grayDeployments": [ { "name": "beta-user", - "version": "gray", + "version": "0.0.1", "enabled": true } ], @@ -107,8 +106,7 @@ 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: diff --git a/plugins/wasm-go/extensions/frontend-gray/main.go b/plugins/wasm-go/extensions/frontend-gray/main.go index 81eb3034da..b1b5d28aff 100644 --- a/plugins/wasm-go/extensions/frontend-gray/main.go +++ b/plugins/wasm-go/extensions/frontend-gray/main.go @@ -21,14 +21,13 @@ func main() { wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), wrapper.ProcessResponseHeadersBy(onHttpResponseHeader), wrapper.ProcessResponseBodyBy(onHttpResponseBody), - wrapper.ProcessStreamingResponseBodyBy(onStreamingResponseBody), ) } 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")) + log.Debugf("Rewrite: %v, GrayDeployments: %v", json.Get("rewrite"), json.Get("grayDeployments")) return nil } @@ -98,15 +97,17 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, } else { rewritePath = util.PrefixFileRewrite(path, deployment.Version, grayConfig.Rewrite.File) } - log.Infof("rewrite path: %s %s %v", path, deployment.Version, rewritePath) - proxywasm.ReplaceHttpRequestHeader(":path", rewritePath) + if path != rewritePath { + log.Infof("rewrite path:%s, rewritePath:%s, Version:%v", path, rewritePath, deployment.Version) + proxywasm.ReplaceHttpRequestHeader(":path", rewritePath) + } } - return types.ActionContinue } func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action { if !util.IsGrayEnabled(grayConfig) { + ctx.DontReadResponseBody() return types.ActionContinue } isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool) @@ -117,6 +118,9 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, if !isPageRequest { ctx.DontReadResponseBody() return types.ActionContinue + } else { + // 不会进去Streaming 的Body处理 + ctx.BufferResponseBody() } status, err := proxywasm.GetHttpResponseHeader(":status") @@ -159,8 +163,6 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, return types.ActionContinue } - // 不会进去Streaming 的Body处理 - ctx.BufferResponseBody() proxywasm.ReplaceHttpResponseHeader("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate") frontendVersion := ctx.GetContext(config.XPreHigressTag).(string) @@ -184,6 +186,11 @@ func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, b if !ok { isPageRequest = false // 默认值 } + // 只处理首页相关请求 + if !isPageRequest { + return types.ActionContinue + } + frontendVersion := ctx.GetContext(config.XPreHigressTag).(string) isNotFound, ok := ctx.GetContext(config.IsNotFound).(bool) if !ok { @@ -212,7 +219,8 @@ func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, b return types.ActionContinue } - if isPageRequest && isNotFound && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" { + // 针对404页面处理 + if isNotFound && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" { client := wrapper.NewClusterClient(wrapper.RouteCluster{Host: grayConfig.Rewrite.Host}) client.Get(strings.Replace(grayConfig.Rewrite.NotFound, "{version}", frontendVersion, -1), nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) { @@ -222,20 +230,18 @@ func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, b return types.ActionPause } - if isPageRequest { - // 将原始字节转换为字符串 - newBody := string(body) - - newBody = util.InjectContent(newBody, grayConfig.Injection) - - if err := proxywasm.ReplaceHttpResponseBody([]byte(newBody)); err != nil { - return types.ActionContinue - } + // 处理响应体HTML + newBody := string(body) + newBody = util.InjectContent(newBody, grayConfig.Injection) + if grayConfig.LocalStorageGrayKey != "" { + localStr := strings.ReplaceAll(` + `, "@@X_GRAY_KEY", grayConfig.LocalStorageGrayKey) + newBody = strings.ReplaceAll(newBody, "", "\n"+localStr) + } + if err := proxywasm.ReplaceHttpResponseBody([]byte(newBody)); err != nil { + return types.ActionContinue } - return types.ActionContinue } - -func onStreamingResponseBody(ctx wrapper.HttpContext, pluginConfig config.GrayConfig, chunk []byte, isLastChunk bool, log wrapper.Log) []byte { - return chunk -} diff --git a/plugins/wasm-go/extensions/frontend-gray/util/utils.go b/plugins/wasm-go/extensions/frontend-gray/util/utils.go index 80291a2c3c..e67d3e0893 100644 --- a/plugins/wasm-go/extensions/frontend-gray/util/utils.go +++ b/plugins/wasm-go/extensions/frontend-gray/util/utils.go @@ -142,8 +142,22 @@ func IsPageRequest(fetchMode string, myPath string) bool { // 首页Rewrite func IndexRewrite(path, version string, matchRules map[string]string) string { - for prefix, rewrite := range matchRules { + // Create a slice of keys in matchRules and sort them by length in descending order + keys := make([]string, 0, len(matchRules)) + for prefix := range matchRules { + keys = append(keys, prefix) + } + sort.Slice(keys, func(i, j int) bool { + if len(keys[i]) != len(keys[j]) { + return len(keys[i]) > len(keys[j]) // Sort by length + } + return keys[i] < keys[j] // Sort lexicographically + }) + + // Iterate over sorted keys to find the longest match + for _, prefix := range keys { if strings.HasPrefix(path, prefix) { + rewrite := matchRules[prefix] newPath := strings.Replace(rewrite, "{version}", version, -1) return newPath } 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 d51e17f323..b6681c98af 100644 --- a/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go +++ b/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go @@ -53,6 +53,30 @@ func TestIndexRewrite(t *testing.T) { } } +func TestIndexRewrite2(t *testing.T) { + matchRules := map[string]string{ + "/": "/{version}/index.html", + "/sta": "/sta/{version}/index.html", + "/static": "/static/{version}/index.html", + } + + var tests = []struct { + path, output string + }{ + {"/static123", "/static/v1.0.0/index.html"}, + {"/static", "/static/v1.0.0/index.html"}, + {"/sta", "/sta/v1.0.0/index.html"}, + {"/", "/v1.0.0/index.html"}, + } + for _, test := range tests { + testName := test.path + t.Run(testName, func(t *testing.T) { + output := IndexRewrite(testName, "v1.0.0", matchRules) + assert.Equal(t, test.output, output) + }) + } +} + func TestPrefixFileRewrite(t *testing.T) { matchRules := map[string]string{ // 前缀匹配 From 49bb5ec2b9f9ba628667a8a95d498e33f56e8459 Mon Sep 17 00:00:00 2001 From: Lisheng Zheng Date: Fri, 18 Oct 2024 15:34:34 +0800 Subject: [PATCH 27/32] fix: add HTTP2 protocol options to skywalking and otel cluster (#1379) --- Makefile.core.mk | 8 +- pkg/ingress/kube/configmap/tracing.go | 145 +++++++++++++++++--------- 2 files changed, 102 insertions(+), 51 deletions(-) diff --git a/Makefile.core.mk b/Makefile.core.mk index 55a9662f70..b790d3c878 100644 --- a/Makefile.core.mk +++ b/Makefile.core.mk @@ -72,17 +72,17 @@ go.test.coverage: prebuild .PHONY: build build: prebuild $(OUT) - GOPROXY=$(GOPROXY) GOOS=$(GOOS_LOCAL) GOARCH=$(GOARCH_LOCAL) LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh $(OUT)/ $(HIGRESS_BINARIES) + GOPROXY="$(GOPROXY)" GOOS=$(GOOS_LOCAL) GOARCH=$(GOARCH_LOCAL) LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh $(OUT)/ $(HIGRESS_BINARIES) .PHONY: build-linux build-linux: prebuild $(OUT) - GOPROXY=$(GOPROXY) GOOS=linux GOARCH=$(GOARCH_LOCAL) LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh $(OUT_LINUX)/ $(HIGRESS_BINARIES) + GOPROXY="$(GOPROXY)" GOOS=linux GOARCH=$(GOARCH_LOCAL) LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh $(OUT_LINUX)/ $(HIGRESS_BINARIES) $(AMD64_OUT_LINUX)/higress: - GOPROXY=$(GOPROXY) GOOS=linux GOARCH=amd64 LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh ./out/linux_amd64/ $(HIGRESS_BINARIES) + GOPROXY="$(GOPROXY)" GOOS=linux GOARCH=amd64 LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh ./out/linux_amd64/ $(HIGRESS_BINARIES) $(ARM64_OUT_LINUX)/higress: - GOPROXY=$(GOPROXY) GOOS=linux GOARCH=arm64 LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh ./out/linux_arm64/ $(HIGRESS_BINARIES) + GOPROXY="$(GOPROXY)" GOOS=linux GOARCH=arm64 LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh ./out/linux_arm64/ $(HIGRESS_BINARIES) .PHONY: build-hgctl build-hgctl: prebuild $(OUT) diff --git a/pkg/ingress/kube/configmap/tracing.go b/pkg/ingress/kube/configmap/tracing.go index 209c610d8f..0529ccf858 100644 --- a/pkg/ingress/kube/configmap/tracing.go +++ b/pkg/ingress/kube/configmap/tracing.go @@ -255,69 +255,120 @@ func (t *TracingController) ConstructEnvoyFilters() ([]*config.Config, error) { return configs, nil } - config := &config.Config{ - Meta: config.Meta{ - GroupVersionKind: gvk.EnvoyFilter, - Name: higressTracingEnvoyFilterName, - Namespace: namespace, - }, - Spec: &networking.EnvoyFilter{ - ConfigPatches: []*networking.EnvoyFilter_EnvoyConfigObjectPatch{ - { - ApplyTo: networking.EnvoyFilter_NETWORK_FILTER, - Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{ - Context: networking.EnvoyFilter_GATEWAY, - ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Listener{ - Listener: &networking.EnvoyFilter_ListenerMatch{ - FilterChain: &networking.EnvoyFilter_ListenerMatch_FilterChainMatch{ - Filter: &networking.EnvoyFilter_ListenerMatch_FilterMatch{ - Name: "envoy.filters.network.http_connection_manager", - }, - }, + configPatches := []*networking.EnvoyFilter_EnvoyConfigObjectPatch{ + { + ApplyTo: networking.EnvoyFilter_NETWORK_FILTER, + Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{ + Context: networking.EnvoyFilter_GATEWAY, + ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Listener{ + Listener: &networking.EnvoyFilter_ListenerMatch{ + FilterChain: &networking.EnvoyFilter_ListenerMatch_FilterChainMatch{ + Filter: &networking.EnvoyFilter_ListenerMatch_FilterMatch{ + Name: "envoy.filters.network.http_connection_manager", }, }, }, - Patch: &networking.EnvoyFilter_Patch{ - Operation: networking.EnvoyFilter_Patch_MERGE, - Value: util.BuildPatchStruct(tracingConfig), - }, }, - { - ApplyTo: networking.EnvoyFilter_HTTP_FILTER, - Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{ - Context: networking.EnvoyFilter_GATEWAY, - ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Listener{ - Listener: &networking.EnvoyFilter_ListenerMatch{ - FilterChain: &networking.EnvoyFilter_ListenerMatch_FilterChainMatch{ - Filter: &networking.EnvoyFilter_ListenerMatch_FilterMatch{ - Name: "envoy.filters.network.http_connection_manager", - SubFilter: &networking.EnvoyFilter_ListenerMatch_SubFilterMatch{ - Name: "envoy.filters.http.router", - }, - }, + }, + Patch: &networking.EnvoyFilter_Patch{ + Operation: networking.EnvoyFilter_Patch_MERGE, + Value: util.BuildPatchStruct(tracingConfig), + }, + }, + { + ApplyTo: networking.EnvoyFilter_HTTP_FILTER, + Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{ + Context: networking.EnvoyFilter_GATEWAY, + ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Listener{ + Listener: &networking.EnvoyFilter_ListenerMatch{ + FilterChain: &networking.EnvoyFilter_ListenerMatch_FilterChainMatch{ + Filter: &networking.EnvoyFilter_ListenerMatch_FilterMatch{ + Name: "envoy.filters.network.http_connection_manager", + SubFilter: &networking.EnvoyFilter_ListenerMatch_SubFilterMatch{ + Name: "envoy.filters.http.router", }, }, }, }, - Patch: &networking.EnvoyFilter_Patch{ - Operation: networking.EnvoyFilter_Patch_MERGE, - Value: util.BuildPatchStruct(`{ + }, + }, + Patch: &networking.EnvoyFilter_Patch{ + Operation: networking.EnvoyFilter_Patch_MERGE, + Value: util.BuildPatchStruct(`{ "name":"envoy.filters.http.router", "typed_config":{ "@type": "type.googleapis.com/envoy.extensions.filters.http.router.v3.Router", "start_child_span": true } }`), - }, - }, }, }, } + patches := t.constructTracingExtendPatches(tracing) + configPatches = append(configPatches, patches...) + + config := &config.Config{ + Meta: config.Meta{ + GroupVersionKind: gvk.EnvoyFilter, + Name: higressTracingEnvoyFilterName, + Namespace: namespace, + }, + Spec: &networking.EnvoyFilter{ + ConfigPatches: configPatches, + }, + } + configs = append(configs, config) return configs, nil } +func tracingClusterName(port, service string) string { + return fmt.Sprintf("outbound|%s||%s", port, service) +} + +func (t *TracingController) constructHTTP2ProtocolOptionsPatch(port, service string) *networking.EnvoyFilter_EnvoyConfigObjectPatch { + http2ProtocolOptions := `{"typed_extension_protocol_options": { + "envoy.extensions.upstreams.http.v3.HttpProtocolOptions": { + "@type": "type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions", + "explicit_http_config": { + "http2_protocol_options": {} + } + } +}}` + + return &networking.EnvoyFilter_EnvoyConfigObjectPatch{ + ApplyTo: networking.EnvoyFilter_CLUSTER, + Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{ + Context: networking.EnvoyFilter_GATEWAY, + ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Cluster{ + Cluster: &networking.EnvoyFilter_ClusterMatch{ + Name: tracingClusterName(port, service), + }, + }, + }, + Patch: &networking.EnvoyFilter_Patch{ + Operation: networking.EnvoyFilter_Patch_MERGE, + Value: util.BuildPatchStruct(http2ProtocolOptions), + }, + } +} + +func (t *TracingController) constructTracingExtendPatches(tracing *Tracing) []*networking.EnvoyFilter_EnvoyConfigObjectPatch { + if tracing == nil { + return nil + } + var patches []*networking.EnvoyFilter_EnvoyConfigObjectPatch + if skywalking := tracing.Skywalking; skywalking != nil { + patches = append(patches, t.constructHTTP2ProtocolOptionsPatch(skywalking.Port, skywalking.Service)) + } + if otel := tracing.OpenTelemetry; otel != nil { + patches = append(patches, t.constructHTTP2ProtocolOptionsPatch(otel.Port, otel.Service)) + } + + return patches +} + func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace string) string { tracingConfig := "" timeout := float32(tracing.Timeout) / 1000 @@ -338,7 +389,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s }, "grpc_service": { "envoy_grpc": { - "cluster_name": "outbound|%s||%s" + "cluster_name": "%s" }, "timeout": "%.3fs" } @@ -349,7 +400,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s } } } -}`, namespace, skywalking.AccessToken, skywalking.Port, skywalking.Service, timeout, tracing.Sampling) +}`, namespace, skywalking.AccessToken, tracingClusterName(skywalking.Port, skywalking.Service), timeout, tracing.Sampling) } if tracing.Zipkin != nil { @@ -363,7 +414,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s "name": "envoy.tracers.zipkin", "typed_config": { "@type": "type.googleapis.com/envoy.config.trace.v3.ZipkinConfig", - "collector_cluster": "outbound|%s||%s", + "collector_cluster": "%s", "collector_endpoint": "/api/v2/spans", "collector_hostname": "higress-gateway", "collector_endpoint_version": "HTTP_JSON", @@ -375,7 +426,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s } } } -}`, zipkin.Port, zipkin.Service, tracing.Sampling) +}`, tracingClusterName(zipkin.Port, zipkin.Service), tracing.Sampling) } if tracing.OpenTelemetry != nil { @@ -392,7 +443,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s "service_name": "higress-gateway.%s", "grpc_service": { "envoy_grpc": { - "cluster_name": "outbound|%s||%s" + "cluster_name": "%s" }, "timeout": "%.3fs" } @@ -403,7 +454,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s } } } -}`, namespace, opentelemetry.Port, opentelemetry.Service, timeout, tracing.Sampling) +}`, namespace, tracingClusterName(opentelemetry.Port, opentelemetry.Service), timeout, tracing.Sampling) } return tracingConfig } From 32e5a59ae01e14c5f2accc823e5401c0bce2bb8e Mon Sep 17 00:00:00 2001 From: rinfx <893383980@qq.com> Date: Fri, 18 Oct 2024 16:32:48 +0800 Subject: [PATCH 28/32] fix special charactor handle in ai-security-guard plugin (#1394) --- .../extensions/ai-security-guard/main.go | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/plugins/wasm-go/extensions/ai-security-guard/main.go b/plugins/wasm-go/extensions/ai-security-guard/main.go index 3051fe22ea..ca59f7f6a1 100644 --- a/plugins/wasm-go/extensions/ai-security-guard/main.go +++ b/plugins/wasm-go/extensions/ai-security-guard/main.go @@ -81,7 +81,7 @@ func (config *AISecurityConfig) incrementCounter(metricName string, inc uint64) func urlEncoding(rawStr string) string { encodedStr := url.PathEscape(rawStr) - encodedStr = strings.ReplaceAll(encodedStr, "+", "%20") + encodedStr = strings.ReplaceAll(encodedStr, "+", "%2B") encodedStr = strings.ReplaceAll(encodedStr, ":", "%3A") encodedStr = strings.ReplaceAll(encodedStr, "=", "%3D") encodedStr = strings.ReplaceAll(encodedStr, "&", "%26") @@ -106,7 +106,7 @@ func getSign(params map[string]string, secret string) string { }) canonicalStr := strings.Join(paramArray, "&") signStr := "POST&%2F&" + urlEncoding(canonicalStr) - // proxywasm.LogInfo(signStr) + proxywasm.LogDebugf("String to sign is: %s", signStr) return hmacSha1(signStr, secret) } @@ -196,10 +196,11 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte, log wrapper.Log) types.Action { log.Debugf("checking request body...") - content := gjson.GetBytes(body, config.requestContentJsonPath).String() + content := gjson.GetBytes(body, config.requestContentJsonPath).Raw model := gjson.GetBytes(body, "model").Raw ctx.SetContext("requestModel", model) - if content != "" { + 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{ @@ -212,7 +213,7 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body [] "AccessKeyId": config.ak, "Timestamp": timestamp, "Service": config.requestCheckService, - "ServiceParameters": `{"content": "` + content + `"}`, + "ServiceParameters": fmt.Sprintf(`{"content": %s}`, content), } signature := getSign(params, config.sk+"&") reqParams := url.Values{} @@ -339,7 +340,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [ if isStreamingResponse { content = extractMessageFromStreamingBody(body, config.responseStreamContentJsonPath) } else { - content = gjson.GetBytes(body, config.responseContentJsonPath).String() + content = gjson.GetBytes(body, config.responseContentJsonPath).Raw } log.Debugf("Raw response content is: %s", content) if len(content) > 0 { @@ -355,7 +356,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [ "AccessKeyId": config.ak, "Timestamp": timestamp, "Service": config.responseCheckService, - "ServiceParameters": `{"content": "` + content + `"}`, + "ServiceParameters": fmt.Sprintf(`{"content": %s}`, content), } signature := getSign(params, config.sk+"&") reqParams := url.Values{} @@ -400,10 +401,10 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [ jsonData = []byte(denyMessage) } else if strings.Contains(strings.Join(hdsMap["content-type"], ";"), "event-stream") { randomID := generateRandomID() - jsonData = []byte(fmt.Sprintf(OpenAIStreamResponseFormat, randomID, model, respAdvice.Array()[0].Get("Answer").String(), randomID, model)) + jsonData = []byte(fmt.Sprintf(OpenAIStreamResponseFormat, randomID, model, denyMessage, randomID, model)) } else { randomID := generateRandomID() - jsonData = []byte(fmt.Sprintf(OpenAIResponseFormat, randomID, model, respAdvice.Array()[0].Get("Answer").String())) + jsonData = []byte(fmt.Sprintf(OpenAIResponseFormat, randomID, model, denyMessage)) } delete(hdsMap, "content-length") hdsMap[":status"] = []string{fmt.Sprint(config.denyCode)} @@ -432,10 +433,10 @@ func extractMessageFromStreamingBody(data []byte, jsonPath string) string { 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()) + jsonRaw := gjson.GetBytes(chunk, jsonPath).Raw + if len(jsonRaw) > 2 { + strChunks = append(strChunks, jsonRaw[1:len(jsonRaw)-1]) } } - return strings.Join(strChunks, "") + return fmt.Sprintf(`"%s"`, strings.Join(strChunks, "")) } From d96994767c92a1fe4e802359508ab4a20eddfc9e Mon Sep 17 00:00:00 2001 From: 007gzs <007gzs@gmail.com> Date: Mon, 21 Oct 2024 09:44:01 +0800 Subject: [PATCH 29/32] Change http_content to Rc in HttpWrapper (#1391) --- plugins/wasm-rust/Cargo.lock | 50 +++-- plugins/wasm-rust/Cargo.toml | 1 + .../extensions/ai-data-masking/Cargo.lock | 137 +++++++------ .../extensions/ai-data-masking/src/lib.rs | 4 +- .../wasm-rust/extensions/demo-wasm/Cargo.lock | 50 +++-- .../wasm-rust/extensions/demo-wasm/src/lib.rs | 109 +++++------ .../extensions/request-block/Cargo.lock | 93 ++++++--- .../extensions/request-block/src/lib.rs | 8 +- .../wasm-rust/extensions/say-hello/Cargo.lock | 81 ++++++-- plugins/wasm-rust/src/plugin_wrapper.rs | 184 ++++++++++-------- plugins/wasm-rust/src/rule_matcher.rs | 25 +-- 11 files changed, 442 insertions(+), 300 deletions(-) diff --git a/plugins/wasm-rust/Cargo.lock b/plugins/wasm-rust/Cargo.lock index 899d559b0d..9cf5140c77 100644 --- a/plugins/wasm-rust/Cargo.lock +++ b/plugins/wasm-rust/Cargo.lock @@ -32,6 +32,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "fnv" version = "1.0.7" @@ -63,6 +69,7 @@ dependencies = [ name = "higress-wasm-rust" version = "0.1.0" dependencies = [ + "downcast-rs", "http", "lazy_static", "multimap", @@ -97,9 +104,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "log" @@ -124,15 +131,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" dependencies = [ "unicode-ident", ] @@ -140,17 +147,18 @@ dependencies = [ [[package]] name = "proxy-wasm" version = "0.2.2" -source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b" +source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#6735737fad486c8a7cc324241f58df4a160e7887" dependencies = [ + "downcast-rs", "hashbrown", "log", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -163,18 +171,18 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -183,9 +191,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.125" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", "memchr", @@ -195,9 +203,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.74" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -206,15 +214,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "uuid" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ "getrandom", ] diff --git a/plugins/wasm-rust/Cargo.toml b/plugins/wasm-rust/Cargo.toml index a1e5472c63..e4fc274311 100644 --- a/plugins/wasm-rust/Cargo.toml +++ b/plugins/wasm-rust/Cargo.toml @@ -13,3 +13,4 @@ uuid = { version = "1.3.3", features = ["v4"] } multimap = "0" http = "1" lazy_static = "1" +downcast-rs="1" diff --git a/plugins/wasm-rust/extensions/ai-data-masking/Cargo.lock b/plugins/wasm-rust/extensions/ai-data-masking/Cargo.lock index 853fde94fb..914d5b9689 100644 --- a/plugins/wasm-rust/extensions/ai-data-masking/Cargo.lock +++ b/plugins/wasm-rust/extensions/ai-data-masking/Cargo.lock @@ -82,11 +82,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" + [[package]] name = "cc" -version = "1.1.11" +version = "1.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fb8dd288a69fc53a1996d7ecfbf4a20d59065bff137ce7e56bbd620de191189" +checksum = "b16803a61b81d9eabb7eae2588776c4c1e584b738ede45fdbb4c972cec1e9945" dependencies = [ "shlex", ] @@ -108,9 +114,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "cpufeatures" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" dependencies = [ "libc", ] @@ -162,18 +168,18 @@ dependencies = [ [[package]] name = "derive_builder" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ "derive_builder_macro", ] [[package]] name = "derive_builder_core" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ "darling", "proc-macro2", @@ -183,9 +189,9 @@ dependencies = [ [[package]] name = "derive_builder_macro" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", "syn", @@ -201,6 +207,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "fancy-regex" version = "0.13.0" @@ -278,6 +290,9 @@ dependencies = [ name = "higress-wasm-rust" version = "0.1.0" dependencies = [ + "downcast-rs", + "http", + "lazy_static", "multimap", "proxy-wasm", "serde", @@ -285,6 +300,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -314,9 +340,9 @@ dependencies = [ [[package]] name = "jsonpath-rust" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64f9886fc067a709ab27faf63b7d3f4d1ec570a700705408b0b0683e2f43897" +checksum = "514f8a353ad9e85443b30fefe169ce93794ec7c98054a4312ab08530f15b7bb3" dependencies = [ "pest", "pest_derive", @@ -333,9 +359,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "log" @@ -366,9 +392,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "onig" @@ -394,9 +420,9 @@ dependencies = [ [[package]] name = "pest" -version = "2.7.11" +version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95" +checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442" dependencies = [ "memchr", "thiserror", @@ -405,9 +431,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.11" +version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a" +checksum = "d214365f632b123a47fd913301e14c946c61d1c183ee245fa76eb752e59a02dd" dependencies = [ "pest", "pest_generator", @@ -415,9 +441,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.11" +version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183" +checksum = "eb55586734301717aea2ac313f50b2eb8f60d2fc3dc01d190eefa2e625f60c4e" dependencies = [ "pest", "pest_meta", @@ -428,9 +454,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.7.11" +version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f" +checksum = "b75da2a70cf4d9cb76833c990ac9cd3923c9a8905a8929789ce347c84564d03d" dependencies = [ "once_cell", "pest", @@ -477,15 +503,15 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" dependencies = [ "unicode-ident", ] @@ -493,17 +519,18 @@ dependencies = [ [[package]] name = "proxy-wasm" version = "0.2.2" -source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b" +source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#6735737fad486c8a7cc324241f58df4a160e7887" dependencies = [ + "downcast-rs", "hashbrown", "log", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -525,9 +552,9 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "regex" -version = "1.10.6" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", @@ -537,9 +564,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", @@ -548,9 +575,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rust-embed" @@ -603,18 +630,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -623,9 +650,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.125" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", "memchr", @@ -670,9 +697,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.74" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -681,18 +708,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", @@ -707,21 +734,21 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "ucd-trie" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "uuid" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ "getrandom", ] diff --git a/plugins/wasm-rust/extensions/ai-data-masking/src/lib.rs b/plugins/wasm-rust/extensions/ai-data-masking/src/lib.rs index a6114bdd8b..01573585ac 100644 --- a/plugins/wasm-rust/extensions/ai-data-masking/src/lib.rs +++ b/plugins/wasm-rust/extensions/ai-data-masking/src/lib.rs @@ -65,7 +65,7 @@ struct AiDataMaskingRoot { rule_matcher: SharedRuleMatcher, } struct AiDataMasking { - config: Option, + config: Option>, mask_map: HashMap>, is_openai: bool, stream: bool, @@ -585,7 +585,7 @@ impl HttpContext for AiDataMasking { } } impl HttpContextWrapper for AiDataMasking { - fn on_config(&mut self, config: &AiDataMaskingConfig) { + fn on_config(&mut self, config: Rc) { self.config = Some(config.clone()); } fn cache_request_body(&self) -> bool { diff --git a/plugins/wasm-rust/extensions/demo-wasm/Cargo.lock b/plugins/wasm-rust/extensions/demo-wasm/Cargo.lock index 85e2edaea3..c1e197caf6 100644 --- a/plugins/wasm-rust/extensions/demo-wasm/Cargo.lock +++ b/plugins/wasm-rust/extensions/demo-wasm/Cargo.lock @@ -43,6 +43,12 @@ dependencies = [ "serde", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "fnv" version = "1.0.7" @@ -74,6 +80,7 @@ dependencies = [ name = "higress-wasm-rust" version = "0.1.0" dependencies = [ + "downcast-rs", "http", "lazy_static", "multimap", @@ -108,9 +115,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.157" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374af5f94e54fa97cf75e945cce8a6b201e88a1a07e688b47dfd2a59c66dbd86" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "log" @@ -135,15 +142,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" dependencies = [ "unicode-ident", ] @@ -151,17 +158,18 @@ dependencies = [ [[package]] name = "proxy-wasm" version = "0.2.2" -source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b" +source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#6735737fad486c8a7cc324241f58df4a160e7887" dependencies = [ + "downcast-rs", "hashbrown", "log", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -174,18 +182,18 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.208" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.208" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -194,9 +202,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.125" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", "memchr", @@ -206,9 +214,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.75" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6af063034fc1935ede7be0122941bafa9bacb949334d090b77ca98b5817c7d9" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -217,15 +225,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "uuid" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ "getrandom", ] diff --git a/plugins/wasm-rust/extensions/demo-wasm/src/lib.rs b/plugins/wasm-rust/extensions/demo-wasm/src/lib.rs index 55647a83cc..62dfa055d0 100644 --- a/plugins/wasm-rust/extensions/demo-wasm/src/lib.rs +++ b/plugins/wasm-rust/extensions/demo-wasm/src/lib.rs @@ -1,8 +1,6 @@ use higress_wasm_rust::cluster_wrapper::DnsCluster; use higress_wasm_rust::log::Log; -use higress_wasm_rust::plugin_wrapper::{ - HttpCallArgStorage, HttpCallbackFn, HttpContextWrapper, RootContextWrapper, -}; +use higress_wasm_rust::plugin_wrapper::{HttpContextWrapper, RootContextWrapper}; use higress_wasm_rust::rule_matcher::{on_configure, RuleMatcher, SharedRuleMatcher}; use http::Method; use multimap::MultiMap; @@ -12,7 +10,7 @@ use proxy_wasm::types::{Bytes, ContextType, DataAction, HeaderAction, LogLevel}; use serde::Deserialize; use std::cell::RefCell; use std::ops::DerefMut; -use std::rc::Rc; +use std::rc::{Rc, Weak}; use std::time::Duration; proxy_wasm::main! {{ @@ -37,40 +35,27 @@ fn format_body(body: Option>) -> String { format!("{:?}", body) } -fn test_callback( - this: &mut DemoWasm, - status_code: u16, - headers: &MultiMap, - body: Option>, -) { - this.log.info(&format!( - "test_callback status_code:{}, headers: {:?}, body: {}", - status_code, - headers, - format_body(body) - )); - this.reset_http_request(); -} struct DemoWasm { // 每个请求对应的插件实例 log: Log, - config: Option, - - arg_storage: HttpCallArgStorage>>, + config: Option>, + weak: Weak>>>, } impl Context for DemoWasm {} impl HttpContext for DemoWasm {} -impl HttpContextWrapper>> for DemoWasm { +impl HttpContextWrapper for DemoWasm { + fn init_self_weak( + &mut self, + self_weak: Weak>>>, + ) { + self.weak = self_weak; + self.log.info("init_self_rc"); + } fn log(&self) -> &Log { &self.log } - fn get_http_call_storage( - &mut self, - ) -> Option<&mut HttpCallArgStorage>>> { - Some(&mut self.arg_storage) - } - fn on_config(&mut self, config: &DemoWasmConfig) { + fn on_config(&mut self, config: Rc) { // 获取config self.log.info(&format!("on_config {}", config.test)); self.config = Some(config.clone()) @@ -101,16 +86,6 @@ impl HttpContextWrapper>> for DemoW // 是否缓存返回body true } - fn on_http_call_response_detail( - &mut self, - _token_id: u32, - arg: Box>, - status_code: u16, - headers: &MultiMap, - body: Option>, - ) { - arg(self, status_code, headers, body) - } fn on_http_request_complete_body(&mut self, req_body: &Bytes) -> DataAction { // 请求body获取完成回调 self.log.info(&format!( @@ -118,23 +93,41 @@ impl HttpContextWrapper>> for DemoW String::from_utf8(req_body.clone()).unwrap_or("".to_string()) )); let cluster = DnsCluster::new("httpbin", "httpbin.org", 80); - if self - .http_call( - &cluster, - &Method::POST, - "http://httpbin.org/post", - MultiMap::new(), - Some("test_body".as_bytes()), - // Box::new(move |this, _status_code, _headers, _body| this.resume_http_request()), - Box::new(test_callback), - Duration::from_secs(5), - ) - .is_ok() - { - DataAction::StopIterationAndBuffer - } else { - self.log.info("http_call fail"); - DataAction::Continue + + let self_rc = match self.weak.upgrade() { + Some(rc) => rc.clone(), + None => { + self.log.error("self_weak upgrade error"); + return DataAction::Continue; + } + }; + let http_call_res = self.http_call( + &cluster, + &Method::POST, + "http://httpbin.org/post", + MultiMap::new(), + Some("test_body".as_bytes()), + Box::new(move |status_code, headers, body| { + if let Some(this) = self_rc.borrow().downcast_ref::() { + this.log.info(&format!( + "test_callback status_code:{}, headers: {:?}, body: {}", + status_code, + headers, + format_body(body) + )); + this.resume_http_request(); + } else { + self_rc.borrow().resume_http_request(); + } + }), + Duration::from_secs(5), + ); + match http_call_res { + Ok(_) => DataAction::StopIterationAndBuffer, + Err(e) => { + self.log.info(&format!("http_call fail {:?}", e)); + DataAction::Continue + } } } fn on_http_response_complete_body(&mut self, res_body: &Bytes) -> DataAction { @@ -185,7 +178,7 @@ impl RootContext for DemoWasmRoot { } } -impl RootContextWrapper>> for DemoWasmRoot { +impl RootContextWrapper for DemoWasmRoot { fn rule_matcher(&self) -> &SharedRuleMatcher { &self.rule_matcher } @@ -193,11 +186,11 @@ impl RootContextWrapper>> for DemoW fn create_http_context_wrapper( &self, _context_id: u32, - ) -> Option>>>> { + ) -> Option>> { Some(Box::new(DemoWasm { config: None, log: Log::new(PLUGIN_NAME.to_string()), - arg_storage: HttpCallArgStorage::new(), + weak: Weak::default(), })) } } diff --git a/plugins/wasm-rust/extensions/request-block/Cargo.lock b/plugins/wasm-rust/extensions/request-block/Cargo.lock index acc9caeb26..243fe87a49 100644 --- a/plugins/wasm-rust/extensions/request-block/Cargo.lock +++ b/plugins/wasm-rust/extensions/request-block/Cargo.lock @@ -29,12 +29,30 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "getrandom" version = "0.2.15" @@ -60,6 +78,9 @@ dependencies = [ name = "higress-wasm-rust" version = "0.1.0" dependencies = [ + "downcast-rs", + "http", + "lazy_static", "multimap", "proxy-wasm", "serde", @@ -67,17 +88,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" -version = "0.2.155" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "log" @@ -102,15 +140,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" dependencies = [ "unicode-ident", ] @@ -118,26 +156,27 @@ dependencies = [ [[package]] name = "proxy-wasm" version = "0.2.2" -source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b" +source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#6735737fad486c8a7cc324241f58df4a160e7887" dependencies = [ + "downcast-rs", "hashbrown", "log", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] [[package]] name = "regex" -version = "1.10.6" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", @@ -147,9 +186,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", @@ -158,9 +197,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "request-block" @@ -182,18 +221,18 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -202,9 +241,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.125" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", "memchr", @@ -214,9 +253,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.74" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -225,15 +264,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "uuid" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ "getrandom", ] diff --git a/plugins/wasm-rust/extensions/request-block/src/lib.rs b/plugins/wasm-rust/extensions/request-block/src/lib.rs index 38d50de187..d120acb902 100644 --- a/plugins/wasm-rust/extensions/request-block/src/lib.rs +++ b/plugins/wasm-rust/extensions/request-block/src/lib.rs @@ -41,7 +41,7 @@ struct RquestBlockRoot { struct RquestBlock { log: Log, - config: Option, + config: Option>, cache_request: bool, } @@ -141,9 +141,9 @@ impl RootContextWrapper for RquestBlockRoot { impl Context for RquestBlock {} impl HttpContext for RquestBlock {} impl HttpContextWrapper for RquestBlock { - fn on_config(&mut self, _config: &RquestBlockConfig) { - self.config = Some(_config.clone()); - self.cache_request = !_config.block_bodies.is_empty(); + fn on_config(&mut self, config: Rc) { + self.cache_request = !config.block_bodies.is_empty(); + self.config = Some(config.clone()); } fn cache_request_body(&self) -> bool { self.cache_request diff --git a/plugins/wasm-rust/extensions/say-hello/Cargo.lock b/plugins/wasm-rust/extensions/say-hello/Cargo.lock index 5a50473fcd..dc98fd709a 100644 --- a/plugins/wasm-rust/extensions/say-hello/Cargo.lock +++ b/plugins/wasm-rust/extensions/say-hello/Cargo.lock @@ -20,12 +20,30 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "getrandom" version = "0.2.15" @@ -51,6 +69,9 @@ dependencies = [ name = "higress-wasm-rust" version = "0.1.0" dependencies = [ + "downcast-rs", + "http", + "lazy_static", "multimap", "proxy-wasm", "serde", @@ -58,17 +79,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" -version = "0.2.155" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "log" @@ -93,15 +131,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" dependencies = [ "unicode-ident", ] @@ -109,17 +147,18 @@ dependencies = [ [[package]] name = "proxy-wasm" version = "0.2.2" -source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b" +source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#6735737fad486c8a7cc324241f58df4a160e7887" dependencies = [ + "downcast-rs", "hashbrown", "log", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -142,18 +181,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.207" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -162,9 +201,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.125" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", "memchr", @@ -174,9 +213,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.74" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -185,15 +224,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "uuid" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ "getrandom", ] diff --git a/plugins/wasm-rust/src/plugin_wrapper.rs b/plugins/wasm-rust/src/plugin_wrapper.rs index 25d445f22f..5a00c0bda4 100644 --- a/plugins/wasm-rust/src/plugin_wrapper.rs +++ b/plugins/wasm-rust/src/plugin_wrapper.rs @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::cell::RefCell; use std::collections::HashMap; +use std::rc::{Rc, Weak}; use std::time::Duration; use crate::cluster_wrapper::Cluster; @@ -28,10 +30,13 @@ use serde::de::DeserializeOwned; lazy_static! { static ref LOG: Log = Log::new("plugin_wrapper".to_string()); } +thread_local! { + static HTTP_CALLBACK_DISPATCHER: HttpCallbackDispatcher = HttpCallbackDispatcher::new(); +} -pub trait RootContextWrapper: RootContext +pub trait RootContextWrapper: RootContext where - PluginConfig: Default + DeserializeOwned + 'static + Clone, + PluginConfig: Default + DeserializeOwned + Clone + 'static, { // fn create_http_context(&self, context_id: u32) -> Option> { fn create_http_context_use_wrapper(&self, context_id: u32) -> Option> { @@ -48,38 +53,48 @@ where fn create_http_context_wrapper( &self, _context_id: u32, - ) -> Option>> { + ) -> Option>> { None } } -pub type HttpCallbackFn = dyn FnOnce(&mut T, u16, &MultiMap, Option>); -pub struct HttpCallArgStorage { - args: HashMap, +pub type HttpCallbackFn = dyn FnOnce(u16, &MultiMap, Option>); + +pub struct HttpCallbackDispatcher { + call_fns: RefCell>>, } -impl Default for HttpCallArgStorage { +impl Default for HttpCallbackDispatcher { fn default() -> Self { Self::new() } } -impl HttpCallArgStorage { +impl HttpCallbackDispatcher { pub fn new() -> Self { - HttpCallArgStorage { - args: HashMap::new(), + HttpCallbackDispatcher { + call_fns: RefCell::new(HashMap::new()), } } - pub fn set(&mut self, token_id: u32, arg: HttpCallArg) { - self.args.insert(token_id, arg); + pub fn set(&self, token_id: u32, arg: Box) { + self.call_fns.borrow_mut().insert(token_id, arg); } - pub fn pop(&mut self, token_id: u32) -> Option { - self.args.remove(&token_id) + pub fn pop(&self, token_id: u32) -> Option> { + self.call_fns.borrow_mut().remove(&token_id) } } -pub trait HttpContextWrapper: HttpContext { + +pub trait HttpContextWrapper: HttpContext +where + PluginConfig: Default + DeserializeOwned + Clone + 'static, +{ + fn init_self_weak( + &mut self, + _self_weak: Weak>>>, + ) { + } fn log(&self) -> &Log { &LOG } - fn on_config(&mut self, _config: &PluginConfig) {} + fn on_config(&mut self, _config: Rc) {} fn on_http_request_complete_headers( &mut self, _headers: &MultiMap, @@ -105,16 +120,6 @@ pub trait HttpContextWrapper: HttpContext { DataAction::Continue } - #[allow(clippy::too_many_arguments)] - fn on_http_call_response_detail( - &mut self, - _token_id: u32, - _arg: HttpCallArg, - _status_code: u16, - _headers: &MultiMap, - _body: Option>, - ) { - } fn replace_http_request_body(&mut self, body: &[u8]) { self.set_http_request_body(0, i32::MAX as usize, body) } @@ -122,10 +127,6 @@ pub trait HttpContextWrapper: HttpContext { self.set_http_response_body(0, i32::MAX as usize, body) } - fn get_http_call_storage(&mut self) -> Option<&mut HttpCallArgStorage> { - None - } - #[allow(clippy::too_many_arguments)] fn http_call( &mut self, @@ -134,7 +135,7 @@ pub trait HttpContextWrapper: HttpContext { raw_url: &str, headers: MultiMap, body: Option<&[u8]>, - arg: HttpCallArg, + call_fn: Box, timeout: Duration, ) -> Result { if let Ok(uri) = raw_url.parse::() { @@ -162,17 +163,13 @@ pub trait HttpContextWrapper: HttpContext { ); if let Ok(token_id) = ret { - if let Some(storage) = self.get_http_call_storage() { - storage.set(token_id, arg); - self.log().debug( - &format!( - "http call start, id: {}, cluster: {}, method: {}, url: {}, body: {:?}, timeout: {:?}", - token_id, cluster.cluster_name(), method.as_str(), raw_url, body, timeout - ) - ); - } else { - return Err(Status::InternalFailure); - } + HTTP_CALLBACK_DISPATCHER.with(|dispatcher| dispatcher.set(token_id, call_fn)); + self.log().debug( + &format!( + "http call start, id: {}, cluster: {}, method: {}, url: {}, body: {:?}, timeout: {:?}", + token_id, cluster.cluster_name(), method.as_str(), raw_url, body, timeout + ) + ); } ret } else { @@ -181,20 +178,30 @@ pub trait HttpContextWrapper: HttpContext { } } } -pub struct PluginHttpWrapper { + +downcast_rs::impl_downcast!(HttpContextWrapper where PluginConfig: Default + DeserializeOwned + Clone); + +pub struct PluginHttpWrapper { req_headers: MultiMap, res_headers: MultiMap, req_body_len: usize, res_body_len: usize, - config: Option, + config: Option>, rule_matcher: SharedRuleMatcher, - http_content: Box>, + http_content: Rc>>>, } -impl PluginHttpWrapper { +impl PluginHttpWrapper +where + PluginConfig: Default + DeserializeOwned + Clone + 'static, +{ pub fn new( rule_matcher: &SharedRuleMatcher, - http_content: Box>, + http_content: Box>, ) -> Self { + let rc_content = Rc::new(RefCell::new(http_content)); + rc_content + .borrow_mut() + .init_self_weak(Rc::downgrade(&rc_content)); PluginHttpWrapper { req_headers: MultiMap::new(), res_headers: MultiMap::new(), @@ -202,18 +209,17 @@ impl PluginHttpWrapper { res_body_len: 0, config: None, rule_matcher: rule_matcher.clone(), - http_content, + http_content: rc_content, } } - fn get_http_call_arg(&mut self, token_id: u32) -> Option { - if let Some(storage) = self.http_content.get_http_call_storage() { - storage.pop(token_id) - } else { - None - } + fn get_http_call_fn(&mut self, token_id: u32) -> Option> { + HTTP_CALLBACK_DISPATCHER.with(|dispatcher| dispatcher.pop(token_id)) } } -impl Context for PluginHttpWrapper { +impl Context for PluginHttpWrapper +where + PluginConfig: Default + DeserializeOwned + Clone + 'static, +{ fn on_http_call_response( &mut self, token_id: u32, @@ -221,7 +227,7 @@ impl Context for PluginHttpWrapper Context for PluginHttpWrapper Context for PluginHttpWrapper { - self.http_content.log().warn(&format!( + self.http_content.borrow().log().warn(&format!( "http call response header contains non-ASCII characters header: {}", k )); } } } - self.http_content.log().warn(&format!( - "http call end, id: {}, code: {}, normal: {}, body: {:?}", + self.http_content.borrow().log().warn(&format!( + "http call end, id: {}, code: {}, normal: {}, body: {:?}", /* */ token_id, status_code, normal_response, body )); - self.http_content.on_http_call_response_detail( + call_fn(status_code, &headers, body) + } else { + self.http_content.borrow_mut().on_http_call_response( token_id, - arg, - status_code, - &headers, - body, + num_headers, + body_size, + num_trailers, ) - } else { - self.http_content - .on_http_call_response(token_id, num_headers, body_size, num_trailers) } } fn on_grpc_call_response(&mut self, token_id: u32, status_code: u32, response_size: usize) { self.http_content + .borrow_mut() .on_grpc_call_response(token_id, status_code, response_size) } fn on_grpc_stream_initial_metadata(&mut self, token_id: u32, num_elements: u32) { self.http_content + .borrow_mut() .on_grpc_stream_initial_metadata(token_id, num_elements) } fn on_grpc_stream_message(&mut self, token_id: u32, message_size: usize) { self.http_content + .borrow_mut() .on_grpc_stream_message(token_id, message_size) } fn on_grpc_stream_trailing_metadata(&mut self, token_id: u32, num_elements: u32) { self.http_content + .borrow_mut() .on_grpc_stream_trailing_metadata(token_id, num_elements) } fn on_grpc_stream_close(&mut self, token_id: u32, status_code: u32) { self.http_content + .borrow_mut() .on_grpc_stream_close(token_id, status_code) } fn on_done(&mut self) -> bool { - self.http_content.on_done() + self.http_content.borrow_mut().on_done() } } -impl HttpContext for PluginHttpWrapper +impl HttpContext for PluginHttpWrapper where - PluginConfig: Default + DeserializeOwned + Clone, + PluginConfig: Default + DeserializeOwned + Clone + 'static, { fn on_http_request_headers(&mut self, num_headers: usize, end_of_stream: bool) -> HeaderAction { let binding = self.rule_matcher.borrow(); @@ -306,7 +316,7 @@ where self.req_headers.insert(k, header_value); } Err(_) => { - self.http_content.log().warn(&format!( + self.http_content.borrow().log().warn(&format!( "request http header contains non-ASCII characters header: {}", k )); @@ -315,22 +325,25 @@ where } if let Some(config) = &self.config { - self.http_content.on_config(config); + self.http_content.borrow_mut().on_config(config.clone()); } let ret = self .http_content + .borrow_mut() .on_http_request_headers(num_headers, end_of_stream); if ret != HeaderAction::Continue { return ret; } self.http_content + .borrow_mut() .on_http_request_complete_headers(&self.req_headers) } fn on_http_request_body(&mut self, body_size: usize, end_of_stream: bool) -> DataAction { - if !self.http_content.cache_request_body() { + if !self.http_content.borrow().cache_request_body() { return self .http_content + .borrow_mut() .on_http_request_body(body_size, end_of_stream); } self.req_body_len += body_size; @@ -343,11 +356,15 @@ where req_body = body; } } - self.http_content.on_http_request_complete_body(&req_body) + self.http_content + .borrow_mut() + .on_http_request_complete_body(&req_body) } fn on_http_request_trailers(&mut self, num_trailers: usize) -> Action { - self.http_content.on_http_request_trailers(num_trailers) + self.http_content + .borrow_mut() + .on_http_request_trailers(num_trailers) } fn on_http_response_headers( @@ -361,7 +378,7 @@ where self.res_headers.insert(k, header_value); } Err(_) => { - self.http_content.log().warn(&format!( + self.http_content.borrow().log().warn(&format!( "response http header contains non-ASCII characters header: {}", k )); @@ -371,18 +388,21 @@ where let ret = self .http_content + .borrow_mut() .on_http_response_headers(num_headers, end_of_stream); if ret != HeaderAction::Continue { return ret; } self.http_content + .borrow_mut() .on_http_response_complete_headers(&self.res_headers) } fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> DataAction { - if !self.http_content.cache_response_body() { + if !self.http_content.borrow().cache_response_body() { return self .http_content + .borrow_mut() .on_http_response_body(body_size, end_of_stream); } self.res_body_len += body_size; @@ -397,14 +417,18 @@ where res_body = body; } } - self.http_content.on_http_response_complete_body(&res_body) + self.http_content + .borrow_mut() + .on_http_response_complete_body(&res_body) } fn on_http_response_trailers(&mut self, num_trailers: usize) -> Action { - self.http_content.on_http_response_trailers(num_trailers) + self.http_content + .borrow_mut() + .on_http_response_trailers(num_trailers) } fn on_log(&mut self) { - self.http_content.on_log() + self.http_content.borrow_mut().on_log() } } diff --git a/plugins/wasm-rust/src/rule_matcher.rs b/plugins/wasm-rust/src/rule_matcher.rs index 6478344334..dc42b433fc 100644 --- a/plugins/wasm-rust/src/rule_matcher.rs +++ b/plugins/wasm-rust/src/rule_matcher.rs @@ -20,6 +20,7 @@ use proxy_wasm::traits::RootContext; use proxy_wasm::types::LogLevel; use serde::de::DeserializeOwned; use serde_json::{from_slice, Map, Value}; +use std::borrow::Borrow; use std::cell::RefCell; use std::collections::HashSet; use std::rc::Rc; @@ -50,13 +51,13 @@ struct RuleConfig { category: Category, routes: HashSet, hosts: Vec, - config: PluginConfig, + config: Rc, } #[derive(Default)] pub struct RuleMatcher { rule_config: Vec>, - global_config: Option, + global_config: Option>, } impl RuleMatcher @@ -71,7 +72,7 @@ where let mut key_count = object.len(); if object.is_empty() { - self.global_config = Some(PluginConfig::default()); + self.global_config = Some(Rc::new(PluginConfig::default())); return Ok(()); } @@ -86,7 +87,7 @@ where if key_count > 0 { match serde_json::from_value::(config.clone()) { Ok(plugin_config) => { - self.global_config = Some(plugin_config); + self.global_config = Some(Rc::new(plugin_config)); } Err(err) => { log( @@ -134,14 +135,14 @@ where category, routes, hosts, - config, + config: Rc::new(config), }) } Ok(()) } - pub fn get_match_config(&self) -> Option<(i64, &PluginConfig)> { + pub fn get_match_config(&self) -> Option<(i64, Rc)> { let host = get_http_request_header(":authority").unwrap_or_default(); let route_name = get_property(vec!["route_name"]).unwrap_or_default(); @@ -149,7 +150,7 @@ where match rule.category { Category::Host => { if self.host_match(rule, host.as_str()) { - return Some((i as i64, &rule.config)); + return Some((i as i64, rule.config.clone())); } } Category::Route => { @@ -158,7 +159,7 @@ where .unwrap_or_else(|_| "".to_string()) .as_str(), ) { - return Some((i as i64, &rule.config)); + return Some((i as i64, rule.config.clone())); } } } @@ -166,14 +167,16 @@ where self.global_config .as_ref() - .map(|config| (usize::MAX as i64, config)) + .map(|config| (usize::MAX as i64, config.clone())) } pub fn rewrite_config(&mut self, rewrite: fn(config: &PluginConfig) -> PluginConfig) { - self.global_config = self.global_config.as_ref().map(rewrite); + if let Some(global_config) = &self.global_config { + self.global_config = Some(Rc::new(rewrite(global_config.borrow()))); + } for rule_config in &mut self.rule_config { - rule_config.config = rewrite(&rule_config.config); + rule_config.config = Rc::new(rewrite(rule_config.config.borrow())); } } From fc6902ded20a4bb0c3abf5e82ae271c59fd075a6 Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Mon, 21 Oct 2024 16:20:45 +0900 Subject: [PATCH 30/32] docs: add Japanese README and CONTRIBUTING files (#1407) --- CONTRIBUTING_JP.md | 195 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- README_EN.md | 2 +- README_JP.md | 189 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 CONTRIBUTING_JP.md create mode 100644 README_JP.md diff --git a/CONTRIBUTING_JP.md b/CONTRIBUTING_JP.md new file mode 100644 index 0000000000..7916c56dd4 --- /dev/null +++ b/CONTRIBUTING_JP.md @@ -0,0 +1,195 @@ +# Higress への貢献 + +Higress のハッキングに興味がある場合は、温かく歓迎します。まず、このような意欲を非常に奨励します。そして、以下は貢献ガイドのリストです。 + +[[中文](./CONTRIBUTING.md)] | [[English Contributing Document](./CONTRIBUTING_EN.md)] + +## トピック + +- [Higress への貢献](#higress-への貢献) + - [トピック](#トピック) + - [セキュリティ問題の報告](#セキュリティ問題の報告) + - [一般的な問題の報告](#一般的な問題の報告) + - [コードとドキュメントの貢献](#コードとドキュメントの貢献) + - [ワークスペースの準備](#ワークスペースの準備) + - [ブランチの定義](#ブランチの定義) + - [コミットルール](#コミットルール) + - [コミットメッセージ](#コミットメッセージ) + - [コミット内容](#コミット内容) + - [PR 説明](#pr-説明) + - [テストケースの貢献](#テストケースの貢献) + - [何かを手伝うための参加](#何かを手伝うための参加) + - [コードスタイル](#コードスタイル) + +## セキュリティ問題の報告 + +セキュリティ問題は常に真剣に扱われます。通常の原則として、セキュリティ問題を広めることは推奨しません。Higress のセキュリティ問題を発見した場合は、公開で議論せず、公開の問題を開かないでください。代わりに、[higress@googlegroups.com](mailto:higress@googlegroups.com) にプライベートなメールを送信して報告することをお勧めします。 + +## 一般的な問題の報告 + +正直なところ、Higress のすべてのユーザーを非常に親切な貢献者と見なしています。Higress を体験した後、プロジェクトに対するフィードバックがあるかもしれません。その場合は、[NEW ISSUE](https://github.com/alibaba/higress/issues/new/choose) を通じて問題を開くことを自由に行ってください。 + +Higress プロジェクトを分散型で協力しているため、**よく書かれた**、**詳細な**、**明確な**問題報告を高く評価します。コミュニケーションをより効率的にするために、問題が検索リストに存在するかどうかを検索することを希望します。存在する場合は、新しい問題を開くのではなく、既存の問題のコメントに詳細を追加してください。 + +問題の詳細をできるだけ標準化するために、問題報告者のために [ISSUE TEMPLATE](./.github/ISSUE_TEMPLATE) を設定しました。テンプレートのフィールドに従って指示に従って記入してください。 + +問題を開く場合は多くのケースがあります: + +* バグ報告 +* 機能要求 +* パフォーマンス問題 +* 機能提案 +* 機能設計 +* 助けが必要 +* ドキュメントが不完全 +* テストの改善 +* プロジェクトに関する質問 +* その他 + +また、新しい問題を記入する際には、投稿から機密データを削除することを忘れないでください。機密データには、パスワード、秘密鍵、ネットワークの場所、プライベートなビジネスデータなどが含まれる可能性があります。 + +## コードとドキュメントの貢献 + +Higress プロジェクトをより良くするためのすべての行動が奨励されます。GitHub では、Higress のすべての改善は PR(プルリクエストの略)を通じて行うことができます。 + +* タイプミスを見つけた場合は、修正してみてください! +* バグを見つけた場合は、修正してみてください! +* 冗長なコードを見つけた場合は、削除してみてください! +* 欠落しているテストケースを見つけた場合は、追加してみてください! +* 機能を強化できる場合は、**ためらわないでください**! +* コードが不明瞭な場合は、コメントを追加して明確にしてください! +* コードが醜い場合は、リファクタリングしてみてください! +* ドキュメントの改善に役立つ場合は、さらに良いです! +* ドキュメントが不正確な場合は、修正してください! +* ... + +実際には、それらを完全にリストすることは不可能です。1つの原則を覚えておいてください: + +> あなたからの PR を楽しみにしています。 + +Higress を PR で改善する準備ができたら、ここで PR ルールを確認することをお勧めします。 + +* [ワークスペースの準備](#ワークスペースの準備) +* [ブランチの定義](#ブランチの定義) +* [コミットルール](#コミットルール) +* [PR 説明](#pr-説明) + +### ワークスペースの準備 + +PR を提出するために、GitHub ID に登録していることを前提とします。その後、以下の手順で準備を完了できます: + +1. Higress を自分のリポジトリに **FORK** します。この作業を行うには、[alibaba/higress](https://github.com/alibaba/higress) のメインページの右上にある Fork ボタンをクリックするだけです。その後、`https://github.com//higress` に自分のリポジトリが作成されます。ここで、`your-username` はあなたの GitHub ユーザー名です。 + +2. 自分のリポジトリをローカルに **CLONE** します。`git clone git@github.com:/higress.git` を使用してリポジトリをローカルマシンにクローンします。その後、新しいブランチを作成して、行いたい変更を完了できます。 + +3. リモートを `git@github.com:alibaba/higress.git` に設定します。以下の2つのコマンドを使用します: + +```bash +git remote add upstream git@github.com:alibaba/higress.git +git remote set-url --push upstream no-pushing +``` + +このリモート設定を使用すると、git リモート設定を次のように確認できます: + +```shell +$ git remote -v +origin git@github.com:/higress.git (fetch) +origin git@github.com:/higress.git (push) +upstream git@github.com:alibaba/higress.git (fetch) +upstream no-pushing (push) +``` + +これを追加すると、ローカルブランチを上流ブランチと簡単に同期できます。 + +### ブランチの定義 + +現在、プルリクエストを通じたすべての貢献は Higress の [main ブランチ](https://github.com/alibaba/higress/tree/main) に対するものであると仮定します。貢献する前に、ブランチの定義を理解することは非常に役立ちます。 + +貢献者として、プルリクエストを通じたすべての貢献は main ブランチに対するものであることを再度覚えておいてください。Higress プロジェクトには、リリースブランチ(例:0.6.0、0.6.1)、機能ブランチ、ホットフィックスブランチなど、いくつかの他のブランチがあります。 + +正式にバージョンをリリースする際には、リリースブランチが作成され、バージョン番号で命名されます。 + +リリース後、リリースブランチのコミットを main ブランチにマージします。 + +特定のバージョンにバグがある場合、後のバージョンで修正するか、特定のホットフィックスバージョンで修正するかを決定します。ホットフィックスバージョンで修正することを決定した場合、対応するリリースブランチに基づいてホットフィックスブランチをチェックアウトし、コード修正と検証を行い、main ブランチにマージします。 + +大きな機能については、開発と検証のために機能ブランチを引き出します。 + +### コミットルール + +実際には、Higress ではコミット時に2つのルールを真剣に考えています: + +* [コミットメッセージ](#コミットメッセージ) +* [コミット内容](#コミット内容) + +#### コミットメッセージ + +コミットメッセージは、提出された PR の目的をレビュアーがよりよく理解するのに役立ちます。また、コードレビューの手続きを加速するのにも役立ちます。貢献者には、曖昧なメッセージではなく、**明確な**コミットメッセージを使用することを奨励します。一般的に、以下のコミットメッセージタイプを推奨します: + +* docs: xxxx. 例:"docs: add docs about Higress cluster installation". +* feature: xxxx. 例:"feature: use higress config instead of istio config". +* bugfix: xxxx. 例:"bugfix: fix panic when input nil parameter". +* refactor: xxxx. 例:"refactor: simplify to make codes more readable". +* test: xxx. 例:"test: add unit test case for func InsertIntoArray". +* その他の読みやすく明確な表現方法。 + +一方で、以下のような方法でのコミットメッセージは推奨しません: + +* ~~バグ修正~~ +* ~~更新~~ +* ~~ドキュメント追加~~ + +迷った場合は、[Git コミットメッセージの書き方](http://chris.beams.io/posts/git-commit/) を参照してください。 + +#### コミット内容 + +コミット内容は、1つのコミットに含まれるすべての内容の変更を表します。1つのコミットに、他のコミットの助けを借りずにレビュアーが完全にレビューできる内容を含めるのが最善です。言い換えれば、1つのコミットの内容は CI を通過でき、コードの混乱を避けることができます。簡単に言えば、次の3つの小さなルールを覚えておく必要があります: + +* コミットで非常に大きな変更を避ける; +* 各コミットが完全でレビュー可能であること。 +* コミット時に git config(`user.name`、`user.email`)を確認して、それが GitHub ID に関連付けられていることを確認します。 + +```bash +git config --get user.name +git config --get user.email +``` + +* pr を提出する際には、'changes/' フォルダーの下の XXX.md ファイルに現在の変更の簡単な説明を追加してください。 + +さらに、コード変更部分では、すべての貢献者が Higress の [コードスタイル](#コードスタイル) を読むことをお勧めします。 + +コミットメッセージやコミット内容に関係なく、コードレビューに重点を置いています。 + +### PR 説明 + +PR は Higress プロジェクトファイルを変更する唯一の方法です。レビュアーが目的をよりよく理解できるようにするために、PR 説明は詳細すぎることはありません。貢献者には、[PR テンプレート](./.github/PULL_REQUEST_TEMPLATE.md) に従ってプルリクエストを完了することを奨励します。 + +### 開発前の準備 + +```shell +make prebuild && go mod tidy +``` + +## テストケースの貢献 + +テストケースは歓迎されます。現在、Higress の機能テストケースが高優先度です。 + +* 単体テストの場合、同じモジュールの test ディレクトリに xxxTest.go という名前のテストファイルを作成する必要があります。 +* 統合テストの場合、統合テストを test ディレクトリに配置できます。 +//TBD + +## 何かを手伝うための参加 + +GitHub を Higress の協力の主要な場所として選択しました。したがって、Higress の最新の更新は常にここにあります。PR を通じた貢献は明確な助けの方法ですが、他の方法も呼びかけています。 + +* 可能であれば、他の人の質問に返信する; +* 他のユーザーの問題を解決するのを手伝う; +* 他の人の PR 設計をレビューするのを手伝う; +* 他の人の PR のコードをレビューするのを手伝う; +* Higress について議論して、物事を明確にする; +* GitHub 以外で Higress 技術を宣伝する; +* Higress に関するブログを書くなど。 + +## コードスタイル +//TBD +要するに、**どんな助けも貢献です。** diff --git a/README.md b/README.md index 87468ed9ad..761f8591a8 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

- English | 中文 + English | 中文 | 日本語

diff --git a/README_EN.md b/README_EN.md index 8ce33aa069..b099fa3e5b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -15,7 +15,7 @@

- English | 中文 + English | 中文 | 日本語

Higress is a cloud-native api gateway based on Alibaba's internal gateway practices. diff --git a/README_JP.md b/README_JP.md new file mode 100644 index 0000000000..8573576ee0 --- /dev/null +++ b/README_JP.md @@ -0,0 +1,189 @@ +

+ Higress +
+ AIゲートウェイ +

+

AIネイティブAPIゲートウェイ

+ +[![Build Status](https://github.com/alibaba/higress/actions/workflows/build-and-test.yaml/badge.svg?branch=main)](https://github.com/alibaba/higress/actions) +[![license](https://img.shields.io/github/license/alibaba/higress.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) + +[**公式サイト**](https://higress.cn/)   | +  [**ドキュメント**](https://higress.cn/docs/latest/overview/what-is-higress/)   | +  [**ブログ**](https://higress.cn/blog/)   | +  [**電子書籍**](https://higress.cn/docs/ebook/wasm14/)   | +  [**開発ガイド**](https://higress.cn/docs/latest/dev/architecture/)   | +  [**AIプラグイン**](https://higress.cn/plugin/)   + + +

+ English | 中文 | 日本語 +

+ + +Higressは、IstioとEnvoyをベースにしたクラウドネイティブAPIゲートウェイで、Go/Rust/JSなどを使用してWasmプラグインを作成できます。数十の既製の汎用プラグインと、すぐに使用できるコンソールを提供しています(デモは[こちら](http://demo.higress.io/))。 + +Higressは、Tengineのリロードが長時間接続のビジネスに影響を与える問題や、gRPC/Dubboの負荷分散能力の不足を解決するために、Alibaba内部で誕生しました。 + +Alibaba Cloudは、Higressを基盤にクラウドネイティブAPIゲートウェイ製品を構築し、多くの企業顧客に99.99%のゲートウェイ高可用性保証サービスを提供しています。 + +Higressは、AIゲートウェイ機能を基盤に、Tongyi Qianwen APP、Bailian大規模モデルAPI、機械学習PAIプラットフォームなどのAIビジネスをサポートしています。また、国内の主要なAIGC企業(例:ZeroOne)やAI製品(例:FastGPT)にもサービスを提供しています。 + +![](https://img.alicdn.com/imgextra/i2/O1CN011AbR8023V8R5N0HcA_!!6000000007260-2-tps-1080-606.png) + + +## 目次 + +- [**クイックスタート**](#クイックスタート) +- [**機能紹介**](#機能紹介) +- [**使用シナリオ**](#使用シナリオ) +- [**主な利点**](#主な利点) +- [**コミュニティ**](#コミュニティ) + +## クイックスタート + +HigressはDockerだけで起動でき、個人開発者がローカルで学習用にセットアップしたり、簡易サイトを構築するのに便利です。 + +```bash +# 作業ディレクトリを作成 +mkdir higress; cd higress +# Higressを起動し、設定ファイルを作業ディレクトリに書き込みます +docker run -d --rm --name higress-ai -v ${PWD}:/data \ + -p 8001:8001 -p 8080:8080 -p 8443:8443 \ + higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/all-in-one:latest +``` + +リスンポートの説明は以下の通りです: + +- 8001ポート:Higress UIコンソールのエントリーポイント +- 8080ポート:ゲートウェイのHTTPプロトコルエントリーポイント +- 8443ポート:ゲートウェイのHTTPSプロトコルエントリーポイント + +**HigressのすべてのDockerイメージは専用のリポジトリを使用しており、Docker Hubの国内アクセス不可の影響を受けません** + +K8sでのHelmデプロイなどの他のインストール方法については、公式サイトの[クイックスタートドキュメント](https://higress.cn/docs/latest/user/quickstart/)を参照してください。 + + +## 使用シナリオ + +- **AIゲートウェイ**: + + Higressは、国内外のすべてのLLMモデルプロバイダーと統一されたプロトコルで接続でき、豊富なAI可観測性、多モデル負荷分散/フォールバック、AIトークンフロー制御、AIキャッシュなどの機能を備えています。 + + ![](https://img.alicdn.com/imgextra/i1/O1CN01fNnhCp1cV8mYPRFeS_!!6000000003605-0-tps-1080-608.jpg) + +- **Kubernetes Ingressゲートウェイ**: + + HigressはK8sクラスターのIngressエントリーポイントゲートウェイとして機能し、多くのK8s Nginx Ingressの注釈に対応しています。K8s Nginx IngressからHigressへのスムーズな移行が可能です。 + + [Gateway API](https://gateway-api.sigs.k8s.io/)標準をサポートし、ユーザーがIngress APIからGateway APIにスムーズに移行できるようにします。 + + ingress-nginxと比較して、リソースの消費が大幅に減少し、ルーティングの変更が10倍速く反映されます。 + + ![](https://img.alicdn.com/imgextra/i1/O1CN01bhEtb229eeMNBWmdP_!!6000000008093-2-tps-750-547.png) + ![](https://img.alicdn.com/imgextra/i1/O1CN01bqRets1LsBGyitj4S_!!6000000001354-2-tps-887-489.png) + +- **マイクロサービスゲートウェイ**: + + Higressはマイクロサービスゲートウェイとして機能し、Nacos、ZooKeeper、Consul、Eurekaなどのさまざまなサービスレジストリからサービスを発見し、ルーティングを構成できます。 + + また、[Dubbo](https://github.com/apache/dubbo)、[Nacos](https://github.com/alibaba/nacos)、[Sentinel](https://github.com/alibaba/Sentinel)などのマイクロサービス技術スタックと深く統合されています。Envoy C++ゲートウェイコアの優れたパフォーマンスに基づいて、従来のJavaベースのマイクロサービスゲートウェイと比較して、リソース使用率を大幅に削減し、コストを削減できます。 + + ![](https://img.alicdn.com/imgextra/i4/O1CN01v4ZbCj1dBjePSMZ17_!!6000000003698-0-tps-1613-926.jpg) + +- **セキュリティゲートウェイ**: + + Higressはセキュリティゲートウェイとして機能し、WAF機能を提供し、key-auth、hmac-auth、jwt-auth、basic-auth、oidcなどのさまざまな認証戦略をサポートします。 + +## 主な利点 + +- **プロダクションレベル** + + Alibabaで2年以上のプロダクション検証を経た内部製品から派生し、毎秒数十万のリクエストを処理する大規模なシナリオをサポートします。 + + Nginxのリロードによるトラフィックの揺れを完全に排除し、構成変更がミリ秒単位で反映され、ビジネスに影響を与えません。AIビジネスなどの長時間接続シナリオに特に適しています。 + +- **ストリーム処理** + + リクエスト/レスポンスボディの完全なストリーム処理をサポートし、Wasmプラグインを使用してSSE(Server-Sent Events)などのストリームプロトコルのメッセージをカスタマイズして処理できます。 + + AIビジネスなどの大帯域幅シナリオで、メモリ使用量を大幅に削減できます。 + +- **拡張性** + + AI、トラフィック管理、セキュリティ保護などの一般的な機能をカバーする豊富な公式プラグインライブラリを提供し、90%以上のビジネスシナリオのニーズを満たします。 + + Wasmプラグイン拡張を主力とし、サンドボックス隔離を通じてメモリの安全性を確保し、複数のプログラミング言語をサポートし、プラグインバージョンの独立したアップグレードを許可し、トラフィックに影響を与えずにゲートウェイロジックをホットアップデートできます。 + +- **安全で使いやすい** + + Ingress APIおよびGateway API標準に基づき、すぐに使用できるUIコンソールを提供し、WAF保護プラグイン、IP/Cookie CC保護プラグインをすぐに使用できます。 + + Let's Encryptの自動証明書発行および更新をサポートし、K8sを使用せずにデプロイでき、1行のDockerコマンドで起動でき、個人開発者にとって便利です。 + + +## 機能紹介 + +### AIゲートウェイデモ展示 + +[OpenAIから他の大規模モデルへの移行を30秒で完了 +](https://www.bilibili.com/video/BV1dT421a7w7/?spm_id_from=333.788.recommend_more_video.14) + + +### Higress UIコンソール + +- **豊富な可観測性** + + すぐに使用できる可観測性を提供し、Grafana&Prometheusは組み込みのものを使用することも、自分で構築したものを接続することもできます。 + + ![](./docs/images/monitor.gif) + + +- **プラグイン拡張メカニズム** + + 公式にはさまざまなプラグインが提供されており、ユーザーは[独自のプラグインを開発](./plugins/wasm-go)し、Docker/OCIイメージとして構築し、コンソールで構成して、プラグインロジックをリアルタイムで変更できます。トラフィックに影響を与えずにプラグインロジックをホットアップデートできます。 + + ![](./docs/images/plugin.gif) + + +- **さまざまなサービス発見** + + デフォルトでK8s Serviceサービス発見を提供し、構成を通じてNacos/ZooKeeperなどのレジストリに接続してサービスを発見することも、静的IPまたはDNSに基づいて発見することもできます。 + + ![](./docs/images/service-source.gif) + + +- **ドメインと証明書** + + TLS証明書を作成および管理し、ドメインのHTTP/HTTPS動作を構成できます。ドメインポリシーでは、特定のドメインに対してプラグインを適用することができます。 + + ![](./docs/images/domain.gif) + + +- **豊富なルーティング機能** + + 上記で定義されたサービス発見メカニズムを通じて、発見されたサービスはサービスリストに表示されます。ルーティングを作成する際に、ドメインを選択し、ルーティングマッチングメカニズムを定義し、ターゲットサービスを選択してルーティングを行います。ルーティングポリシーでは、特定のルーティングに対してプラグインを適用することができます。 + + ![](./docs/images/route-service.gif) + + +## コミュニティ + +### 感謝 + +EnvoyとIstioのオープンソースの取り組みがなければ、Higressは実現できませんでした。これらのプロジェクトに最も誠実な敬意を表します。 + +### 交流グループ + +![image](https://img.alicdn.com/imgextra/i2/O1CN01BkopaB22ZsvamFftE_!!6000000007135-0-tps-720-405.jpg) + +### 技術共有 + +WeChat公式アカウント: + +![](https://img.alicdn.com/imgextra/i1/O1CN01WnQt0q1tcmqVDU73u_!!6000000005923-0-tps-258-258.jpg) + +### 関連リポジトリ + +- Higressコンソール:https://github.com/higress-group/higress-console +- Higress(スタンドアロン版):https://github.com/higress-group/higress-standalone From badf4b7101526f2a8cb9dd7cc76d6cc06cb55e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Mon, 21 Oct 2024 15:43:01 +0800 Subject: [PATCH 31/32] ai cache plugin support set skip ai cache header (#1380) --- plugins/wasm-go/extensions/ai-cache/README.md | 5 +++++ plugins/wasm-go/extensions/ai-cache/README_EN.md | 4 ++++ plugins/wasm-go/extensions/ai-cache/main.go | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/plugins/wasm-go/extensions/ai-cache/README.md b/plugins/wasm-go/extensions/ai-cache/README.md index 97728f5177..1de252f12c 100644 --- a/plugins/wasm-go/extensions/ai-cache/README.md +++ b/plugins/wasm-go/extensions/ai-cache/README.md @@ -9,6 +9,11 @@ description: AI 缓存插件配置参考 LLM 结果缓存插件,默认配置方式可以直接用于 openai 协议的结果缓存,同时支持流式和非流式响应的缓存。 +**提示** + +携带请求头`x-higress-skip-ai-cache: on`时,当前请求将不会使用缓存中的内容,而是直接转发给后端服务,同时也不会缓存该请求返回响应的内容 + + ## 运行属性 插件执行阶段:`认证阶段` diff --git a/plugins/wasm-go/extensions/ai-cache/README_EN.md b/plugins/wasm-go/extensions/ai-cache/README_EN.md index 81099e509c..7544995999 100644 --- a/plugins/wasm-go/extensions/ai-cache/README_EN.md +++ b/plugins/wasm-go/extensions/ai-cache/README_EN.md @@ -6,6 +6,10 @@ description: AI Cache Plugin Configuration Reference ## Function Description LLM result caching plugin, the default configuration can be directly used for result caching under the OpenAI protocol, and it supports caching of both streaming and non-streaming responses. +**Tips** + +When carrying the request header `x-higress-skip-ai-cache: on`, the current request will not use content from the cache but will be directly forwarded to the backend service. Additionally, the response content from this request will not be cached. + ## Runtime Properties Plugin Execution Phase: `Authentication Phase` Plugin Execution Priority: `10` diff --git a/plugins/wasm-go/extensions/ai-cache/main.go b/plugins/wasm-go/extensions/ai-cache/main.go index dc5df1a6a8..7886d5698f 100644 --- a/plugins/wasm-go/extensions/ai-cache/main.go +++ b/plugins/wasm-go/extensions/ai-cache/main.go @@ -22,6 +22,7 @@ const ( ToolCallsContextKey = "toolCalls" StreamContextKey = "stream" DefaultCacheKeyPrefix = "higress-ai-cache:" + SkipCacheHeader = "x-higress-skip-ai-cache" ) func main() { @@ -172,6 +173,12 @@ func parseConfig(json gjson.Result, c *PluginConfig, log wrapper.Log) error { } func onHttpRequestHeaders(ctx wrapper.HttpContext, config PluginConfig, log wrapper.Log) types.Action { + skipCache, _ := proxywasm.GetHttpRequestHeader(SkipCacheHeader) + if skipCache == "on" { + ctx.SetContext(SkipCacheHeader, struct{}{}) + ctx.DontReadRequestBody() + return types.ActionContinue + } contentType, _ := proxywasm.GetHttpRequestHeader("content-type") // The request does not have a body. if contentType == "" { @@ -270,6 +277,11 @@ func processSSEMessage(ctx wrapper.HttpContext, config PluginConfig, sseMessage } func onHttpResponseHeaders(ctx wrapper.HttpContext, config PluginConfig, log wrapper.Log) types.Action { + skipCache := ctx.GetContext(SkipCacheHeader) + if skipCache != nil { + ctx.DontReadResponseBody() + return types.ActionContinue + } contentType, _ := proxywasm.GetHttpResponseHeader("content-type") if strings.Contains(contentType, "text/event-stream") { ctx.SetContext(StreamContextKey, struct{}{}) From f8d62a8ac3d8ff56b9784f4e41a120ff514ca979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Mon, 21 Oct 2024 16:46:18 +0800 Subject: [PATCH 32/32] add model router plugin (#1414) --- plugins/wasm-cpp/WORKSPACE | 6 +- plugins/wasm-cpp/bazel/wasm.bzl | 6 +- .../wasm-cpp/extensions/model_router/BUILD | 70 +++++++ .../extensions/model_router/README.md | 64 ++++++ .../extensions/model_router/README_EN.md | 63 ++++++ .../extensions/model_router/plugin.cc | 189 ++++++++++++++++++ .../wasm-cpp/extensions/model_router/plugin.h | 85 ++++++++ .../extensions/model_router/plugin_test.cc | 144 +++++++++++++ 8 files changed, 621 insertions(+), 6 deletions(-) create mode 100644 plugins/wasm-cpp/extensions/model_router/BUILD create mode 100644 plugins/wasm-cpp/extensions/model_router/README.md create mode 100644 plugins/wasm-cpp/extensions/model_router/README_EN.md create mode 100644 plugins/wasm-cpp/extensions/model_router/plugin.cc create mode 100644 plugins/wasm-cpp/extensions/model_router/plugin.h create mode 100644 plugins/wasm-cpp/extensions/model_router/plugin_test.cc diff --git a/plugins/wasm-cpp/WORKSPACE b/plugins/wasm-cpp/WORKSPACE index dc55483d9c..ed78d0df06 100644 --- a/plugins/wasm-cpp/WORKSPACE +++ b/plugins/wasm-cpp/WORKSPACE @@ -16,15 +16,15 @@ load("@io_bazel_rules_docker//repositories:deps.bzl", container_deps = "deps") container_deps() -PROXY_WASM_CPP_SDK_SHA = "fd0be8405db25de0264bdb78fae3a82668c03782" +PROXY_WASM_CPP_SDK_SHA = "eaec483b5b3c7bcb89fd208b5a1fa5d79d626f61" -PROXY_WASM_CPP_SDK_SHA256 = "c57de2425b5c61d7f630c5061e319b4557ae1f1c7526e5a51c33dc1299471b08" +PROXY_WASM_CPP_SDK_SHA256 = "1140bc8114d75db56a6ca6b18423d4df50d988d40b4cec929a1eb246cf5a4a3d" http_archive( name = "proxy_wasm_cpp_sdk", sha256 = PROXY_WASM_CPP_SDK_SHA256, strip_prefix = "proxy-wasm-cpp-sdk-" + PROXY_WASM_CPP_SDK_SHA, - url = "https://github.com/proxy-wasm/proxy-wasm-cpp-sdk/archive/" + PROXY_WASM_CPP_SDK_SHA + ".tar.gz", + url = "https://github.com/higress-group/proxy-wasm-cpp-sdk/archive/" + PROXY_WASM_CPP_SDK_SHA + ".tar.gz", ) load("@proxy_wasm_cpp_sdk//bazel/dep:deps.bzl", "wasm_dependencies") diff --git a/plugins/wasm-cpp/bazel/wasm.bzl b/plugins/wasm-cpp/bazel/wasm.bzl index a100ccc0b6..1f061fbc69 100644 --- a/plugins/wasm-cpp/bazel/wasm.bzl +++ b/plugins/wasm-cpp/bazel/wasm.bzl @@ -33,14 +33,14 @@ def wasm_libraries(): urls = ["https://github.com/google/googletest/archive/release-1.10.0.tar.gz"], ) - PROXY_WASM_CPP_HOST_SHA = "f38347360feaaf5b2a733f219c4d8c9660d626f0" - PROXY_WASM_CPP_HOST_SHA256 = "bf10de946eb5785813895c2bf16504afc0cd590b9655d9ee52fb1074d0825ea3" + PROXY_WASM_CPP_HOST_SHA = "7850d1721fe3dd2ccfb86a06116f76c23b1f1bf8" + PROXY_WASM_CPP_HOST_SHA256 = "740690fc1d749849f6e24b5bc48a07dabc0565a7d03b6cd13425dba693956c57" http_archive( name = "proxy_wasm_cpp_host", sha256 = PROXY_WASM_CPP_HOST_SHA256, strip_prefix = "proxy-wasm-cpp-host-" + PROXY_WASM_CPP_HOST_SHA, - url = "https://github.com/proxy-wasm/proxy-wasm-cpp-host/archive/" + PROXY_WASM_CPP_HOST_SHA +".tar.gz", + url = "https://github.com/higress-group/proxy-wasm-cpp-host/archive/" + PROXY_WASM_CPP_HOST_SHA +".tar.gz", ) http_archive( diff --git a/plugins/wasm-cpp/extensions/model_router/BUILD b/plugins/wasm-cpp/extensions/model_router/BUILD new file mode 100644 index 0000000000..67cfa547db --- /dev/null +++ b/plugins/wasm-cpp/extensions/model_router/BUILD @@ -0,0 +1,70 @@ +# 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. + +load("@proxy_wasm_cpp_sdk//bazel/wasm:wasm.bzl", "wasm_cc_binary") +load("//bazel:wasm.bzl", "declare_wasm_image_targets") + +wasm_cc_binary( + name = "model_router.wasm", + srcs = [ + "plugin.cc", + "plugin.h", + ], + deps = [ + "@proxy_wasm_cpp_sdk//:proxy_wasm_intrinsics_higress", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/time", + "//common:json_util", + "//common:http_util", + "//common:rule_util", + ], +) + +cc_library( + name = "model_router_lib", + srcs = [ + "plugin.cc", + ], + hdrs = [ + "plugin.h", + ], + copts = ["-DNULL_PLUGIN"], + deps = [ + "@com_google_absl//absl/strings", + "@com_google_absl//absl/time", + "//common:json_util", + "@proxy_wasm_cpp_host//:lib", + "//common:http_util_nullvm", + "//common:rule_util_nullvm", + ], +) + +cc_test( + name = "model_router_test", + srcs = [ + "plugin_test.cc", + ], + copts = ["-DNULL_PLUGIN"], + deps = [ + ":model_router_lib", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + "@proxy_wasm_cpp_host//:lib", + ], +) + +declare_wasm_image_targets( + name = "model_router", + wasm_file = ":model_router.wasm", +) diff --git a/plugins/wasm-cpp/extensions/model_router/README.md b/plugins/wasm-cpp/extensions/model_router/README.md new file mode 100644 index 0000000000..b63be35d8f --- /dev/null +++ b/plugins/wasm-cpp/extensions/model_router/README.md @@ -0,0 +1,64 @@ +## 功能说明 +`model-router`插件实现了基于LLM协议中的model参数路由的功能 + +## 运行属性 + +插件执行阶段:`默认阶段` +插件执行优先级:`260` + +## 配置字段 + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +| ----------- | --------------- | ----------------------- | ------ | ------------------------------------------- | +| `enable` | bool | 选填 | false | 是否开启基于model参数路由 | +| `model_key` | string | 选填 | model | 请求body中model参数的位置 | +| `add_header_key` | string | 选填 | x-higress-llm-provider | 从model参数中解析出的provider名字放到哪个请求header中 | + + +## 效果说明 + +如下开启基于model参数路由的功能: + +```yaml +enable: true +``` + +开启后,插件将请求中 model 参数的 provider 部分(如果有)提取出来,设置到 x-higress-llm-provider 这个请求 header 中,用于后续路由,并将 model 参数重写为模型名称部分。举例来说,原生的 LLM 请求体是: + +```json +{ + "model": "qwen/qwen-long", + "frequency_penalty": 0, + "max_tokens": 800, + "stream": false, + "messages": [{ + "role": "user", + "content": "higress项目主仓库的github地址是什么" + }], + "presence_penalty": 0, + "temperature": 0.7, + "top_p": 0.95 +} +``` + +经过这个插件后,将添加下面这个请求头(可以用于路由匹配): + +x-higress-llm-provider: qwen + +原始的 LLM 请求体将被改成: + +```json +{ + "model": "qwen-long", + "frequency_penalty": 0, + "max_tokens": 800, + "stream": false, + "messages": [{ + "role": "user", + "content": "higress项目主仓库的github地址是什么" + }], + "presence_penalty": 0, + "temperature": 0.7, + "top_p": 0.95 +} +``` diff --git a/plugins/wasm-cpp/extensions/model_router/README_EN.md b/plugins/wasm-cpp/extensions/model_router/README_EN.md new file mode 100644 index 0000000000..4d2eaf1fee --- /dev/null +++ b/plugins/wasm-cpp/extensions/model_router/README_EN.md @@ -0,0 +1,63 @@ +## Function Description +The `model-router` plugin implements the functionality of routing based on the `model` parameter in the LLM protocol. + +## Runtime Properties + +Plugin Execution Phase: `Default Phase` +Plugin Execution Priority: `260` + +## Configuration Fields + +| Name | Data Type | Filling Requirement | Default Value | Description | +| -------------------- | ------------- | --------------------- | ---------------------- | ----------------------------------------------------- | +| `enable` | bool | Optional | false | Whether to enable routing based on the `model` parameter | +| `model_key` | string | Optional | model | The location of the `model` parameter in the request body | +| `add_header_key` | string | Optional | x-higress-llm-provider | The header where the parsed provider name from the `model` parameter will be placed | + +## Effect Description + +To enable routing based on the `model` parameter, use the following configuration: + +```yaml +enable: true +``` + +After enabling, the plugin extracts the provider part (if any) from the `model` parameter in the request, and sets it in the `x-higress-llm-provider` request header for subsequent routing. It also rewrites the `model` parameter to the model name part. For example, the original LLM request body is: + +```json +{ + "model": "openai/gpt-4o", + "frequency_penalty": 0, + "max_tokens": 800, + "stream": false, + "messages": [{ + "role": "user", + "content": "What is the GitHub address for the main repository of the Higress project?" + }], + "presence_penalty": 0, + "temperature": 0.7, + "top_p": 0.95 +} +``` + +After processing by the plugin, the following request header (which can be used for routing matching) will be added: + +`x-higress-llm-provider: openai` + +The original LLM request body will be modified to: + +```json +{ + "model": "gpt-4o", + "frequency_penalty": 0, + "max_tokens": 800, + "stream": false, + "messages": [{ + "role": "user", + "content": "What is the GitHub address for the main repository of the Higress project?" + }], + "presence_penalty": 0, + "temperature": 0.7, + "top_p": 0.95 +} +``` diff --git a/plugins/wasm-cpp/extensions/model_router/plugin.cc b/plugins/wasm-cpp/extensions/model_router/plugin.cc new file mode 100644 index 0000000000..457864d268 --- /dev/null +++ b/plugins/wasm-cpp/extensions/model_router/plugin.cc @@ -0,0 +1,189 @@ +// 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. + +#include "extensions/model_router/plugin.h" + +#include +#include + +#include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" +#include "common/http_util.h" +#include "common/json_util.h" + +using ::nlohmann::json; +using ::Wasm::Common::JsonArrayIterate; +using ::Wasm::Common::JsonGetField; +using ::Wasm::Common::JsonObjectIterate; +using ::Wasm::Common::JsonValueAs; + +#ifdef NULL_PLUGIN + +namespace proxy_wasm { +namespace null_plugin { +namespace model_router { + +PROXY_WASM_NULL_PLUGIN_REGISTRY + +#endif + +static RegisterContextFactory register_ModelRouter( + CONTEXT_FACTORY(PluginContext), ROOT_FACTORY(PluginRootContext)); + +namespace { + +constexpr std::string_view SetDecoderBufferLimitKey = + "SetRequestBodyBufferLimit"; +constexpr std::string_view DefaultMaxBodyBytes = "10485760"; + +} // namespace + +bool PluginRootContext::parsePluginConfig(const json& configuration, + ModelRouterConfigRule& rule) { + if (auto it = configuration.find("enable"); it != configuration.end()) { + if (it->is_boolean()) { + rule.enable_ = it->get(); + } else { + LOG_WARN("Invalid type for enable. Expected boolean."); + return false; + } + } + + if (auto it = configuration.find("model_key"); it != configuration.end()) { + if (it->is_string()) { + rule.model_key_ = it->get(); + } else { + LOG_WARN("Invalid type for model_key. Expected string."); + return false; + } + } + + if (auto it = configuration.find("add_header_key"); + it != configuration.end()) { + if (it->is_string()) { + rule.add_header_key_ = it->get(); + } else { + LOG_WARN("Invalid type for add_header_key. Expected string."); + return false; + } + } + + return true; +} + +bool PluginRootContext::onConfigure(size_t size) { + // Parse configuration JSON string. + if (size > 0 && !configure(size)) { + LOG_WARN("configuration has errors initialization will not continue."); + return false; + } + return true; +} + +bool PluginRootContext::configure(size_t configuration_size) { + auto configuration_data = getBufferBytes(WasmBufferType::PluginConfiguration, + 0, configuration_size); + // Parse configuration JSON string. + auto result = ::Wasm::Common::JsonParse(configuration_data->view()); + if (!result) { + LOG_WARN(absl::StrCat("cannot parse plugin configuration JSON string: ", + configuration_data->view())); + return false; + } + if (!parseAuthRuleConfig(result.value())) { + LOG_WARN(absl::StrCat("cannot parse plugin configuration JSON string: ", + configuration_data->view())); + return false; + } + return true; +} + +FilterHeadersStatus PluginRootContext::onHeader( + const ModelRouterConfigRule& rule) { + if (!rule.enable_ || !Wasm::Common::Http::hasRequestBody()) { + return FilterHeadersStatus::Continue; + } + auto content_type_value = + getRequestHeader(Wasm::Common::Http::Header::ContentType); + if (!absl::StrContains(content_type_value->view(), + Wasm::Common::Http::ContentTypeValues::Json)) { + return FilterHeadersStatus::Continue; + } + removeRequestHeader(Wasm::Common::Http::Header::ContentLength); + setFilterState(SetDecoderBufferLimitKey, DefaultMaxBodyBytes); + return FilterHeadersStatus::StopIteration; +} + +FilterDataStatus PluginRootContext::onBody(const ModelRouterConfigRule& rule, + std::string_view body) { + const auto& model_key = rule.model_key_; + const auto& add_header_key = rule.add_header_key_; + auto body_json_opt = ::Wasm::Common::JsonParse(body); + if (!body_json_opt) { + LOG_WARN(absl::StrCat("cannot parse body to JSON string: ", body)); + return FilterDataStatus::Continue; + } + auto body_json = body_json_opt.value(); + if (body_json.contains(model_key)) { + std::string model_value = body_json[model_key]; + auto pos = model_value.find('/'); + if (pos != std::string::npos) { + const auto& provider = model_value.substr(0, pos); + const auto& model = model_value.substr(pos + 1); + replaceRequestHeader(add_header_key, provider); + body_json[model_key] = model; + setBuffer(WasmBufferType::HttpRequestBody, 0, + std::numeric_limits::max(), body_json.dump()); + LOG_DEBUG(absl::StrCat("model route to provider:", provider, + ", model:", model)); + } else { + LOG_DEBUG(absl::StrCat("model route not work, model:", model_value)); + } + } + return FilterDataStatus::Continue; +} + +FilterHeadersStatus PluginContext::onRequestHeaders(uint32_t, bool) { + auto* rootCtx = rootContext(); + return rootCtx->onHeaders([rootCtx, this](const auto& config) { + auto ret = rootCtx->onHeader(config); + if (ret == FilterHeadersStatus::StopIteration) { + this->config_ = &config; + } + return ret; + }); +} + +FilterDataStatus PluginContext::onRequestBody(size_t body_size, + bool end_stream) { + if (config_ == nullptr) { + return FilterDataStatus::Continue; + } + body_total_size_ += body_size; + if (!end_stream) { + return FilterDataStatus::StopIterationAndBuffer; + } + auto body = + getBufferBytes(WasmBufferType::HttpRequestBody, 0, body_total_size_); + auto* rootCtx = rootContext(); + return rootCtx->onBody(*config_, body->view()); +} + +#ifdef NULL_PLUGIN + +} // namespace model_router +} // namespace null_plugin +} // namespace proxy_wasm + +#endif diff --git a/plugins/wasm-cpp/extensions/model_router/plugin.h b/plugins/wasm-cpp/extensions/model_router/plugin.h new file mode 100644 index 0000000000..16cfdf8509 --- /dev/null +++ b/plugins/wasm-cpp/extensions/model_router/plugin.h @@ -0,0 +1,85 @@ +/* + * 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. + */ + +#include + +#include +#include + +#include "common/route_rule_matcher.h" +#define ASSERT(_X) assert(_X) + +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else + +#include "include/proxy-wasm/null_plugin.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace model_router { + +#endif + +struct ModelRouterConfigRule { + bool enable_ = false; + std::string model_key_ = "model"; + std::string add_header_key_ = "x-higress-llm-provider"; +}; + +// PluginRootContext is the root context for all streams processed by the +// thread. It has the same lifetime as the worker thread and acts as target for +// interactions that outlives individual stream, e.g. timer, async calls. +class PluginRootContext : public RootContext, + public RouteRuleMatcher { + public: + PluginRootContext(uint32_t id, std::string_view root_id) + : RootContext(id, root_id) {} + ~PluginRootContext() {} + bool onConfigure(size_t) override; + FilterHeadersStatus onHeader(const ModelRouterConfigRule&); + FilterDataStatus onBody(const ModelRouterConfigRule&, std::string_view); + bool configure(size_t); + + private: + bool parsePluginConfig(const json&, ModelRouterConfigRule&) override; +}; + +// Per-stream context. +class PluginContext : public Context { + public: + explicit PluginContext(uint32_t id, RootContext* root) : Context(id, root) {} + FilterHeadersStatus onRequestHeaders(uint32_t, bool) override; + FilterDataStatus onRequestBody(size_t, bool) override; + + private: + inline PluginRootContext* rootContext() { + return dynamic_cast(this->root()); + } + + size_t body_total_size_ = 0; + const ModelRouterConfigRule* config_ = nullptr; +}; + +#ifdef NULL_PLUGIN + +} // namespace model_router +} // namespace null_plugin +} // namespace proxy_wasm + +#endif diff --git a/plugins/wasm-cpp/extensions/model_router/plugin_test.cc b/plugins/wasm-cpp/extensions/model_router/plugin_test.cc new file mode 100644 index 0000000000..9ce5998051 --- /dev/null +++ b/plugins/wasm-cpp/extensions/model_router/plugin_test.cc @@ -0,0 +1,144 @@ +// 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. + +#include "extensions/model_router/plugin.h" + +#include + +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "include/proxy-wasm/context.h" +#include "include/proxy-wasm/null.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace model_router { + +NullPluginRegistry* context_registry_; +RegisterNullVmPluginFactory register_model_router_plugin("model_router", []() { + return std::make_unique(model_router::context_registry_); +}); + +class MockContext : public proxy_wasm::ContextBase { + public: + MockContext(WasmBase* wasm) : ContextBase(wasm) {} + MOCK_METHOD(BufferInterface*, getBuffer, (WasmBufferType)); + MOCK_METHOD(WasmResult, log, (uint32_t, std::string_view)); + MOCK_METHOD(WasmResult, setBuffer, + (WasmBufferType, size_t, size_t, std::string_view)); + MOCK_METHOD(WasmResult, getHeaderMapValue, + (WasmHeaderMapType /* type */, std::string_view /* key */, + std::string_view* /*result */)); + MOCK_METHOD(WasmResult, addHeaderMapValue, + (WasmHeaderMapType, std::string_view, std::string_view)); + MOCK_METHOD(WasmResult, getProperty, (std::string_view, std::string*)); + MOCK_METHOD(WasmResult, setProperty, (std::string_view, std::string_view)); +}; +class ModelRouterTest : public ::testing::Test { + protected: + ModelRouterTest() { + // Initialize test VM + test_vm_ = createNullVm(); + wasm_base_ = std::make_unique( + std::move(test_vm_), "test-vm", "", "", + std::unordered_map{}, + AllowedCapabilitiesMap{}); + wasm_base_->load("model_router"); + wasm_base_->initialize(); + // Initialize host side context + mock_context_ = std::make_unique(wasm_base_.get()); + current_context_ = mock_context_.get(); + // Initialize Wasm sandbox context + root_context_ = std::make_unique(0, ""); + context_ = std::make_unique(1, root_context_.get()); + + ON_CALL(*mock_context_, log(testing::_, testing::_)) + .WillByDefault([](uint32_t, std::string_view m) { + std::cerr << m << "\n"; + return WasmResult::Ok; + }); + + ON_CALL(*mock_context_, getBuffer(testing::_)) + .WillByDefault([&](WasmBufferType type) { + if (type == WasmBufferType::HttpRequestBody) { + return &body_; + } + return &config_; + }); + ON_CALL(*mock_context_, getHeaderMapValue(WasmHeaderMapType::RequestHeaders, + testing::_, testing::_)) + .WillByDefault([&](WasmHeaderMapType, std::string_view header, + std::string_view* result) { + if (header == "content-type") { + *result = "application/json"; + } else if (header == "content-length") { + *result = "1024"; + } + return WasmResult::Ok; + }); + ON_CALL(*mock_context_, addHeaderMapValue(WasmHeaderMapType::RequestHeaders, + testing::_, testing::_)) + .WillByDefault([&](WasmHeaderMapType, std::string_view header, + std::string_view value) { return WasmResult::Ok; }); + ON_CALL(*mock_context_, getProperty(testing::_, testing::_)) + .WillByDefault([&](std::string_view path, std::string* result) { + *result = route_name_; + return WasmResult::Ok; + }); + ON_CALL(*mock_context_, setProperty(testing::_, testing::_)) + .WillByDefault( + [&](std::string_view, std::string_view) { return WasmResult::Ok; }); + } + ~ModelRouterTest() override {} + std::unique_ptr wasm_base_; + std::unique_ptr test_vm_; + std::unique_ptr mock_context_; + std::unique_ptr root_context_; + std::unique_ptr context_; + std::string route_name_; + BufferBase body_; + BufferBase config_; +}; + +TEST_F(ModelRouterTest, RewriteModelAndHeader) { + std::string configuration = R"( +{ + "enable": true + })"; + + config_.set(configuration); + EXPECT_TRUE(root_context_->configure(configuration.size())); + + std::string request_json = R"({"model": "qwen/qwen-long"})"; + EXPECT_CALL(*mock_context_, + setBuffer(testing::_, testing::_, testing::_, testing::_)) + .WillOnce([&](WasmBufferType, size_t, size_t, std::string_view body) { + EXPECT_EQ(body, R"({"model":"qwen-long"})"); + return WasmResult::Ok; + }); + + EXPECT_CALL( + *mock_context_, + addHeaderMapValue(testing::_, std::string_view("x-higress-llm-provider"), + std::string_view("qwen"))); + + body_.set(request_json); + EXPECT_EQ(context_->onRequestHeaders(0, false), + FilterHeadersStatus::StopIteration); + EXPECT_EQ(context_->onRequestBody(28, true), FilterDataStatus::Continue); +} + +} // namespace model_router +} // namespace null_plugin +} // namespace proxy_wasm