diff --git a/Makefile.core.mk b/Makefile.core.mk index ab44cc733..7100701ad 100644 --- a/Makefile.core.mk +++ b/Makefile.core.mk @@ -137,6 +137,8 @@ endif # for now docker is limited to Linux compiles - why ? include docker/docker.mk +docker-build-amd64: docker.higress-amd64 ## Build and push amdd64 docker images to registry defined by $HUB and $TAG + docker-build: docker.higress ## Build and push docker images to registry defined by $HUB and $TAG docker-buildx-push: clean-env docker.higress-buildx @@ -144,7 +146,7 @@ docker-buildx-push: clean-env docker.higress-buildx export PARENT_GIT_TAG:=$(shell cat VERSION) export PARENT_GIT_REVISION:=$(TAG) -export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.1.8/envoy-symbol-ARCH.tar.gz +export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.1.9/envoy-symbol-ARCH.tar.gz build-envoy: prebuild ./tools/hack/build-envoy.sh @@ -192,7 +194,7 @@ install: pre-install helm install higress helm/higress -n higress-system --create-namespace --set 'global.local=true' HIGRESS_LATEST_IMAGE_TAG ?= latest -ENVOY_LATEST_IMAGE_TAG ?= latest +ENVOY_LATEST_IMAGE_TAG ?= 48da465cfd0dc5c9ac851bd2b9743780dc82dd8a ISTIO_LATEST_IMAGE_TAG ?= latest install-dev: pre-install diff --git a/docker/Dockerfile.higress b/docker/Dockerfile.higress index cd52b74ee..3f940e07f 100644 --- a/docker/Dockerfile.higress +++ b/docker/Dockerfile.higress @@ -6,11 +6,11 @@ ARG BASE_VERSION=latest ARG HUB +ARG TARGETARCH + # The following section is used as base image if BASE_DISTRIBUTION=debug # This base image is provided by istio, see: https://github.com/istio/istio/blob/master/docker/Dockerfile.base -FROM ${HUB}/base:${BASE_VERSION} - -ARG TARGETARCH +FROM ${HUB}/base:${BASE_VERSION}-${TARGETARCH} COPY ${TARGETARCH:-amd64}/higress /usr/local/bin/higress diff --git a/docker/docker.mk b/docker/docker.mk index f9315a327..8e1851359 100644 --- a/docker/docker.mk +++ b/docker/docker.mk @@ -17,6 +17,11 @@ docker.higress: $(OUT_LINUX)/higress docker.higress: docker/Dockerfile.higress $(HIGRESS_DOCKER_RULE) +docker.higress-amd64: BUILD_ARGS=--build-arg BASE_VERSION=${HIGRESS_BASE_VERSION} --build-arg HUB=${HUB} +docker.higress-amd64: $(AMD64_OUT_LINUX)/higress +docker.higress-amd64: docker/Dockerfile.higress + $(HIGRESS_DOCKER_AMD64_RULE) + docker.higress-buildx: BUILD_ARGS=--build-arg BASE_VERSION=${HIGRESS_BASE_VERSION} --build-arg HUB=${HUB} docker.higress-buildx: $(AMD64_OUT_LINUX)/higress docker.higress-buildx: $(ARM64_OUT_LINUX)/higress @@ -40,3 +45,4 @@ IMG_URL ?= $(HUB)/$(IMG):$(TAG) HIGRESS_DOCKER_BUILDX_RULE ?= $(foreach VARIANT,$(DOCKER_BUILD_VARIANTS), time (mkdir -p $(HIGRESS_DOCKER_BUILD_TOP)/$@ && TARGET_ARCH=$(TARGET_ARCH) ./docker/docker-copy.sh $^ $(HIGRESS_DOCKER_BUILD_TOP)/$@ && cd $(HIGRESS_DOCKER_BUILD_TOP)/$@ $(BUILD_PRE) && docker buildx create --name higress --node higress0 --platform linux/amd64,linux/arm64 --use && docker buildx build --no-cache --platform linux/amd64,linux/arm64 $(BUILD_ARGS) --build-arg BASE_DISTRIBUTION=$(call normalize-tag,$(VARIANT)) -t $(IMG_URL)$(call variant-tag,$(VARIANT)) -f Dockerfile.higress . --push ); ) HIGRESS_DOCKER_RULE ?= $(foreach VARIANT,$(DOCKER_BUILD_VARIANTS), time (mkdir -p $(HIGRESS_DOCKER_BUILD_TOP)/$@ && TARGET_ARCH=$(TARGET_ARCH) ./docker/docker-copy.sh $^ $(HIGRESS_DOCKER_BUILD_TOP)/$@ && cd $(HIGRESS_DOCKER_BUILD_TOP)/$@ $(BUILD_PRE) && docker build $(BUILD_ARGS) --build-arg BASE_DISTRIBUTION=$(call normalize-tag,$(VARIANT)) -t $(IMG_URL)$(call variant-tag,$(VARIANT)) -f Dockerfile.higress . ); ) +HIGRESS_DOCKER_AMD64_RULE ?= $(foreach VARIANT,$(DOCKER_BUILD_VARIANTS), time (mkdir -p $(HIGRESS_DOCKER_BUILD_TOP)/$@ && TARGET_ARCH=amd64 ./docker/docker-copy.sh $^ $(HIGRESS_DOCKER_BUILD_TOP)/$@ && cd $(HIGRESS_DOCKER_BUILD_TOP)/$@ $(BUILD_PRE) && docker build $(BUILD_ARGS) --build-arg BASE_DISTRIBUTION=$(call normalize-tag,$(VARIANT)) --build-arg TARGETARCH=amd64 -t $(IMG_URL)$(call variant-tag,$(VARIANT)) -f Dockerfile.higress . ); ) diff --git a/envoy/envoy b/envoy/envoy index e2707255f..7f18940fb 160000 --- a/envoy/envoy +++ b/envoy/envoy @@ -1 +1 @@ -Subproject commit e2707255f18baa841e723e85f7b7ea13ea93c2e5 +Subproject commit 7f18940fbc390f7ca1be76d86dd8615c4c5009ce diff --git a/istio/proxy b/istio/proxy index d411a4f01..ced6d8167 160000 --- a/istio/proxy +++ b/istio/proxy @@ -1 +1 @@ -Subproject commit d411a4f019545e015018826e660301c2f9a7edb6 +Subproject commit ced6d8167a01fe1c1630b3df0ac39e1b563f05dc diff --git a/pkg/ingress/config/ingress_config.go b/pkg/ingress/config/ingress_config.go index 1f8532aec..76796a8e7 100644 --- a/pkg/ingress/config/ingress_config.go +++ b/pkg/ingress/config/ingress_config.go @@ -628,6 +628,7 @@ func (m *IngressConfig) convertEnvoyFilter(convertOptions *common.ConvertOptions mappings := map[string]*common.Rule{} initHttp2RpcGlobalConfig := true + initMcpSseGlobalFilter := true for _, routes := range convertOptions.HTTPRoutes { for _, route := range routes { if strings.HasSuffix(route.HTTPRoute.Name, "app-root") { @@ -647,6 +648,19 @@ func (m *IngressConfig) convertEnvoyFilter(convertOptions *common.ConvertOptions } } + loadBalance := route.WrapperConfig.AnnotationsConfig.LoadBalance + if loadBalance != nil && loadBalance.McpSseStateful { + IngressLog.Infof("Found MCP SSE stateful session for route %s", route.HTTPRoute.Name) + envoyFilter, err := m.constructMcpSseStatefulSessionEnvoyFilter(route, m.namespace, initMcpSseGlobalFilter) + if err != nil { + IngressLog.Errorf("Construct MCP SSE stateful session EnvoyFilter error %v", err) + } else { + IngressLog.Infof("Append MCP SSE stateful session EnvoyFilter for route %s", route.HTTPRoute.Name) + envoyFilters = append(envoyFilters, *envoyFilter) + initMcpSseGlobalFilter = false + } + } + auth := route.WrapperConfig.AnnotationsConfig.Auth if auth == nil { continue @@ -1511,7 +1525,7 @@ func (m *IngressConfig) constructHttp2RpcEnvoyFilter(http2rpcConfig *annotations return &config.Config{ Meta: config.Meta{ GroupVersionKind: gvk.EnvoyFilter, - Name: common.CreateConvertedName(constants.IstioIngressGatewayName, "http2rpc", http2rpcConfig.Name, "route", httpRoute.Name), + Name: common.CreateConvertedName(constants.IstioIngressGatewayName, "http2rpc", http2rpcConfig.Name, "route", common.ConvertToDNSLabelValid(httpRoute.Name)), Namespace: namespace, }, Spec: &networking.EnvoyFilter{ @@ -1940,6 +1954,99 @@ func (m *IngressConfig) Delete(config.GroupVersionKind, string, string, *string) return common.ErrUnsupportedOp } +func (m *IngressConfig) constructMcpSseStatefulSessionEnvoyFilter(route *common.WrapperHTTPRoute, namespace string, initGlobalFilter bool) (*config.Config, error) { + httpRoute := route.HTTPRoute + + var configPatches []*networking.EnvoyFilter_EnvoyConfigObjectPatch + + // Add global HTTP filter if this is the first route using MCP SSE stateful session + if initGlobalFilter { + configPatches = append(configPatches, &networking.EnvoyFilter_EnvoyConfigObjectPatch{ + ApplyTo: networking.EnvoyFilter_HTTP_FILTER, + Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{ + Context: networking.EnvoyFilter_GATEWAY, + ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Listener{ + Listener: &networking.EnvoyFilter_ListenerMatch{ + FilterChain: &networking.EnvoyFilter_ListenerMatch_FilterChainMatch{ + Filter: &networking.EnvoyFilter_ListenerMatch_FilterMatch{ + Name: "envoy.filters.network.http_connection_manager", + SubFilter: &networking.EnvoyFilter_ListenerMatch_SubFilterMatch{ + Name: "envoy.filters.http.router", + }, + }, + }, + }, + }, + }, + Patch: &networking.EnvoyFilter_Patch{ + Operation: networking.EnvoyFilter_Patch_INSERT_BEFORE, + Value: buildPatchStruct(`{ + "name": "envoy.filters.http.mcp_sse_stateful_session", + "typed_config": { + "@type": "type.googleapis.com/udpa.type.v1.TypedStruct", + "type_url": "type.googleapis.com/envoy.extensions.filters.http.mcp_sse_stateful_session.v3alpha.McpSseStatefulSession" + } + }`), + }, + }) + } + + // Add route-specific configuration + configPatches = append(configPatches, &networking.EnvoyFilter_EnvoyConfigObjectPatch{ + ApplyTo: networking.EnvoyFilter_HTTP_ROUTE, + Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{ + Context: networking.EnvoyFilter_GATEWAY, + ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_RouteConfiguration{ + RouteConfiguration: &networking.EnvoyFilter_RouteConfigurationMatch{ + Vhost: &networking.EnvoyFilter_RouteConfigurationMatch_VirtualHostMatch{ + Route: &networking.EnvoyFilter_RouteConfigurationMatch_RouteMatch{ + Name: httpRoute.Name, + }, + }, + }, + }, + }, + Patch: &networking.EnvoyFilter_Patch{ + Operation: networking.EnvoyFilter_Patch_MERGE, + Value: buildPatchStruct(`{ + "typed_per_filter_config": { + "envoy.filters.http.mcp_sse_stateful_session": { + "@type": "type.googleapis.com/udpa.type.v1.TypedStruct", + "type_url": "type.googleapis.com/envoy.extensions.filters.http.mcp_sse_stateful_session.v3alpha.McpSseStatefulSessionPerRoute", + "value": { + "mcp_sse_stateful_session": { + "session_state": { + "name": "envoy.http.mcp_sse_stateful_session.envelope", + "typed_config": { + "@type": "type.googleapis.com/udpa.type.v1.TypedStruct", + "type_url": "type.googleapis.com/envoy.extensions.http.mcp_sse_stateful_session.envelope.v3alpha.EnvelopeSessionState", + "value": { + "param_name": "sessionId", + "chunk_end_patterns": ["\r\n\r\n", "\n\n", "\r\r"] + } + } + }, + "strict": true + } + } + } + } + }`), + }, + }) + + return &config.Config{ + Meta: config.Meta{ + GroupVersionKind: gvk.EnvoyFilter, + Name: common.CreateConvertedName(constants.IstioIngressGatewayName, "mcp-lb-route", common.ConvertToDNSLabelValid(httpRoute.Name)), + Namespace: namespace, + }, + Spec: &networking.EnvoyFilter{ + ConfigPatches: configPatches, + }, + }, nil +} + func (m *IngressConfig) notifyXDSFullUpdate(gvk config.GroupVersionKind, reason istiomodel.TriggerReason, updatedConfigName *util.ClusterNamespacedName) { var configsUpdated map[istiomodel.ConfigKey]struct{} if updatedConfigName != nil { diff --git a/pkg/ingress/kube/annotations/loadbalance.go b/pkg/ingress/kube/annotations/loadbalance.go index 13ae7e08b..a0aec1fb6 100644 --- a/pkg/ingress/kube/annotations/loadbalance.go +++ b/pkg/ingress/kube/annotations/loadbalance.go @@ -66,9 +66,10 @@ type consistentHashByCookie struct { } type LoadBalanceConfig struct { - simple networking.LoadBalancerSettings_SimpleLB - other *consistentHashByOther - cookie *consistentHashByCookie + simple networking.LoadBalancerSettings_SimpleLB + other *consistentHashByOther + cookie *consistentHashByCookie + McpSseStateful bool } type loadBalance struct{} @@ -129,7 +130,11 @@ func (l loadBalance) Parse(annotations Annotations, config *Ingress, _ *GlobalCo } else { if lb, err := annotations.ParseStringASAP(loadBalanceAnnotation); err == nil { lb = strings.ToUpper(lb) - loadBalanceConfig.simple = networking.LoadBalancerSettings_SimpleLB(networking.LoadBalancerSettings_SimpleLB_value[lb]) + if lb == "MCP-SSE" { + loadBalanceConfig.McpSseStateful = true + } else { + loadBalanceConfig.simple = networking.LoadBalancerSettings_SimpleLB(networking.LoadBalancerSettings_SimpleLB_value[lb]) + } } } diff --git a/pkg/ingress/kube/common/tool.go b/pkg/ingress/kube/common/tool.go index 1d5b40b93..b50022137 100644 --- a/pkg/ingress/kube/common/tool.go +++ b/pkg/ingress/kube/common/tool.go @@ -146,7 +146,7 @@ func GetHost(annotations map[string]string) string { // Istio requires that the name of the gateway must conform to the DNS label. // For details, you can view: https://github.com/istio/istio/blob/2d5c40ad5e9cceebe64106005aa38381097da2ba/pkg/config/validation/validation.go#L478 -func convertToDNSLabelValid(input string) string { +func ConvertToDNSLabelValid(input string) string { hasher := md5.New() hasher.Write([]byte(input)) hash := hasher.Sum(nil) @@ -156,7 +156,7 @@ func convertToDNSLabelValid(input string) string { // CleanHost follow the format of mse-ops for host. func CleanHost(host string) string { - return convertToDNSLabelValid(host) + return ConvertToDNSLabelValid(host) } func CreateConvertedName(items ...string) string { diff --git a/test/e2e/conformance/tests/ingress-loadbalance-mcp-sse.go b/test/e2e/conformance/tests/ingress-loadbalance-mcp-sse.go new file mode 100644 index 000000000..8f69fac4f --- /dev/null +++ b/test/e2e/conformance/tests/ingress-loadbalance-mcp-sse.go @@ -0,0 +1,67 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tests + +import ( + "testing" + + "github.com/alibaba/higress/test/e2e/conformance/utils/envoy" + "github.com/alibaba/higress/test/e2e/conformance/utils/suite" +) + +func init() { + Register(IngressLoadBalanceMcpSse) +} + +var IngressLoadBalanceMcpSse = suite.ConformanceTest{ + ShortName: "IngressLoadBalanceMcpSse", + Description: "The Envoy config should contain MCP SSE stateful session filter when load-balance annotation is set to mcp-sse", + Manifests: []string{"tests/ingress-loadbalance-mcp-sse.yaml"}, + Features: []suite.SupportedFeature{suite.EnvoyConfigConformanceFeature}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + testCases := []struct { + name string + envoyAssertion []envoy.Assertion + }{ + { + name: "MCP SSE stateful session global filter should be added", + envoyAssertion: []envoy.Assertion{ + { + Path: "configs.#.dynamic_listeners.#.active_state.listener.filter_chains.#.filters.#.typed_config.http_filters", + CheckType: envoy.CheckTypeMatch, + TargetNamespace: "higress-system", + ExpectEnvoyConfig: map[string]interface{}{ + "name": "envoy.filters.http.mcp_sse_stateful_session", + "typed_config": map[string]interface{}{ + "@type": "type.googleapis.com/udpa.type.v1.TypedStruct", + "type_url": "type.googleapis.com/envoy.extensions.filters.http.mcp_sse_stateful_session.v3alpha.McpSseStatefulSession", + }, + }, + }, + }, + }, + // TODO: add per router filter check + } + + t.Run("Ingress LoadBalance MCP SSE", func(t *testing.T) { + for _, testcase := range testCases { + t.Logf("Test Case %s", testcase.name) + for _, assertion := range testcase.envoyAssertion { + envoy.AssertEnvoyConfig(t, suite.TimeoutConfig, assertion) + } + } + }) + }, +} diff --git a/test/e2e/conformance/tests/ingress-loadbalance-mcp-sse.yaml b/test/e2e/conformance/tests/ingress-loadbalance-mcp-sse.yaml new file mode 100644 index 000000000..0c303d481 --- /dev/null +++ b/test/e2e/conformance/tests/ingress-loadbalance-mcp-sse.yaml @@ -0,0 +1,34 @@ +# Copyright (c) 2022 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: higress-conformance-infra-ingress-loadbalance-mcp-sse-test + namespace: higress-conformance-infra + annotations: + higress.io/load-balance: "mcp-sse" +spec: + ingressClassName: higress + rules: + - host: "mcp-sse.example.com" + http: + paths: + - pathType: Prefix + path: "/api" + backend: + service: + name: infra-backend-v3 + port: + number: 8080 \ No newline at end of file