From 1f8d50c0b10b9f96b162b6d6164fe6e8095842b9 Mon Sep 17 00:00:00 2001 From: Kent Dong Date: Tue, 8 Oct 2024 10:42:42 +0800 Subject: [PATCH 01/12] 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/12] =?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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 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

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

FJ4YxS7LR!CWlE-czP|Dg2QUuTnRX6^)rW>) z6GmwjEjE`LFKhU9&jlN;Cw9ltrwj(n$VIU&-Jz+4qn4v6qZh;PpQE}GI%4U-5GOiD|&+FMahAReK}$)`7Yv{epG z=V+%SC-dU!>|155t7Cg>KXY|sp%b~N#kKmz!p*##n)UINcWD1-0_RCn!FN69i(vcjtL&`1b@( zoiU6UVz9dS_Fgb4e{U+tx5~YB?1~PH!>yVa?I0`oIXO9D_V(V&CvCreVt(_=Pl=i= zHnS+9SQPdR1uE}17SeT~?xx?R{sk;pjFjZCWmctZZ%uF>i^2kh+3#s2zZ9?cRw3rx zU_OO<7A83=->u*am>sM-80<# zySLibX~Ct~G`Zj1Uu4H93qj|cpQO#GdZF{3%W=`Dw(gv%8Zv7uT zp3tTL=)Z7&(H-a+VM9=@J7k$6ZuP|`B*5(L&)>6(TJb2aiRA$ZKA^mO5j^mIP=Guc zSdw$AWmBQzL@$FM4V}qi^j1pdR_D#VL5CJzTIlQBK6x`5Ojsaut|vo1JEaLhguq>q zcz23#Zqhe)ENSF7ckI$<);^la?5#C;*kSio(!Y_FBix^vis`pJ_R7+8^-c-K23zg? z?jmQ+RQg|W-~0F>x1KP|QI7?Kzx!`3s8CH%QEY6Cn_+wW2Kc*^VDU1f+T-EcN=lsz z+bm!f7c|aB^h_egStjvc55Fp?BF=^Y1HD4;(V8qCUfu;zPi0XLC4WI}1Rw)ssd9QP z;#z~jM`|$FvCN^pg>=XqTl^x7X%+kKUVXO`BdQwSNEm8ni^W}z+Q z7AE8rj$X}?vsrRHwdKE@$_UDaGj;)55Y^Xn;kk{Fcy=8T6_OV5Trka@(G34;b>6(3 z&JxETG2%QWJ6yez%(I}|p>Y@D60 z@L@IjbdKjap{>M7k`ld!lwR@IELO?FX5A%n@@i!LP!ND3o3&BZa#W|jM|BpgYFrchI-xwjM(d6 zXa2o8h~`1=y&l{*D`UIs<^g7NVXFC)TCXRPDAqB@3WzQ}odWgrxpmx`FTgnmNI+!T zK}O&3EDR z!(*eP)kj`$`C57YK8$zI$r=dX!=+dG1=BS)DL#8uU6_1Xx z{Fjv_3}xWcz{X`x_vNXQi;>xGX*Ji?1h3XyEgTgg*93-y)Ra&1GMpzuWbWwM6MEiC z3&LniGucI)bV_leiXwktNFRxhko{R$)RxE{>@QKCo9v<%(rcOvxfdvO3>QbJDDJ(p z>r|&J<>f+s#J&g*yDR-Q1@zgMg+qZv6iku zPZZi^RU7S-E-AU2Z+3`I^Kq8X{i|{vSw#vzk)2!`Ma_( zozIXa8Ddz}?amu?BXD%x9NXmoVqszt)SA-g>+3Zzc9*;BKnlrVBan$pB!b?@!S8|k zi?EL@u~de%4#R+rhp%b{JE!>vKAe@&+xw-eg01cN*0uvXkL=N^6zwXUlPYz+ElHlJ zAKY(jY>d6Y)A!_nB7K057<3E2k1{DMa5q+1)#dDC`@@%Qio=gH714XS{1W7KEv_bL z{|AEOUEr-H)S1QgheqN>lesV(C&mfM%yY7lQz1R1UGar9*`{Xv4OIsj zNF>C^l!rsv-PDDu`#O6A)NsI`T=K#eNp+?{eQ0iU{X^9nDhlk1tyXhEhmnCf!g*sv z<)cbxqDKEA8j?;nHvCo;Uo^P@3@b%3MI|Q}r@2KUV1vH6f`Wx#w-0lnuIZXR%b`b~ zytUQ7^k1^sIc~>)hw#E;nGQMHo5#a})+Mvp?H*0+JB3fOyxWdHKSr^$24bO_^Z#AB z@)WZdd*=e_KjDDKtWJ2MLruzxK#=#u55UZTgAIHI0ZGLD&fCNkaYC=%8>n8}zX#^z zs3)3C>iJM&%c;ZY*p|Jf#(EZ2_}f6aTSPwD-1|TvBdNHyn|j5y|6x~@{Ax6N=`e(< zi0IK@>*nDpRO**5dIJ#T7BngJOJ3at%y>2t5-Mq!*<+!sTS33`4_!zroD+VbVMg0( zvIdBqnT%@Mx1OOK|2O7Izn|b&E#>Y2|KyFg~K^4VK%{>zB6T=RL&q)N~+i zZ~~AOgIvyccYw+NLyTZr@DDLUh5HXNnhztBhI#q;#y@}FeTjsw^AZ6kLe$`KBT@Ol z!GqKCUG7^helmjTXgzhF-;l-}G313(k?=AiN=1y&z5E9$3LL4zfQpzKrpR(9BBtKo zU4lAb+qP{dydYK0N5|Sm|Vj6B9$}bmKp8Z zmO)MyRSL0w{2xzechglku^WU|J^jD8wMPVM_-nZhkwx*FPebb^(#wKfJks3wn8bF{ zdZIrZC(-xf3=smrBI?QKu&|pbw$2AA`PcxHd`=H!$;XLtWF8gDOIOQohTNACpI8q$ zVR%D5R21u8I?V}=a&h(PrS~Twe$+*5Ec zvg9v%xz_n-P1sR6o38ct_CgbTrUn2Nt`c`0Gli^%@i<7%a^;h*UX9w~2tc94!V6#{ zLQwb#2Rm|tmr`mlW+xj3U~)Gg70QZk8pzO@`JvV7P9Bid!6R7~3Ke?}cpiR0>q#ee zP-@*q_&bv>$~0(-s3r0fFl?{^m&5<@%sJECZCTo>5VJ_HZ9?4%01$@4IYq0mjnwGu zSDP4OFm0c{O4=SNAu56vSg^5#{`?U`%o5%=R4?(n17trK8h? z-(R}t2lm%@?)JiF^Or`v7j8d7DwzMV^5X{vXYdJ7wFMHG4>83E*1<6t7ia6i)*KW2 z-WSRkxsr$B^~LIq^&9q?;saQ(9R5g*ho(SufL5q@Go-x4vKqo>1|=^@=v*3>)ow0c zf-)McTi(}rcn1O;L|^zMV;rO_Se7sUS0JNBg`dge)>br{pTT+*AB3Y!L+FiL*(V0+Qc_YfGUP^s{9AzUfw>C&V4p(V8mPtsUW8iVRkB}U*1DU9AWaMn6Sy4H zVUC4BFt>17c#^k0B^TW>%)U&qfQ35O2A*I#HjnON3A2#WRi9ebKb*ELu_=V;BgX?S zlYa?+mF|LPkOmSiy0{B?5rt^~aT?$k{WO|O0uwOWIAC@|ot>SG7!TRks3MM~djBkv z(KSOQM@)DSP4?HZolj%X8}@!9OaYNcA@#(=I}Kqa955SMn$9thQHKC$T8}1mk=Sw~ zyheyS{USdDeJ?D^r!WPUZtE`%ie{oS@osh;XftVT9ZU;B^EcIA>S~8L)aeNslP1TE zAc=j0>FboNXuxwZr4*(ij1E@V6q;eI0oP`wXZH^1r~#AKAdI6UBCHD+NLu1(5e0#e z;ND|uTnYa-z#G+pI!J@CTvT=71kQW$0Hzr*;jOnq2=g$UjNpje+lUP_h_T3#kc6>N zqqnr<8Xv`dA#Q2iQG;$meNUWENVpJZfmg9rL~j=0n1;iUD8;Qg46hbD8#D08JUl%o z$Hrjx^#_0O@=p9wc&-H$XB&pWq^Ju6rG8y8f;<gk38LhmL()%hCgI=v97}zeKRv+mw%={)AM~6?NcP26 zxp=(wZur41m&%~KgIf5L1chOs6AhloZJs586Ac)QC2}ejl*F$j+~p7hy?Or+5d5=-tM_=y|Fg7Teuk9U9%7+0e=NW<*Ha@*h?L@D6B1k3mA*-4x^?EV$Gh>1S} z%)&e&KoF#tANa7)a1kD?s6sHybEBxp6{8aPkEZ7OW8b0g289h|6$2<5=#p@!pc8{~ znNFCuzQ3yu+ahQ@K^y@&efK|CEc8(K@hppri*cLb((HqW39YXil-d||<(GF-0%BKH zRRtstR?jb?>>wHj{0;+GGfg9R#0I z;*tE3ERlcW6BBnp#|k`FOj!&dgG5(0OtLl}^(i))GL|;>Y5^e>0*@a!p@6iDdVoE@ z)`%aN5FXb*zx0S>ho~-Am*`+dGgK8}7*F(H7=|zz^dqP5`J(Z914Rs0OW?a2HRu2R zf@6n9BlTji)|7@#Scuwb-b`AzJFveoHTr*DEKRU-j@Gh0FTlf-&){lm%Ea6RFqC71 zMir2%P)i~xfhR*a(;uR`g(F*I#zn@la^P>Rj9Yh^qhdBYCdpUflOW~>Wmq{yx28Tf zL8X;^1^?Q}|MZm_Nr(V!cm>673p-#;RZVbo?M8YmHl>+O>=sJnu%Zc3^zDD(I4=qp z76Q%Rn+fL!KQ#~+%>YAwtgMtpF_sehDG>0)&Zl0x_N;@8f&LAG3S-P+c>fkB9OQ_` zAp>gSf0SospF7OdeU=o6DsD>cOTd;wJk#Vm_~ro_LoUgryJ!DyuVZ$lLoPPO|D?4w zD1#3flJJCs-~s8GH}L>&igK0{=kqnla-m}w*?sh{K!xerOF#Yn!^m94r%|ry%LJy7 zPJvcH1qDEG{_l#3G5XuT&<=|95`G*}SD*mw@QVo6gsS;{95Pg3#t>ifE*b`@y-{r- z>{sromddgW`8*g7akJH7?0))6HsXBFgqI!qtE(B(0#t;s_8B26LPEen2xJ0cNP9tg z;J5%ldiE5Fr{V11Ga~J}UVWu}l3&r83^*+6i9z(-Wxqu{g+!QA*IUf9VB5Au-5Uyd z5oI-q6L{!p8Fe8?B0#)dG3R`aO`2OtqaJEd&)UOSV(`HL|Gx{6Sx(28NymT)C4O!H zr`b<=paqiKvB=OrSQy~~A(Q>R+A?xB@M7x56Ua1Z$@9o;^L5Z@X*@O1j)W6^qd<4_ zqC@#>cN8Po6}5#?>euhzxQyxG|7a``R;~5eIn+LI5bPgvVZDX4JTAFZ3ddu?fA6)u z*L}*4Fef$oUHJF>_8!FEP!08b42&f^dvW7~6hvYgrk-x8$A{f{_NS`he~N}|x)C4i z#Iu*X3h4-0kNhGWb5sNm?WprUw<_PAPu5N^(goufm`@B9MLi!($UO7t40KnROAlG6 zZ#_s-f0Ka}+;gxjO~w0)Nhk%gep@Begj}%85+^8#mZkR+TY#Y|;WfR9yP!`4z?E33 z4OR|$>|5}L#BTSgl3={{;_vRulv$LbXqyl=&msfki`^wQei4BV0(Aw!hH)hN_xQwL zJ|hfvWNOl`s%_cg55@^@E-1{M;SUHx8*^*IJUrEiR64w!jEJLHeTD$D+tl4fzwLZ%vKL1q{owocYMce{w4(a_}pANey#r zm|vsW{{PP$-UKDq`BC|GG|$ROXc%KouyKr4s`&IWdH(_HSx+y#%RMq7mE{jp(L`~R z&BM&f$_kc_ojn`n1|T0?qeRD6gfkQYaQwZD6M=nnH=$!94DsM{<{Y13jG-rrpr9aD zldd9WHBe^-yn;<47jvT@+~Z`={1Q?3-w-kE^7x!aAhQeaiH*0<3taI03RyAg8R$@U zHLre8URWJaowIP+E;~%IMV1Vy<#vA_q7^ftGdSUNg8OJ)+ptyT>}`zU4-DD-pBc;S z>Sa2}#C6H3S{S-inPnG@Z?^7wsv&2!R7qyqZ}m8i&`U zh%yzPXIA?4!k}Ik6_dJPYE}Tv{8?KYHXYT_T8WhfiSnBLb00 z&u$jJul#jyTq{)pJ{TV}_I%_}w?>jMl$bcQ(XP_v?d#Wd2#>2@bs^NFgduE+AvK}L zz!q>@`fbFGKX*Uh11RTQXqMvi>(#kxQLpgRr%}rjE<;%Gh~>PTNu%6e{HT+LF+5Yb zPunG{wU(%kkV24xySxO`%b6i1hRV3P9iiKa*Oxu!Htf8q4) z`0a&_nSF3<-FF`>3aAPiH>=>d zfkzeAnZVmWd=sDy23Lo0<#jF#oqPQRd=hTskUa4SO{geQ4b_O=bwUSJgG?DFkf+Xk?82vMAjuZ0 zZ*a_TBH~hiBUsdK9g6%43K8eWOLx6^6cV>vLr~-y3CqXvKfZj%otYQX6{+54Gh0c& z>?>V{VXB^!iABYMN?$A#ojOpub^USr`M7~)p$nS@?Pz+ZTok1} z$@%{g^(Ej`uHE|)o3IU;w~`?lLXtT$4@u^Pkcgy`v7}PjhK#9@OqqwGl(|%3pIkETqKF?bBy4St#wMw|DUFRoe;f|P2P?>Y`YQ&SA^I56VSPtVYmbu%vtjqc)Wd1H z)shTn1q20mZuyRyVT)s>$%AqsB3n5?x>sOv^CIUTz39vR@@H}1U9b}AJ&?6YkkF+o z>mU0C(&csNUujtGvH?*QV3aw8R4vw zy{;~uhs!6`BiG@RCsM19Lf1APo?N{)Ha4f2U(+48q5AEnSG5-r6QduLJ5|;YE6Al6 zsk>b_MMU7qcif+g)rB4$Mbsac z?}?EkA4eF`5#yxLWUvsy8|=W7>qqbiV*?QO>UTp)f%3> zC&Kd*AftD&8KFHrbe^+;@Q%6I(S$`kH_|_>uKMrY((i{obh{reh^^8(f!4Bc)7QG7 zNj{gn;%)!Hg?QnX?Ke=k*!;POj_PlN$ry2r9i(fqEJ)eIebA?T6~iT5Q(fs}AL|8; zpEE>I`Bw*W$sNkos?F9BN%UGhhI6tQ>4qQi8pR)-xU$G_r;L{W!7(XXr)lB4RaWk|^P{VtA|3OM$$M z**>G)vrT`6kfe47pBu~_v(%fHCZ}X&vFDf^UIlog_Z)fHe%!$T?QM^NowhD7SJTtW zlKEL9o~jqvT6(3`wv!=a;*dBFg)nX8Fd+okbY39t6GPp*pIl@+eLY(~P|6???A-Bdovd_|Pw z)C|m^wSZ7bJ@wpDtksoP_OWu(=yG1gy`V`u6Mm@;W$#6IMy^6eRB|ndP|r3#FDuR& zgGC6DV&pl1g8Ho8HC*~`*{x%0@=;S)NzFenq#<>D1DqEz3zlb{{>B%gy4D$hRe(>; zMD&`hlAmsKYY(I;vY+**NBeg~W$#Z=2ZC{W@6!s(zFENK>+HD}A=YmC0yUwd=*26@fHrfg-Oa{> z^Zp*!#4wtASY>;rb-E{oY1^-Mll!+&QdW9z7846o61Z(>zDaaT&ZG!y*^LMjolNV8 z{{1gZ!d$gxl`bHAzL`4cd;wdtJ!I|@s?tEFQ~drQIP!si9?cXMuF<<@+CQtGL+F99 zu9K-|4FMlRv$-!BAI4-B&61^(LUc=LQ-|S`hXC`O%tv^cv9X0Ks{0PCO0obqE}{uK z*pJgX+>^9c=?K@?`ndqk&f3-9{xO#|Ok_WSJ zc-D1FrM!_A)1|bPGUUpp{<^CB(8$_H>2>0p{E}6PVc6jmQEM%JR4EYVL3dH&)hL8b z85kRjwl?=u(u8EMrlgR1 za~2Qi)G#nGWWV@^JxLiu^6w6+!9gsRpkrz33Ges=7~&Lg)(=Dg1C1s8bASV=(H z{=j7$gO)gEq!+*>j(AnPjCDB<32@ITg^~+=O;x9iDlBk$oQ@ zojo#Mp+s0|^?o~Tr`YrKlS*>gfG!j$@jiFoalK!g4OcV5miD%|ghYh(9vC`&4+tLc zBDAuzAT=9~-4>CRWs3&K$_XD`ke9YSSyd43a^_XU^rK85d_=ifSUdOU&v1b~E@;uE zOaTWBy?evzvTJOks5n6MAjx8tDX|Q2G+u|#vC9N4X4kAQXWAy2ud9yn&{!yWSlBcrwKb{Kj=krJt_%4kgE6?FWEsrw3DE@p#!;h5+Xyzf zoIhbj*{5`58Rfx<$|d?cr-ledfF`1r30}(T%Z=n@NICXqXIy&l!g>RfRe>olwNv6@ z!Hb5l9Vn3tLq4xbA%G!ebobMWIOwz zq;-&E!iYiVVF4-;Kk7hW$Ce_>lX?Rd7pkMwu-(g=sa=kal;N$|Uf9ntNu=H614M^L zJrhi#sDw||{_E>Sf6sob{O*vGOgRcBmo_q&DCx0peIQ_cVuoz@o7{zTn^-`R)17e1 z*ECJoC%Y9J;95R`>++dihxrumX`|8lEaqe3EG_XDYW&B$BxnK(fRQ+8^5;^MpA^pB^L-#P+xqt7O2I*rFg{W7>!)%-PA~$5;Bk z=20iANGTuL!-`;xM7U3-*OX%uYir&sC+Qq}ueXUrf8AFjQ1y@1@y5*ohY*_x4fplc z^iNCO?ix{b=ddg!i!rZJ%9P4yZ8snLDbVn}WKz_&s7Eq3l(_#2yyT?yK>!Wt7T=8p zI`mJM)ah0R%&FD&)j)*Fzu%HE!}>A&&Yk0c%2TxuAKbXHAEXp)9-out-SBtC%{Wrq zJ}6k0XJJH4fYxxF{uFyDEFo~TP|f6R`i+ba1wJf~0q0t`^>Ec(*YRhhL<-3sQJ~z+ z-IXdKDtaJ;t65T#4&mo(EC2qEIk=;SUHX_hu|F> z9X+VR?Pc3qI&wF^k@>l>N=Nv)oi97pQm@#1dI;aXC|-OfY1LUNnIn2>5d}6x$+^z^ zNRW^3X+V+0DX<0pIGvbM{hm{0;>&AFbx3pga;D>?;&X#6>upEg$nt!F45qfR4n2?6 zFXqRKZs7P^o-RE7=08_(=4d2zygOrIK|MP^ePQNkJSi{gx^BqM{nXBTn>{3Qc)7TK ze7tB!9e>uG=!bErHoY5i0llMrFHEy49q`GQT_Ty^fC$ zre1PwSz1C*YP~wee0=3A)Ais!k6%v=*F3DU$WUuM=EEi|pI?XZ@fDDM6e)r>Cs8pvFI8gQKlfHG9d--z&Y?%|BdNBFeK8+B3_sv9Yx-7Sc3Dgg00Oi7SqgE;aBabce!K?3=eM;kkmid^ouWCM|B-Q>=+ISt!s47dAJ`Q{_W(j?ws|bfgel52L1zo$JHAQe+imMAH9KW zj32z3Xai1Hu3TaJIH|VxYo6;)<66mrwAf;$)c*>#@{`Qo2A0rCl(}@Z3Vrpf(5b#m zqRvtd1tvZZPm~BcD6z4*Qd*MiS#ZR_WQ%DL(QT7RnA?4v?|o9+fT&_AdKxC)mX@3d z!iPYP)bgYKHA2#zUI)Zy6%QMd<33>ToeqKq%Mi-g<>r>#czXS)wKF4OKB{72WU*qn zmGw(mU|)x!S5#S&uz0)sQrPCNY643tXQZ1eDQ1l2;qNA|j9L3x-{ay~zjcz)AV(>6 z^qbm7W*!QlV>-JSi~ z_%L)@Iy~<1CR0_r1}G>lh^FKa>5eO6&}`v}y5*gJtsTjQq{hlGhGzG@p0zwbXvcm% zx7RgdsVH zeKUZvJn=bRe*kWc*IHu~$}ry+6BdM9J@fk!_OrtHKnH;LrJf>>22cZ_=V!hG%K8v- zZyzbjzr8&$Bns29-7?x?888Wo=aAvjpY@?fwlQAjf! ztsLPUXXer2n@#sLDjs|WDiXbEzt$vaICwoTeEfJBQ+AKkKAD7u{)d9J!$4fKM*T8G zNHG69@@5FK3836}dyN9`ldHoP_LoL&JEZpzvzq7hkh`u1ZvIPL`S& zG?m1eTl#`8QflLo99hA_=V@|V=O+KoXN*4hsg?D%cl_q$#QTts=f+M1W!(vQHTg?p zwEopkKbJdie;Qt@=vMgwsG~cFHhW!Tf+pq$VNHP7Nc{xgf@w6cvYNck{F>l(?5V7* zoSmIT(;(=<`w(|`w48cRKmZ}yjMfi!4jAquJUKWy6|hzL4I^DAV}uzCd4Tynwf{O! zpa=)15#&^vztJ12twQ7<&c!lu_F&Y-J^`FcA!WcFG?AnTLXSy-)Ut^@H;ge#(r@Sb zg9u`?^JAOha2j|hS86`D3m)DV~#=1;qW@bYwOuFvZuwA?k9}B&q;-`2hzSOfhV_(1B zZ0s26{_E4DePS+pK$@`O4OZGl;*kxV+3B7JAQ{+Hf-LY$9ywxAy4Eg&aVyj zKn!upZKE7K%E8Q>iZ^DPVzted?)2d$$36RXQwNzOJh2?YZ3|7f=ckded`VT!>iot; z2_H^c1bPfMHohZmftto7byRT^0>w2#~ z5_Yavy%fq}$Pse;VSPOmqIK{5(2jeW9oP7U^k5n+E!fU;=axZ~cJX-;!@ect!zWHu z*VNR=T=im|Ammo)!{p7P<)XI$Xsg@AyJ3EL9&IyJR3)q1U%%#fTxH7kSagW3Rn06F zQ#aGA6Y}19zS{wyDz1$-{TIBx^VYd1M6|Rr`&(lx+L^@3b4ZPT)NU+K&qzvYN#ioY zW?nP1HdNJhjc+=}Rt-f7O|izyejV;$-pspq6vDWIQMhS7p^db3P}(D}ynPInFal?8 zrd+3M{OX*H?LM@bcRyWk$Sc6zW|c zh)F1)0DdpS5}4A!tEd)(xjH)Mk7-{^JcO%hye^|(siR(Cd!WUyZy$st!x~0NfAuJB zcPFWPoHB1wj-m&P?`_}9QRuC}lhyyoVEsY2xt*K$P&*w2JpVQd=@bUzoC0LgMk&bF~@H$rSfW2}>qvVts)Y+G>>oTE9Y5h~(Bm5(#sWYrJd+k=R6^iZmd?mn6W>>hZ|6k$z0W-fsff|4iMDW;ot+vvgr;81d3uvm1*?^ASK` z4EFqS*YcFL9l9fMWZbvZZ2kCQBaL}eIzbW+8o)M?=DOeOU z)a}6Cew2wT@3T^>yWsW{vb~pDhBz5cN@!@@$3$Xqo0g^~^mc9SCm1vs-Z7KO!=wk< zD!-7Kjf9KGn^Vx#Tx)K*JJ?1i>%ex+e6ZymN8=&bt7WWgSaWa>WO676TG`pjZ{PrA zQj77gx~bVI4Wrie`j!_S^JkH3QMazibXdb}zONC<1Oo>xDsBsm^yvh?pznRIZIe|0@Mk<|UvOWCk@wUR+O zCJFei-MQ$?Rq3DC{CC$jMMXB#da-|>Pie8=a_j2#>n~=l*d8XILKLQLA{aQ+r%;2A zUxw(whY+IfRRBxeQ1UEZ=o$IM1toh}E2-L=dfiR?=ZBud@Sn6xo&%YC!q=otAW*pv zy=A5n8xYC~3i*}mo)Gk*iTOgd-b=*A&>SeE!PF&W4zz<;)0OoCJI>TL`cdNE zcvVkdt?uU*6g=!ZYpogfvJ;)>B1)`Djz~^WxyIgs-awCl3$9 z+m|n2#_E;bWNO@b_=gTvQJCXdBgZiwiHq`kv_%Nt`oOmU{g8edY+sn4r#(8mZ}+8K zyta~71x&xIM_;=1Y`-$u3WUSPj*(}giO+vePY;j+vPs~}Z>PM^(9i>sVs(P;Li ziqx{tN;kTiQ!guC(8$f=WMR5zBB7-=m!`n54@D)k%0b)&KnW+woP*zcW7r>_zl+K<+OFStuWXPAZ zDqqP;PD^XO@z3ACHz<22>2}vd&Q`W)+CIY^I-y}WKYkrl^wDB0SXJ9wRz^n@BOqB`Er?~ChjK~dDzpiThEqkm!PrtP%I>y7Z=q1 z5&^RY23%<>YV=?CtWErFtMN9*;x1%-Lv_fOG|M=z@|kVeuyb<*p>dKRMqOiK6qj1Y z+JNEmjujZKy0anO;9S=QlJ<5>DoDAI$hv5^-j9@BC7;y%Uuft-VAc5!hGBJ7)oKX(Av_w8KFaj*7%9qIVO8eg!RfQVBXcWe zzkR?MN@GxVqdVzp7Pm<$;z-jE@jH~jiJqMgI5)H}UZBlj!~3Pq4nyCLPT{W4&Xr@E z4=ePtN_u4oY0jf4u(n_jJYNBM`lbIO(vc~G$C#!H78anFdit(+FqT*|3d_JWi9HlK zhkFL(0uyH?lAYpz_Nney!rG$Oz5ii(emQ*usXiSoTw995 z+tN7sF(f(l4oL}Ov56#`-c4y|G2?mcY)am%@zlZ_(_^98emQOV5@<)67Udms;)2(P zWO%DArA?N=)^?U&(tDk}Ilj9FIfCL&#Jy@W8x5$P#qR#k2F22!%4{T)AVJf6r5f0Yd0E%iSFh$~X2iZ{(2l%G4;+29 zUTs#%)+}jv9_dY{P9h@N;G+?#cC>tz9>gBN#vzu~nMylx=ecr!VP^Gnt<~+rJ2}5{-;tN}fr=_I3}{0^AHR z@hf16{|!NPN3iY;B2}%NZIQ)Tml!?{+>n&72ta7Yu{>dCvpW5v1UwpZ( z>at;*-VX&RVJ|J{F6C`P&qZWVB$pW6Lq8X&vn%QC982PZM3+Qslbvge*99fGTR%SH z>pP8@jd7@KS*XZ;%@)fKE^bU_zdow6_bC(?4{r1`5b6Z}mDTHLH_-Lo`0ZvP zMKgp>pzlP?9xuzVLk-SGFGF?2%-CFoVq-`iip+bss)OROJu42Y3+>Y6A#_Hh~({}Jsk8quJ zugEtYW=RVPOBP#Bc}{0`W_J@sC+55^ea5wo2t)f*K+G9=PNAtGZyU{_4MPmRoKwZ18W$K3c&$R4faTDoY z=fZ=+rl?p{m>_59P#(VwI zt>kakd`{t(OG8j=C|=FHpp%=CL>c|S+qf^;^yEMT9k7^)4HKD?Ul9@y{Jc27HQ7Na z)uDKa9xWBZovAf!JAohMe4(Q4*&&b=t&j5cOEeFLu?|}{?ZiGIVl~2i%}R;wdeVdg z2FhVV8L7|>Tqj9vi*S3_dG=OeQRrjEoUe3+?kRyMMtfY9k{OF9EgfC}LXMp$7Tv2b zZH48>P!UFbXd-9U$f=Z>`*-Jh6aJdpt}f+dcr~lvCnNY5cGcOXt_wNxmZ-O5z>0Y1 z?*eQj7_I82b|rz!NlCVjYa}E?7Z`ktPe$+zJ|aG0dp(lYBA6C{OH?X8CWaZo0_uJyj$NI zO-bpCZxYu>XR0fZk=*h6P&Lq$VK4BwV zcH&zhRiJ%@W;^c?eX}v6sK0f@vNjfTb$V|a<|wuU;&@1vAA+)B1#Zqw59-VO#67Lutv~_OVg5^;V#ZlIga`}KLCO*G zuj-7xzCNf!EdvO#NNut#-Fy3c6ZOwQ&ARzk{W$(D#{Em4<-pPEsVfp*!-)kI~790_sb);T6?P_4jSR%)a7*jM3_NGqB*GWbVK%;4N9kDZ&KO> z3;zHs@~Uq=+d^BVIbi!*UbSCt`}Te%o_LhlSO`G3a*%;MjDBsyhy|drZpZn^T1t`d z=<+TP0JXfWhv6nkytBY3DoycLm1(al`#94BlsLq)Xee|ZG73p)KpPwbA~?^k%0>2>!;6n_4;)<@7ZMRbuJ*@ zDi;^m^h0=#{PB3d#ol^dpXDAq&*V6k$$2+Qn^Hrhopg!+#TS}EcdQ>&SJwF_k!~>) zckkM@XSjRdnR2S(WJGY|f!!L-%sO4J7y%w6y)};ScCn7ikHuO*+YizJY*1F(Y6G?D zmakLmz_H4E15Q3L$Ds!w3a}|ZzK|>UE@MAN*PE+Z=c00*-<|S!j>>tk`oKaFgeq9T z)w+nsnpQdTt}NQ3SVr)u(yc?4J3RgnM8~Ee-Uu^=_|`8{UW>4dU!}_I zkpt$f_$lH|0QG|?+0J^E%e6wV86V@S9XB`ayf6W;`|017r7pkg;iU1oKrBMqR?5d*u}&WHjN& zZb>6UL%~OE>J1u=eJTE43f-$6%}X*p9WxxL9-f}on_g;oSAN!U4_m~X&UvBmdzLjm z&bD8`W7ai(OC&Yxx07#Q()j)j1rO9H05H`UIQ8L_-ufSDVy2`gAdI)m(f^>h=1(0PinP^#Q)geT4mNGX_8;8Ep(?CNdSfPsPsSpD%B z!uOkf?SM?;%-w*O`yoo(Gv$b(6gj`$mFNvgPw?gCT{n7;3*McemoF^0 zSje!SXJFTCL6QS`WGx|R-#Y=i9Beb@2aouUDW3eMi#DlMJTwUdqzC#GVi;D)=>}%o zY`$1p(_BZx?XIBNvPqSPzyO5kbmG~o<8HdX+E~evmz#?t-A0yw1)0-85dIJ2I%hFN zGCJb+Dod*$%3yB`9ZU~+74XAtSl$$~UxHMItpV)W@qTik_Um%Oh><-cnv4-0Mojn5 z+y{DY)!1+}dZ&`!1l_jOt2$=XrSB?BYbz*6clyb4QVWmoE_!YHEdTR;O53~aKRlL^ z=%O*QY>EEOuiCfXn@sb+-VPbR^|l$5fu{(rmP%9^nWBYM^s6Syi-q1lcxC6#)js!XoLR1{#B5t*WvbRa*H$zS$e$bR$-m6e z3P}^1Vn1AtDTQ<#2PMDRKz=?x|8(vE0Doxo#sZZmSCl0yRV;B@_x-?7>!1}y*_#J3 z4b&6#LCJUSY*FS9kBTye-W;tsOd{UijTZ1n%-eX`P+~^cJJ4P&asgXN~xm6qJEYV0Ly-3;E8icRi*A0szg zT8LZ?s~!eRcvLheF8iHc<;+s zZKLfozlbJZBC$@!BM0rM;3Q%atTA2nb`k*eZ;uB_A9n zI*ixn?76u{-Rhax#K!6@fG_das5oji_C?3)4W3&UD)c}Du+UH$x|2wusaKeXrewme%pJ^_JZZxw4}PlCx>fu8+LrAD zqoYyY`O3oUSwGzU_Wsnaf-n1=*I^YmDs7pcgg6JFhKWr;=a>8P^><+dp0+aNLad~Q ziYV3^zU)a9ZeB0B;FR~~6~It~gz{y$ zJ|OyNVPf;w#~NHI3=eewSw0MuX4qIRS8381zwUVys^BBVIuUHr?W18sHcI6^I&9SI zS{>6|XkLy%TuO~PGYfss%BE&nqlcxK`W;aD8#Wr|pUZvzZ9xw3w^@QUfVbDqk~T%Y zPFeK0^A`Mq17&r6gAAyLUp#*fJDqUhBQW7mR+NFRX`qDhf&Q&WvM%+hLg6t*8_Ayg zFgtHykx^!JqSM|j@yeB^W9(sJJI`Ql+GphL`>Plk>>0$-`Z9fr3glHFGzEJH+Q)u;@V_E>$M;7%w6Y8~!Pr%4oZPgPV@pejUgzkY8h<<`RcC~(o)^!QI-lTS!O z$#=VOTbo&h`pu>`Ed5ZC+=SgF*+M&aDcS$YQ63?Gt1Z4P(svzgFu{W-f)86#n`>B3 zL_HOAENLX%VIE?~lG(LAg~MvRApZiLp{0LvY*=FJ*3OamzPmU^Y)c`ulHYA*#Z^fD=%tXks0|*jTTRAECUU*(+}#RJMVq|0hM|oO+5{ ztGMyS%a>nN8WVKb;85_kmXp(#W=D_NF0`t>(e)c=2}5&~5R0YlHZsP{w|RBO2~ z=srPyhx7eAfVMda&Vb!DsW>3M=~TR!3*FDajPd~C zXeh#b!hozbrFpk(vm{ZEvcf%v#rXwQyWD<#4R=d5DH-U6?%KRYLfW)ZFKX zNt5+)bJ{JMY!aKHfs$II<5!t!L+M>;Bn_(Y7ePcP^>zh(Jt6Ma`7!?(&>eDy^iaJM zlfR9H#f_(eNGi-HbTSPqh&_IrH>s=Qtc7agL#En%3vN zloTPd0s#xF2*x5}8!!5r%d_L$+ED|9Kl~psDa3#MbA+7Flm83K3tXE8NdIYT5D{P@ z0!79*R58dzK~P4Z*@^)oWPb^9@ztuUKL`NbHGf;|NT3!+sWxSkuA5;iu;JHfuu{65GL+zn zS(%u)ILzd`H1rpGZh-G5i+Sq8on%-`7C>V#ouR3z`ET0NdA8c>T6e*%qR!W?G8=(Bu>;wFt{< zT%(`Z%!To5bhFr(*4)wBy48>PRd^}zJV5Pm7PBWv`Z!Nnb`2bhvGEG)cbpM;kr<4Z z(dUQLr-uE`i#YgxeZak+c@P`{O+X95R$>^)Q@tT=oWV z2v#zrtNDrb(6=hW+c_~Y5uJVTAvyUFLr(bz0r1_=4$IIwmvl-7HVoi~06$(Z0OG>^ogXngl; zb~dnjN?o+}_n3yBc|baMwvo%PnVB%BAeju>zJ|{vt2+vbVC8ea5 zU=4%dKv6&<#`x{MmwvWA=V9Pyyc2`3Ia3CI_K-)@ir5{)pUBcPC6p9eej{{Y=h9wI z(_z?kz&E1CCZgrc%+Q)&=XDpdFtsxy{qTUqT_ES1xkTf-KZA-Fd`r|msQ%n2QBmU% z*d`MAx?;1=5heXdKcd6RCeOtyY_X|kjgdPXJxcy142+gAU+RL*My+m#&ZqrCN?r}* zu!m!qvR3~4t#$AIvwINyaR_^ht(v>LE6_c|QGX(5tmO}NmOiRz$lPvsixabp=guwT zF~K#)`Y|CfLJ_bl)7-L*0HD9r8f{AV-VqK`g&q7-`te715A44hF!K_=bl?ycZ~H4j zk&-)wgDbsxGwa@G*rz3M?nvM|$I6fyRFx{+FY~$U#KKSok8Rhgkazdy|E&SNf>p5)*kU$#mHAI}w+Uw-$oH{&0_PPA!m!m7`Sm{_ z6cFUWXPd9XrE39=G-6E*a42mXjt_h)2x}X3(uv}WhbarGx|5i5$G<}KMl`#A?cF!{ z)s3U-f4)|64)+8}PqgNF09-6EAhf&cK5Q}&UfV0~L!^YGU_}()T^+_fZP=V(Wd(^= zj_#%6W-I`q{J1#bT;WNsr}U_MheCUW=+``)s(gfx9N3barf@*d#KZ)GJ%kZu@S2p* zYS@2=rft`}ky8jtO=fea&-jvh4!W@+r%#_gbqbo-Qp|YQE?AZ!``xiAV$YXlIEJxt zpnxOmy4NTRoG(g9uSN{3t+E{D$z9DNsG5K4(xD38TK%z3qhTTgq-U~eihKKJLydDk zCBvvYfA0LL;NWboFVap7lvuSBQ}#Ym5J;EY%D(u&(G=d?Mp-&x$tv|}*}C1u!9B&Z zuK1O-(w3v=pWS&xxror;{3|LLSX7ck>C0w5?Bi?W@;J22+z)H(HU%Ds+g6$I8wldi zuvOc$j_dRl&=g#Y#NPO69`iO#)=2dO%M!b)DhxTdzQm-gm#-|$>B%U3-ae@WNfqkfyh>(I*rviu&&CKsSY(vOi#xoUMKuK zaPvw$B#|GU*lgEAtO3bw9ovC>jbIOJD4g6whYrDO>g`TGF>Q$cTeRb;5+eWIajI+T zuHz4(m@SRS00Y43$yQr2+K{spv#k-ByXWJLI4Psb%*%Fg&)wtG=j=b%61Gdw!63>M zb-2jj{9THV$H;={CtY6(;W=m-w#T3P0Ap>1?5kH-@%*>^@Yeai@EYKsIg0*4tV|?| zSO5WxIIgz#Mg0uzPt6V-8l|uSLeuTl|=48VsSDW6^H|iPcXSyOaHteqrq?PW%Rl`{sG{AAZ%rBI)KigGlu8at{7SQJI%e(^JHp1ebhkG~6AvPi-Jvj46@s7yb zjmpZz+>R8_mZEMvFec3qe3#D-Cv9Mn4H~h;x{dt|zb(0%s7eSMxr?NVL`?N}q}dXP zcayCEx~uw`=itAC3)ON7E+bh@iY%{Bimc1zk|cYPAx?|eDrldK6Ah<`T-o%XY~8&hmQ4?A3(RF=@i(>Q}MN|7-48R z8;sU&Wfvh}CIA%x6ehfU@E<~qH60ANbtyK?v5B|QaO%_JbC82pQvl!-CWR;z4KSbi z02A2dh={jQd*PbBVr@e^!Ho9YZCVkr7GI?+Q&DB!)ZmblLQJ&b`LG3^>k=qK-1QBp z!`i4wfjlpzRj)Q!h^i$(C?b$_*otQ9(o;|aaS42QL~6xRNyaB6ylQJ3igd#}0O?-* zf;bt7VK;98Xb@}MJOJ>AFZ)WbLyQQhi0xLt!WiwPG)fIe1OA%GRcr~6q0yY6fQIx3DKKQLhk``$tx9v+CzTe8h;6>JM#hm+Sivb7SGUa2dRDY{N)Ziz7av;ymO z!~uNXSOFE_GP&b(2s~NNi^>j2Pv(hF@-o~IQpyR(EhW0zLiyDKNE3p}gF7|1MZaj@5D7Zq#^1eX+?CA?3 z##=lay#e_SS-vrr*{>Ob&!XF*YeCIa4=_?nWrupzRvRFYa2d30B#M4nZeK`heE3R5 zG&Is=QZ`WuHz3q0nwR14E0V;oMNUacG1?+JKDrg@oB&p$=g+ba5%6ZhX8nC)E2I5~r2MhP>0GGY;_rgfJg} zRu7g?xWdJX(MVn6aU=W;tfw#s?Nf9Id<#-6KCWJY0loz`47unjzIi2E=^}~qzZ$tf zq%Rhy9iH$pX?bY1oP%($h*Ne|Z(pt<>rJE27!H?`k@-B%kTV50sxTiJK^0l%6-m!Hch?kbiW)*k+MA(M0 z4m9BpK+SqP5ALpmn<5Hne7)h^;^mb)Ffr{Bt$rW{6X8}% zFglFrmnC-C@|>Rp;g6@3yr$E&nz4)!qAeH6u|?w4VetX%>R{V`MY7DSp&+u=TKh%` zCSPrL6s&P9-X&PWqDuIgl0K?IEZQOX=5`Tbt!MsS643GwJZMTn3Ad@;*KpG0TdEMi4I3b8~k6f}bJm#T@WkNI3NM^srtSqTx0~9{GFg)hG$- z@YRDaAKY+w?!W)^=@YW#HGy~+fML`jzX6SFyn9<;zPyE$385&gKz^M3boMPg?r;SG zZeBJw=i%>U`}^#H&(L!f8Rh^iM^te;nRFXsOBBJ+E~D!a#tk6GF`Nco9Q7J_=(li3 z&bW(TJ>``R(15L^aAt&|sNx;9oGxw(#oN*@>I(_iQV53gU_i&6CH$7#azi~6h^6ZR zI-L3;cP+{xvox59%%LiDP0UPPECUH4xZ{#6-c z7h8+=+uB&A)Rv`fyYMxOLjxBC<6B#(0Km<4vU3do8RMJ4uAtF|Rj(jiiP#rVt6MG& zIc4Go3@#%>#Kp$4a&T-=^CkK{OG`wh$(H%_bd^&I;3ezv+r+}NT3=D%{Jr5KW0(mG z<14`jqajeZiX-g9{LchiD!1)ISS<-Py}xOh_PJ4vN)RuFbfhF6U!wEq_oL% zJ-3}t&kVf)EbraK$3V9T8^>TH6dIV$WN7@xz{Heih_09fay4$&39-Whh^FJ8YMje>6(Q3m|uHt^?`0cqp+ zO#)w_#v?cpAQ1dZzbzjgo>o;={f+knn?w$m3GfI=nvZaNkoh2{hMi6@ND`E|_45#w zC#R(mUaVmD0dFwt4t5#O41Qx~CMJFV9An^$0~V43etdL&%_JZ(m^&dsK}?WO zVAv7+m(O6t66#pI5n?AC9uFEt*g>`fv;)E*evvZPs1x9AlbHZ$2#hS?I4b)Onapnm zeaFheLct^o@rwZ|aAq~dI~;N92p6yf9Ya27KEi&Uk7SjzhD{YRFq|gCo#>-aZb1gF zJ&4=ux}@ZYPRjd(CLJyn$eocBOy5SJtcEs3XL&7gDVUkh$F7MWDW5i=`}Q4bwE?;f zw&ynh8%Z$%gS z6AUUi?Z@V#8XA-xfws1``Aq=y17`5EWchEFg)z7ngy=qqkJm;J=Tvx3Orlf}6(pV0 zSDa2mskY4R{5#Owt!U;&c2Opzp`YMLJQ^4Qo4ZD?8;TyLsZ9uMgO`E-{KA@mfuLe% z`mSk9w_^bZ(&{(_%i_E^uXN`C#|N8v2q}TuH^LYwcNK@Q2A}!jD#qFwko8p6PsVgR z&f;nfK#BJEI~~jPca#yj6;4xB^li}+3pW6=!0Ukb^=Xqpnkayr-IvEpz$-lTw-Gf! zI>>!X{P(*1K?~p$qe#J5odCivdl)OTx>(EIk4KTFy|@*ki}LNkbb?8S>+y!rFt(1} z`{HdBXL#%CGwxKMllQYoRLAU1s~^Fc(dY}}+i&eH)|}xpdK5O;;S9f&G{b$_3Oj@D zLb9s8u8Ye@lpcSGgp-8YTrlujEl@t>w?i9~Pm|q#8%-LsIAS08WzRj2UB{1qCu|h)h7m895#R}$3ATVB)lPu*!}*78 zAHkyGz!KCJba+rI)$_x5N(Jk4z&c@aH11!S4^Rw<4uta_KT;jP8@S*#SVded1bw(N z{y-s#gMeQ8iW@Ex9o{w)8{o1tt`d$B#QgZj`&F6SODYQ+&&}8 z;5wXxc1x2T(QAkQ&~spPKyk42S;QbgS_nZ4M{HvmXF0@+H+j-!Fs#WiVi$`b%XOA( zyxBUpWe!Ro-0Wmid)rrkun>j=ujC@m@%U_}SO0s z$I%{iRw2ACfbh!M)N;Lyu=wRU`3Zrw_MiQgd{blk0qCW~bx3G=dkh{!8R=+(ecB`F z=3q^DSRc%&zY^;UCOc6B;@lB3dAB^!EC>T^aCJp+#4HS4iC5jdP`C^n#mvqQW6&Xj zuh!QWRUuL?vMl~EVH1Ps15+DLq2N8T_T}eWNYdmhf*>3_1W+%9H}v5|L&$)0Pj~n( z#vjI5*7DxWJx~0Ajo}QltCd#g)AP{V(zUFrKc}|jJ3IKPxh!fk2Zyly_Kcw5QQy|V z*yiQPezLZZu*#iH1gP~@hTkz^i~nd30k;Pvn}v*bHIaem%8{q8G@NHbY0}BO+whkS zoMQIGfz+j^5Y|+hRRpkI`RfsBJIsQ{V+cnF?l>dmg+=s10CFe3el3&PN>xnI6chW5 z;Ce=t_>IJ0GGP0T;-qerTv@BDK-tq7gasLke?j{aM;v#4d4H5Nz?H|JnPDKz(Z8(! z`(>6y&5ZLV&MOK!0B<07LH*2p`H8<^D)2lpRI&V+-G-TsEiO9xa=8idrz}gPq@>`u zzZ3Tdg+O>K#8*fX8-Ean6oec6)QVR~-d1hxbKs3}sg{<-%yLk_;v!=!nc63OVpWZw zc8pl^N||3XwEB6rfG#K28WJNY-iiboz(eZ8O}%*oY=K{TBzk*zp+uKB$-V$@7MKc9 zaV_zKHV|d0{)NXxz8_dvx8qEz%MvVz;Hu9_y9{3LR7glu?}X zr>;F5-Gir3p8P^;0-mhA){1ac6a2&N5mm%?Q^?#@^r0M-v@1~JB(Xvbmc`%!0`yPi z+`UMcwg55Fk}w;orh?=JNu=(D&;zsgtlswPh zrZ9`geTl*g5;_>~smoakH~ngiOtgwyRBq_N8(l&yBU|-h-C zS&CT{nrN0w*}dqJK{LAYg_z(jS7|c;lB_3qp*1Wm4fZZF6?hszvs>dS$2K4s^gTYe zJU#6zbsyzGmKAaZ&Mvs)z%P0)h-da63O!qGybieFLLv!8m9PXP`YZ8dJIGG2%OL(6 z$zdLZeK?#_5$xpcbj+h5>A;XSdis#pochMtD32>QKOcPG2B`5Rah%ZqySV|@Q_OZ^ zP%tu#(jDV++*rAbhD8Pl1CN&G`rYn=XBxc$7UUi{FF0Z2UGwws6idg(!~iY*KsJWV z8`~jqCh(oUf`kRq(M0M_+YKGED{ROn>JAftayD+w9JF&!o;>ONfHj=38I602oN2X` zU9j@7ns7rBj7v~Z{5xBrf4{0$ngEppbz=V%Quyb6D;~mC$NE#8^1wKQkrrHtk@Q!3 z_wF4o05&J74BIf8%3{-8(Fs=gEnde647}N46^o+CkOep7f(kpCOlDz08oTuP@i}C8 z=!)Lr38C==E6;K?uHP^Gn4Z=t=tXmjo;XFQ#+Kkyk|;nCIvq6C*NMG#Fb&0F00O1J zZcyeF=;A>6WP+f2_UwypV#G|`0_7->j_r3Kwo$_M64Pd=rNi&7+Rt?93Z_Pg+Ny7T z@KwAx!k%Mz7`I_a!Rzz|S{zbklWvil-dU})3rR)3ibPB4uCCxFO)af2pFam{yv;8z zW)3StMQCQK&ViEQhVl_R4`J4uMUAl3C`rTM`U)gR0c5?DeQZU$+=M)QIMI-TSj_Gv zW&>GOT~7T*35mB9DwSd)%Cu5qb8iv*DqcQ5;UcH%vw zMPj|=RO`E9D?#+{UiqRRJUuv z=MFd_pVdHzciKR{34QGA`9u|gFbtfy`q)#Ob1%@HL*IZ=J)WwQCXelkU|3g!XHg9h3IRdQozw?R-=zSl; zz?=&t{n697v6z|B)THOgW6tjiZXd~WO(TkH1xqOwL)C__&nB@?b+1j=v=2g_aBQ_Kp_&h#XoJER_i_3Q;DAqmH@wi_+3>AR?Pg`1G6_Oz&vz3@C zO~-Gdg<&d=x^{$4ep5{yVSp;dpCq7KtA-yW#&ieuAb6+|&my{0HtyUauW3_hglUE8 zLEqmEU(mQHi$HhT7a#xT%Qjr#SddKDh>H0YxPqa`)>gbPD^b{H`~w8Qi`UjgqTA+= zEg{3h5qg>6xP+WCfX2Fh(Z5=^{s-Ql_l^%E+Y75V9|c^bO&Jm7+m-!2wRhXpb3n?K@Sf@Bct~nf9&*+Zy()NO~V$nY|C$|PuB`xzTb6s8^<)# z&jt^~>lE5Z>;Exz<>6Gd?e|EIGF37UNm3G0W|dhPA~KVqGRqK=l*macLLw45=DB1Z zQfV}2E+R@Yrva7FZ*6_Q>w5qCzU$37d!N0Z=eh5D-D|BIvzDV8yP@uZ`#F+4%nB%5 zVK<>CRtP3d-Vn6=D|2y>Q8&4p`T#wJVS}O)?FCoO9Hsh)$i5V^MODrkx>k(K1pIS? z@Y<33yt1(PUpTD8S#Q_LHHhDWLfmIO!PhYt0&>xRtk;FMr=5Vb==(oQ6RfU1-0+xk zzsGcafNt>o4?WFU#kcg;p%*ZOX9Wqau5?J{pj{B*SV!G^IqC@F+Ad$HzhOakl`OeMC&?tecF~+v?vt&KYwhK zG^6Z1yf)n;6m{W8A{pcgt)aoexiZiFq*Cb+4pu~C_pY3F9B!3`Pd78mp8BYoZKoM~@!;{c20zHwi2qA}h&}$nyAcVFF+epooP(e?aMs zekU3Xz_ix?+m`LyU%h^fvOPe4SKzAEs78bS%!?1!MPeP&oJArFPCTb~=tIG{%Qc zKUs%U7Z{)}ouLaNEmkmqU??00Q&)rag&~S_a0Oc2*1bB@Fw5|v$P{WyGnzto+~Vn}7ek}{ zfZ%JS!oV~*2nFF1cq8ebpQf^6a7hG(Z-2mP42Y>;8<~DrliFK>$ci&XjUSAW{ufp;YvcXk)jLogBpUK%56?IRiDOE~uRVTQJb=H6ga6V>l z8(v92P@57&QWKKAE?UePYgVtV*g?IG_D7G-=0vls>C*O#8sWXeL2yn+mS@TT9hvDq zcCy~-IV^dc-5lI)<)&Nhm~0uO7%ZhWsk4` z@63ML99o@zVPAb0UXwXj3+tARM>c-ENj{>sf6F=l-T4Zsn`9x0Ls_dvRZHH&9LHk% z&_i&C;NPxI3R*`Q%q+7$8k5^v&F?(`*dWTnuq;jJ!bElfQWgxWyUn>MzSIAHss>@) zNQ%c-! z=VP`dJXg&h!6qjqMe%#|rHv!V;3&3PGL&30Webq|iwQu__0f?62%n=LvZdC^Kv5!R zFru1Lnn+Fbsj0dOX;RAefXN*nSUwGRe)3gu0FxC*LUdN z5Q`l@EvIBOJa+l7S0pUJ+KhBHYXfy2)CLyLp4SPjMGol zPF_+}YtkQP7gQj;yKBDv0sj800tYLQ(dP$rFAIHihUpf=nl+m*Ui^t9^)0#qULr?y z+5}VB!Jvl9>y(*lbI!93JD4Zm30BdS(KgI3FP+n3+nQc=Y}P}lN{w~H-YvDgU!yXV zO)iGn1MLa*mbzo^fG0h1<={(nGB)3)J~t0$CXXNKxXo5KSUL)e+YOqayVX={&}f@l z{t0K$+Bx+>40yv+?o%ovyFazCx{DDNEBC`7ccoQh$g49CGl0pgh?K7W-JuoC|Z#=$>l^M3(j97^_JUr{127`mku9 z1-F759BIqaNsJ#EzJCWGv~M6jS}|=|MC1upQ%ERo*T(C^VrwBGsrWt zI!mcH)o5yH3?1oM-vUT1)r0W1n+$EbR-Lx{T6B6V->fN7Ea*I)mf`XZ`_fxN9HB&= zC0(M{&y=W4aQwU|;gSxGY?LoYW&(I3s^w`GwTQwPh`hm4?5E^--Z2Ro)@p`D|DH^B z#>&*+djn>347tw}(;I}ge)ySxkzVi?UYv00pTtmOK5DcN9*^z_c^M_ofqZ${0i^nG= zMVLl?yz(&c4@WQ5jmK|ew#Dl4e5i&{WZ6z=K0n|)D(-`vxid&XQ&`nbAP z@e+N88hT0x4qSHe)9utr3`5xqd<`oRsi*VZf>#oRe%OlA^a};w#pIkw3-7*wj>(^g1k~^ z{q^ThIr%fs8F{1}o(=8Rr;Gans_ehS#xQ=3NSRN%H=2Ut6s!CC<{R1;MXnhNuCcs# zv?^^-a0gyVb*1g0NB~)NjCq(xg*PKjcLG>e|By6h5^N45k}-0HN^+dhME^!k*o^9> zC?zxC2zTGPE~BpB-+}2)bV>@c^REaMMuQu=IHePsCE2(@KY!JUDI0Kh1yT`+257{# zd#GT+hZ>cQ9|5K$VvG)4Rh9WTvzR!YCN~5a zzpBL{^*63@=dN^u%&B)s0mD~;6M74W0jk8B2UbK&H^3FAcvTBK^~w%Kp7&drKi^9Z zHihrsb*J>cXiz{VI^)?NtS-T&$JdvFutg7WXY;5N5qC{(&{ZS)ZP=O~UJhCm@R2FJ zNwghyMErZF47@NY2@GlN@_|?yS~4{lXFU>^V{KGb@~Bna6mWIqg=I?B?=UH9Oq*cH zR8*~E>(q3*?t=X8;_r)hTDFw2XVfM6Z7nTP6)NdvXbn*zRD*Tepc{)TD7W&hf6cv_ z*|BJVVgKJ9UkgWsp)`jv)C;Ebc%ekA_pu&`d0jp?o&pc}fTYR0l0bUBL{Jia`In|v zrbe`#l=DI>#>0m$XNE@S?h1pYQZ@JMo^!9n(Ag(DblbtmnY2K1)E3$)PYbZ;MdvI= z0K%+kO-LYtG$|;f?*?+yGd%X(w8~U_poSn1pwI$=^ZTx0Us0Fk36mL_~Dz-D*G{-V}A!WLU2eDR#fX0gw?PF2%j!)m>(c#W+5q zQzgk$zh7RQ z3v{ZFd3I!uo6|L=dgmUb28oOD%!e3AC!e(J@O%2;Cz=12!;=M9HlkX-NlI2>-u#?| znY!R6YOrStOc9g?Kq>^fi8AtA%?M-Begj$?;RX8A^#NAG+qQj_yOaV8!R1ggpq#t$ z@$n-(CxHWE`_9e`3MiCr`=X^u;;gs(xP3?#v*j#}jk`b&|MnBaE_4Aqwn?J&`@(bH z_AsS=RkzBvuL+re<8zV_7z;)#3q|yY z24Bdrvx8!rSspw>x%(Hy?yC%nPP^IHmEN~#-gM1{b;`r?NJ#>_)XNUzq!TmD>1iZA zxkS?w7O6d1TkfJQcxewRA+5qO#HlL1wy$th$P-B(xih4~!YdmQg9a76JsFgdZ;$l z<~V<5US?O`ze@b(*TvsHu65u+psRTq#hZcVc*Ly`GRC{P^cIhxlrOtatGkbRXZzic zKravi(x+!TlEC@Mt#yYb(DMxoKWAR!WWqaQ;&7H^6SzMU0SGZ8!Sd#tUKU*XWhAlUSSuKQIY}W_JzR?&#}TTyFDTmqTAbEqy zl+8tV*n%CKkr8iWB1m>Vt9}w%7_b2B)ZggnQOwNI<^p=3**pF>m?`+DX9Hunqm}BY z7o}6hPyk!-B{ReNC(%wg%C@b24j%ICj~|_ptM;Q~s;p6^GJw{Zp*6!UB(T|d3W=f= zRd4U&)KV4+6AI|L7vJ@-+RvONVc@>-b3-oH%ja?`H|Ka7DzW z-bMMNl-mrFAsIJ%`#bpc+aeVe8>en`_jUF>bSZS*ZL-HTU{P{^_&EEiuG$A0s%769 z7GjFuuf=iN&E2*Iq$+`~3zDuU+@Ip0A5ZvFs+5pd*ZAOCLGRB!h8Th8CUip!0-Vij zFJ7;dzLni;{j`e|kZD=$!PQb8B2S!?TSaD>z<1woJRU0GHI8{zn*%YK3?%~0-+r-_ zpJ<5p;qcPLqJnS0Jw0_Z)j>f+8FSF}E2`))VPWgmOAMYjNitsu2XSVd8F4vd(kG|R z5y|k0N-5=YSNDACV=LV5ZpHlabw+rk!H%5^uYI&zYGwH4jcv--^c{FrYFZYXQCE~1 zudpFMNOQ{1v+b%*v1{ta+Z-B*r@}}yJlm)jT{v~Bh*>Z)MXsa4yk0Z!_ltI|V5^yi z9lE{diyk5R;Mr4UvOvJ%7Z6w{NOM&)WkO6l{eGR;S&ZiEv>0so(8gda>>??jgNV`8 z+S;REb9`0qmZDE=vnFb`Yx8-UHPeUr6i+)p38ul`U+E$kapu4>{-Sh3MTMN4d6;QK z&ifLJ7In#2eO<2|mqZWwCIobJ?Xw86V0P55arDVi=us}~bu_c$?MpOctY?cXzc-dq z8d|G=$jwAon};cC=aR#1W0?dTOQN3Irs6CQiKa?YGF#rJeumdxqA(zN7Ej*P2(YS#yKc zt;sE~&Y5#gjs?Z7m(J@8!x@0A)6+k6I4h5~y?eoymlPw-#eepywq1ZJM_4~^tkX4~ z0?V`C9zAb8L$qV0h;hbnCx6r(2RUha&oaV9zbTI*PxGv>RvX)%YcigCJc)Ph8+fm! zu`DbscvCvt0KI?uiq3ey!K&F?a+7nab!r<@i^vXB$AL|+-n7~s03`aC`ukmOWgL^Z zw&U$1@CYcy~;4%|foX`3GTxwo)JV>rE|U3YHgzN_at0SRF3%o}{U{+CY0? zSdCNDeQ$!9d-V%%U$Y?m}22W5TPn|3Qncs<-4Z!1(!ZKwK+|!%g&Ym zR)(E!pFUiRI_JYPEB@B1zQdMpvFsi&X*{!?=s-yjzB9Fvz>*u?GciAE%5BxmN4yv0 z);cvCMnkqtys!O(1VQW&r6R63flo2p(V(jiB|d(-O8DJgc-V#W9^_TN@NnV5X7D;7 zA~di--m~8BR>tnRW2HogXWRE&^#i`Yf~E$3+IBnsq5t~#Q&-=siyI3IkN+{wFYGeR z?Kxhim8%x++jTL23(HZe$tlJc)srf8{qjZf>owWpQiJPFH6C0Exj8e}I2Y~tkY)R! z-3sU5EB^{Ps;wV$)2CATeFMJ?rO>9$g8B zuWhmw=w~Zm_PncDQS#etvZut?uINQT?-{M_l-yqi=A!o}jUFF0PO2;2a3hLc?`7&qupo+b5OR(0>=xBg2^J!?jxOkN)9&C3hVXY$=fB0YKM1-aJpAK$qN6N~)F z)*^q@-%}_DfNGA<=$||(RyH+`YKDcMyl&H>%*59lIXJSGbo27u$x`3Tu7I3a^!5=) zd!j!1510$^D!;r=-~x`7>C-wov6cP-dY=h$x$v>}YCCj-7wgM_cp39P5tZI;55Y&V zBN@ikpzg#Q$>hDZL!&sB&fxslI{MS+$7nY*GOL!BX!=;ZyTUmr5s2FY3FW-Sd3{oBfH0^d`(c*2yM!>YNXwT)h(;o3V-c{TUmLrK-jYlD@*_-d768 zn-zO3UkhYZWtu2cWcsR(Sug#qC=r$U%}7roZOe2dSQQreEVk0-BMHM`KxUb1UiYP^ zr?+_=9*)kTRcYaTV)M;}gfUEi+&@7}r>x6-H&?S#fyip>Lymco^Suv6ijLb$dI(-I zRn;63x8K)uK6w;_1Wb+|<&zTvCC7vRb)GaT%nLp)8!So~1Gpmu-1SE*#DYVlxB{mf zQU#sg^`2Kl%^$`$xh}?c@156nb*6jYEtGYURFubF@7R+tmu>98uf}t1O;!sc;SCWW}+eJqric;mmI+|=UW2`NvmeVliL{nx`%b6*jQ ziV{tZ4o~gyI5*o6PGjDl9z8F2Pg+?+1ijO{+xYDYcn*WV-$Q zjvR^kac7;wN2!=jx}Udl9-Eq%mE@Z^kUPuomR-EK?CayxWRO!Uowt-Y%4k(FQ&ym^ zMag$3-=x!%GmJBF+!^*Zv9;igZP2o4iNSCRUUGzO>n%$MN$`^xmtWD7B0hjH$ownV z(HrY7+}r11+v_>jBt>$=lD-`Vcww8*CA zTEF%yzd#vEEYF0d)lr8kf~oUz-nnae^l0S2pG=Cx=;Kih5|ccOwzfS-Oz zPSCmZ$=CxRm=$wiwzL9)Aj@0a^%xrD3wWUQ=l`xe|M{tq4bg1Iaz!zjExzx z<|mz+c`BcQ(E1=|m(oap;zG zMbV4r40?73Zw?v#aw;3}Otud>8S#@^9LLS0!@q~?Z$S$?E}nBVwVr+DX{r{dhk2{UEUt;8XCjSOXVdHL==-q zZIu{xUN@LD-&g7`G9i`drX-P4g+Upw()LA}b1|QMYQ*XOvn82WOg2+8m=92XyFKOn@pgWr56M4ZdMu8jHkPi2>R1G6L}Q|L zTaz6z>tvc3n~W8hR7Zqxl7TMzc$tSgp&;5pEO%XgzcCPcipe~nn&3f{-3bw}bNCcy z1j571<9&iV^Kf1vz5e%wL>Ob=d$Yii=uLLvlXkN_KlPO8HiK z1%s|N}%y^jqcKe>9#o8ERO-RC1>`<8$J91*dDKEIA zP$?!cC`m;u4y_@PNI7xn=b!)HupRaVJC-)P`DQ8!6ciO1^c)JG4?QyhkO`jz?5}a| z;7qa@*Gr_&;#q;`P0K)|oSHF8N0>WmZSC^f`sh(ShVzbGzw0*OYrnRlmWX=Cgr+FA zH)skEjWDhOU)5LP>A8DeICqh47_?8~4xy_|u|r~n^awq3G@v1azr3;*)Gsro?*EGe zfyfRW$Cwt^;hUcf4}f!T$82<9O0{u3z@OWBM(OXOkA>vg zN0~dfZq*>}Rg#mF)6ySK!z=e{Q~UA$U9@}}dGKw)wZoSUEG%y0XdIs5AKcB(!z0S* zcdR{4lS68qE-0=Wlke1t&Pu&S-`!<&|Gs_pG}7EB!IUT_lgi>_VgNgC$zDYYHaw-S zrC-MRa&RvqYTr{h(c-~jb!XN3oH-LucP=%)4jg@zy))zEI~b`AcBik^^N_kafXj|| zcA@WLfqO~+c)1NVhkcy{l!J-$U!ZWo4#%3z5Qnt3R_ra|sM$yGQX4{Qv1brXvy=ah z#M`di6F^Dgo=Ra+w5-ywilmZ$j(6DU;dS3fRv^6Z3i2GR_SNl~3Q12Fnzr`vxQA$- z&9*>`Un_{wk@Kx@-k5Prh1!rO-&O3IUPSBBo<>yE1_CDA{Xq5OWk@-~G8CjOhwzGd z+M)dwYrOK7*)@?v8r_K z7R7S`X8qu;uUVK4`42`3i}?+#H5YItTUB{y7SO1OgUfqM*##grNQPZbe~RE|G-J3yb!sJX9<}1G{wK z)h91MzpUgGX_pwW;BAy%H%>{F69jKEh|2R-`7RAEG>-*Zyx(_@& zd-Duf`*Cu7}?ipdrS|^j*N}vdGDZuY2_CBctUn z6i)GUZT3YVT^RL{sJC#{CjvRxA6Q%}LVa+p4&sNIWIfMAf?Iu>np3!g@$(r>C+aH$ zIki6I1~OmssncH{f~@@G73NdnfMLZ^k~IzJB6sE7KpCGFia`>>#HyWN@b?aW$;QXm zERfUDam0d_$c6{T^;-*19pLhWDEpf;j$gQy%rqJ zQ7Pu?fVS*gw!-pmVq_%K$0`@gfC%NI<6^P+F_MbUiFJI5iA*AY!w>xN#!yxDVnN*< z|4)kH1N*lnEVIIiCuV+<2&|4Rvyr!z`cJ1AD@}IOME?FPzMr-a|`ptIEk4qL=yWJEy9-a z)n72(iya+LOMnA@<4lXq!GVId*LEp^^W4+t?d+(;;#$e7NYr@@+`7JRk&Pt10rIh- z$1_kT#mW>BR7a(wXdT~>@u~J1jigX0FSgR!{*ZA4gb`0C6%JMD&1=Nyj9b&#f`4X$ z{_5_148I}bb3?<` zc|zAxOiE<(Lu==MU$$XGojW;MwWE0wWWR@JJ|Vp?KFJCN0KvVfm3$+=9wDEiIbJC> zQ}<>8B>P|1{xh{ddA=%)U*ENL>+_~2U8i~jWE_7V9CJD`Ak8L2;ol!%yb@nTAz!wL zWz3(N+dWH6^F3e)z5;riTbeG%-@e@-Hxy08`{D<=*2f4-9Z}I32P3FGHqJ19o1Kky zFmk~FtLYa|IoPMh;K1uE+bO;X;aI$ zg96hIU_D?!gE}@O{uea+PkfF=-Xl8imjTUh-?pu>SUcz0hiG8eiAztd-)~4}JUzGP z?LmP+4MtC>5I9tB)?pFiC?JujSE1NB0ue%$@29abuJ!A^(7m&2D9OmU#M28Tg%Bgl zWP;I;;bE=V-#*Yg6Z2j_``g=Z)x*?@O5aJ2ma|YfL5xj4yWZ5#&(GL67QzO|!TjCC zb=Gati!Di{pN_2Fb(-YR`xZKCaK`R=C|CJ^OG|1v>2J=g_`rd90DnOF7WZF{V|FE% z+^I9Cc`v@OGyDdXhIFW~sE9Ze`d&)ok(q$u2?woD6B833Kc;;yeL0JnZteL&FfA;m zMvPUWbyl@~+o#FN{4d66ZJaoM{1e}04f>Rn%bCSdV}__;V`8>sy8@zGjs_|DAn`OH zTfozB-60_nsia3x$$x)G3?K#Pei|KxT`D?=kNPf`osyTACp>zKkAu0LdoZwwSZ8zN zFP|gBE{gKhKXmA5`N`XBKH;@c}d2XdX?#JQM zc7+fxwY9b3P;6M$Wdp|(JYn_13Akq=nq3YHGs@qwcdu2e87=&ZO3NW}LH7X?Ify(` zkPCy&gxw7?+|nNAg}7};cmPcunz{q6HJl1Jo4MPLiD2SkI(Yo9MXAl>!^1vs$w}V5 zz(X(m9@K1TS9($24(#i8N(sywJZ0~MY*JuzG9&k$Ju;N`Q48X*imoF)+3as$zLFVD zy!!I$6G{Cnl+H-P;FfI3?X^sd9!HAQf|5vq6%=us=f-|mn+Rq{{6RGPf%PrYOJ2RY z8UKcm1I-4)P_}^_98Iu2mcdMcwKuAcVO(NpVnPwxgLUiHCCXn|{6!ob&G5njEfWt$ z>ddt9RV5Gne#mR$YmU9a;{~iI#&vFhEJL|{W|5mTY#^1;`$}~csl=+-er+IUEmDqR zgLN2p#*z8k?Npww4feEj^_zg51Ird92F6dSVJ;G_Ng zROoq#t!R!FL*xTETJIj=ii@Q9G?Rxc(A0}iyv34>KLa<`V-QU^ImPd1^Cm0%^4nwnZ{ ziVD1)Gn(#%{#%^KIPQs$cc5GNn>H>kPS_2#k27B2taTSs03?bzw~LGScyy2Ln=Dvh z@&nRBM2TlX?`XTwEdKM0cCPhcD_!um*4MA29))A{8fqz$3;O$ z_l}JVyU@SDF$>~mMEDvKB6tDP``F#YmLhTwyk^`QiDWPifars) zx}#$jR>w``LLeU;7~}+9{uW(Mjz0{@gHph&X&};jl}v00?>mA!c4+tDnWMJ$hhG$; z&jY}qj6S2cifpud{ys$Gy()l&ZEk<$j7ZJ z!8FdYl1f$Kpr(P&9>$`N9;gEi$u&yZ0KkiqL>E!wlTru4nwN1hq~HV6z7xg^65d3GLJGc|9$yFgtctBV)FMQsL^P!M`A|Dq zkb?V+q_`B{(+mv|_R)cyU@ji!nApB|FK@-eRlz#DC@m?UGV_LeK8Lshk$g(hy2f-!&>UH z!EidK6?C0QGl6B?y8Nh>F5Ze(S_aNHcte!STJq#L)1@${m2+WA61>j?@)(|NaaBgc0NGu6y1Rz2`e) zSkF!M_0eTnjjvvX@e-4|YOzJEM?QbXVPd9FM{+(2Cxh~#3!WtZro09_P^e%_#~#RS z_%*t0oA&hTlA#6{tiSn8zyW`sBMCt$BJt?Ua7SQz`n$A<$SZ%-wt;Ci?CGSq84j1x z4)|-y!spL5mUjy9^NZuCV$Q~ygtP`*E9y}cfh83JthoB&;+6);*tay{19pz!M-4yz zj$Kl0s!V7gps-6~AnBRRaYsb(Da1UExo7)W!cFf0W?0z@wSHB$Coy{fg#hczTa=`7 z;-($dvz<)6g{%7i@FAFg$m3uuD(V;DedOG}&)=Bw2~C1+XctUAaKryPP$<50C$ z1oI`4?D1d3e(rdT#c{)jq(XR?+`OaYFS3vHa3@fsl82!msIV+}#XW`070cFd`>tKD z)0kY%Q3|@n;hXEhZT~g-tffV-XUZB=+pw?18#~qfFU8<8O}9zZ3tNPQe(RE>*5HL_ zLK!$S>(uhQk#m>@~;yMv1iYo zUBM49WEg_kIHSqyeaHHfo-Ems^e!Q`;$h>3i898y-l(g`+e6#*#h?%>0IM25cR(pg z4U5Ia#lYL&!u~Vdd-4sT0aRYcBc0>&o`t?mp6KKVl)~ZxP12+I6^AA}GIjg?u0=%w zWN?mgjjuCzb`}YW^U~;j6j+n=EC~^y2q}V@nb~_8dipR}v33K!4timfNAtcM`P7eB z%)^nZg7@#(wd*g`(l8$T7fyn=gk7xH9M{@2k!4mY%AjQZMKU@1Ke9u+O9c= zkogl6Cjm>r{Dpe5yr2Wt7zn(mX#c{gzpA>ry0UV1@=cVQK0r6H=$6|L3MeQlmP5Ag zd96?xj{*(p3*TOF0^2)v=8OPxQV2Ei;0X)5b-c0#sN=jZ%C$3r-G8HondEsLB#OXU zXwH9ut@H1e80Q-83NkW2A*F>32c`cl!Iw!uswD9lC`9Y6>4Gb!2%-1xuApK;Y~J9r zGAiUw9c^uN0=uw}2zz2AQ)D{2Q$OFp)DuvFrh+?yEDr2@vP=R>NqK-F7w|Wj=|l{{ z)k8W4S_9Hiwre;ZT=u)hTLuz2Dhx)^K_YNl&O{{H;2;o>2aU&MnnS=&3gCu<`V|KN zZh|`rQyN*XFH80E%2SAtnV<#42g^9O&OTm!Kds6zFBVN^wbS%w;-6JIWzKv#`e?Nx z#5+Aa#^^-B`UJaRJ0r=?7VQ*G+Z|v;#n3U^OX9nn({OgYhjXv1IGhkh5$lTksYyvm zW1%A7u`%8f#wZp2Awvq3|@$iX(7$awrt7 zw9=0npvw1Bx?}SfB6TzJ1u%hYuL*Y2`?L`w9#))E~ZbZsXJv@t8>`+H0?*BHL&S0`7qw2x!O2c~mWhDO}Bo;hg z@+sfHdjk$}eL!T|h`no7@bGKk5px3$A`0xJ#LI~MMMXt;{-{GP?@g>nOK9XgIXW&5 zY8+YOFNkXt34o4mW@QZ(#oV`V7Qn#(X`$eRXH%xKF#&)ccNf{Qp=LLWp@g~Bs z0l@;0pi(ctK}z~*6@%pj7O^+Y3v4S$_^tEzzzD`_*v%HUKCc9VVi`p){Q3}v6SR?z z4M_y(N8U7awLLg4#W@WiSt>;VIq{9t^xn}OoCXl?R-oYX{-RSZG0_$zt{8(qPG0M6 z%50?z-7FrIPOG&GKwxj0&}BC~R(%QT+uCy#t?ksC782C9ph6^CRl&!>^dZV*ZjpT0 zBIe?R0Hk}`&u^2w*ASSo7{u~v)iodv*SU79-Q{3NmeLtb%1&-c5x6pZR0dJlq7H1ns>}?$K0c2(Lm_1aS3=G;u7Tt zj(?q-iz{(mL~tOU4%`4H?-L*X;~&>QFfg);VQC3zZR}B%Z#bVCEM)LHx8ob{?z3pk zQ+=hgx1^$?qP!2Rc80H9RrI9yF%Xxa(qchKD=0YB<4VR3nQFmXhS-dci%V--i-Bn| zWvSVe42OyMtfg&BKOg^%V-x`$z8@u=!WrTA1IF$H)h4ZZA4W%UDfl88&hItmL9vG4 zPu$RjBBYr0bZiStcCq_LR$api2m8C;gXIMV9GtwqF)k(LG3*7ag13l>jNyLqYtasH zukAr^8Uq-y_>$jqS&GWYm`G47k#=Aq;%k#L!9aJf<>0>#pEG=R*Dg=QZ9IzBBZ_}A zJDXyYll645Je(0RVS=6jLrydTMED}Pc0X-tY3Zp-?Ob<2SM%V(CfwOy3Nc=;DmX;e zrZsOj82r=CrN>HX02@FmL4Nor|8gXsLI<=k*l4i1F-OiVId(sTLZJ=f>|JoY$3d(Y zDx=p9B^UXI7A-sVKE&&(GLf@;Lc$^N%;4`}Pfw6e&uV-S zHDygaNHt?)TFf;#%mEBO(Smo8Z$^26JaFzIiaVA-Gd67kBKaB~ z=ONcX=+zRaMxyqPCo4w@5D)1|?4#eGc|GC)*q~0Bf^QI0l$Oi>H4}L5U6+VT`!3g} zO|7rXd`_P(eN=p-XLx8x(z%UVR3vGbCVy~ulq0npGLmbqr%%uBacJ&mBw89bOnpR0OpI+5gbqXgccITB21cT+pABPq1kJ(OcP<6QhYxzY0_rQ192r>_v zmseZ6QDj@}|6u}}*3h~aNTCIFK+J(R@@!gP%8R7ZKGDN-WE2aNJTCrfvq?5`@h zbLSm0h!;to?8LH7=)f8HJ4$2^m-MNA8sV7NShFX~26n_5a-@+?@ShYple=WsBR&o@ z{%>KeiaIXoRNocrw|FuOpNLN1!JFZ4jZ96mZV}xl_guBLtgds|@_jM$`kN*X*1Fjm zGP!5hPW`BvdDMBU9w|zkaJeO2RDbh5m^sT*sO)euaP&ZvPoLyZIH~UyE&>Iu2u#fiFwB7}LWG#jspjM5Mcub8 zPg;4GJW_E(;vh#NncD?w&Sdz$9VuT!-oJmJra>P@VW!(RnLkbpPs2;7P;mKE2l@K0 zJowF?-<>xne=*kS*RgLVkD?UcY>~A3aEbK5TBkI8LMk{|*Nj89(O+En5R= z42g-z#z=>DK-4Z}4%26%k;BFHZzUj&l&9cn>;2~s$5Buu)m>Qr?L_3~l9EV7NRpWe z30$pqJ?oP2VaYxJ{u7k*#DD7k&woNNKSiGG%DwrYiyVQMj#Cke@x%YU2o9~Jx_Y80 z=Ef0}vvLp>bTQq!aYGO#Jn29vamqP%!+o=rFc6O+V6pU-Q?&#&&|W9PiDl*fL|1NR zC(^A}J2>&(`p+LOe5sj{Ck7r8KwPjPGFMVCVat9^>MdB2M33eFFAR&idhV0R1PFZ3 z&Uodyn!?&DsSeUBiT~UXVe!#w2UnsMobG=&gk%0QlPr+m|J+bMVzDGH$HE}hfbW!OLlryUUqo&DR5A{J=emy{k6TERggmV% zfWTiMB)ZoY0@$S`mPfNF=4(q4{RI2Mm*~?#>3ep64+hvFjJMkPPSOL_K`T3wV9n4< zelM3;yJVQw(NPI0MgB4F&p@$S7kFIx;7#FLPWWaq`n7UXa*SA7n z8>yMuEDnLrt99-n7ng8 z2V4$4i7|1i9ef&vnqEHRJ_*t^M$DuE&nyEivh5MXA;VBir!gQ%|67u{t^x(Lrv}UG zsDs*hT)Tg$i)0!Y-Q}4rriNS2BQu1f=n?#n^Brqz>%FJPp41c}EmAl$ocE)got+)7 zPaVzV$06SU{FhQtA`!c6{*flvHNQcn73&nmQ_!^NE!;P2m-T(C9gt?zjZ|q_Y&0*wxM^ zh_V@hHyGbSBo6}-rAIcAMn(u)g$wSOxT?SFAWh5>`ok_c?ndp6_;(b_UxtQM@)?+! zGb!7giZ4E6&F$77M*-f8n(PG&s*^KFvf*ac(%2Zo*v0OkFxiq7m_3mC%zQeRj-Rp@mzd!ffq?;Ww{A67?KIR4xSVFH=h~nd^+ zYO$1MR8fywyQ5;&MJ!&3qMR&iSwJqickf=KIj{;R$oYW=u`MDpp_eWhJ*(px`zqhn){a4UZ+2I%tW?2jzVw5>ka z!k5`_Km88T)P#`(0n*HVfO5&PJdG>XRA#84e)$ncciA zp%a6RSZWzC4!SkaQM9Fu0(XtkH+nw2@bZ4~@fQr!Esi*B0Af5mID4VJ#`K3g18JUN zWqVCvyY-hM;VvuaW-2SnHEi9PdTYN-alE+IJQT~F_fdG4erdCttwzTqlj#7>CVh-Z08{nd!^)9qb z(=6`np&Zxj`^U0_cJQ~KwiJamEdsz6noJoRcjk&h9T3=DZMg?cG@b9b#Thn$Ft@|z z$bs)i+SXw$09Va1C>C~S|Cx8{-f+UflJ*;;FLr1jIcZfJAVVhw28a?1(29>O)Sw+k z?H)(Im&T>I)FGjt3mim~dvFeUas8?IWfS^m87KRZ>>VO@|x%l6H%b9MPCZ>!vS+*DyrQVWuVAt7|HgKrWJ@MD)slU&L6$vYS zp^MkPzXp?Kq~Fy4l#T_#Hp_abSk{%>{fJoXMk?tkRRv-A;Rwj(7?a<66I1g8(lpP=J3kQ$L=N{ZBzr=k^7k$Z`$9-q!UoalSyIW z58c^Er*oWL=QtA!OO`mMQ`&@f{BbhevR+dsGCeka%qcO&CUWCu&qdUTT{+rkWBxF8 zn|&xO^L@q1FE<^U+dra}O<%iuNfK-nOYu;j)>3HDsCtUtpRQEP*B&gbOstb<=#cH? z@h1oDd@&ESh#;SkwxMY##j>?~UbR~X@4E12;!rVls9Z_r6@hVWh~()94FUZ->CnFZ zZkrd`QqlsMjYvb>^OI-H)2|!uEdUJ;V|6+qzYlQnRn=jO?RPH636eLkr_=-~N31$V z`RQ88K+~Ie42E=U*3R_f^zQdrI(ucU{29@~M!(nSuG)CypKquPi!kttM``_Szi1Xa z4UJ1;A|exQ3}}Pw$U;v#^7y*LqaGp*3Z=IFyf0^jmhxL)b2s<+i}sgkez+Etsh1W9 zG%fz=F;`bn<(I)BJS4K%@vzGrSaIUiQoo+^jYZ z39Ir5`tJ3=Hmm}UvT=s(g?O26v}~}f`aAXdclT}fn;R-*W%9&E^B% zpIB?1K}_l^gNH;eS(9Y~(D2X`?N>qIHI|BQlDzqB({`s#;}4Ggy@TYGc_l?H0P2%e z_v>D2U-j7^+hlrkKj`nK9A}~vzb1iBUfM`*wGoFoVK7nR zba-0kl47N=62_}7E}joq%OD9FTN=Bmh|pmJp}Rf%G*p$nI)?N6{BO!ruHN`&YZh5i zcThbul2j)oa3l#v?FRA1Stag@BDc*&_vVT$6D;Tsty8BSDALyMEww$JQ#Q=Uz;|4{ zkv#&oFuwU3=X8WO)y3$*M^+vNlKjMg^iG=WXZo*_2H9He`c8fQy`f{Gn}S`EgUAV^ z8UM_E>my9lB%a>B{zKeJvTy6OwR`_y{exSpcs0cWG!w&2)m)y9es-qPmWHN zp>o|Sm5u)ngU`ka^5+UK2N@VP-Ld>Jp)wI6blb0z(U&U%48HI68%u{kDfM5id7$MR zIDeC~Kb)u0SK=@{IM)_=Pjy51k4!#m&9AWQlOzd1T5sO0@Pd_?n~4TH4yFv}sBTiSNr`&EmXkIz9dL;x9Z=ffs@S zZlz{wM=tpov?+B2bP4VkjXn2HL_dGY5iL3ClKK6a@zmtB;g^p&I5?JJmOT5Y?ZL<& zQk8nd{R^KyX!FJD?jB!b#1$DCIsfB_&yfQMa)0M+C)ox4J2MDBTJi6QM;7_Jb#}nM zm7pz^uHUfXSm3MH$A6b~gxNG5Vcs!9-c@3^(LAluD%l1 z=`RDzqS!eqWKE3Yc*Pqj380eZ1&S}ZmxQv0CYm_A#~#rr&YWUnWlbwFk~w&=MrktD z0c}U!I~u=l7T?rL*u~bjqfB#hlxmZ@&(RL+KFN`rt_Z{ygS+Q_1WC@8|8Dlt;&)w- zK3Dp+(sT@#eX24%d`^8?X~=?71_>*W{c6PbgpEN+p?67S?s+)HWm>&fb&0FX@CM2_pO!M;p#UL zmgm*avU5@6azpE?p|SeR4A=wR_SExeJ)B>uevYFJm{bS2P|Qt_Nm;e2po89&W5Cq&l zeEs~&AO1csg))-u|3B{{KH_dUvGv?b4}mRtR7V|GgdlY)7OjkB=i4&)AO5t zZsAhv=((m(6MQKv+r8~P72ord^}=mV5%_VM^^z2qxUO4{c0gD5JtL0yGD@E_`T`5I z$wqGuj8$IMwc62QdAJ~F^4)4u@JejXax6kp(O@Zl>YU{)e|N@j32&aL!_MJ6T>dSK zJMXKuTCy*RZ#a9^EPo)~)wW*IBX_cEa2LO4`rgPhmm)zeh{&vdmKM0ND$Kwm&Evvn z%`Q3pb9eEER0jUNrc-2W(`2!(O-_QlAN1L5ZdbX_?onq}b#j2`XSYWoN6S%%L6(^t ziQ2Po8l)zhzsi0M&*Q!SIF!9w)pF`&-1CopN)M`Q^+=?DFBD4GjQLY2RP0k7PcQi8 zxZF)i@yvgtHT^DEmNb_Bd2iuGn=8Bv3-X2C_N}5Wf70dmdCd>;rIFVTI~7soJZU?x z*7eNXAB)Y`CUCc%Lag5#5&)y=tSoCUa<~F%ylh#5*hc{bD!T%od zXGpx5F{SvhMb33m+I4c1;^k6YdKK&^Chj-tbu{LZ_MYH73E8Xoi_K6 z{W5epccPM3Z^AbEhLiSzbh#H8lrXGf;nA{YVJ9D?(SWwJ+j$CWsq2}cIw-9L9ZjW zvbC^P+~0kaec0^H<@J;9J%eg_J>tYZYB6}pTdJfq_v%JjV}H(c-0^)Seo|d>B01mx zud3?~qIy9}KbxSr8qCrE7>`ieht0R$-mT^;5HzQdYWv^q8N@TRD6SBH) zgRDqHs8ISn?^Ejg^Ix5FKA-n~zsB=CFX4j~(z=j*Z>(hu6uv68TypMMWdPM+_rnt= zAfLDg;QU4Bw9raZpFewcT~KGm9OelY`{3IH?e?sRQlgGuWU zj5SoPrt!#IbKPEO>!hXAq8yN==+<8NSaZBaUn?xRj<+DPCP$~jRA8m{(CR{y7uPk9 zIF?#Jkn`Sgw7Pv|&pG4AjFIB)F~1jr8N9D&P7o|FKr^mgWj$iFhbxD!F5P5nzun+p z?YUrfn%NZj?!R{wS?;rbJ%Y*nvK9pzTeBqhf7UY%@|Ax}Ke02%{J#!Qg_x*?c_n2g z!A7k?yEim%($2UPV~a?+|BX!~TwYwqX7}`Z!Aio5K6|;H>)2M--rFm-R_T5&cx{vE z;ko{)MOY|TYD}0a3+t~|W&iUz=Vmbd-rBLJ=VW=4#Xx=%ea8cWVwY{p*ci{omm~eI z^C*q!oCBi`Qb}bkaR?JQmnOL!wsw@^g9Ei}d0xZ%XNU8Zm;QThb*u0o``*_@*}J%n zQzFJUw>F+Mosd)<@RmuAQXZ?|RjOs=Y*L6(3lJEup%M>rRz?m0!nbNWA=X%AwrE0$ zJH+~0Sy_Th;d54#zwje9rPbDQHP+VlHrYGYA36Sw5uCVDcqg0G$?i*do-dp=4@S}p zh$Y(1TT#EgZ1VA~M(Ijr5AA=TwfWrx*ZL=yRa3=2f-?Xo^|#|A7I$Zu}% zwYbIW-FT<(vP$Ffwt9w|A=$utG<3eN6v{X)X?sSeJ(4w~Z)zXb@b3|aD0=!f-Hi*{ zMX!;_r|Tll?6rVMnsv7V8q=1Ut4qD|*efQav=sA;de|NVBk?9CEe-iam-2%BT7`Gs zNtIMdPgZzP$dr>dacm8hZTGS|aA3XS5t_00)(r*Ym$GjSJa4|T1fw@Va%|M2Y`^c5 z%@r?@MwD*02vb;k8L%PhgYu7USnB%nvVw)39cXU9gRJm>zbF(>#VdctQ*pE(q5*Ud zzzIchZ{ehX)Q)^u>R13M=>G;@;jOvTU|e7PpD!YlA?a8E{`3ES@qOh)h++)q#Xp9@ zh;U#1y*OAsiaO7+u^(lWltiqnf#@wK{P~nK0VjZUdMr!?T}g!Y#~0}YKmi+I?_Pkt z9SZ<2J~I!|#~MyQlTak%WRV&0kB9d{!pgA#i9x3fY)hdfWCDcR0msm@e1kx8`&oo& zI_Ls(=O!gQMoI6D$3~ox-lnRBIB#z7{W_#oX9bXN`&pz@VQ|<~WG3iAwc+u~)uQ;? z(jQ+#KtStnCbZ?+&q6u9BQY`|AvZw}QZ@Qb$OKELlUH+MP(tl+AjOSA&oD~(2-A*_ z(T#Xs>`^JKu{%vjOO`Yt9DAM<^Q6ltD@W~XK^pyt_K6eg_4|=3Is6#b)yEMao|v5e zkc=|=14hwW%jrBk!x6wz4MiY(#47IT>+=I^U`-*In6Iay1%Gj7F~u3ms;8yAy}c(* zZ+0{^H5n%mS=;r8fS(=MN+z2V#ZFnY)@|u&7CJVZk2IagHNb9;)?uLC{Mq}?F=H;KWsKn)KL*BjFPt8T%`p?^CDQ2%5pJS*64?tAe50L}rLA|)Zw8PW6V6@pr|>eiA=8ooje;6pwkTIu|O zKF{WCC4Ux6iYFKTPL{Crs#T6KZ@(6UzNJ=!xmm!On`wxcJ+^cPbcRX8Ghu7a{$AnK zxV#LT#eW%Rxy&FXhT}`&s^3>fs1=)J&s;ao+@ zDMyt1E!Ybj68tz@LXW%)G>7=MJm7@VkZpGYX_|B+J#oov^VkQbvB3`MX-5n0Z{z_9NH>4`nE_AE%z}g_@sqxmg>ddpy zh(3lyHBzsxPnp0VC*?)j0r;+|4K_CkSPrmRL9RZ6Tb}uPCK{kD?B9;Rqgl ziIEn-uHPJyl9EzhJ^}0=KqDx~`~F$kFi_lOsr<|Z0)m2lz_QxeA*$7dZ!{a74++_b z3@+jYn5P1q;=t{ZsQ^L_d1nOnY>FLp4GbC}?~arO>gcLFJ{GaHeTfx4@#V|T82LTO z4!n5r0uB&xGOz`)E7Ar(Lo)_gwGIgICr<-fzyAB==PE=OCqQKvpJ}u;Rdj!2^6A62 z)w>YL+TpqvvyL2r#{2%DCB9#PziYDIx&M1{{l*k;L=|5Uk9vM0+D44^;Z9&d(&l#o zO831pXvyn}v|@XxKj5_R{0Nkt1A68u3)#xEdf6~%zrjoP3}IubA(8J{2MC2YNR@h{ zi0n3Hf%TY;vjwGKmqDyauMsIf7l`^{;sA3?HC92q^l|5zGiNZeq;eBR1#28diks1S zQlP_e0;lNwdGlU(>+9;K>2ErGSlLGeyYO`vDn`y9`#VeT;K6rzQ-7(`J~92vJGHim zNVWU4OO^>y{I@4DE30wz&Zt=$D!aQ=9EK3o zZ33;&$S5-xi1o-XU%r5RYQQ}Xwhl>wrZhthx8LAHf+2QY`e8TRO)sb2_0|}AtB<6; zic$kMOP#lxA?q9H7v0uzkOR@0anaTB$%_XgpFn9HXN%o9eJlIZHi=4|a|=hX+XlWF zmJDH1QW%e}X&!o#=+nI@S==@A+U6l2g9Q&IOqzxaUHy{3;Ph5i^Y;{zIAZ)~(lfpL z%BrekIAQwx`#r1s3z}-=#KjZ6|5lh`KZ;xVm~+D=V<}IQeqK>P06*;}vIeg=qyWf%MBTU((`rnq)$YVr!_}>Jx z`qHsKfTW)1eTK__2U!B&o}zY%%41{%Q5#3%A;TRQ9f7ooeJcN7>Qg(Zf1640R za`fmS=L5LKifErsh97ov%8Z|>+X6cPC$EFyjY>sDMe^lp^5qdkq+IzJh*(q)yK%*a z03o;yZ`CN?+3`}Qr7-{XDikml8Np(-;-xF)sqVl-UY|tmAL4zkR;vp}} zPE7RYj+uU~i+jycTi>yjKK#q-?v-vPZE>I(Rpo7~DIv3JSuwg;uV%W41LHUk!7huh z0YuHv%uEcwyIx-2)OHJ(I_cYX(zgg=8VL{L_9>zTAB07K`>q~1#?!}uhs$HJoHaD6 z(I0!>Y8vWkjaykDD*^jmXa+V|3_`~&)d0M$cHtVeI=~pLNZcV258?gr(`u-% zGV<{w+=dP;9rXE6+qCujpR543Rem0h8Tpk5_d^mxM3?zjBE7Z4Cp^AJ{J_J_37oM0 zlbMhnlXPZ){ieFvbp(jv-|Z$Vj=7&?z5iH~2P8cXTgZ%Dco71#6o_!Q?{*;H5w>ez z9`9pD#5=nr4A;@ktIzAj1kUtgKyz`2=Q#5H#$*LeBeO_sgytvi9FEOJq}N_n`CWPP zgWwFo)4U;hPswvOaFo(dod7oBSV)?o3oS(#*I^d6p*1MQ2|)m6MPbSPMV6c~EOM37 z5>lkglH%2tz;?%dm1oeNj`92qeUAfw^+E_*vQam$%EY%G~ z6*fY>5zkZuvr|)7b({1vfXhL>aHJ!l~9Bav6{fxUuyOsC9pQkByAf zj7wsBjksebt6;mSrEUI~5xaQayv<_<>iUG>*3;8SYdU~IMH#Q^=X*aT;rR6I?dX3& zG3?06ppC?MnuEYy3hk~F!hMTcBBL-HfJ0Zc$74Nc1&8BWkNx0_?;TO^{-Vu`}}k43$1Dhqh0?@>Z?T3c7X-pguD4P_Gd8Zj^;_d)^^k6K-O& zInVIG)54VLDWjBsAvwn--a=wL*0Q-HYI;M5)`47$!p*!^@DySBG0lZmO3hRTu>KjB zW|9+X^Ry@Z*%H><-+u|JoLT9J|CZ3SH@3_E^;idm;szpOJ38lM8iqC>LSCR5V+ z5kJx1+@GNPt{+84AP3AV-i2*wjlFs2`PiC*lc>Orp69xLj-bpNn!B=ASmd+n}G|P-Du>7j{SEA9F zVL)mRUb(SY+mm_cmHdv*0i=M|4-N~`P!=M7`q3W&&gSSEU|FV@|Fbm;t;&jH$b0pQt>i%&0P$1x$i0k{_hTZLd z%YNxY@lu#YZg)`cJmdQ4KX5~5W2HJIZiTC~GMnE_cn}vGJnC`LxyZb-(jr;&OY5gK zg?QHs@8sPcf4xK)3?%1~Hvo&2dUM%3I+kC*o>{9qF$ba+MKCKZA{qx^gEdQ}Ufy!H zx?FP=1e}aPU0|hAIbjS@^?d_+- zH8M?lmI`1kwwGUpZ;M@ZcRkD{V&O6#RegK|L-iB8>_v6qIkW9j<{qLhtM0Bqd}y`m z+5!~gE=f%6z!6LGRD&e{6uS&Ck7Nas$iyMvKwof!#L_+o9?Gyi{RDN9Dj4CR0h(p= zjM(uQ$iv>=ejhrxzRz^<1c+v%;hQlhMD4GUJ9X#TAAq4*i8Yqus3sOHe1i7{fJ)jr zI>R47BBMB_U7->^aRsaHbWjjW#ov$_3vh~Nzii5ajJI;HCRvzXqsgQribxT|=VNg* zOwK9_-MkSOXN^M<*TJU7H}GfDd2zm2XxiHuO|+t9N9I;JwxyJs8VZsAhqI&a@#@h^ zrGtJfsKKY&sMcx)m;--?)iS{AaOzcjyCF>s@alS3AE2Os9}}YeToJ(QkrkmK*k0Hx z6;UZBCnvW!D#2sc$}6}Q1_uYB(~A^19U{hJ_wL`%w;Ol8!K~6Yffwc8q1pq_54J7%%F)|#sf8<8GQVaQ zR@9eWPu>Skr;c3X+=xd$(6qF)EdARPpSi=Nu46nqV2`r{xWTm7Mc8WqQ_j?MFU&T$ znsi{>urU+U(lndFA4@mGzrp($jSOqPtf#BnV#zPFd6tKMxiSD`~`0B0se|%c8 zVMBs`*N<46Je_j~J|-NNBC7N=tOsP&>a1`(E!bbuR61KXuhrvpJTpK!;3jW;P4nXQ zPRqqhJZN2urgKr}Ip)<_o&!$AI`!Y-M*FYgXtDH~Ioh^Z!UVK7y}%hyimw6UUfLN! z3<5Kor`Y`I zY$rf?oVV>@GnA^n@X}9TX++7MT%TlfAGfwlkZa)dU~z)LF;tK*CJssYDq*anRlnYv z>QF#Dh=noJ($aBTt!b9AMj#&e#eokayw@G%FF*$9e^twK z)q8Cye;yZ<|I(A_1E)@C!n{t5cE*`B~Zx}Fxv zfulB8Mam?0=yL;w})JzN~2>8kQ9D@9Z>DAs8-nM|1(CQpBfm}Nu$~m z1GwY~4knCSANZ)&Tr-6ZZL9j$yE}D!T-BMP=iN@pjCqJjA-$2 zGg5)Tb#BVe&Nd=1v9;Cb;Z9U1eOK}>7#LuZX9oudqb=hpWV~>sp$~-Jfj)r!sWbI; zpe0|lm%!u8cAyTNpKAXNh(DC>Z)tAM6s8v8;uOU=^pfVG$n;8905XKT*wj-C_`m;s z_*_H14kk0j7R|}a(6i|UK25XDBD$(Y|fF=j;m8*XVogIdFBK0+I;fR?s47GB`t z?w%aL)gVF!8;5i*-zU_SLl5@B3`7rNNe_%Xc$}F`bXK|d)vL{Dy(;M}%KEyR@f@G{ zlpD)cPzaU*-sluU8iSDLRkf$MrCR9z!AL3O@K(hw_J2Y8(EO~X8A@qjq}JJ?^tB21 z&w0i&vSTw}R^D7l6${w7_JD{DTv@;X`!f+O_+)mtN=i) zHF>G0apmRfrbf!n3((ygSzOfdVL+%m=K$J4`?)+VMRQ;hFsbSn={qkizYQ>@2`{Cw zh%B5N$40YrEO9Fsa#NoW$z3DNhlmW5`nl|J8riW)c?j|;K;jq!&LG#0t3p|!7!6ke zB3hV_Y?YtC32z}88q#6nwg+xOLJ3=*v)0f=3>j&nH3A^?AXK?iqJHd@ewcMd4j8wc zq+9v<`Sc)Mgj1i^4lr=hk`6%X&u%R|iA=7a+trMRC^M1~gfdl~n?)%z^7rfHRa8{S z2%7OghSc=e$U;gT>{=Q%=mJS{1rY!hQ>KOraroL;C0IY?XE+t{Y2I$uuTLW)u&ql# zQCM+%4+5oAeSU&d5;JWDFnv^Kf$z6tQw{HjE>cT0%QuEJcl}daoK3ZZLz}|QisCT~}6&EMv+=?g= znN?iQwQj^En`#++@LTm56}y{)7p`Di36pIFSuJw1JOQQ@&Hk|kVLLXF?|t(rfGarF z0SThxfw!Vy?WEt-=0l8Dl&+zJw6%f=vJbga697`Id8;(?Q)GHj$WuWCfa{j24&XZU zRoUPlg%G&iT9}i+771+kB8T85>NaF?A(BfELZb3l17u`yoFt~fH+kR|HaBVNT_}HE z7u(a>iT@a6L*6fN3n)2LLxQCYsVj`>acd~9(u2;Qr%yd^KaL$Tz<{J_1QMNl7UzTP_Yk1bPr+^S?&LjE59{AX{g~e6a^^1WY>B z7<3QOa~6I^MB3>=U_ap*KQ2f!9@ul70|DDam~U)sY-$@|>x2B0McNN_hrlfmo|}3g zIKwNPqd){DuLJeC)(zPAUrQ*bs&TNZ3!8Az1$=%5k;hlm^QWc(9=ld+4sy6?_i+yd zVLHJm{B^W(CW)}q$$}v44SEpNeWp4uAtR&PnnM8Gq$dh?XXrs-H~;z+q~g{bcxs$} zu60YK2O*jAYj`+%q39hMJdZ<%E_Awmez*JAZ>6MkTBVT?BJah*j_x!tHBFmpGaS>0 zvHU_pndlL!DfudhAjI^m&7y1FG+J}Wm5$${Fs6bC;S)|hxgR{@A_t^EwqvncFA7Re z|Fzd~>OPH-Fvju;HZ?P=vwMx$1_oG|nM(;F|*Vepz^^Ufgq6t@g$N@&PF6vb2!uBfQMiu1&MM{b$3fq^t7Ty@8oqP9)Z5N13+60sqGae~vX%k%Q z7l9z-Jq4D65;w)hFvScY=!%$DROB0^w+!%&`TYF$UIj=d!Dit6o7qpusgu>0|#8+ectFVopM*EZ}aViU2k3zcXM1BkFa zl4M{8Ww3|wEFnBg>JYLO&dX3K2P2=dklRU9Qc^OftsIFH{EK4z9dvYN@bZ@0bln15C@E=^ z&m77r6bH6xR(3WChm~IOf`|rE+XH`5G5zq)KvLQ-%fOKMe4o5IzLb*ZB7JRYXz1wa zv3J>zc8?AZA0K&r0iX54*Bu=lb%7N&dgC4Vs4s1}7;Dso{LNltS3@y}{Zj6IL(*-8 zhiog3ByHsLzlS7(H%bK|Cehnggm88x)N{rSDEXOq3k&-a@P(Y-mR!V2kx1GP8*&Mc z#wyqqSUlj~q4W3sBJ!1i7=f-2d{#4cq_wu-uB?;L&B5d#OIuXy^T;(#MOnEL5h|KG`=2pl3e1{sQaB7B0R>}*lB2&^WYUa_&U06+tk zAQ1*p1fLTpz9L75??VwmSX5LLaX}Fg=p=r`#fJo)MQbZo&%-gjD;u9THF+S?g1sFp zzvm=^V@G(`!T z6|2*Cn5}|2*nRbYC4-T;>nn6FAfbCywp;jYaPaP8hv4;_0LLb4dc7?YdQqzmA~%5j z6W(_Sw>~2IM)C+7;bw;Tg`EBf^7TE!>e$J!6}o9iUzzOL(}}zsoNg(Vd$@Ue_%2Lz zV{Kt804)){jwJZ-)95_le{TPr(9+UU_kfm`7E&D&fOzd<@{AUJdNQOyVZ$a{zN z8QxTFFS*dClNUa|nqo-fgtiX*)^h*8mgS(`-7ByNU{4P=6s>-E?gZ8lh>^3thx_&Q z_SWSgp(!0#GJ@QmkY&03tmt~NSvPV9i|#zMlKZDJXVQq4n!J4v>YGk{FF8dm`MYEn z>!o&a>CD^`OV%B2pVAvF6Ejwhwr=D}$rcl4g{ijVqnKshm7a!2Tei=Q6j+Eqe*Tr; z6u~7&suM9+qjhN>Vq#+Hsj1LG?e6a0ULO&DDLUF4IG?QOCo*Zwx_!59-%fi~XKo() zWK#&czFxmByF_<%luiZWfGD^C#8kYR7ZdOjqnmcZrqLUz@A;_$+T}VrIzU8R9v_(Q zZ*~eteWfonEZ{8UwTMGU%9omD*0a`N)g{N@eAOeSBpFKMivgo;UAvyaZ-H?BHQ4-F zCvkpr1RjHj&YwRY^{8N>8XFti3$q_Qc#3rlp;0}(#|V!j_V(;+YbnA*s=jZY zJ0l1t3nYQ8>_%8K!}UXx`nx@^gPn;u9q6d8d~h&jC)(hU;j%o&PQp!{oV;1~i&xMh zJ{3`TeqKKS5zoBBdL|~TB&DQS+1ZQNt_Pg;#*J|-#8A=8Yrl4WK;QxALVJ6=x+sZa zf3@}U@-nIbO?51qgz*R7yjdh-vu=$Va*F9$S^a)Py&J~njA@Lyjjd>16;Us3ySXy# zgzDIhk1ahyIRO{AFW$b`6_FCTFY2#o<1mj47sHJ&&W~t{7>+Eys%I;Ede@vX1$Uor z$=cGQytrK&W#R4{da~%1xJ~&SES3NFudJ-65`@taF%pm zMP1Z8h#+`)c=Q|&j~l?X=pIn(A|}UE*3*3t#cwaf8N9m(OdAewA0V>aqYpyG{GCOa zYY%M_etM5@lwioS7w#7yK3^;#Ag1Dn9bF;N&A&{J_szFLT!t3~Ln=;od)eL8F`Jah zUof^FD<~??^566wXut!YFFvVr(t^omh{%9)Dks;J(J+hauPx q`0MCO;;1QA-^Y25fBT2_RZ$^FlNMKe+>}keutQUCYu;wY$^QenIuR=X 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/12] 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/12] 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/12] 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/12] 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