diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go index 93bcfc2b..0e1a04ae 100644 --- a/internal/controller/httpproxy_controller.go +++ b/internal/controller/httpproxy_controller.go @@ -799,7 +799,23 @@ func (r *HTTPProxyReconciler) collectDesiredResources( } for backendIndex, backend := range rule.Backends { - if backend.Connector != nil { + // Offline-connector handling differs by emission mode: + // + // * EPP mode (legacy): emit a backend-less route rule. EG translates + // it into a virtual_host, and the connector EPP + // (buildConnectorOfflineEnvoyPatches) inserts the direct_response 503 + // "Tunnel not online" CONNECT route at the front. + // * Extension-server mode: the ext-server keys its offline-503 handling + // on the presence of a connector *cluster*, which EG only emits when + // the route rule carries a backendRef. So we must NOT null the + // backendRef here — fall through and emit the same connector.local + // placeholder EndpointSlice + backendRef as the online case. The + // ext-server then sees the cluster, classifies the connector offline + // from the replicated Connector Ready condition, and inserts the 503 + // route itself. Nulling the backendRef leaves EG with a backend-less + // route, which it renders as a bare direct_response 500 (no offline + // page) — the regression this guards against. + if backend.Connector != nil && r.Config.Gateway.IsEPPEmissionEnabled() { ready, err := connectorReady(ctx, cl, httpProxy.Namespace, backend.Connector.Name) if err != nil { return nil, err diff --git a/internal/controller/httpproxy_controller_test.go b/internal/controller/httpproxy_controller_test.go index 6524dcfe..d1f7b9fd 100644 --- a/internal/controller/httpproxy_controller_test.go +++ b/internal/controller/httpproxy_controller_test.go @@ -553,6 +553,11 @@ func TestHTTPProxyReconcile(t *testing.T) { Name: "connector-1", } }) + connectorModeBHTTPProxy := newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { + h.Spec.Rules[0].Backends[0].Connector = &networkingv1alpha.ConnectorReference{ + Name: "connector-1", + } + }) connectorDownstreamObjects := func(proxy *networkingv1alpha.HTTPProxy) []client.Object { return []client.Object{ @@ -571,6 +576,7 @@ func TestHTTPProxyReconcile(t *testing.T) { existingObjects []client.Object downstreamObjects []client.Object namespaceUID string + eppEmissionDisabled bool postCreateGatewayStatus func(*gatewayv1.Gateway) expectedError bool expectedConditions []metav1.Condition @@ -781,6 +787,82 @@ func TestHTTPProxyReconcile(t *testing.T) { } }, }, + { + // Regression: in extension-server mode (EPP emission disabled) an + // offline connector must keep its backendRef so EG renders a connector + // cluster. The ext-server keys its offline-503 "Tunnel not online" route + // on that cluster. Nulling the backendRef (the EPP-mode behavior) leaves + // EG with a backend-less route, which it renders as a bare + // direct_response 500 with no offline page. + name: "offline connector keeps backendRef in extension-server mode", + httpProxy: connectorModeBHTTPProxy, + downstreamObjects: connectorDownstreamObjects(connectorModeBHTTPProxy), + namespaceUID: string(connectorNamespaceUID), + eppEmissionDisabled: true, + existingObjects: []client.Object{ + &networkingv1alpha1.Connector{ + ObjectMeta: metav1.ObjectMeta{ + Name: "connector-1", + Namespace: "test", + }, + Status: networkingv1alpha1.ConnectorStatus{ + Conditions: []metav1.Condition{ + { + Type: networkingv1alpha1.ConnectorConditionReady, + Status: metav1.ConditionFalse, + Reason: networkingv1alpha1.ConnectorReasonNotReady, + }, + }, + }, + }, + }, + postCreateGatewayStatus: func(g *gatewayv1.Gateway) { + setGatewayProgrammedWithDefaultHTTPSListener(g) + }, + expectedError: false, + expectedConditions: []metav1.Condition{ + { + Type: networkingv1alpha.HTTPProxyConditionAccepted, + Status: metav1.ConditionTrue, + Reason: networkingv1alpha.HTTPProxyReasonAccepted, + }, + { + Type: networkingv1alpha.HTTPProxyConditionProgrammed, + Status: metav1.ConditionTrue, + Reason: networkingv1alpha.HTTPProxyReasonProgrammed, + }, + }, + assert: func(t *testContext, cl client.Client, httpProxy *networkingv1alpha.HTTPProxy) { + ctx := context.Background() + + // Extension-server mode emits no EnvoyPatchPolicy at all. + var patchList envoygatewayv1alpha1.EnvoyPatchPolicyList + assert.NoError(t, t.downstreamClient.List(ctx, &patchList)) + assert.Empty(t, patchList.Items, "no EPP should be emitted in extension-server mode") + + // The offline connector route MUST retain a backendRef pointing at + // the placeholder EndpointSlice so EG renders a connector cluster. + httpRoute := &gatewayv1.HTTPRoute{} + assert.NoError(t, cl.Get(ctx, client.ObjectKeyFromObject(httpProxy), httpRoute)) + if assert.Len(t, httpRoute.Spec.Rules, 1) { + if assert.Len(t, httpRoute.Spec.Rules[0].BackendRefs, 1, + "offline connector route must keep a backendRef in extension-server mode") { + br := httpRoute.Spec.Rules[0].BackendRefs[0] + assert.Equal(t, gatewayv1.Kind("EndpointSlice"), *br.Kind) + } + } + + // The connector.local placeholder EndpointSlice must exist. + var sliceList discoveryv1.EndpointSliceList + assert.NoError(t, cl.List(ctx, &sliceList, client.InNamespace(httpProxy.Namespace))) + if assert.Len(t, sliceList.Items, 1) { + if assert.NotEmpty(t, sliceList.Items[0].Endpoints) && + assert.NotEmpty(t, sliceList.Items[0].Endpoints[0].Addresses) { + assert.Equal(t, "connector.local", sliceList.Items[0].Endpoints[0].Addresses[0]) + } + } + }, + }, { name: "connector patch policy removed when connector cleared", httpProxy: connectorClearedHTTPProxy, @@ -1278,15 +1360,20 @@ func TestHTTPProxyReconcile(t *testing.T) { mgr := &fakeMockManager{cl: fakeClient} + caseConfig := testConfig + if tt.eppEmissionDisabled { + caseConfig.Gateway.EPPEmissionEnabled = ptr.To(false) + } + reconciler := &HTTPProxyReconciler{ mgr: mgr, - Config: testConfig, + Config: caseConfig, DownstreamCluster: &fakeCluster{cl: fakeDownstreamClient}, } gatewayReconciler := &GatewayReconciler{ mgr: mgr, - Config: testConfig, + Config: caseConfig, DownstreamCluster: &fakeCluster{cl: fakeDownstreamClient}, }