From 642d6f396034288d7a5cd43105a028da0a6253b1 Mon Sep 17 00:00:00 2001 From: Ryan Swanson Date: Tue, 16 Jun 2026 06:41:14 -0600 Subject: [PATCH] fix(gateway-api): translate to-host object refs - Translate Gateway infrastructure.parametersRef ConfigMap/Secret refs - Translate Gateway TLS frontend CA refs and backend clientCertificateRef - Translate HTTPRoute filter extensionRef for rule and backendRef filters - Reject unsupported Gateway API refs as terminal unsupported-reference errors - Validate referenced host objects and add referenced-object requeue watches - Add unit and E2E coverage for translated, missing, unsupported, and granted refs Signed-off-by: Ryan Swanson --- chart/values.schema.json | 2 +- chart/values.yaml | 2 + config/config.go | 2 + e2e/test_gatewayapi/test_gatewayapi_sync.go | 51 +++ e2e/vcluster-gatewayapi.yaml | 6 + .../resources/gatewayapi/authz/authz.go | 14 + .../gatewayapi/gatewaysync/gatewaysync.go | 10 +- .../gatewayroutes/translate/translate.go | 49 ++- pkg/controllers/resources/gateways/syncer.go | 26 +- .../resources/gateways/syncer_test.go | 57 +++ .../resources/gateways/translate.go | 102 ++++- .../gateways/translate_frontend_tls_test.go | 387 ++++++++++++++++++ .../resources/gateways/translate_test.go | 85 ++++ .../resources/httproutes/syncer.go | 2 +- .../resources/httproutes/translate.go | 7 + .../resources/httproutes/translate_test.go | 136 ++++++ 16 files changed, 914 insertions(+), 24 deletions(-) create mode 100644 pkg/controllers/resources/gateways/translate_frontend_tls_test.go create mode 100644 pkg/controllers/resources/gateways/translate_test.go create mode 100644 pkg/controllers/resources/httproutes/translate_test.go diff --git a/chart/values.schema.json b/chart/values.schema.json index 8b00ce9328..1a45eca11a 100755 --- a/chart/values.schema.json +++ b/chart/values.schema.json @@ -2586,7 +2586,7 @@ }, "gateways": { "$ref": "#/$defs/EnableSwitchWithPatches", - "description": "Gateways configures tenant-created Gateway sync to the control plane cluster." + "description": "Gateways configures tenant-created Gateway sync to the control plane cluster.\nGateway spec.infrastructure.parametersRef names are translated for core ConfigMap and Secret refs.\nReferenced objects must also be synced to the host, for example with a pod reference or sync.toHost.configMaps.all / sync.toHost.secrets.all." }, "tlsRoutes": { "$ref": "#/$defs/EnableSwitchWithPatches", diff --git a/chart/values.yaml b/chart/values.yaml index e03596b1eb..2e517e47b0 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -91,6 +91,8 @@ sync: # Enabled defines if this option should be enabled. enabled: false # Gateways configures tenant-created Gateway sync to the control plane cluster. + # Gateway spec.infrastructure.parametersRef names are translated for core ConfigMap and Secret refs. + # Referenced objects must also be synced to the host, for example with a pod reference or sync.toHost.configMaps.all / sync.toHost.secrets.all. gateways: # Enabled defines if this option should be enabled. enabled: false diff --git a/config/config.go b/config/config.go index 004a251f3f..453e109f55 100644 --- a/config/config.go +++ b/config/config.go @@ -1308,6 +1308,8 @@ type GatewayAPIEnableSwitchWithPatches struct { HTTPRoutes EnableSwitchWithPatches `json:"httpRoutes,omitempty"` // Gateways configures tenant-created Gateway sync to the control plane cluster. + // Gateway spec.infrastructure.parametersRef names are translated for core ConfigMap and Secret refs. + // Referenced objects must also be synced to the host, for example with a pod reference or sync.toHost.configMaps.all / sync.toHost.secrets.all. Gateways EnableSwitchWithPatches `json:"gateways,omitempty"` // TLSRoutes configures TLSRoute sync to the control plane cluster. diff --git a/e2e/test_gatewayapi/test_gatewayapi_sync.go b/e2e/test_gatewayapi/test_gatewayapi_sync.go index 6c73803fbd..371b6b5930 100644 --- a/e2e/test_gatewayapi/test_gatewayapi_sync.go +++ b/e2e/test_gatewayapi/test_gatewayapi_sync.go @@ -176,17 +176,52 @@ func GatewayAPISyncSpec() { service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "backend-" + suffix, Namespace: ns.Name}, Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 80}}}} Expect(vClusterClient.Create(ctx, service)).To(Succeed()) + parameters := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "gw-params-" + suffix, Namespace: ns.Name}, Data: map[string]string{"profile": "edge"}} + Expect(vClusterClient.Create(ctx, parameters)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + Expect(ctrlclient.IgnoreNotFound(vClusterClient.Delete(ctx, parameters))).To(Succeed()) + }) + clientCert := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "gw-client-cert-" + suffix, Namespace: ns.Name}, Type: corev1.SecretTypeTLS, Data: map[string][]byte{"tls.crt": []byte("cert"), "tls.key": []byte("key")}} + Expect(vClusterClient.Create(ctx, clientCert)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + Expect(ctrlclient.IgnoreNotFound(vClusterClient.Delete(ctx, clientCert))).To(Succeed()) + }) + filterConfig := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "route-filter-" + suffix, Namespace: ns.Name}, Data: map[string]string{"mode": "rule"}} + Expect(vClusterClient.Create(ctx, filterConfig)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + Expect(ctrlclient.IgnoreNotFound(vClusterClient.Delete(ctx, filterConfig))).To(Succeed()) + }) + filterSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "backend-filter-" + suffix, Namespace: ns.Name}, StringData: map[string]string{"mode": "backend"}} + Expect(vClusterClient.Create(ctx, filterSecret)).To(Succeed()) + DeferCleanup(func(ctx context.Context) { + Expect(ctrlclient.IgnoreNotFound(vClusterClient.Delete(ctx, filterSecret))).To(Succeed()) + }) gateway := tenantGateway(ns.Name, "gw-"+suffix, allowed.Name) + gateway.Spec.Infrastructure = &gatewayv1.GatewayInfrastructure{ParametersRef: &gatewayv1.LocalParametersReference{Group: gatewayv1.Group(corev1.GroupName), Kind: gatewayv1.Kind("ConfigMap"), Name: parameters.Name}} + gateway.Spec.TLS = &gatewayv1.GatewayTLSConfig{Backend: &gatewayv1.GatewayBackendTLS{ClientCertificateRef: &gatewayv1.SecretObjectReference{Name: gatewayv1.ObjectName(clientCert.Name)}}} Expect(vClusterClient.Create(ctx, gateway)).To(Succeed()) DeferCleanup(func(ctx context.Context) { Expect(ctrlclient.IgnoreNotFound(vClusterClient.Delete(ctx, gateway))).To(Succeed()) }) hostGatewayName := translate.SafeConcatName(gateway.Name, "x", ns.Name, "x", vClusterName) + hostParametersName := translate.SafeConcatName(parameters.Name, "x", ns.Name, "x", vClusterName) + hostClientCertName := translate.SafeConcatName(clientCert.Name, "x", ns.Name, "x", vClusterName) + hostFilterConfigName := translate.SafeConcatName(filterConfig.Name, "x", ns.Name, "x", vClusterName) + hostFilterSecretName := translate.SafeConcatName(filterSecret.Name, "x", ns.Name, "x", vClusterName) Eventually(func(g Gomega) { + g.Expect(hostClient.Get(ctx, types.NamespacedName{Namespace: vClusterHostNS, Name: hostParametersName}, &corev1.ConfigMap{})).To(Succeed()) + g.Expect(hostClient.Get(ctx, types.NamespacedName{Namespace: vClusterHostNS, Name: hostClientCertName}, &corev1.Secret{})).To(Succeed()) got := &gatewayv1.Gateway{} g.Expect(hostClient.Get(ctx, types.NamespacedName{Namespace: vClusterHostNS, Name: hostGatewayName}, got)).To(Succeed()) g.Expect(got.Spec.GatewayClassName).To(Equal(gatewayv1.ObjectName(allowed.Name))) + g.Expect(got.Spec.Infrastructure).NotTo(BeNil()) + g.Expect(got.Spec.Infrastructure.ParametersRef).NotTo(BeNil()) + g.Expect(got.Spec.Infrastructure.ParametersRef.Name).To(Equal(hostParametersName)) + g.Expect(got.Spec.TLS).NotTo(BeNil()) + g.Expect(got.Spec.TLS.Backend).NotTo(BeNil()) + g.Expect(got.Spec.TLS.Backend.ClientCertificateRef).NotTo(BeNil()) + g.Expect(got.Spec.TLS.Backend.ClientCertificateRef.Name).To(Equal(gatewayv1.ObjectName(hostClientCertName))) g.Expect(got.Spec.Listeners).To(HaveLen(1)) listener := got.Spec.Listeners[0] g.Expect(listener.Name).To(Equal(gatewayv1.SectionName("http"))) @@ -197,6 +232,14 @@ func GatewayAPISyncSpec() { }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) route := tenantHTTPRoute(ns.Name, "route-"+suffix, gateway.Name, service.Name) + route.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{{ + Type: gatewayv1.HTTPRouteFilterExtensionRef, + ExtensionRef: &gatewayv1.LocalObjectReference{Group: gatewayv1.Group(corev1.GroupName), Kind: gatewayv1.Kind("ConfigMap"), Name: gatewayv1.ObjectName(filterConfig.Name)}, + }} + route.Spec.Rules[0].BackendRefs[0].Filters = []gatewayv1.HTTPRouteFilter{{ + Type: gatewayv1.HTTPRouteFilterExtensionRef, + ExtensionRef: &gatewayv1.LocalObjectReference{Group: gatewayv1.Group(corev1.GroupName), Kind: gatewayv1.Kind("Secret"), Name: gatewayv1.ObjectName(filterSecret.Name)}, + }} Expect(vClusterClient.Create(ctx, route)).To(Succeed()) DeferCleanup(func(ctx context.Context) { Expect(ctrlclient.IgnoreNotFound(vClusterClient.Delete(ctx, route))).To(Succeed()) @@ -210,6 +253,14 @@ func GatewayAPISyncSpec() { g.Expect(got.Spec.ParentRefs[0].Name).To(Equal(gatewayv1.ObjectName(hostGatewayName))) g.Expect(got.Spec.ParentRefs[0].SectionName).NotTo(BeNil()) g.Expect(*got.Spec.ParentRefs[0].SectionName).To(Equal(gatewayv1.SectionName("http"))) + g.Expect(got.Spec.Rules).To(HaveLen(1)) + g.Expect(got.Spec.Rules[0].Filters).To(HaveLen(1)) + g.Expect(got.Spec.Rules[0].Filters[0].ExtensionRef).NotTo(BeNil()) + g.Expect(got.Spec.Rules[0].Filters[0].ExtensionRef.Name).To(Equal(gatewayv1.ObjectName(hostFilterConfigName))) + g.Expect(got.Spec.Rules[0].BackendRefs).To(HaveLen(1)) + g.Expect(got.Spec.Rules[0].BackendRefs[0].Filters).To(HaveLen(1)) + g.Expect(got.Spec.Rules[0].BackendRefs[0].Filters[0].ExtensionRef).NotTo(BeNil()) + g.Expect(got.Spec.Rules[0].BackendRefs[0].Filters[0].ExtensionRef.Name).To(Equal(gatewayv1.ObjectName(hostFilterSecretName))) }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) Expect(ctrlclient.IgnoreNotFound(vClusterClient.Delete(ctx, route))).To(Succeed()) diff --git a/e2e/vcluster-gatewayapi.yaml b/e2e/vcluster-gatewayapi.yaml index 4b22b618fb..3f2b3a4181 100644 --- a/e2e/vcluster-gatewayapi.yaml +++ b/e2e/vcluster-gatewayapi.yaml @@ -14,6 +14,12 @@ sync: toHost: services: enabled: true + configMaps: + enabled: true + all: true + secrets: + enabled: true + all: true gatewayApi: gateways: enabled: true diff --git a/pkg/controllers/resources/gatewayapi/authz/authz.go b/pkg/controllers/resources/gatewayapi/authz/authz.go index c3e0d5c5c2..f5b24f0512 100644 --- a/pkg/controllers/resources/gatewayapi/authz/authz.go +++ b/pkg/controllers/resources/gatewayapi/authz/authz.go @@ -75,6 +75,11 @@ func GatewayCertificate(ctx *synccontext.SyncContext, gatewayNamespace string, r return referenceGrant(ctx, gatewayv1.GroupVersion.Group, "Gateway", gatewayNamespace, secretRefTarget(gatewayNamespace, ref)) } +// GatewayCACertificate checks whether a Gateway frontend CA certificateRef is allowed by virtual ReferenceGrants. +func GatewayCACertificate(ctx *synccontext.SyncContext, gatewayNamespace string, ref *gatewayv1.ObjectReference) error { + return referenceGrant(ctx, gatewayv1.GroupVersion.Group, "Gateway", gatewayNamespace, objectRefTarget(gatewayNamespace, ref)) +} + type referenceTarget struct { group string kind string @@ -120,6 +125,15 @@ func secretRefTarget(localNamespace string, ref *gatewayv1.SecretObjectReference } } +func objectRefTarget(localNamespace string, ref *gatewayv1.ObjectReference) referenceTarget { + return referenceTarget{ + group: string(ref.Group), + kind: string(ref.Kind), + namespace: referenceNamespace(localNamespace, ref.Namespace), + name: string(ref.Name), + } +} + func referenceNamespace(localNamespace string, namespace *gatewayv1.Namespace) string { if namespace == nil || *namespace == "" { return localNamespace diff --git a/pkg/controllers/resources/gatewayapi/gatewaysync/gatewaysync.go b/pkg/controllers/resources/gatewayapi/gatewaysync/gatewaysync.go index 9e07bb3cd7..2527353cf4 100644 --- a/pkg/controllers/resources/gatewayapi/gatewaysync/gatewaysync.go +++ b/pkg/controllers/resources/gatewayapi/gatewaysync/gatewaysync.go @@ -36,7 +36,7 @@ func CreateToHost[O any, T interface { pObj, err := toHost() if err != nil { - if recordTerminalRefError(rec, event.Virtual, err) { + if RecordTerminalRefError(rec, event.Virtual, err) { return ctrl.Result{}, nil } @@ -61,7 +61,7 @@ func Sync[T client.Object]( applyToHost func() error, ) (_ ctrl.Result, retErr error) { if err := translateSpec(); err != nil { - if recordTerminalRefError(rec, event.Virtual, err) { + if RecordTerminalRefError(rec, event.Virtual, err) { return patcher.DeleteHostObject(ctx, event.Host, event.Virtual, "virtual reference cannot be synced to the host") } @@ -117,8 +117,10 @@ func CreateToVirtual[O any, T interface { return patcher.CreateVirtualObject(ctx, event.Host, vObj, rec, true) } -// recordTerminalRefError records denied or unsupported reference errors. -func recordTerminalRefError(rec events.EventRecorder, obj client.Object, err error) bool { +// RecordTerminalRefError records denied or unsupported reference errors and reports +// whether err was terminal. Terminal ref errors cannot succeed on requeue, so callers +// surface a Warning event and skip-create / delete-host instead of returning a hard error. +func RecordTerminalRefError(rec events.EventRecorder, obj client.Object, err error) bool { switch { case gatewayauthz.IsNotPermitted(err): RecordRefNotPermitted(rec, obj, err) diff --git a/pkg/controllers/resources/gatewayroutes/translate/translate.go b/pkg/controllers/resources/gatewayroutes/translate/translate.go index e6fc37748d..5cd4da9f85 100644 --- a/pkg/controllers/resources/gatewayroutes/translate/translate.go +++ b/pkg/controllers/resources/gatewayroutes/translate/translate.go @@ -115,6 +115,16 @@ func LocalObjectRefToHost(ctx *synccontext.SyncContext, localNamespace string, r return localObjectRefToHost(ctx, localNamespace, ref, options.validateHostObject) } +func ParametersRefToHost(ctx *synccontext.SyncContext, localNamespace string, ref *gatewayv1.LocalParametersReference, opts ...ToHostOption) error { + options := newToHostOptions(opts...) + return parametersRefToHost(ctx, localNamespace, ref, options.validateHostObject) +} + +func ObjectRefToHost(ctx *synccontext.SyncContext, localNamespace string, ref *gatewayv1.ObjectReference, opts ...ToHostOption) error { + options := newToHostOptions(opts...) + return objectReferenceRefToHost(ctx, localNamespace, ref, options.validateHostObject) +} + func PolicyTargetRefToHost(ctx *synccontext.SyncContext, policyNamespace string, ref *gatewayv1.LocalPolicyTargetReferenceWithSectionName, opts ...ToHostOption) error { options := newToHostOptions(opts...) return policyTargetRefToHost(ctx, policyNamespace, ref, options.validateHostObject) @@ -169,6 +179,30 @@ func localObjectRefToHost(ctx *synccontext.SyncContext, localNamespace string, r return objectRefToHost(ctx, localNamespace, &ref.Name, nil, gvk, validateHostObject) } +func parametersRefToHost(ctx *synccontext.SyncContext, localNamespace string, ref *gatewayv1.LocalParametersReference, validateHostObject bool) error { + gvk, err := parametersReferenceGVK(ref) + if err != nil { + return err + } + + refName := gatewayv1.ObjectName(ref.Name) + if err := objectRefToHost(ctx, localNamespace, &refName, nil, gvk, validateHostObject); err != nil { + return err + } + + ref.Name = string(refName) + return nil +} + +func objectReferenceRefToHost(ctx *synccontext.SyncContext, localNamespace string, ref *gatewayv1.ObjectReference, validateHostObject bool) error { + gvk, err := objectReferenceGVK(ref) + if err != nil { + return err + } + + return objectRefToHost(ctx, localNamespace, &ref.Name, &ref.Namespace, gvk, validateHostObject) +} + func policyTargetRefToHost(ctx *synccontext.SyncContext, policyNamespace string, ref *gatewayv1.LocalPolicyTargetReferenceWithSectionName, validateHostObject bool) error { gvk, err := policyTargetReferenceGVK(ref) if err != nil { @@ -443,9 +477,18 @@ func secretObjectReferenceGVK(ref *gatewayv1.SecretObjectReference) (schema.Grou } func localObjectReferenceGVK(ref *gatewayv1.LocalObjectReference) (schema.GroupVersionKind, error) { - group := string(ref.Group) - kind := string(ref.Kind) + return objectReferenceGroupKindGVK(string(ref.Group), string(ref.Kind), "localObjectRef") +} + +func parametersReferenceGVK(ref *gatewayv1.LocalParametersReference) (schema.GroupVersionKind, error) { + return objectReferenceGroupKindGVK(string(ref.Group), string(ref.Kind), "parametersRef") +} + +func objectReferenceGVK(ref *gatewayv1.ObjectReference) (schema.GroupVersionKind, error) { + return objectReferenceGroupKindGVK(string(ref.Group), string(ref.Kind), "objectRef") +} +func objectReferenceGroupKindGVK(group, kind, field string) (schema.GroupVersionKind, error) { if group == corev1.GroupName && kind == "ConfigMap" { return mappings.ConfigMaps(), nil } @@ -453,7 +496,7 @@ func localObjectReferenceGVK(ref *gatewayv1.LocalObjectReference) (schema.GroupV return mappings.Secrets(), nil } - return schema.GroupVersionKind{}, unsupportedReferencef("localObjectRef group %q kind %q is not supported", group, kind) + return schema.GroupVersionKind{}, unsupportedReferencef("%s group %q kind %q is not supported", field, group, kind) } func policyTargetReferenceGVK(ref *gatewayv1.LocalPolicyTargetReferenceWithSectionName) (schema.GroupVersionKind, error) { diff --git a/pkg/controllers/resources/gateways/syncer.go b/pkg/controllers/resources/gateways/syncer.go index d40ddbd6ea..2b1fc6ed19 100644 --- a/pkg/controllers/resources/gateways/syncer.go +++ b/pkg/controllers/resources/gateways/syncer.go @@ -4,6 +4,9 @@ import ( "fmt" rootconfig "github.com/loft-sh/vcluster/config" + "github.com/loft-sh/vcluster/pkg/controllers/resources/gatewayapi/gatewaysync" + routetranslate "github.com/loft-sh/vcluster/pkg/controllers/resources/gatewayroutes/translate" + "github.com/loft-sh/vcluster/pkg/mappings" "github.com/loft-sh/vcluster/pkg/mappings/resources" "github.com/loft-sh/vcluster/pkg/patcher" "github.com/loft-sh/vcluster/pkg/pro" @@ -17,6 +20,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) @@ -64,6 +68,7 @@ type tenantGatewaySyncer struct { var _ syncertypes.Object = &tenantGatewaySyncer{} var _ syncertypes.Syncer = &tenantGatewaySyncer{} +var _ syncertypes.ControllerModifier = &tenantGatewaySyncer{} func NewToHostSyncer(ctx *synccontext.RegisterContext) (syncertypes.Object, error) { mapper, err := ctx.Mappings.ByGVK(resources.NewImportedGatewayMapper().GroupVersionKind()) @@ -80,6 +85,10 @@ func (s *tenantGatewaySyncer) Syncer() syncertypes.Sync[client.Object] { return syncer.ToGenericSyncer[*gatewayv1.Gateway](s) } +func (s *tenantGatewaySyncer) ModifyController(ctx *synccontext.RegisterContext, builder *builder.Builder) (*builder.Builder, error) { + return routetranslate.RegisterReferencedWatches(ctx, builder, s.GroupVersionKind(), mappings.ConfigMaps(), mappings.Secrets()) +} + func (s *tenantGatewaySyncer) SyncToHost(ctx *synccontext.SyncContext, event *synccontext.SyncToHostEvent[*gatewayv1.Gateway]) (ctrl.Result, error) { if isImportedGateway(event.Virtual) { return ctrl.Result{}, nil @@ -99,6 +108,9 @@ func (s *tenantGatewaySyncer) SyncToHost(ctx *synccontext.SyncContext, event *sy } host, err := s.translate(ctx, event.Virtual) if err != nil { + if gatewaysync.RecordTerminalRefError(s.EventRecorder(), event.Virtual, err) { + return ctrl.Result{}, nil + } return ctrl.Result{}, err } host.Status = gatewayv1.GatewayStatus{} @@ -119,6 +131,15 @@ func (s *tenantGatewaySyncer) Sync(ctx *synccontext.SyncContext, event *synccont if !eligible { return patcher.DeleteHostObject(ctx, event.Host, event.Virtual, "tenant Gateway is no longer eligible for host sync") } + + translated, err := s.translate(ctx, event.Virtual) + if err != nil { + if gatewaysync.RecordTerminalRefError(s.EventRecorder(), event.Virtual, err) { + return patcher.DeleteHostObject(ctx, event.Host, event.Virtual, "virtual reference cannot be synced to the host") + } + return ctrl.Result{}, err + } + patch, err := patcher.NewSyncerPatcher(ctx, event.Host, event.Virtual, patcher.TranslatePatches(ctx.Config.Sync.ToHost.GatewayAPI.Gateways.Patches, false)) if err != nil { return ctrl.Result{}, fmt.Errorf("new syncer patcher: %w", err) @@ -129,11 +150,6 @@ func (s *tenantGatewaySyncer) Sync(ctx *synccontext.SyncContext, event *synccont } }() - translated, err := s.translate(ctx, event.Virtual) - if err != nil { - return ctrl.Result{}, err - } - event.Host.Spec = translated.Spec event.Virtual.Status = event.Host.Status return ctrl.Result{}, nil diff --git a/pkg/controllers/resources/gateways/syncer_test.go b/pkg/controllers/resources/gateways/syncer_test.go index 4f8b618c3b..3a85969358 100644 --- a/pkg/controllers/resources/gateways/syncer_test.go +++ b/pkg/controllers/resources/gateways/syncer_test.go @@ -273,6 +273,63 @@ func TestTenantGatewaySyncToHostCreatesGatewayWhenExplicitlyEnabled(t *testing.T } } +func TestTenantGatewaySyncToHostSkipsUnsupportedParametersRef(t *testing.T) { + vcConfig := &pkgconfig.VirtualClusterConfig{} + vcConfig.Sync.ToHost.GatewayAPI.Gateways.Enabled = true + pClient := testingutil.NewFakeClient(scheme.Scheme) + vClient := testingutil.NewFakeClient(scheme.Scheme, + &gatewayv1.GatewayClass{ObjectMeta: metav1.ObjectMeta{Name: "tenant-class"}}, + ) + registerCtx := syncertesting.NewFakeRegisterContext(vcConfig, pClient, vClient) + syncCtx, object := syncertesting.FakeStartSyncer(t, registerCtx, NewToHost) + syncer := object.(*tenantGatewaySyncer) + + virtual := gatewayWithParametersRef(gatewayv1.LocalParametersReference{Group: "example.com", Kind: "GatewayConfig", Name: "params"}) + _, err := syncer.SyncToHost(syncCtx, synccontext.NewSyncToHostEvent(virtual)) + if err != nil { + t.Fatalf("expected unsupported parametersRef kind to be a warning/skip, not hard error: %v", err) + } + + host := &gatewayv1.Gateway{} + if err := pClient.Get(context.Background(), translate.Default.HostName(syncCtx, "edge", "team-a"), host); err == nil { + t.Fatalf("did not expect host Gateway to be created for unsupported parametersRef kind") + } +} + +func TestTenantGatewaySyncDeletesHostOnUnsupportedParametersRef(t *testing.T) { + vcConfig := &pkgconfig.VirtualClusterConfig{} + vcConfig.Sync.ToHost.GatewayAPI.Gateways.Enabled = true + hostName := types.NamespacedName{Namespace: testingutil.DefaultTestTargetNamespace, Name: "edge-x-team-a-x-suffix"} + host := &gatewayv1.Gateway{ObjectMeta: metav1.ObjectMeta{ + Namespace: hostName.Namespace, + Name: hostName.Name, + Labels: map[string]string{translate.MarkerLabel: translate.VClusterName}, + Annotations: map[string]string{ + translate.NameAnnotation: "edge", + translate.NamespaceAnnotation: "team-a", + }, + }} + virtual := gatewayWithParametersRef(gatewayv1.LocalParametersReference{Group: "example.com", Kind: "GatewayConfig", Name: "params"}) + pClient := testingutil.NewFakeClient(scheme.Scheme, host) + vClient := testingutil.NewFakeClient(scheme.Scheme, + virtual, + &gatewayv1.GatewayClass{ObjectMeta: metav1.ObjectMeta{Name: "tenant-class"}}, + ) + registerCtx := syncertesting.NewFakeRegisterContext(vcConfig, pClient, vClient) + syncCtx, object := syncertesting.FakeStartSyncer(t, registerCtx, NewToHost) + syncer := object.(*tenantGatewaySyncer) + + _, err := syncer.Sync(syncCtx, synccontext.NewSyncEvent(host, virtual)) + if err != nil { + t.Fatalf("expected unsupported parametersRef kind on update to be a warning/skip, not hard error: %v", err) + } + + got := &gatewayv1.Gateway{} + if err := pClient.Get(context.Background(), hostName, got); err == nil { + t.Fatalf("expected stale host Gateway to be deleted when virtual reference cannot be synced") + } +} + func TestTenantGatewaySyncIgnoresImportedMirror(t *testing.T) { vcConfig := &pkgconfig.VirtualClusterConfig{} vcConfig.Sync.ToHost.GatewayAPI.Gateways.Enabled = true diff --git a/pkg/controllers/resources/gateways/translate.go b/pkg/controllers/resources/gateways/translate.go index bbf13bc2d8..f7c46f7557 100644 --- a/pkg/controllers/resources/gateways/translate.go +++ b/pkg/controllers/resources/gateways/translate.go @@ -14,7 +14,7 @@ import ( func (s *tenantGatewaySyncer) translate(ctx *synccontext.SyncContext, vGateway *gatewayv1.Gateway) (_ *gatewayv1.Gateway, retErr error) { newGW := translate.HostMetadata(vGateway, s.VirtualToHost(ctx, types.NamespacedName{Name: vGateway.Name, Namespace: vGateway.Namespace}, vGateway)) - newSpec, retErr := listenersToHost(ctx, vGateway, true) + newSpec, retErr := specToHost(ctx, vGateway, true) if retErr != nil { return nil, retErr } @@ -23,30 +23,112 @@ func (s *tenantGatewaySyncer) translate(ctx *synccontext.SyncContext, vGateway * return newGW, nil } -func listenersToHost(ctx *synccontext.SyncContext, vGateway *gatewayv1.Gateway, validateRefs bool) (*gatewayv1.GatewaySpec, error) { +func specToHost(ctx *synccontext.SyncContext, vGateway *gatewayv1.Gateway, validateRefs bool) (*gatewayv1.GatewaySpec, error) { retSpec := vGateway.Spec.DeepCopy() + if err := tlsRefsToHost(ctx, vGateway.Namespace, retSpec.TLS, validateRefs); err != nil { + return nil, err + } + for i := range retSpec.Listeners { ensureAllowedRoutesAttachableOnHost(retSpec.Listeners[i].AllowedRoutes) if tls := retSpec.Listeners[i].TLS; tls != nil { for j := range tls.CertificateRefs { - err := gatewayauthz.GatewayCertificate(ctx, vGateway.Namespace, &retSpec.Listeners[i].TLS.CertificateRefs[j]) - if err != nil { - return nil, fmt.Errorf("authorize listeners[%d].tls.certificateRefs[%d]: %w", i, j, err) - } - - err = routetranslate.SecretObjectRefToHost(ctx, vGateway.Namespace, &retSpec.Listeners[i].TLS.CertificateRefs[j], routetranslate.WithValidateHostObject(validateRefs)) - if err != nil { - return nil, fmt.Errorf("translate listeners[%d].tls.certificateRefs[%d]: %w", i, j, err) + field := fmt.Sprintf("listeners[%d].tls.certificateRefs[%d]", i, j) + if err := gatewayCertificateRefToHost(ctx, vGateway.Namespace, field, &retSpec.Listeners[i].TLS.CertificateRefs[j], validateRefs); err != nil { + return nil, err } } } } + if infra := retSpec.Infrastructure; infra != nil && infra.ParametersRef != nil { + if err := routetranslate.ParametersRefToHost(ctx, vGateway.Namespace, infra.ParametersRef, routetranslate.WithValidateHostObject(validateRefs)); err != nil { + return nil, fmt.Errorf("translate infrastructure.parametersRef: %w", err) + } + } + return retSpec, nil } +func tlsRefsToHost(ctx *synccontext.SyncContext, gatewayNamespace string, tls *gatewayv1.GatewayTLSConfig, validateRefs bool) error { + if tls == nil { + return nil + } + + if err := backendTLSRefToHost(ctx, gatewayNamespace, tls.Backend, validateRefs); err != nil { + return err + } + + return frontendTLSRefsToHost(ctx, gatewayNamespace, tls.Frontend, validateRefs) +} + +func backendTLSRefToHost(ctx *synccontext.SyncContext, gatewayNamespace string, backend *gatewayv1.GatewayBackendTLS, validateRefs bool) error { + if backend == nil || backend.ClientCertificateRef == nil { + return nil + } + + return gatewayCertificateRefToHost(ctx, gatewayNamespace, "tls.backend.clientCertificateRef", backend.ClientCertificateRef, validateRefs) +} + +func gatewayCertificateRefToHost(ctx *synccontext.SyncContext, gatewayNamespace, field string, ref *gatewayv1.SecretObjectReference, validateRefs bool) error { + if err := gatewayauthz.GatewayCertificate(ctx, gatewayNamespace, ref); err != nil { + return fmt.Errorf("authorize %s: %w", field, err) + } + + if err := routetranslate.SecretObjectRefToHost(ctx, gatewayNamespace, ref, routetranslate.WithValidateHostObject(validateRefs)); err != nil { + return fmt.Errorf("translate %s: %w", field, err) + } + + return nil +} + +func frontendTLSRefsToHost(ctx *synccontext.SyncContext, gatewayNamespace string, frontend *gatewayv1.FrontendTLSConfig, validateRefs bool) error { + if frontend == nil { + return nil + } + + if frontend.Default.Validation != nil { + for i := range frontend.Default.Validation.CACertificateRefs { + err := gatewayCACertificateRefToHost(ctx, gatewayNamespace, &frontend.Default.Validation.CACertificateRefs[i], validateRefs) + if err != nil { + return fmt.Errorf("tls.frontend.default.validation.caCertificateRefs[%d]: %w", i, err) + } + } + } + + for i := range frontend.PerPort { + validation := frontend.PerPort[i].TLS.Validation + if validation == nil { + continue + } + + for j := range validation.CACertificateRefs { + err := gatewayCACertificateRefToHost(ctx, gatewayNamespace, &validation.CACertificateRefs[j], validateRefs) + if err != nil { + return fmt.Errorf("tls.frontend.perPort[%d].tls.validation.caCertificateRefs[%d]: %w", i, j, err) + } + } + } + + return nil +} + +func gatewayCACertificateRefToHost(ctx *synccontext.SyncContext, gatewayNamespace string, ref *gatewayv1.ObjectReference, validateRefs bool) error { + err := gatewayauthz.GatewayCACertificate(ctx, gatewayNamespace, ref) + if err != nil { + return fmt.Errorf("authorize: %w", err) + } + + err = routetranslate.ObjectRefToHost(ctx, gatewayNamespace, ref, routetranslate.WithValidateHostObject(validateRefs)) + if err != nil { + return fmt.Errorf("translate: %w", err) + } + + return nil +} + // In single-namespace mode, vCluster authorizes virtual route attachment before // syncing routes, then collapses all virtual namespaces into one host namespace. // Host Gateway controllers cannot evaluate virtual namespace selectors, so keep diff --git a/pkg/controllers/resources/gateways/translate_frontend_tls_test.go b/pkg/controllers/resources/gateways/translate_frontend_tls_test.go new file mode 100644 index 0000000000..8d699ac626 --- /dev/null +++ b/pkg/controllers/resources/gateways/translate_frontend_tls_test.go @@ -0,0 +1,387 @@ +package gateways + +import ( + "strings" + "testing" + + pkgconfig "github.com/loft-sh/vcluster/pkg/config" + routetranslate "github.com/loft-sh/vcluster/pkg/controllers/resources/gatewayroutes/translate" + "github.com/loft-sh/vcluster/pkg/scheme" + "github.com/loft-sh/vcluster/pkg/syncer/synccontext" + syncertesting "github.com/loft-sh/vcluster/pkg/syncer/testing" + testingutil "github.com/loft-sh/vcluster/pkg/util/testing" + utiltranslate "github.com/loft-sh/vcluster/pkg/util/translate" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestSpecToHostTranslatesDefaultFrontendCACertificateRefs(t *testing.T) { + tests := []struct { + name string + ref gatewayv1.ObjectReference + configMap *corev1.ConfigMap + secret *corev1.Secret + }{ + { + name: "configmap", + ref: gatewayv1.ObjectReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "client-ca"}, + configMap: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "client-ca"}}, + }, + { + name: "secret", + ref: gatewayv1.ObjectReference{Group: corev1.GroupName, Kind: "Secret", Name: "client-ca"}, + secret: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "client-ca"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, tt.configMap, tt.secret) + expected := utiltranslate.Default.HostName(syncCtx, "client-ca", "team-a") + gateway := gatewayWithDefaultFrontendCACertificateRefs(tt.ref) + + spec, err := specToHost(syncCtx, gateway, true) + if err != nil { + t.Fatalf("translate Gateway spec: %v", err) + } + gotRefs := spec.TLS.Frontend.Default.Validation.CACertificateRefs + if len(gotRefs) != 1 { + t.Fatalf("expected one default frontend CA certificate ref, got %#v", gotRefs) + } + if gotRefs[0].Name != gatewayv1.ObjectName(expected.Name) { + t.Fatalf("expected CA certificate ref name %q, got %q", expected.Name, gotRefs[0].Name) + } + if gateway.Spec.TLS.Frontend.Default.Validation.CACertificateRefs[0].Name != "client-ca" { + t.Fatalf("expected virtual Gateway to stay unchanged, got %q", gateway.Spec.TLS.Frontend.Default.Validation.CACertificateRefs[0].Name) + } + }) + } +} + +func TestSpecToHostAllowsGrantedCrossNamespaceDefaultFrontendCACertificateRef(t *testing.T) { + restore := setDefaultGatewayFrontendTLSTranslator(utiltranslate.NewSingleNamespaceTranslator("vcluster-host")) + defer restore() + + caNamespace := gatewayv1.Namespace("security") + grant := &gatewayv1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{Namespace: "security", Name: "allow-gateway-ca"}, + Spec: gatewayv1.ReferenceGrantSpec{ + From: []gatewayv1.ReferenceGrantFrom{{Group: gatewayv1.Group(gatewayv1.GroupVersion.Group), Kind: gatewayv1.Kind("Gateway"), Namespace: gatewayv1.Namespace("team-a")}}, + To: []gatewayv1.ReferenceGrantTo{{Group: gatewayv1.Group(corev1.GroupName), Kind: gatewayv1.Kind("ConfigMap"), Name: ptr.To(gatewayv1.ObjectName("client-ca"))}}, + }, + } + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "security", Name: "client-ca"}}, + nil, + grant, + ) + expected := utiltranslate.Default.HostName(syncCtx, "client-ca", "security") + gateway := gatewayWithDefaultFrontendCACertificateRefs(gatewayv1.ObjectReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "client-ca", Namespace: &caNamespace}) + + spec, err := specToHost(syncCtx, gateway, true) + if err != nil { + t.Fatalf("translate Gateway spec: %v", err) + } + got := spec.TLS.Frontend.Default.Validation.CACertificateRefs[0] + if got.Name != gatewayv1.ObjectName(expected.Name) || got.Namespace == nil || *got.Namespace != gatewayv1.Namespace(expected.Namespace) { + t.Fatalf("expected CA certificate ref %s/%s, got %#v", expected.Namespace, expected.Name, got) + } +} + +func TestSpecToHostRequiresReferenceGrantForCrossNamespaceDefaultFrontendCACertificateRef(t *testing.T) { + restore := setDefaultGatewayFrontendTLSTranslator(utiltranslate.NewSingleNamespaceTranslator("vcluster-host")) + defer restore() + + caNamespace := gatewayv1.Namespace("security") + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "security", Name: "client-ca"}}, + nil, + ) + gateway := gatewayWithDefaultFrontendCACertificateRefs(gatewayv1.ObjectReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "client-ca", Namespace: &caNamespace}) + + _, err := specToHost(syncCtx, gateway, true) + if err == nil || !strings.Contains(err.Error(), "ReferenceGrant") { + t.Fatalf("expected cross-namespace default frontend CA certificate ref to require ReferenceGrant, got %v", err) + } +} + +func TestSpecToHostAllowsGrantedCrossNamespacePerPortFrontendCACertificateRef(t *testing.T) { + restore := setDefaultGatewayFrontendTLSTranslator(utiltranslate.NewSingleNamespaceTranslator("vcluster-host")) + defer restore() + + caNamespace := gatewayv1.Namespace("security") + grant := &gatewayv1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{Namespace: "security", Name: "allow-gateway-ca"}, + Spec: gatewayv1.ReferenceGrantSpec{ + From: []gatewayv1.ReferenceGrantFrom{{Group: gatewayv1.Group(gatewayv1.GroupVersion.Group), Kind: gatewayv1.Kind("Gateway"), Namespace: gatewayv1.Namespace("team-a")}}, + To: []gatewayv1.ReferenceGrantTo{{Group: gatewayv1.Group(corev1.GroupName), Kind: gatewayv1.Kind("ConfigMap"), Name: ptr.To(gatewayv1.ObjectName("client-ca"))}}, + }, + } + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "security", Name: "client-ca"}}, + nil, + grant, + ) + expected := utiltranslate.Default.HostName(syncCtx, "client-ca", "security") + gateway := gatewayWithPerPortFrontendCACertificateRefs(443, gatewayv1.ObjectReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "client-ca", Namespace: &caNamespace}) + + spec, err := specToHost(syncCtx, gateway, true) + if err != nil { + t.Fatalf("translate Gateway spec: %v", err) + } + got := spec.TLS.Frontend.PerPort[0].TLS.Validation.CACertificateRefs[0] + if got.Name != gatewayv1.ObjectName(expected.Name) || got.Namespace == nil || *got.Namespace != gatewayv1.Namespace(expected.Namespace) { + t.Fatalf("expected per-port CA certificate ref %s/%s, got %#v", expected.Namespace, expected.Name, got) + } + if gateway.Spec.TLS.Frontend.PerPort[0].TLS.Validation.CACertificateRefs[0].Name != "client-ca" { + t.Fatalf("expected virtual Gateway to stay unchanged, got %q", gateway.Spec.TLS.Frontend.PerPort[0].TLS.Validation.CACertificateRefs[0].Name) + } +} + +func TestSpecToHostSkipsNilPerPortFrontendTLSValidationAndTranslatesLaterRefs(t *testing.T) { + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "client-ca"}}, + nil, + ) + expected := utiltranslate.Default.HostName(syncCtx, "client-ca", "team-a") + gateway := gatewayWithPerPortFrontendTLSConfigs( + gatewayv1.TLSPortConfig{ + Port: 80, + TLS: gatewayv1.TLSConfig{}, + }, + gatewayv1.TLSPortConfig{ + Port: 443, + TLS: gatewayv1.TLSConfig{ + Validation: &gatewayv1.FrontendTLSValidation{ + CACertificateRefs: []gatewayv1.ObjectReference{{Group: corev1.GroupName, Kind: "ConfigMap", Name: "client-ca"}}, + }, + }, + }, + ) + + spec, err := specToHost(syncCtx, gateway, true) + if err != nil { + t.Fatalf("translate Gateway spec: %v", err) + } + if spec.TLS.Frontend.PerPort[0].TLS.Validation != nil { + t.Fatalf("expected nil per-port TLS validation to stay nil, got %#v", spec.TLS.Frontend.PerPort[0].TLS.Validation) + } + got := spec.TLS.Frontend.PerPort[1].TLS.Validation.CACertificateRefs[0] + if got.Name != gatewayv1.ObjectName(expected.Name) { + t.Fatalf("expected later per-port CA certificate ref name %q, got %q", expected.Name, got.Name) + } + if gateway.Spec.TLS.Frontend.PerPort[1].TLS.Validation.CACertificateRefs[0].Name != "client-ca" { + t.Fatalf("expected virtual Gateway to stay unchanged, got %q", gateway.Spec.TLS.Frontend.PerPort[1].TLS.Validation.CACertificateRefs[0].Name) + } +} + +func TestSpecToHostReportsPerPortFrontendCACertificateRefIndexes(t *testing.T) { + caNamespace := gatewayv1.Namespace("security") + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "client-ca"}}, + nil, + ) + gateway := gatewayWithPerPortFrontendTLSConfigs( + gatewayv1.TLSPortConfig{ + Port: 80, + TLS: gatewayv1.TLSConfig{}, + }, + gatewayv1.TLSPortConfig{ + Port: 443, + TLS: gatewayv1.TLSConfig{ + Validation: &gatewayv1.FrontendTLSValidation{ + CACertificateRefs: []gatewayv1.ObjectReference{ + {Group: corev1.GroupName, Kind: "ConfigMap", Name: "client-ca"}, + {Group: corev1.GroupName, Kind: "ConfigMap", Name: "other-client-ca", Namespace: &caNamespace}, + }, + }, + }, + }, + ) + + _, err := specToHost(syncCtx, gateway, true) + if err == nil || !strings.Contains(err.Error(), "ReferenceGrant") || !strings.Contains(err.Error(), "tls.frontend.perPort[1].tls.validation.caCertificateRefs[1]") { + t.Fatalf("expected cross-namespace per-port frontend CA certificate ref to require ReferenceGrant with indexed field context, got %v", err) + } +} + +func TestSpecToHostRequiresReferenceGrantForCrossNamespacePerPortFrontendCACertificateRef(t *testing.T) { + restore := setDefaultGatewayFrontendTLSTranslator(utiltranslate.NewSingleNamespaceTranslator("vcluster-host")) + defer restore() + + caNamespace := gatewayv1.Namespace("security") + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "security", Name: "client-ca"}}, + nil, + ) + gateway := gatewayWithPerPortFrontendCACertificateRefs(443, gatewayv1.ObjectReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "client-ca", Namespace: &caNamespace}) + + _, err := specToHost(syncCtx, gateway, true) + if err == nil || !strings.Contains(err.Error(), "ReferenceGrant") || !strings.Contains(err.Error(), "tls.frontend.perPort[0].tls.validation.caCertificateRefs[0]") { + t.Fatalf("expected cross-namespace per-port frontend CA certificate ref to require ReferenceGrant with field context, got %v", err) + } +} + +func TestSpecToHostTranslatesGrantedBackendTLSClientCertificateRef(t *testing.T) { + restore := setDefaultGatewayFrontendTLSTranslator(utiltranslate.NewSingleNamespaceTranslator("vcluster-host")) + defer restore() + + certNamespace := gatewayv1.Namespace("security") + grant := &gatewayv1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{Namespace: "security", Name: "allow-gateway-client-cert"}, + Spec: gatewayv1.ReferenceGrantSpec{ + From: []gatewayv1.ReferenceGrantFrom{{Group: gatewayv1.Group(gatewayv1.GroupVersion.Group), Kind: gatewayv1.Kind("Gateway"), Namespace: gatewayv1.Namespace("team-a")}}, + To: []gatewayv1.ReferenceGrantTo{{Group: gatewayv1.Group(corev1.GroupName), Kind: gatewayv1.Kind("Secret"), Name: ptr.To(gatewayv1.ObjectName("client-cert"))}}, + }, + } + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, + nil, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "security", Name: "client-cert"}}, + grant, + ) + expected := utiltranslate.Default.HostName(syncCtx, "client-cert", "security") + gateway := gatewayWithBackendTLSClientCertificateRef(gatewayv1.SecretObjectReference{Name: "client-cert", Namespace: &certNamespace}) + + spec, err := specToHost(syncCtx, gateway, true) + if err != nil { + t.Fatalf("translate Gateway spec: %v", err) + } + got := spec.TLS.Backend.ClientCertificateRef + if got == nil { + t.Fatalf("expected backend TLS client certificate ref") + } + if got.Name != gatewayv1.ObjectName(expected.Name) || got.Namespace == nil || *got.Namespace != gatewayv1.Namespace(expected.Namespace) { + t.Fatalf("expected backend TLS client certificate ref %s/%s, got %#v", expected.Namespace, expected.Name, got) + } + if gateway.Spec.TLS.Backend.ClientCertificateRef.Name != "client-cert" { + t.Fatalf("expected virtual Gateway to stay unchanged, got %q", gateway.Spec.TLS.Backend.ClientCertificateRef.Name) + } +} + +func TestSpecToHostRequiresReferenceGrantForCrossNamespaceBackendTLSClientCertificateRef(t *testing.T) { + restore := setDefaultGatewayFrontendTLSTranslator(utiltranslate.NewSingleNamespaceTranslator("vcluster-host")) + defer restore() + + certNamespace := gatewayv1.Namespace("security") + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, + nil, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "security", Name: "client-cert"}}, + ) + gateway := gatewayWithBackendTLSClientCertificateRef(gatewayv1.SecretObjectReference{Name: "client-cert", Namespace: &certNamespace}) + + _, err := specToHost(syncCtx, gateway, true) + if err == nil || !strings.Contains(err.Error(), "ReferenceGrant") || !strings.Contains(err.Error(), "tls.backend.clientCertificateRef") { + t.Fatalf("expected cross-namespace backend TLS client certificate ref to require ReferenceGrant with field context, got %v", err) + } +} + +func TestSpecToHostRequiresManagedHostBackendTLSClientCertificateRef(t *testing.T) { + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, nil, nil) + gateway := gatewayWithBackendTLSClientCertificateRef(gatewayv1.SecretObjectReference{Name: "client-cert"}) + + _, err := specToHost(syncCtx, gateway, true) + if err == nil || !strings.Contains(err.Error(), "has no synced host object") || !strings.Contains(err.Error(), "tls.backend.clientCertificateRef") { + t.Fatalf("expected missing host Secret to reject backend TLS client certificate ref with field context, got %v", err) + } +} + +func TestSpecToHostRequiresManagedHostDefaultFrontendCACertificateRef(t *testing.T) { + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, nil, nil) + gateway := gatewayWithDefaultFrontendCACertificateRefs(gatewayv1.ObjectReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "client-ca"}) + + _, err := specToHost(syncCtx, gateway, true) + if err == nil || !strings.Contains(err.Error(), "has no synced host object") { + t.Fatalf("expected missing host ConfigMap to reject Gateway, got %v", err) + } +} + +func TestSpecToHostRejectsUnsupportedDefaultFrontendCACertificateRef(t *testing.T) { + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, nil, nil) + gateway := gatewayWithDefaultFrontendCACertificateRefs(gatewayv1.ObjectReference{Group: gatewayv1.Group(gatewayv1.GroupVersion.Group), Kind: "GatewayClass", Name: "client-ca"}) + + _, err := specToHost(syncCtx, gateway, true) + if !routetranslate.IsUnsupportedReference(err) { + t.Fatalf("expected unsupported default frontend CA certificate ref to be terminal, got %v", err) + } +} + +func newGatewayFrontendTLSTranslateSyncContext(t *testing.T, configMap *corev1.ConfigMap, secret *corev1.Secret, virtualObjects ...runtime.Object) *synccontext.SyncContext { + t.Helper() + vcConfig := &pkgconfig.VirtualClusterConfig{} + seedCtx := syncertesting.NewFakeRegisterContext(vcConfig, testingutil.NewFakeClient(scheme.Scheme), testingutil.NewFakeClient(scheme.Scheme)).ToSyncContext("gateway-frontend-tls-translate-test") + + var hostObjects []runtime.Object + if configMap != nil { + hostObjects = append(hostObjects, utiltranslate.HostMetadata(configMap, utiltranslate.Default.HostName(seedCtx, configMap.Name, configMap.Namespace))) + } + if secret != nil { + hostObjects = append(hostObjects, utiltranslate.HostMetadata(secret, utiltranslate.Default.HostName(seedCtx, secret.Name, secret.Namespace))) + } + + pClient := testingutil.NewFakeClient(scheme.Scheme, hostObjects...) + vClient := testingutil.NewFakeClient(scheme.Scheme, virtualObjects...) + return syncertesting.NewFakeRegisterContext(vcConfig, pClient, vClient).ToSyncContext("gateway-frontend-tls-translate-test") +} + +func setDefaultGatewayFrontendTLSTranslator(translator utiltranslate.Translator) func() { + previous := utiltranslate.Default + utiltranslate.Default = translator + return func() { utiltranslate.Default = previous } +} + +func gatewayWithDefaultFrontendCACertificateRefs(refs ...gatewayv1.ObjectReference) *gatewayv1.Gateway { + return &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "edge"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "tenant-class", + TLS: &gatewayv1.GatewayTLSConfig{ + Frontend: &gatewayv1.FrontendTLSConfig{ + Default: gatewayv1.TLSConfig{ + Validation: &gatewayv1.FrontendTLSValidation{CACertificateRefs: refs}, + }, + }, + }, + }, + } +} + +func gatewayWithPerPortFrontendCACertificateRefs(port gatewayv1.PortNumber, refs ...gatewayv1.ObjectReference) *gatewayv1.Gateway { + return gatewayWithPerPortFrontendTLSConfigs(gatewayv1.TLSPortConfig{ + Port: port, + TLS: gatewayv1.TLSConfig{ + Validation: &gatewayv1.FrontendTLSValidation{CACertificateRefs: refs}, + }, + }) +} + +func gatewayWithPerPortFrontendTLSConfigs(perPort ...gatewayv1.TLSPortConfig) *gatewayv1.Gateway { + return &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "edge"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "tenant-class", + TLS: &gatewayv1.GatewayTLSConfig{ + Frontend: &gatewayv1.FrontendTLSConfig{ + Default: gatewayv1.TLSConfig{}, + PerPort: perPort, + }, + }, + }, + } +} + +func gatewayWithBackendTLSClientCertificateRef(ref gatewayv1.SecretObjectReference) *gatewayv1.Gateway { + return &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "edge"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "tenant-class", + TLS: &gatewayv1.GatewayTLSConfig{ + Backend: &gatewayv1.GatewayBackendTLS{ + ClientCertificateRef: &ref, + }, + }, + }, + } +} diff --git a/pkg/controllers/resources/gateways/translate_test.go b/pkg/controllers/resources/gateways/translate_test.go new file mode 100644 index 0000000000..27f3a28a1c --- /dev/null +++ b/pkg/controllers/resources/gateways/translate_test.go @@ -0,0 +1,85 @@ +package gateways + +import ( + "strings" + "testing" + + utiltranslate "github.com/loft-sh/vcluster/pkg/util/translate" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestSpecToHostTranslatesInfrastructureParametersRef(t *testing.T) { + tests := []struct { + name string + ref gatewayv1.LocalParametersReference + configMap *corev1.ConfigMap + secret *corev1.Secret + }{ + { + name: "configmap", + ref: gatewayv1.LocalParametersReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "params"}, + configMap: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "params"}}, + }, + { + name: "secret", + ref: gatewayv1.LocalParametersReference{Group: corev1.GroupName, Kind: "Secret", Name: "params"}, + secret: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "params"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, tt.configMap, tt.secret) + expected := utiltranslate.Default.HostName(syncCtx, "params", "team-a") + gateway := gatewayWithParametersRef(tt.ref) + + spec, err := specToHost(syncCtx, gateway, true) + if err != nil { + t.Fatalf("translate Gateway spec: %v", err) + } + if spec.Infrastructure == nil || spec.Infrastructure.ParametersRef == nil { + t.Fatalf("expected infrastructure.parametersRef to be preserved") + } + if spec.Infrastructure.ParametersRef.Name != expected.Name { + t.Fatalf("expected parametersRef name %q, got %q", expected.Name, spec.Infrastructure.ParametersRef.Name) + } + if gateway.Spec.Infrastructure.ParametersRef.Name != "params" { + t.Fatalf("expected virtual Gateway to stay unchanged, got %q", gateway.Spec.Infrastructure.ParametersRef.Name) + } + }) + } +} + +func TestSpecToHostRejectsUnsupportedInfrastructureParametersRef(t *testing.T) { + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, nil, nil) + gateway := gatewayWithParametersRef(gatewayv1.LocalParametersReference{Group: "example.com", Kind: "GatewayConfig", Name: "params"}) + + _, err := specToHost(syncCtx, gateway, true) + if err == nil || !strings.Contains(err.Error(), "parametersRef group \"example.com\" kind \"GatewayConfig\" is not supported") { + t.Fatalf("expected unsupported infrastructure.parametersRef to reject Gateway, got %v", err) + } +} + +func TestSpecToHostRequiresManagedHostInfrastructureParametersRef(t *testing.T) { + syncCtx := newGatewayFrontendTLSTranslateSyncContext(t, nil, nil) + gateway := gatewayWithParametersRef(gatewayv1.LocalParametersReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "params"}) + + _, err := specToHost(syncCtx, gateway, true) + if err == nil || !strings.Contains(err.Error(), "has no synced host object") { + t.Fatalf("expected missing host ConfigMap to reject Gateway, got %v", err) + } +} + +func gatewayWithParametersRef(ref gatewayv1.LocalParametersReference) *gatewayv1.Gateway { + return &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "edge"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "tenant-class", + Infrastructure: &gatewayv1.GatewayInfrastructure{ + ParametersRef: &ref, + }, + }, + } +} diff --git a/pkg/controllers/resources/httproutes/syncer.go b/pkg/controllers/resources/httproutes/syncer.go index b2c2e95cc6..4a2a8a3396 100644 --- a/pkg/controllers/resources/httproutes/syncer.go +++ b/pkg/controllers/resources/httproutes/syncer.go @@ -60,7 +60,7 @@ func (s *httpRouteSyncer) Options() *syncertypes.Options { func (s *httpRouteSyncer) ModifyController(ctx *synccontext.RegisterContext, builder *builder.Builder) (*builder.Builder, error) { builder = gatewayauthz.RegisterHTTPRouteWatches(ctx, builder) - return routetranslate.RegisterReferencedWatches(ctx, builder, s.GroupVersionKind(), mappings.Gateways(), mappings.Services()) + return routetranslate.RegisterReferencedWatches(ctx, builder, s.GroupVersionKind(), mappings.Gateways(), mappings.Services(), mappings.ConfigMaps(), mappings.Secrets()) } func (s *httpRouteSyncer) SyncToHost(ctx *synccontext.SyncContext, event *synccontext.SyncToHostEvent[*gatewayv1.HTTPRoute]) (ctrl.Result, error) { diff --git a/pkg/controllers/resources/httproutes/translate.go b/pkg/controllers/resources/httproutes/translate.go index b6f3d0be60..1e4da8b74c 100644 --- a/pkg/controllers/resources/httproutes/translate.go +++ b/pkg/controllers/resources/httproutes/translate.go @@ -131,6 +131,13 @@ func filterToHost(ctx *synccontext.SyncContext, routeNamespace string, filter *g } } + if filter.ExtensionRef != nil { + err := routetranslate.LocalObjectRefToHost(ctx, routeNamespace, filter.ExtensionRef, translateOpts...) + if err != nil { + return fmt.Errorf("translate extensionRef: %w", err) + } + } + return nil } diff --git a/pkg/controllers/resources/httproutes/translate_test.go b/pkg/controllers/resources/httproutes/translate_test.go new file mode 100644 index 0000000000..320705bd1c --- /dev/null +++ b/pkg/controllers/resources/httproutes/translate_test.go @@ -0,0 +1,136 @@ +package httproutes + +import ( + "strings" + "testing" + + pkgconfig "github.com/loft-sh/vcluster/pkg/config" + routetranslate "github.com/loft-sh/vcluster/pkg/controllers/resources/gatewayroutes/translate" + "github.com/loft-sh/vcluster/pkg/scheme" + "github.com/loft-sh/vcluster/pkg/syncer/synccontext" + syncertesting "github.com/loft-sh/vcluster/pkg/syncer/testing" + testingutil "github.com/loft-sh/vcluster/pkg/util/testing" + utiltranslate "github.com/loft-sh/vcluster/pkg/util/translate" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestSpecToHostTranslatesRuleFilterExtensionRef(t *testing.T) { + syncCtx := newHTTPRouteTranslateSyncContext(t, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "filter-config"}}, + ) + expected := utiltranslate.Default.HostName(syncCtx, "filter-config", "team-a") + route := httpRouteWithRuleFilterExtensionRef(gatewayv1.LocalObjectReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "filter-config"}) + + spec, err := specToHost(syncCtx, route, true) + if err != nil { + t.Fatalf("translate HTTPRoute spec: %v", err) + } + got := spec.Rules[0].Filters[0].ExtensionRef + if got == nil { + t.Fatalf("expected rule filter extensionRef") + } + if got.Name != gatewayv1.ObjectName(expected.Name) { + t.Fatalf("expected rule filter extensionRef name %q, got %q", expected.Name, got.Name) + } + if route.Spec.Rules[0].Filters[0].ExtensionRef.Name != "filter-config" { + t.Fatalf("expected virtual HTTPRoute to stay unchanged, got %q", route.Spec.Rules[0].Filters[0].ExtensionRef.Name) + } +} + +func TestSpecToHostTranslatesBackendRefFilterExtensionRef(t *testing.T) { + syncCtx := newHTTPRouteTranslateSyncContext(t, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "backend"}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "filter-secret"}}, + ) + expected := utiltranslate.Default.HostName(syncCtx, "filter-secret", "team-a") + route := httpRouteWithBackendRefFilterExtensionRef(gatewayv1.LocalObjectReference{Group: corev1.GroupName, Kind: "Secret", Name: "filter-secret"}) + + spec, err := specToHost(syncCtx, route, true) + if err != nil { + t.Fatalf("translate HTTPRoute spec: %v", err) + } + got := spec.Rules[0].BackendRefs[0].Filters[0].ExtensionRef + if got == nil { + t.Fatalf("expected backendRef filter extensionRef") + } + if got.Name != gatewayv1.ObjectName(expected.Name) { + t.Fatalf("expected backendRef filter extensionRef name %q, got %q", expected.Name, got.Name) + } + if route.Spec.Rules[0].BackendRefs[0].Filters[0].ExtensionRef.Name != "filter-secret" { + t.Fatalf("expected virtual HTTPRoute to stay unchanged, got %q", route.Spec.Rules[0].BackendRefs[0].Filters[0].ExtensionRef.Name) + } +} + +func TestSpecToHostRequiresManagedHostFilterExtensionRef(t *testing.T) { + syncCtx := newHTTPRouteTranslateSyncContext(t) + route := httpRouteWithRuleFilterExtensionRef(gatewayv1.LocalObjectReference{Group: corev1.GroupName, Kind: "ConfigMap", Name: "filter-config"}) + + _, err := specToHost(syncCtx, route, true) + if err == nil || !strings.Contains(err.Error(), "has no synced host object") || !strings.Contains(err.Error(), "extensionRef") { + t.Fatalf("expected missing host ConfigMap to reject extensionRef with field context, got %v", err) + } +} + +func TestSpecToHostRejectsUnsupportedFilterExtensionRef(t *testing.T) { + syncCtx := newHTTPRouteTranslateSyncContext(t) + route := httpRouteWithRuleFilterExtensionRef(gatewayv1.LocalObjectReference{Group: gatewayv1.Group(gatewayv1.GroupVersion.Group), Kind: "GatewayClass", Name: "filter-config"}) + + _, err := specToHost(syncCtx, route, true) + if !routetranslate.IsUnsupportedReference(err) { + t.Fatalf("expected unsupported extensionRef to be terminal, got %v", err) + } +} + +func newHTTPRouteTranslateSyncContext(t *testing.T, virtualHostObjects ...runtime.Object) *synccontext.SyncContext { + t.Helper() + vcConfig := &pkgconfig.VirtualClusterConfig{} + seedCtx := syncertesting.NewFakeRegisterContext(vcConfig, testingutil.NewFakeClient(scheme.Scheme), testingutil.NewFakeClient(scheme.Scheme)).ToSyncContext("httproute-translate-test") + + hostObjects := make([]runtime.Object, 0, len(virtualHostObjects)) + for _, obj := range virtualHostObjects { + clientObj, ok := obj.(ctrlclient.Object) + if !ok { + t.Fatalf("host object %T does not implement client.Object", obj) + } + hostObjects = append(hostObjects, utiltranslate.HostMetadata(clientObj, utiltranslate.Default.HostName(seedCtx, clientObj.GetName(), clientObj.GetNamespace()))) + } + + pClient := testingutil.NewFakeClient(scheme.Scheme, hostObjects...) + vClient := testingutil.NewFakeClient(scheme.Scheme) + return syncertesting.NewFakeRegisterContext(vcConfig, pClient, vClient).ToSyncContext("httproute-translate-test") +} + +func httpRouteWithRuleFilterExtensionRef(ref gatewayv1.LocalObjectReference) *gatewayv1.HTTPRoute { + return &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "route"}, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{{ + Filters: []gatewayv1.HTTPRouteFilter{{ + Type: gatewayv1.HTTPRouteFilterExtensionRef, + ExtensionRef: &ref, + }}, + }}, + }, + } +} + +func httpRouteWithBackendRefFilterExtensionRef(ref gatewayv1.LocalObjectReference) *gatewayv1.HTTPRoute { + return &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "route"}, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{{ + BackendRefs: []gatewayv1.HTTPBackendRef{{ + BackendRef: gatewayv1.BackendRef{BackendObjectReference: gatewayv1.BackendObjectReference{Name: "backend"}}, + Filters: []gatewayv1.HTTPRouteFilter{{ + Type: gatewayv1.HTTPRouteFilterExtensionRef, + ExtensionRef: &ref, + }}, + }}, + }}, + }, + } +}