Repository: solo-io/gloo
Pull request #10687: feat: support http2 and h2c app protocol
Author: login: davidjumani
Labels: name: keep pr updated
Created: 2025-03-06T21:13:58Z | Merged: 2025-03-07T21:50:40Z

# Description

Adds support for http2 via the service port appProtocol spec

# Context

https://github.com/solo-io/solo-projects/issues/7824

## Testing steps

- Added kuberentes tests : Validates appProtocol: http2
- Updated conformance tests : Validates appProtocol: kuberntes.io/h2c
- Added unit tests : Everything else

# Checklist:

- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my feature works

<!---
# Author reminders (delete before opening)
- Include a concise, user-facing changelog (for details, see https://github.com/solo-io/go-utils/tree/main/changelogutils) referencing the issue that is resolved
  - Include `resolvesIssue: false` unless the issue does not require a release to be resolved; only a subset of non-user-facing issues can be considered resolved without release
- Run codegen via `make -B install-go-tools generated-code`
- Follow guidelines laid out in the Gloo Edge [contribution guide](https://docs.solo.io/gloo-edge/latest/contributing/)
- If not ready for review, open a draft PR or apply the `work in progress` label
-->



Diff:
diff --git a/Makefile b/Makefile
index 4e752c0c15b..b78f295bfd2 100644
--- a/Makefile
+++ b/Makefile
@@ -1243,7 +1243,7 @@ $(TEST_ASSET_DIR)/conformance/conformance_test.go:
 	cat $(shell go list -json -m sigs.k8s.io/gateway-api | jq -r '.Dir')/conformance/conformance_test.go >> $@
 	go fmt $@
 
-CONFORMANCE_SUPPORTED_FEATURES ?= -supported-features=Gateway,ReferenceGrant,HTTPRoute,HTTPRouteQueryParamMatching,HTTPRouteMethodMatching,HTTPRouteResponseHeaderModification,HTTPRoutePortRedirect,HTTPRouteHostRewrite,HTTPRouteSchemeRedirect,HTTPRoutePathRedirect,HTTPRouteHostRewrite,HTTPRoutePathRewrite,HTTPRouteRequestMirror,TLSRoute
+CONFORMANCE_SUPPORTED_FEATURES ?= -supported-features=Gateway,ReferenceGrant,HTTPRoute,HTTPRouteQueryParamMatching,HTTPRouteMethodMatching,HTTPRouteResponseHeaderModification,HTTPRoutePortRedirect,HTTPRouteHostRewrite,HTTPRouteSchemeRedirect,HTTPRoutePathRedirect,HTTPRouteHostRewrite,HTTPRoutePathRewrite,HTTPRouteRequestMirror,TLSRoute,HTTPRouteBackendProtocolH2C
 CONFORMANCE_SUPPORTED_PROFILES ?= -conformance-profiles=GATEWAY-HTTP
 CONFORMANCE_REPORT_ARGS ?= -report-output=$(TEST_ASSET_DIR)/conformance/$(VERSION)-report.yaml -organization=solo.io -project=gloo-gateway -version=$(VERSION) -url=github.com/solo-io/gloo -contact=github.com/solo-io/gloo/issues/new/choose
 CONFORMANCE_ARGS := -gateway-class=gloo-gateway $(CONFORMANCE_SUPPORTED_FEATURES) $(CONFORMANCE_SUPPORTED_PROFILES) $(CONFORMANCE_REPORT_ARGS)
diff --git a/changelog/v1.19.0-beta13/support-approto.yaml b/changelog/v1.19.0-beta13/support-approto.yaml
new file mode 100644
index 00000000000..574cc7408ea
--- /dev/null
+++ b/changelog/v1.19.0-beta13/support-approto.yaml
@@ -0,0 +1,6 @@
+changelog:
+- type: NEW_FEATURE
+  issueLink: https://github.com/solo-io/solo-projects/issues/7824
+  resolvesIssue: false
+  description: Adds support for http2 via the service port appProtocol spec
+
diff --git a/projects/gloo/pkg/plugins/kubernetes/serviceconverter/use_http2_annotation_converter.go b/projects/gloo/pkg/plugins/kubernetes/serviceconverter/use_http2_annotation_converter.go
index 85349a97031..8a1b1ac5a0c 100644
--- a/projects/gloo/pkg/plugins/kubernetes/serviceconverter/use_http2_annotation_converter.go
+++ b/projects/gloo/pkg/plugins/kubernetes/serviceconverter/use_http2_annotation_converter.go
@@ -19,6 +19,15 @@ var http2PortNames = []string{
 	"http2",
 }
 
+var http2AppProtocolNames = map[string]bool{
+	// Defined by istio : https://istio.io/latest/docs/ops/configuration/traffic-management/protocol-selection/
+	"http2":    true,
+	"grpc":     true,
+	"grpc-web": true,
+	// Defined by GEP-1911 : https://gateway-api.sigs.k8s.io/geps/gep-1911/#api-semantics
+	"kubernetes.io/h2c": true,
+}
+
 // UseHttp2Converter sets UseHttp2 on the upstream if:
 // (1) the service has the "h2_service" annotation; or
 // (2) the "h2_service" annotation defined in Settings.UpstreamOptions; or
@@ -46,6 +55,10 @@ func useHttp2(ctx context.Context, svc *corev1.Service, port corev1.ServicePort)
 		}
 	}
 
+	if port.AppProtocol != nil && http2AppProtocolNames[*port.AppProtocol] {
+		return &wrappers.BoolValue{Value: true}
+	}
+
 	for _, http2Name := range http2PortNames {
 		if strings.HasPrefix(port.Name, http2Name) {
 			return &wrappers.BoolValue{Value: true}
diff --git a/projects/gloo/pkg/plugins/kubernetes/uds_convert_test.go b/projects/gloo/pkg/plugins/kubernetes/uds_convert_test.go
index 7f331057ff0..291447dad42 100644
--- a/projects/gloo/pkg/plugins/kubernetes/uds_convert_test.go
+++ b/projects/gloo/pkg/plugins/kubernetes/uds_convert_test.go
@@ -101,6 +101,29 @@ var _ = Describe("UdsConvert", func() {
 			Entry("exactly http2", "http2"),
 		)
 
+		DescribeTable("should create upstream with use_http2=true when port appProtocol is a supported type", func(appProtocol string, useHttp2 bool) {
+			svc := &corev1.Service{
+				Spec: corev1.ServiceSpec{},
+			}
+			svc.Name = "test"
+			svc.Namespace = "test-ns"
+
+			port := corev1.ServicePort{
+				Port:        123,
+				AppProtocol: &appProtocol,
+			}
+			up := uc.CreateUpstream(context.TODO(), svc, port)
+			Expect(up.GetUseHttp2().GetValue()).To(Equal(useHttp2))
+		},
+			Entry("http2", "http2", true),
+			Entry("grpc", "grpc", true),
+			Entry("grpc-web", "grpc-web", true),
+			Entry("kubernetes.io/h2c", "kubernetes.io/h2c", true),
+			Entry("http2-suffix", "http2-suffix", false),
+			Entry("grpc-suffix", "grpc-suffix", false),
+			Entry("tcp", "tcp", false),
+		)
+
 		Describe("Upstream Config when Annotations Exist", func() {
 
 			It("Should create upstream with use_http2=true when annotation exists", testSetUseHttp2Converter)
diff --git a/test/gomega/matchers/have_http_response.go b/test/gomega/matchers/have_http_response.go
index 0a065eb89a4..b3730c389f8 100644
--- a/test/gomega/matchers/have_http_response.go
+++ b/test/gomega/matchers/have_http_response.go
@@ -74,6 +74,9 @@ type HttpResponse struct {
 	// Each header can be of type: {string, GomegaMatcher}
 	// Optional: If not provided, does not perform header validation
 	Headers map[string]interface{}
+	// Protocol is the expected protocol of an http.Response
+	// Optional: If not provided, does not perform additional validation
+	Protocol string
 	// Custom is a generic matcher that can be applied to validate any other properties of an http.Response
 	// Optional: If not provided, does not perform additional validation
 	Custom types.GomegaMatcher
@@ -90,8 +93,8 @@ func (r *HttpResponse) String() string {
 		bodyString = fmt.Sprintf("%#v", bodyMatcher)
 	}
 
-	return fmt.Sprintf("HttpResponse{StatusCode: %d, Body: %s, Headers: %v, Custom: %v}",
-		r.StatusCode, bodyString, r.Headers, r.Custom)
+	return fmt.Sprintf("HttpResponse{StatusCode: %d, Body: %s, Headers: %v, Protocol: %s, Custom: %v}",
+		r.StatusCode, bodyString, r.Headers, r.Protocol, r.Custom)
 
 }
 
@@ -116,6 +119,9 @@ func HaveHttpResponse(expected *HttpResponse) types.GomegaMatcher {
 			Expected: expected.Body,
 		})
 	}
+	if expected.Protocol != "" {
+		partialResponseMatchers = append(partialResponseMatchers, HaveProtocol(expected.Protocol))
+	}
 	for headerName, headerMatch := range expected.Headers {
 		partialResponseMatchers = append(partialResponseMatchers, &matchers.HaveHTTPHeaderWithValueMatcher{
 			Header: headerName,
diff --git a/test/gomega/matchers/have_protocol.go b/test/gomega/matchers/have_protocol.go
new file mode 100644
index 00000000000..e6960c5c7d7
--- /dev/null
+++ b/test/gomega/matchers/have_protocol.go
@@ -0,0 +1,33 @@
+package matchers
+
+import (
+	"github.com/onsi/gomega"
+	"github.com/onsi/gomega/gstruct"
+	"github.com/onsi/gomega/types"
+	"github.com/solo-io/gloo/test/gomega/transforms"
+)
+
+const (
+	HTTP1Protocol = "HTTP/1.1"
+	HTTP2Protocol = "HTTP/2"
+)
+
+// HaveProtocol expects an http response with the given protocol
+func HaveProtocol(protocol string) types.GomegaMatcher {
+	if protocol == "" {
+		// If protocol is not defined, we create a matcher that always succeeds
+		return gstruct.Ignore()
+	}
+	//nolint:bodyclose // The caller of this matcher constructor should be responsible for ensuring the body close
+	return gomega.WithTransform(transforms.WithProtocol(), gomega.Equal(protocol))
+}
+
+// HaveHTTP1Protocol expects an http response with the HTTP/1.1 protocol
+func HaveHTTP1Protocol() types.GomegaMatcher {
+	return HaveProtocol(HTTP1Protocol)
+}
+
+// HaveHTTP2Protocol expects an http response with the HTTP/2 protocol
+func HaveHTTP2Protocol() types.GomegaMatcher {
+	return HaveProtocol(HTTP2Protocol)
+}
diff --git a/test/gomega/matchers/have_protocol_test.go b/test/gomega/matchers/have_protocol_test.go
new file mode 100644
index 00000000000..ed04441e6b0
--- /dev/null
+++ b/test/gomega/matchers/have_protocol_test.go
@@ -0,0 +1,28 @@
+package matchers_test
+
+import (
+	"net/http"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/solo-io/gloo/test/gomega/matchers"
+)
+
+var _ = Describe("HaveProtocol", func() {
+
+	It("succeeds if there is no protocol specified", func() {
+		httpResponse := &http.Response{
+			Proto: matchers.HTTP1Protocol,
+		}
+		Expect(httpResponse).To(matchers.HaveProtocol(""))
+	})
+
+	It("matches the specified protocol", func() {
+		httpResponse := &http.Response{
+			Proto: matchers.HTTP1Protocol,
+		}
+		Expect(httpResponse).To(matchers.HaveProtocol(matchers.HTTP1Protocol))
+		Expect(httpResponse).To(matchers.HaveHTTP1Protocol())
+		Expect(httpResponse).ToNot(matchers.HaveHTTP2Protocol())
+	})
+})
diff --git a/test/gomega/transforms/curl.go b/test/gomega/transforms/curl.go
index d92c56997dc..2e9cfee003a 100644
--- a/test/gomega/transforms/curl.go
+++ b/test/gomega/transforms/curl.go
@@ -22,12 +22,13 @@ const (
 // WithCurlHttpResponse is a Gomega Transform that converts the string returned by an exec.Curl
 // and transforms it into an http.Response. This is useful to be used in tandem with matchers.HaveHttpResponse
 // NOTE: This is not feature complete, as we do not convert the entire response.
-// For now, we handle HTTP/1.1 response headers, status, and body.
+// For now, we handle HTTP/1.1 && HTTP/2 response headers, status, protocol and body.
 // The curl must be executed with verbose=true to include both the response headers/status
 // and response body.
 func WithCurlHttpResponse(curlResponse string) *http.Response {
 	headers := make(http.Header)
 	statusCode := 0
+	protocol := ""
 	var bodyBuf bytes.Buffer
 
 	for _, line := range strings.Split(curlResponse, "\n") {
@@ -37,9 +38,10 @@ func WithCurlHttpResponse(curlResponse string) *http.Response {
 			continue
 		}
 
-		code := processResponseCode(line)
+		proto, code := processResponseCodeAndProtocol(line)
 		if code != 0 {
 			statusCode = code
+			protocol = proto
 			continue
 		}
 
@@ -52,6 +54,7 @@ func WithCurlHttpResponse(curlResponse string) *http.Response {
 	}
 
 	return &http.Response{
+		Proto:      protocol,
 		StatusCode: statusCode,
 		Header:     headers,
 		Body:       bytesBody(bodyBuf.Bytes()),
@@ -61,6 +64,7 @@ func WithCurlHttpResponse(curlResponse string) *http.Response {
 func WithCurlResponse(curlResponse *kubectl.CurlResponse) *http.Response {
 	headers := make(http.Header)
 	statusCode := 0
+	protocol := ""
 	var bodyBuf bytes.Buffer
 
 	// Curl writes the body to stdout and the headers/status to stderr
@@ -72,9 +76,10 @@ func WithCurlResponse(curlResponse *kubectl.CurlResponse) *http.Response {
 			continue
 		}
 
-		code := processResponseCode(line)
+		proto, code := processResponseCodeAndProtocol(line)
 		if code != 0 {
 			statusCode = code
+			protocol = proto
 		}
 	}
 
@@ -82,6 +87,7 @@ func WithCurlResponse(curlResponse *kubectl.CurlResponse) *http.Response {
 	bodyBuf.WriteString(curlResponse.StdOut)
 
 	return &http.Response{
+		Proto:      protocol,
 		StatusCode: statusCode,
 		Header:     headers,
 		Body:       bytesBody(bodyBuf.Bytes()),
@@ -102,21 +108,21 @@ func processResponseHeader(line string) (string, string) {
 	return "", ""
 }
 
-// processResponseCode processes the current line if it's a response status code.
-// Returns the status code if the line was processed, otherwise returns 0.
-func processResponseCode(line string) int {
+// processResponseCodeAndProtocol processes the current line if it's a response status code with the protocol.
+// Returns the status code and protocol if the line was processed, otherwise returns 0 and an empty string.
+func processResponseCodeAndProtocol(line string) (string, int) {
 	// check for response status. the line with the response code will be in the format
-	// `< HTTP/1.1 <code> <message>` or `< HTTP/2 <code> <message>`
+	// `< HTTP/1.1 <code> <message>` or `< HTTP/2 <code>`
 	if strings.HasPrefix(line, responseStatusPrefix1dot1) || strings.HasPrefix(line, responseStatusPrefix2) {
 		statusParts := strings.Split(line, " ")
-		if len(statusParts) >= 4 {
+		if len(statusParts) >= 3 {
 			statusCode, err := strconv.Atoi(statusParts[2])
 			if err == nil {
-				return statusCode
+				return statusParts[1], statusCode
 			}
 		}
 	}
-	return 0
+	return "", 0
 }
 
 // isResponseBody returns true if the current line is part of the response body, false otherwise.
diff --git a/test/gomega/transforms/transforms.go b/test/gomega/transforms/transforms.go
index 58dc7447671..8b281164ecf 100644
--- a/test/gomega/transforms/transforms.go
+++ b/test/gomega/transforms/transforms.go
@@ -67,3 +67,10 @@ func BytesToInt(b []byte) int {
 	Expect(err).NotTo(HaveOccurred())
 	return i
 }
+
+// WithProtocol returns a Gomega Transform that extracts the protocol of a request
+func WithProtocol() func(response *http.Response) string {
+	return func(response *http.Response) string {
+		return response.Proto
+	}
+}
diff --git a/test/kubernetes/e2e/features/services/httproute/suite.go b/test/kubernetes/e2e/features/services/httproute/suite.go
index 86a3dc5d1b2..45040b69efa 100644
--- a/test/kubernetes/e2e/features/services/httproute/suite.go
+++ b/test/kubernetes/e2e/features/services/httproute/suite.go
@@ -10,45 +10,23 @@ import (
 	"github.com/solo-io/gloo/projects/gateway2/wellknown"
 	"github.com/solo-io/gloo/test/kubernetes/e2e"
 	"github.com/solo-io/gloo/test/kubernetes/e2e/defaults"
+	"github.com/solo-io/gloo/test/kubernetes/e2e/tests/base"
 )
 
 // testingSuite is the entire Suite of tests for testing K8s Service-specific features/fixes
 type testingSuite struct {
-	suite.Suite
-
-	ctx context.Context
-
-	// testInstallation contains all the metadata/utilities necessary to execute a series of tests
-	// against an installation of Gloo Gateway
-	testInstallation *e2e.TestInstallation
+	*base.BaseTestingSuite
 }
 
 func NewTestingSuite(ctx context.Context, testInst *e2e.TestInstallation) suite.TestingSuite {
 	return &testingSuite{
-		ctx:              ctx,
-		testInstallation: testInst,
+		base.NewBaseTestingSuite(ctx, testInst, base.SimpleTestCase{}, testCases),
 	}
 }
 
 func (s *testingSuite) TestConfigureHTTPRouteBackingDestinationsWithService() {
-	s.T().Cleanup(func() {
-		err := s.testInstallation.Actions.Kubectl().DeleteFile(s.ctx, routeWithServiceManifest)
-		s.NoError(err, "can delete manifest")
-		err = s.testInstallation.Actions.Kubectl().DeleteFile(s.ctx, serviceManifest)
-		s.NoError(err, "can delete manifest")
-		s.testInstallation.Assertions.EventuallyObjectsNotExist(s.ctx, proxyService, proxyDeployment)
-	})
-
-	err := s.testInstallation.Actions.Kubectl().ApplyFile(s.ctx, routeWithServiceManifest)
-	s.Assert().NoError(err, "can apply gloo.solo.io Route manifest")
-
-	// apply the service manifest separately, after the route table is applied, to ensure it can be applied after the route table
-	err = s.testInstallation.Actions.Kubectl().ApplyFile(s.ctx, serviceManifest)
-	s.Assert().NoError(err, "can apply gloo.solo.io Service manifest")
-
-	s.testInstallation.Assertions.EventuallyObjectsExist(s.ctx, proxyService, proxyDeployment)
-	s.testInstallation.Assertions.AssertEventualCurlResponse(
-		s.ctx,
+	s.TestInstallation.Assertions.AssertEventualCurlResponse(
+		s.Ctx,
 		defaults.CurlPodExecOpt,
 		[]curl.Option{
 			curl.WithHost(kubeutils.ServiceFQDN(proxyService.ObjectMeta)),
@@ -59,30 +37,17 @@ func (s *testingSuite) TestConfigureHTTPRouteBackingDestinationsWithService() {
 
 func (s *testingSuite) TestConfigureHTTPRouteBackingDestinationsWithServiceAndWithoutTCPRoute() {
 	s.T().Cleanup(func() {
-		err := s.testInstallation.Actions.Kubectl().DeleteFile(s.ctx, routeWithServiceManifest)
-		s.NoError(err, "can delete manifest")
-		err = s.testInstallation.Actions.Kubectl().DeleteFile(s.ctx, serviceManifest)
-		s.NoError(err, "can delete manifest")
-		s.testInstallation.Assertions.EventuallyObjectsNotExist(s.ctx, proxyService, proxyDeployment)
-		err = s.testInstallation.Actions.Kubectl().ApplyFile(s.ctx, tcpRouteCrdManifest)
+		err := s.TestInstallation.Actions.Kubectl().ApplyFile(s.Ctx, tcpRouteCrdManifest)
 		s.NoError(err, "can apply manifest")
-		s.testInstallation.Assertions.EventuallyObjectsExist(s.ctx, &wellknown.TCPRouteCRD)
+		s.TestInstallation.Assertions.EventuallyObjectsExist(s.Ctx, &wellknown.TCPRouteCRD)
 	})
 
 	// Remove the TCPRoute CRD to assert HTTPRoute services still work.
-	err := s.testInstallation.Actions.Kubectl().DeleteFile(s.ctx, tcpRouteCrdManifest)
+	err := s.TestInstallation.Actions.Kubectl().DeleteFile(s.Ctx, tcpRouteCrdManifest)
 	s.NoError(err, "can delete manifest")
 
-	err = s.testInstallation.Actions.Kubectl().ApplyFile(s.ctx, routeWithServiceManifest)
-	s.Assert().NoError(err, "can apply gloo.solo.io Route manifest")
-
-	// apply the service manifest separately, after the route table is applied, to ensure it can be applied after the route table
-	err = s.testInstallation.Actions.Kubectl().ApplyFile(s.ctx, serviceManifest)
-	s.Assert().NoError(err, "can apply gloo.solo.io Service manifest")
-
-	s.testInstallation.Assertions.EventuallyObjectsExist(s.ctx, proxyService, proxyDeployment)
-	s.testInstallation.Assertions.AssertEventualCurlResponse(
-		s.ctx,
+	s.TestInstallation.Assertions.AssertEventualCurlResponse(
+		s.Ctx,
 		defaults.CurlPodExecOpt,
 		[]curl.Option{
 			curl.WithHost(kubeutils.ServiceFQDN(proxyService.ObjectMeta)),
@@ -90,3 +55,15 @@ func (s *testingSuite) TestConfigureHTTPRouteBackingDestinationsWithServiceAndWi
 		},
 		expectedSvcResp)
 }
+
+func (s *testingSuite) TestHTTP2AppProtocol() {
+	s.TestInstallation.Assertions.AssertEventualCurlResponse(
+		s.Ctx,
+		defaults.CurlPodExecOpt,
+		[]curl.Option{
+			curl.WithHost(kubeutils.ServiceFQDN(proxyService.ObjectMeta)),
+			curl.WithHostHeader("example.com"),
+			curl.WithArgs([]string{"--http2-prior-knowledge"}),
+		},
+		expectedHTTP2SvcResp)
+}
diff --git a/test/kubernetes/e2e/features/services/httproute/testdata/http2-service-for-route.yaml b/test/kubernetes/e2e/features/services/httproute/testdata/http2-service-for-route.yaml
new file mode 100644
index 00000000000..92394d96cff
--- /dev/null
+++ b/test/kubernetes/e2e/features/services/httproute/testdata/http2-service-for-route.yaml
@@ -0,0 +1,60 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: nginx-conf
+data:
+  nginx.conf: |
+    user nginx;
+    worker_processes  1;
+    events {
+      worker_connections  10240;
+    }
+    http {
+      server {
+          listen       80 http2;
+          server_name  localhost;
+          location / {
+            root   /usr/share/nginx/html;
+            index  index.html index.htm;
+        }
+      }
+    }
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: example-svc
+spec:
+  selector:
+    app.kubernetes.io/name: nginx
+  ports:
+    - protocol: TCP
+      port: 8080
+      targetPort: http-web-svc
+      appProtocol: http2
+---
+apiVersion: v1
+kind: Pod
+metadata:
+  name: nginx
+  labels:
+    app.kubernetes.io/name: nginx
+spec:
+  containers:
+    - name: nginx
+      image: nginx:stable
+      ports:
+        - containerPort: 80
+          name: http-web-svc
+      volumeMounts:
+        - name: nginx-conf
+          mountPath: /etc/nginx/nginx.conf
+          subPath: nginx.conf
+          readOnly: true
+  volumes:
+  - name: nginx-conf
+    configMap:
+      name: nginx-conf
+      items:
+        - key: nginx.conf
+          path: nginx.conf
\ No newline at end of file
diff --git a/test/kubernetes/e2e/features/services/httproute/types.go b/test/kubernetes/e2e/features/services/httproute/types.go
index 4ae4a30ef94..3060901be44 100644
--- a/test/kubernetes/e2e/features/services/httproute/types.go
+++ b/test/kubernetes/e2e/features/services/httproute/types.go
@@ -4,7 +4,10 @@ import (
 	"net/http"
 	"path/filepath"
 
+	"github.com/solo-io/gloo/test/gomega/matchers"
 	testmatchers "github.com/solo-io/gloo/test/gomega/matchers"
+	"github.com/solo-io/gloo/test/kubernetes/e2e/tests/base"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	"github.com/solo-io/skv2/codegen/util"
 
@@ -18,8 +21,8 @@ import (
 var (
 	routeWithServiceManifest = filepath.Join(util.MustGetThisDir(), "testdata", "route-with-service.yaml")
 	serviceManifest          = filepath.Join(util.MustGetThisDir(), "testdata", "service-for-route.yaml")
+	http2ServiceManifest     = filepath.Join(util.MustGetThisDir(), "testdata", "http2-service-for-route.yaml")
 	tcpRouteCrdManifest      = filepath.Join(crds.AbsPathToCrd("tcproute-crd.yaml"))
-
 	// Proxy resource to be translated
 	glooProxyObjectMeta = metav1.ObjectMeta{
 		Name:      "gloo-proxy-gw",
@@ -32,4 +35,31 @@ var (
 		StatusCode: http.StatusOK,
 		Body:       gstruct.Ignore(),
 	}
+
+	expectedHTTP2SvcResp = &testmatchers.HttpResponse{
+		StatusCode: http.StatusOK,
+		Protocol:   matchers.HTTP2Protocol,
+	}
+
+	// test cases
+	testCases = map[string]*base.TestCase{
+		"TestConfigureHTTPRouteBackingDestinationsWithService": {
+			SimpleTestCase: base.SimpleTestCase{
+				Manifests: []string{routeWithServiceManifest, serviceManifest},
+				Resources: []client.Object{proxyService, proxyDeployment},
+			},
+		},
+		"TestConfigureHTTPRouteBackingDestinationsWithServiceAndWithoutTCPRoute": {
+			SimpleTestCase: base.SimpleTestCase{
+				Manifests: []string{routeWithServiceManifest, serviceManifest},
+				Resources: []client.Object{proxyService, proxyDeployment},
+			},
+		},
+		"TestHTTP2AppProtocol": {
+			SimpleTestCase: base.SimpleTestCase{
+				Manifests: []string{routeWithServiceManifest, http2ServiceManifest},
+				Resources: []client.Object{proxyService, proxyDeployment},
+			},
+		},
+	}
 )


Reviews:
user: login: andy-fong
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-03-07T16:51:58Z
user: login: andy-fong
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-07T17:00:11Z
user: login: davidjumani
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T17:57:45Z
user: login: davidjumani
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T17:59:04Z
user: login: davidjumani
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T18:04:39Z
user: login: andy-fong
state: APPROVED
author_association: NONE
submitted_at: 2025-03-07T18:24:36Z
user: login: sam-heilbron
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-07T20:11:30Z
user: login: davidjumani
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T21:11:01Z
user: login: davidjumani
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T21:12:40Z
user: login: davidjumani
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T21:13:20Z
user: login: davidjumani
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T21:14:20Z
user: login: sam-heilbron
state: APPROVED
author_association: NONE
submitted_at: 2025-03-07T21:20:04Z

Review comments:
diff_hunk: @@ -101,6 +101,26 @@ var _ = Describe("UdsConvert", func() {
 			Entry("exactly http2", "http2"),
 		)
 
+		DescribeTable("should create upstream with use_http2=true when port appProtocol is a supported type", func(appProtocol string) {
+			svc := &corev1.Service{
+				Spec: corev1.ServiceSpec{},
+			}
+			svc.Name = "test"
+			svc.Namespace = "test-ns"
+
+			port := corev1.ServicePort{
+				Port:        123,
+				AppProtocol: &appProtocol,
+			}
+			up := uc.CreateUpstream(context.TODO(), svc, port)
+			Expect(up.GetUseHttp2().GetValue()).To(BeTrue())
+		},
+			Entry("http2", "http2"),
+			Entry("grpc", "grpc"),
+			Entry("grpc-web", "grpc-web"),
+			Entry("kubernetes.io/h2c", "kubernetes.io/h2c"),
path: projects/gloo/pkg/plugins/kubernetes/uds_convert_test.go
user: login: andy-fong
body: nit: might be good to add a negative testcase to make sure we are returning false. 
created_at: 2025-03-07T16:24:59Z
author_association: NONE
diff_hunk: @@ -104,19 +110,19 @@ func processResponseHeader(line string) (string, string) {
 
 // processResponseCode processes the current line if it's a response status code.
 // Returns the status code if the line was processed, otherwise returns 0.
path: test/gomega/transforms/curl.go
user: login: andy-fong
body: This comment needs to be updated.
created_at: 2025-03-07T16:36:48Z
author_association: NONE
diff_hunk: @@ -104,19 +110,19 @@ func processResponseHeader(line string) (string, string) {
 
 // processResponseCode processes the current line if it's a response status code.
 // Returns the status code if the line was processed, otherwise returns 0.
-func processResponseCode(line string) int {
+func processResponseCodeAndProtocol(line string) (string, int) {
 	// check for response status. the line with the response code will be in the format
 	// `< HTTP/1.1 <code> <message>` or `< HTTP/2 <code> <message>`
path: test/gomega/transforms/curl.go
user: login: andy-fong
body: I think this comment is wrong. At least from my curl output:
Http/1.1:
```
< HTTP/1.1 200 OK
```
Http/2:
```
< HTTP/2 200
```
Notice there is no message for HTTP/2. So, the following check for len >= 4 will not work for HTTP/2 response. Can you double check this?
created_at: 2025-03-07T16:39:28Z
author_association: NONE
diff_hunk: @@ -0,0 +1,34 @@
+package matchers
+
+import (
+	"github.com/onsi/gomega"
+	"github.com/onsi/gomega/gstruct"
+	"github.com/onsi/gomega/types"
+	"github.com/solo-io/gloo/test/gomega/transforms"
+)
+
+const (
+	HTTP1Protocol = "HTTP/1.1"
+	HTTP2Protocol = "HTTP/2"
+)
+
+// HaveProtocol expects an http response with the given protocol
+func HaveProtocol(protocol string) types.GomegaMatcher {
+	if protocol == "" {
+		// If protocol is defined, we create a matcher that always succeeds
+		// If we do not this we will create an And matcher for 0 objects, which leads to a panic
path: test/gomega/matchers/have_protocol.go
user: login: andy-fong
body: sound like something is missing between `not` and `this` and can you clarify this comment. See my comment on the curl response status line parsing, are we ignoring if protocol is empty which make the test pass? I am not very familiar with this. 
created_at: 2025-03-07T16:48:37Z
author_association: NONE
diff_hunk: @@ -104,19 +110,19 @@ func processResponseHeader(line string) (string, string) {
 
 // processResponseCode processes the current line if it's a response status code.
 // Returns the status code if the line was processed, otherwise returns 0.
-func processResponseCode(line string) int {
+func processResponseCodeAndProtocol(line string) (string, int) {
 	// check for response status. the line with the response code will be in the format
 	// `< HTTP/1.1 <code> <message>` or `< HTTP/2 <code> <message>`
path: test/gomega/transforms/curl.go
user: login: andy-fong
body: actually, if I send the output to a file, I see it has a trailing space:
```
< HTTP/2 200 ^M
```
so, maybe it has an empty field at the end which make it to work. But may be fix the comment.
created_at: 2025-03-07T17:00:11Z
author_association: NONE
diff_hunk: @@ -104,19 +110,19 @@ func processResponseHeader(line string) (string, string) {
 
 // processResponseCode processes the current line if it's a response status code.
 // Returns the status code if the line was processed, otherwise returns 0.
-func processResponseCode(line string) int {
+func processResponseCodeAndProtocol(line string) (string, int) {
 	// check for response status. the line with the response code will be in the format
 	// `< HTTP/1.1 <code> <message>` or `< HTTP/2 <code> <message>`
path: test/gomega/transforms/curl.go
user: login: davidjumani
body: So there's a space after the "< HTTP/2 200 " which is why it works with http2. I've updated it to ensure the split has 3 parts not 4 just to be safe
created_at: 2025-03-07T17:57:45Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,34 @@
+package matchers
+
+import (
+	"github.com/onsi/gomega"
+	"github.com/onsi/gomega/gstruct"
+	"github.com/onsi/gomega/types"
+	"github.com/solo-io/gloo/test/gomega/transforms"
+)
+
+const (
+	HTTP1Protocol = "HTTP/1.1"
+	HTTP2Protocol = "HTTP/2"
+)
+
+// HaveProtocol expects an http response with the given protocol
+func HaveProtocol(protocol string) types.GomegaMatcher {
+	if protocol == "" {
+		// If protocol is defined, we create a matcher that always succeeds
+		// If we do not this we will create an And matcher for 0 objects, which leads to a panic
path: test/gomega/matchers/have_protocol.go
user: login: davidjumani
body: Updated the comment. If the expected protocol passed is empty it should ignore it. I've manually verified the test fails if you pass a different protocol from the one in the request
created_at: 2025-03-07T17:59:04Z
author_association: COLLABORATOR
diff_hunk: @@ -101,6 +101,26 @@ var _ = Describe("UdsConvert", func() {
 			Entry("exactly http2", "http2"),
 		)
 
+		DescribeTable("should create upstream with use_http2=true when port appProtocol is a supported type", func(appProtocol string) {
+			svc := &corev1.Service{
+				Spec: corev1.ServiceSpec{},
+			}
+			svc.Name = "test"
+			svc.Namespace = "test-ns"
+
+			port := corev1.ServicePort{
+				Port:        123,
+				AppProtocol: &appProtocol,
+			}
+			up := uc.CreateUpstream(context.TODO(), svc, port)
+			Expect(up.GetUseHttp2().GetValue()).To(BeTrue())
+		},
+			Entry("http2", "http2"),
+			Entry("grpc", "grpc"),
+			Entry("grpc-web", "grpc-web"),
+			Entry("kubernetes.io/h2c", "kubernetes.io/h2c"),
path: projects/gloo/pkg/plugins/kubernetes/uds_convert_test.go
user: login: davidjumani
body: Added negative scenarios
created_at: 2025-03-07T18:04:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,34 @@
+package matchers
+
+import (
+	"github.com/onsi/gomega"
+	"github.com/onsi/gomega/gstruct"
+	"github.com/onsi/gomega/types"
+	"github.com/solo-io/gloo/test/gomega/transforms"
+)
+
+const (
+	HTTP1Protocol = "HTTP/1.1"
+	HTTP2Protocol = "HTTP/2"
+)
+
+// HaveProtocol expects an http response with the given protocol
+func HaveProtocol(protocol string) types.GomegaMatcher {
+	if protocol == "" {
+		// If protocol is defined, we create a matcher that always succeeds
path: test/gomega/matchers/have_protocol.go
user: login: sam-heilbron
body: nit: If protocol is _not_ defined

Plus, there is a reference in the comment about "an And matcher for 0 objects". To me that feels like an implementation detail of how this matcher is consumed, and it might mislead people 
created_at: 2025-03-07T20:03:15Z
author_association: NONE
diff_hunk: @@ -28,6 +28,7 @@ const (
 func WithCurlHttpResponse(curlResponse string) *http.Response {
 	headers := make(http.Header)
 	statusCode := 0
+	protocol := ""
path: test/gomega/transforms/curl.go
user: login: sam-heilbron
body: Does this work (and some previous work) render the comment on this function out of date? (that references we handle http/1.1 headers).

Should we default the protocol to some value, or is there a benefit in leaving the "emtpy string" as a sentinel that we could not parse the protocol?
created_at: 2025-03-07T20:09:30Z
author_association: NONE
diff_hunk: @@ -0,0 +1,28 @@
+package matchers_test
+
+import (
+	"net/http"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/solo-io/gloo/test/gomega/matchers"
+)
+
+var _ = Describe("HaveProtocol", func() {
+
+	It("ignores if there is no protocol specified", func() {
+		httpResponse := &http.Response{
+			Proto: matchers.HTTP1Protocol,
+		}
+		Expect(httpResponse).To(matchers.HaveProtocol(""))
path: test/gomega/matchers/have_protocol_test.go
user: login: sam-heilbron
body: I'm not quite sure how this test works. As I read it, we define a response with the http1 protocol, and then assert there is an empty protocol. Can you help me understand what I'm missing?
created_at: 2025-03-07T20:10:36Z
author_association: NONE
diff_hunk: @@ -0,0 +1,28 @@
+package matchers_test
+
+import (
+	"net/http"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/solo-io/gloo/test/gomega/matchers"
+)
+
+var _ = Describe("HaveProtocol", func() {
+
+	It("ignores if there is no protocol specified", func() {
+		httpResponse := &http.Response{
+			Proto: matchers.HTTP1Protocol,
+		}
+		Expect(httpResponse).To(matchers.HaveProtocol(""))
+	})
+
+	It("matches the specified protocol", func() {
+		httpResponse := &http.Response{
+			Proto: matchers.HTTP1Protocol,
+		}
+		Expect(httpResponse).To(matchers.HaveProtocol(matchers.HTTP1Protocol))
+		Expect(httpResponse).To(matchers.HaveHTTP1Protocol())
+		Expect(httpResponse).ToNot(matchers.HaveHTTP2Protocol())
path: test/gomega/matchers/have_protocol_test.go
user: login: sam-heilbron
body: nit: These two assertions feel redundant to me. I'm not sure how having both improves our certainty of the behavior of this utility. Can you help me understand?
created_at: 2025-03-07T20:11:22Z
author_association: NONE
diff_hunk: @@ -0,0 +1,34 @@
+package matchers
+
+import (
+	"github.com/onsi/gomega"
+	"github.com/onsi/gomega/gstruct"
+	"github.com/onsi/gomega/types"
+	"github.com/solo-io/gloo/test/gomega/transforms"
+)
+
+const (
+	HTTP1Protocol = "HTTP/1.1"
+	HTTP2Protocol = "HTTP/2"
+)
+
+// HaveProtocol expects an http response with the given protocol
+func HaveProtocol(protocol string) types.GomegaMatcher {
+	if protocol == "" {
+		// If protocol is defined, we create a matcher that always succeeds
path: test/gomega/matchers/have_protocol.go
user: login: davidjumani
body: Thanks, I've updated this to be more clear
created_at: 2025-03-07T21:11:01Z
author_association: COLLABORATOR
diff_hunk: @@ -28,6 +28,7 @@ const (
 func WithCurlHttpResponse(curlResponse string) *http.Response {
 	headers := make(http.Header)
 	statusCode := 0
+	protocol := ""
path: test/gomega/transforms/curl.go
user: login: davidjumani
body: Thanks for pointing this out, updated the comments
I think that an empty string is better to indicate that the protocol was not parsed
created_at: 2025-03-07T21:12:40Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,28 @@
+package matchers_test
+
+import (
+	"net/http"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/solo-io/gloo/test/gomega/matchers"
+)
+
+var _ = Describe("HaveProtocol", func() {
+
+	It("ignores if there is no protocol specified", func() {
+		httpResponse := &http.Response{
+			Proto: matchers.HTTP1Protocol,
+		}
+		Expect(httpResponse).To(matchers.HaveProtocol(""))
path: test/gomega/matchers/have_protocol_test.go
user: login: davidjumani
body: This test verifies that if an empty expected protocol is passed, it always succeeds. I've updated the test name
created_at: 2025-03-07T21:13:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,28 @@
+package matchers_test
+
+import (
+	"net/http"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/solo-io/gloo/test/gomega/matchers"
+)
+
+var _ = Describe("HaveProtocol", func() {
+
+	It("ignores if there is no protocol specified", func() {
+		httpResponse := &http.Response{
+			Proto: matchers.HTTP1Protocol,
+		}
+		Expect(httpResponse).To(matchers.HaveProtocol(""))
+	})
+
+	It("matches the specified protocol", func() {
+		httpResponse := &http.Response{
+			Proto: matchers.HTTP1Protocol,
+		}
+		Expect(httpResponse).To(matchers.HaveProtocol(matchers.HTTP1Protocol))
+		Expect(httpResponse).To(matchers.HaveHTTP1Protocol())
+		Expect(httpResponse).ToNot(matchers.HaveHTTP2Protocol())
path: test/gomega/matchers/have_protocol_test.go
user: login: davidjumani
body: The aim is to prove a negative test case where the protocol does not match the expected value
created_at: 2025-03-07T21:14:20Z
author_association: COLLABORATOR

PR comments:
user: login: solo-changelog-bot[bot]
created_at: 2025-03-06T21:14:14Z
body: Issues linked to changelog: 
https://github.com/solo-io/solo-projects/issues/7824

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-06T21:27:27Z
body: Visit the preview URL for this PR (updated for commit d88d876):

[https://gloo-edge--pr10687-support-approto-nkysj289.web.app](https://gloo-edge--pr10687-support-approto-nkysj289.web.app)

<sub>(expires Fri, 14 Mar 2025 21:28:50 GMT)</sub>

<sub>🔥 via [Firebase Hosting GitHub Action](https://github.com/marketplace/actions/deploy-to-firebase-hosting) 🌎</sub>

<sub>Sign: 77c2b86e287749579b7ff9cadb81e099042ef677</sub>
author_association: NONE
user: login: davidjumani
created_at: 2025-03-06T23:17:26Z
body: Not sure why the linter is acting up since the response body is never opened

Edit: Refactored and added the nolint comment, similar to other areas in the code
author_association: COLLABORATOR