Repository: kubernetes-sigs/gateway-api

Issue:
Conformance: Add Port Support to Echo Server
Currently the echo server provides pod name, namespace, etc in the response headers. For use cases such as running multiple echo servers in a single pod, the echo server should optionally support echo'ing the HTTP server listening port number in the response headers. For example:

```yaml
# Data Parallelism (DP) backend deployment: 3 pods, each listening on three ports to simulate vLLM ranks
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dp-inference-model-server-deployment
  namespace: inference-conformance-app-backend
  labels:
    app: dp-inference-model-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: dp-inference-model-server
  template:
    metadata:
      labels:
        app: dp-inference-model-server
    spec:
      containers:
      - name: echoserver-3000
        image: gcr.io/k8s-staging-gateway-api/echo-basic:v20240412-v1.0.0-394-g40c666fd
        ports:
        - containerPort: 3000
        readinessProbe:
          httpGet:
            path: /
            port: 3000
          initialDelaySeconds: 3
          periodSeconds: 5
          failureThreshold: 2
        env:
        - name: HTTP_PORT # Default port for HTTP echo server
          value: "3000"
        - name: H2C_PORT # Default port for HTC echo server
          value: "3001"
        - name: POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        - name: POD_IP
          valueFrom:
            fieldRef:
              fieldPath: status.podIP
      - name: echoserver-3002
        image: gcr.io/k8s-staging-gateway-api/echo-basic:v20240412-v1.0.0-394-g40c666fd
        ports:
        - containerPort: 3002
        readinessProbe:
          httpGet:
            path: /
            port: 3002
          initialDelaySeconds: 3
          periodSeconds: 5
          failureThreshold: 2
        env:
        - name: HTTP_PORT
          value: "3002"
        - name: H2C_PORT
          value: "3003"
        - name: POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        - name: POD_IP
          valueFrom:
            fieldRef:
              fieldPath: status.podIP
      - name: echoserver-3004
        image: gcr.io/k8s-staging-gateway-api/echo-basic:v20240412-v1.0.0-394-g40c666fd
        ports:
        - containerPort: 3004
        readinessProbe:
          httpGet:
            path: /
            port: 3004
          initialDelaySeconds: 3
          periodSeconds: 5
          failureThreshold: 2
        env:
        - name: HTTP_PORT
          value: "3004"
        - name: H2C_PORT
          value: "3005"
        - name: POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        - name: POD_IP
          valueFrom:
            fieldRef:
              fieldPath: status.podIP
```

xref GIE issue: https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/1680

Discussion:
@danehans: cc: @robscott 

Solution patch:
diff --git a/conformance/echo-basic/echo-basic.go b/conformance/echo-basic/echo-basic.go
index 98f8de5a..7ae8c2df 100644
--- a/conformance/echo-basic/echo-basic.go
+++ b/conformance/echo-basic/echo-basic.go
@@ -39,11 +39,12 @@ import (
 
 // RequestAssertions contains information about the request and the Ingress
 type RequestAssertions struct {
-	Path    string              `json:"path"`
-	Host    string              `json:"host"`
-	Method  string              `json:"method"`
-	Proto   string              `json:"proto"`
-	Headers map[string][]string `json:"headers"`
+	Path     string              `json:"path"`
+	Host     string              `json:"host"`
+	Method   string              `json:"method"`
+	Proto    string              `json:"proto"`
+	Headers  map[string][]string `json:"headers"`
+	HTTPPort string              `json:"httpPort"`
 
 	Context `json:",inline"`
 
@@ -76,7 +77,10 @@ type Context struct {
 	Pod       string `json:"pod"`
 }
 
-var context Context
+var (
+	context  Context
+	httpPort string
+)
 
 func main() {
 	if os.Getenv("GRPC_ECHO_SERVER") != "" {
@@ -84,7 +88,7 @@ func main() {
 		return
 	}
 
-	httpPort := os.Getenv("HTTP_PORT")
+	httpPort = os.Getenv("HTTP_PORT")
 	if httpPort == "" {
 		httpPort = "3000"
 	}
@@ -216,6 +220,7 @@ func echoHandler(w http.ResponseWriter, r *http.Request) {
 		r.Method,
 		r.Proto,
 		r.Header,
+		httpPort,
 
 		context,
 
diff --git a/conformance/utils/http/http.go b/conformance/utils/http/http.go
index 2d116423..4f50eb56 100644
--- a/conformance/utils/http/http.go
+++ b/conformance/utils/http/http.go
@@ -87,6 +87,8 @@ type ExpectedRequest struct {
 	// AbsentHeaders are names of headers that are expected
 	// *not* to be present on the request.
 	AbsentHeaders []string
+	// If set, CompareRoundTrip asserts the echoed httpPort equals this value.
+	HTTPPort string
 }
 
 // Response defines expected properties of a response from a backend.
@@ -362,6 +364,10 @@ func CompareRoundTrip(t *testing.T, req *roundtripper.Request, cReq *roundtrippe
 			}
 		}
 
+		if expected.ExpectedRequest.HTTPPort != "" && expected.ExpectedRequest.HTTPPort != cReq.HTTPPort {
+			return fmt.Errorf("expected httpPort %q, got %q", expected.ExpectedRequest.HTTPPort, cReq.HTTPPort)
+		}
+
 		if expected.Response.Headers != nil {
 			if cRes.Headers == nil {
 				return fmt.Errorf("no headers captured, expected %v", len(expected.ExpectedRequest.Headers))
diff --git a/conformance/utils/roundtripper/roundtripper.go b/conformance/utils/roundtripper/roundtripper.go
index 9490b648..27c3bb79 100644
--- a/conformance/utils/roundtripper/roundtripper.go
+++ b/conformance/utils/roundtripper/roundtripper.go
@@ -85,6 +85,7 @@ type CapturedRequest struct {
 	Method   string              `json:"method"`
 	Protocol string              `json:"proto"`
 	Headers  map[string][]string `json:"headers"`
+	HTTPPort string              `json:"httpPort,omitempty"`
 
 	Namespace string `json:"namespace"`
 	Pod       string `json:"pod"`



Test patch:
diff --git a/conformance/echo-basic/echo-basic_test.go b/conformance/echo-basic/echo-basic_test.go
index 5cbe4b31..aec399b1 100644
--- a/conformance/echo-basic/echo-basic_test.go
+++ b/conformance/echo-basic/echo-basic_test.go
@@ -228,6 +228,70 @@ func TestWriteEchoResponseHeaders(t *testing.T) {
 	}
 }
 
+func TestEchoHandler_IncludesHTTPPort(t *testing.T) {
+	// Preserve global across tests.
+	prevHTTPPort := httpPort
+	defer func() { httpPort = prevHTTPPort }()
+
+	httpPort = "3002"
+
+	// Minimal context to satisfy marshaling (values don’t matter for this check).
+	context = Context{
+		Namespace: "testNamespace",
+		Ingress:   "testIngress",
+		Service:   "testService",
+		Pod:       "testPod",
+	}
+
+	req := httptest.NewRequest("GET", "/", nil)
+	rr := httptest.NewRecorder()
+
+	echoHandler(rr, req)
+
+	if status := rr.Code; status != http.StatusOK {
+		t.Fatalf("expected status %d, got %d", http.StatusOK, status)
+	}
+
+	var resp RequestAssertions
+	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
+		t.Fatalf("failed to unmarshal echo response: %v", err)
+	}
+	if resp.HTTPPort != "3002" {
+		t.Errorf("expected HTTPPort %q, got %q", "3002", resp.HTTPPort)
+	}
+}
+
+func TestEchoHandler_HTTPPortReflectsChange(t *testing.T) {
+	// Preserve global across tests.
+	prevHTTPPort := httpPort
+	defer func() { httpPort = prevHTTPPort }()
+
+	httpPort = "3004"
+	context = Context{
+		Namespace: "ns",
+		Ingress:   "ing",
+		Service:   "svc",
+		Pod:       "pod",
+	}
+
+	req := httptest.NewRequest("GET", "/", nil)
+	rr := httptest.NewRecorder()
+
+	echoHandler(rr, req)
+
+	if status := rr.Code; status != http.StatusOK {
+		t.Fatalf("expected status %d, got %d", http.StatusOK, status)
+	}
+
+	var resp RequestAssertions
+	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
+		t.Fatalf("failed to unmarshal echo response: %v", err)
+	}
+	if resp.HTTPPort != "3004" {
+		t.Errorf("expected HTTPPort %q, got %q", "3004", resp.HTTPPort)
+	}
+}
+
 func TestProcessError(t *testing.T) {
 	// Create a response recorder to capture the response
 	rr := httptest.NewRecorder()