From 3a1d86be20aa2a0ae695f18ad59794ea9d1c2339 Mon Sep 17 00:00:00 2001 From: Andy Bursavich Date: Sun, 15 Aug 2021 14:24:52 -0700 Subject: [PATCH] gateway-httproute: add source --- main.go | 2 + pkg/apis/externaldns/types.go | 8 +- source/gateway.go | 327 +++++++++++++++ source/gateway_httproute.go | 55 +++ source/gateway_httproute_test.go | 664 +++++++++++++++++++++++++++++++ source/store.go | 4 + 6 files changed, 1059 insertions(+), 1 deletion(-) create mode 100644 source/gateway.go create mode 100644 source/gateway_httproute.go create mode 100644 source/gateway_httproute_test.go diff --git a/main.go b/main.go index fd3b48465..3a11faba6 100644 --- a/main.go +++ b/main.go @@ -114,6 +114,8 @@ func main() { IgnoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, IgnoreIngressTLSSpec: cfg.IgnoreIngressTLSSpec, IgnoreIngressRulesSpec: cfg.IgnoreIngressRulesSpec, + GatewayNamespace: cfg.GatewayNamespace, + GatewayLabelFilter: cfg.GatewayLabelFilter, Compatibility: cfg.Compatibility, PublishInternal: cfg.PublishInternal, PublishHostIP: cfg.PublishHostIP, diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 3b06e3a00..21b423dad 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -60,6 +60,8 @@ type Config struct { IgnoreHostnameAnnotation bool IgnoreIngressTLSSpec bool IgnoreIngressRulesSpec bool + GatewayNamespace string + GatewayLabelFilter string Compatibility string PublishInternal bool PublishHostIP bool @@ -204,6 +206,8 @@ var defaultConfig = &Config{ IgnoreHostnameAnnotation: false, IgnoreIngressTLSSpec: false, IgnoreIngressRulesSpec: false, + GatewayNamespace: "", + GatewayLabelFilter: "", Compatibility: "", PublishInternal: false, PublishHostIP: false, @@ -375,7 +379,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("skipper-routegroup-groupversion", "The resource version for skipper routegroup").Default(source.DefaultRoutegroupVersion).StringVar(&cfg.SkipperRouteGroupVersion) // Flags related to processing source - app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, fake, connector, istio-gateway, istio-virtualservice, cloudfoundry, contour-ingressroute, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "pod", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-ingressroute", "contour-httpproxy", "gloo-proxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host", "kong-tcpingress") + app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, fake, connector, gateway-httproute, istio-gateway, istio-virtualservice, cloudfoundry, contour-ingressroute, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "pod", "gateway-httproute", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-ingressroute", "contour-httpproxy", "gloo-proxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host", "kong-tcpingress") app.Flag("openshift-router-name", "if source is openshift-route then you can pass the ingress controller name. Based on this name external-dns will select the respective router from the route status and map that routerCanonicalHostname to the route host while creating a CNAME record.").StringVar(&cfg.OCPRouterName) app.Flag("namespace", "Limit sources of endpoints to a specific namespace (default: all namespaces)").Default(defaultConfig.Namespace).StringVar(&cfg.Namespace) app.Flag("annotation-filter", "Filter sources managed by external-dns via annotation using label selector semantics (default: all sources)").Default(defaultConfig.AnnotationFilter).StringVar(&cfg.AnnotationFilter) @@ -384,6 +388,8 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("combine-fqdn-annotation", "Combine FQDN template and Annotations instead of overwriting").BoolVar(&cfg.CombineFQDNAndAnnotation) app.Flag("ignore-hostname-annotation", "Ignore hostname annotation when generating DNS names, valid only when using fqdn-template is set (optional, default: false)").BoolVar(&cfg.IgnoreHostnameAnnotation) app.Flag("ignore-ingress-tls-spec", "Ignore tls spec section in ingresses resources, applicable only for ingress sources (optional, default: false)").BoolVar(&cfg.IgnoreIngressTLSSpec) + app.Flag("gateway-namespace", "Limit Gateways of Route endpoints to a specific namespace (default: all namespaces)").StringVar(&cfg.GatewayNamespace) + app.Flag("gateway-label-filter", "Filter Gateways of Route endpoints via label selector (default: all gateways)").StringVar(&cfg.GatewayLabelFilter) app.Flag("compatibility", "Process annotation semantics from legacy implementations (optional, options: mate, molecule, kops-dns-controller)").Default(defaultConfig.Compatibility).EnumVar(&cfg.Compatibility, "", "mate", "molecule", "kops-dns-controller") app.Flag("ignore-ingress-rules-spec", "Ignore rules spec section in ingresses resources, applicable only for ingress sources (optional, default: false)").BoolVar(&cfg.IgnoreIngressRulesSpec) app.Flag("publish-internal-services", "Allow external-dns to publish DNS records for ClusterIP services (optional)").BoolVar(&cfg.PublishInternal) diff --git a/source/gateway.go b/source/gateway.go new file mode 100644 index 000000000..93614f714 --- /dev/null +++ b/source/gateway.go @@ -0,0 +1,327 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "fmt" + "sort" + "strings" + "text/template" + + log "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + cache "k8s.io/client-go/tools/cache" + "sigs.k8s.io/gateway-api/apis/v1alpha2" + gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/gateway/versioned" + informers "sigs.k8s.io/gateway-api/pkg/client/informers/gateway/externalversions" + informers_v1a2 "sigs.k8s.io/gateway-api/pkg/client/informers/gateway/externalversions/apis/v1alpha2" + + "sigs.k8s.io/external-dns/endpoint" +) + +type gatewayRoute interface { + // Object returns the underlying Route object to be used by templates. + Object() kubeObject + // Metadata returns the Route's metadata. + Metadata() *metav1.ObjectMeta + // Hostnames returns the Route's specified hostnames. + Hostnames() []v1alpha2.Hostname + // Status returns the Route's status, including associated gateways. + Status() v1alpha2.RouteStatus +} + +type newGatewayRouteInformerFunc func(informers.SharedInformerFactory) gatewayRouteInfomer + +type gatewayRouteInfomer interface { + List(namespace string, selector labels.Selector) ([]gatewayRoute, error) + Informer() cache.SharedIndexInformer +} + +func newGatewayInformerFactory(client gateway.Interface, namespace string, labelSelector labels.Selector) informers.SharedInformerFactory { + var opts []informers.SharedInformerOption + if namespace != "" { + opts = append(opts, informers.WithNamespace(namespace)) + } + if labelSelector != nil && !labelSelector.Empty() { + lbls := labelSelector.String() + opts = append(opts, informers.WithTweakListOptions(func(o *metav1.ListOptions) { + o.LabelSelector = lbls + })) + } + return informers.NewSharedInformerFactoryWithOptions(client, 0, opts...) +} + +type gatewayRouteSource struct { + gwNamespace string + gwLabels labels.Selector + gwInformer informers_v1a2.GatewayInformer + + rtKind string + rtNamespace string + rtLabels labels.Selector + rtAnnotations labels.Selector + rtInformer gatewayRouteInfomer + + fqdnTemplate *template.Template + combineFQDNAnnotation bool + ignoreHostnameAnnotation bool +} + +func newGatewayRouteSource(clients ClientGenerator, config *Config, kind string, newInformerFn newGatewayRouteInformerFunc) (Source, error) { + gwLabels, err := getLabelSelector(config.GatewayLabelFilter) + if err != nil { + return nil, err + } + rtLabels := config.LabelFilter + if rtLabels == nil { + rtLabels = labels.Everything() + } + rtAnnotations, err := getLabelSelector(config.AnnotationFilter) + if err != nil { + return nil, err + } + tmpl, err := parseTemplate(config.FQDNTemplate) + if err != nil { + return nil, err + } + + client, err := clients.GatewayClient() + if err != nil { + return nil, err + } + + informerFactory := newGatewayInformerFactory(client, config.GatewayNamespace, gwLabels) + gwInformer := informerFactory.Gateway().V1alpha2().Gateways() // TODO: gateway informer should be shared across gateway sources + gwInformer.Informer() // Register with factory before starting + + rtInformerFactory := informerFactory + if config.Namespace != config.GatewayNamespace || !selectorsEqual(rtLabels, gwLabels) { + rtInformerFactory = newGatewayInformerFactory(client, config.Namespace, rtLabels) + } + rtInformer := newInformerFn(rtInformerFactory) + rtInformer.Informer() // Register with factory before starting + + informerFactory.Start(wait.NeverStop) + if rtInformerFactory != informerFactory { + rtInformerFactory.Start(wait.NeverStop) + + if err := waitForCacheSync(context.Background(), rtInformerFactory); err != nil { + return nil, err + } + } + if err := waitForCacheSync(context.Background(), informerFactory); err != nil { + return nil, err + } + + src := &gatewayRouteSource{ + gwNamespace: config.GatewayNamespace, + gwLabels: gwLabels, + gwInformer: gwInformer, + + rtKind: kind, + rtNamespace: config.Namespace, + rtLabels: rtLabels, + rtAnnotations: rtAnnotations, + rtInformer: rtInformer, + + fqdnTemplate: tmpl, + combineFQDNAnnotation: config.CombineFQDNAndAnnotation, + ignoreHostnameAnnotation: config.IgnoreHostnameAnnotation, + } + return src, nil +} + +func (src *gatewayRouteSource) AddEventHandler(ctx context.Context, handler func()) { + log.Debugf("Adding event handler for %s", src.rtKind) + src.gwInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) + src.rtInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) +} + +func (src *gatewayRouteSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { + var endpoints []*endpoint.Endpoint + routes, err := src.rtInformer.List(src.rtNamespace, src.rtLabels) + if err != nil { + return nil, err + } + gwList, err := src.gwInformer.Lister().Gateways(src.gwNamespace).List(src.gwLabels) + if err != nil { + return nil, err + } + gateways := gatewaysByRef(gwList) + for _, rt := range routes { + eps, err := src.endpoints(rt, gateways) + if err != nil { + return nil, err + } + endpoints = append(endpoints, eps...) + } + for _, ep := range endpoints { + sort.Sort(ep.Targets) + } + return endpoints, nil +} + +func (src *gatewayRouteSource) endpoints(rt gatewayRoute, gateways map[types.NamespacedName]*v1alpha2.Gateway) ([]*endpoint.Endpoint, error) { + // Filter by annotations. + meta := rt.Metadata() + annotations := meta.Annotations + if !src.rtAnnotations.Matches(labels.Set(meta.Annotations)) { + return nil, nil + } + + // Check controller annotation to see if we are responsible. + if v, ok := meta.Annotations[controllerAnnotationKey]; ok && v != controllerAnnotationValue { + log.Debugf("Skipping %s %s/%s because controller value does not match, found: %s, required: %s", + src.rtKind, meta.Namespace, meta.Name, v, controllerAnnotationValue) + return nil, nil + } + + // Get hostnames. + hostnames, err := src.hostnames(rt) + if err != nil { + return nil, err + } + if len(hostnames) == 0 { + log.Debugf("No hostnames could be generated from %s %s/%s", src.rtKind, meta.Namespace, meta.Name) + return nil, nil + } + + // Get targets. + targets := src.targets(rt, gateways) + if len(targets) == 0 { + log.Debugf("No targets could be generated from %s %s/%s", src.rtKind, meta.Namespace, meta.Name) + return nil, nil + } + + // Create endpoints. + ttl, err := getTTLFromAnnotations(annotations) + if err != nil { + log.Warn(err) + } + providerSpecific, setIdentifier := getProviderSpecificAnnotations(annotations) + var endpoints []*endpoint.Endpoint + for _, hostname := range hostnames { + endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier)...) + } + log.Debugf("Endpoints generated from %s %s/%s: %v", src.rtKind, meta.Namespace, meta.Name, endpoints) + + kind := strings.ToLower(src.rtKind) + resourceKey := fmt.Sprintf("%s/%s/%s", kind, meta.Namespace, meta.Name) + for _, ep := range endpoints { + ep.Labels[endpoint.ResourceLabelKey] = resourceKey + } + return endpoints, nil +} + +func (src *gatewayRouteSource) hostnames(rt gatewayRoute) ([]string, error) { + var hostnames []string + for _, name := range rt.Hostnames() { + hostnames = append(hostnames, string(name)) + } + meta := rt.Metadata() + // TODO: The ignore-hostname-annotation flag help says "valid only when using fqdn-template" + // but other sources don't check if fqdn-template is set. Which should it be? + if !src.ignoreHostnameAnnotation { + hostnames = append(hostnames, getHostnamesFromAnnotations(meta.Annotations)...) + } + // TODO: The combine-fqdn-annotation flag is similarly vague. + if src.fqdnTemplate != nil && (len(hostnames) == 0 || src.combineFQDNAnnotation) { + hosts, err := execTemplate(src.fqdnTemplate, rt.Object()) + if err != nil { + return nil, err + } + hostnames = append(hostnames, hosts...) + } + return hostnames, nil +} + +func (src *gatewayRouteSource) targets(rt gatewayRoute, gateways map[types.NamespacedName]*v1alpha2.Gateway) endpoint.Targets { + var targets endpoint.Targets + meta := rt.Metadata() + for _, rps := range rt.Status().Parents { + ref := rps.ParentRef + if (ref.Group != nil && *ref.Group != "gateway.networking.k8s.io") || (ref.Kind != nil && *ref.Kind != "Gateway") { + log.Debugf("Unsupported parent %v/%v for %s %s/%s", ref.Group, ref.Kind, src.rtKind, meta.Namespace, meta.Name) + continue + } + namespace := meta.Namespace + if ref.Namespace != nil { + namespace = string(*ref.Namespace) + } + gw, ok := gateways[types.NamespacedName{ + Namespace: namespace, + Name: string(ref.Name), + }] + if !ok { + log.Debugf("Gateway %s/%s not found for %s %s/%s", namespace, ref.Name, src.rtKind, meta.Namespace, meta.Name) + continue + } + if !gwRouteIsAdmitted(rps.Conditions) { + log.Debugf("Gateway %s/%s has not admitted %s %s/%s", namespace, ref.Name, src.rtKind, meta.Namespace, meta.Name) + continue + } + for _, addr := range gw.Status.Addresses { + // TODO: Should we validate address type? + // The spec says it should always be an IP. + targets = append(targets, addr.Value) + } + } + return targets +} + +func gwRouteIsAdmitted(conds []metav1.Condition) bool { + for _, c := range conds { + if v1alpha2.RouteConditionType(c.Type) == v1alpha2.ConditionRouteAccepted { + return c.Status == metav1.ConditionTrue + } + } + return false +} + +func gatewaysByRef(list []*v1alpha2.Gateway) map[types.NamespacedName]*v1alpha2.Gateway { + if len(list) == 0 { + return nil + } + set := make(map[types.NamespacedName]*v1alpha2.Gateway, len(list)) + for _, gw := range list { + set[types.NamespacedName{Namespace: gw.Namespace, Name: gw.Name}] = gw + } + return set +} + +func selectorsEqual(a, b labels.Selector) bool { + if a == nil || b == nil { + return a == b + } + aReq, aOK := a.DeepCopySelector().Requirements() + bReq, bOK := b.DeepCopySelector().Requirements() + if aOK != bOK || len(aReq) != len(bReq) { + return false + } + sort.Stable(labels.ByKey(aReq)) + sort.Stable(labels.ByKey(bReq)) + for i, r := range aReq { + if !r.Equal(bReq[i]) { + return false + } + } + return true +} diff --git a/source/gateway_httproute.go b/source/gateway_httproute.go new file mode 100644 index 000000000..3d8d345b2 --- /dev/null +++ b/source/gateway_httproute.go @@ -0,0 +1,55 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/gateway-api/apis/v1alpha2" + informers "sigs.k8s.io/gateway-api/pkg/client/informers/gateway/externalversions" + informers_v1a2 "sigs.k8s.io/gateway-api/pkg/client/informers/gateway/externalversions/apis/v1alpha2" +) + +// NewGatewayHTTPRouteSource creates a new Gateway HTTPRoute source with the given config. +func NewGatewayHTTPRouteSource(clients ClientGenerator, config *Config) (Source, error) { + return newGatewayRouteSource(clients, config, "HTTPRoute", func(factory informers.SharedInformerFactory) gatewayRouteInfomer { + return &gatewayHTTPRouteInformer{factory.Gateway().V1alpha2().HTTPRoutes()} + }) +} + +type gatewayHTTPRoute struct{ route *v1alpha2.HTTPRoute } + +func (rt *gatewayHTTPRoute) Object() kubeObject { return rt.route } +func (rt *gatewayHTTPRoute) Metadata() *metav1.ObjectMeta { return &rt.route.ObjectMeta } +func (rt *gatewayHTTPRoute) Hostnames() []v1alpha2.Hostname { return rt.route.Spec.Hostnames } +func (rt *gatewayHTTPRoute) Status() v1alpha2.RouteStatus { return rt.route.Status.RouteStatus } + +type gatewayHTTPRouteInformer struct { + informers_v1a2.HTTPRouteInformer +} + +func (inf gatewayHTTPRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) { + list, err := inf.HTTPRouteInformer.Lister().HTTPRoutes(namespace).List(selector) + if err != nil { + return nil, err + } + routes := make([]gatewayRoute, len(list)) + for i, rt := range list { + routes[i] = &gatewayHTTPRoute{rt} + } + return routes, nil +} diff --git a/source/gateway_httproute_test.go b/source/gateway_httproute_test.go new file mode 100644 index 000000000..c9614bba3 --- /dev/null +++ b/source/gateway_httproute_test.go @@ -0,0 +1,664 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + kubefake "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/gateway/versioned/fake" +) + +func mustGetLabelSelector(s string) labels.Selector { + v, err := getLabelSelector(s) + if err != nil { + panic(err) + } + return v +} + +func gatewayStatus(ips ...string) v1alpha2.GatewayStatus { + typ := v1alpha2.IPAddressType + addrs := make([]v1alpha2.GatewayAddress, len(ips)) + for i, ip := range ips { + addrs[i] = v1alpha2.GatewayAddress{Type: &typ, Value: ip} + } + return v1alpha2.GatewayStatus{Addresses: addrs} +} + +func routeStatus(refs ...v1alpha2.ParentRef) v1alpha2.RouteStatus { + var v v1alpha2.RouteStatus + for _, ref := range refs { + v.Parents = append(v.Parents, v1alpha2.RouteParentStatus{ + ParentRef: ref, + Conditions: []metav1.Condition{ + { + Type: string(v1alpha2.ConditionRouteAccepted), + Status: metav1.ConditionTrue, + }, + }, + }) + } + return v +} + +func httpRouteStatus(refs ...v1alpha2.ParentRef) v1alpha2.HTTPRouteStatus { + return v1alpha2.HTTPRouteStatus{RouteStatus: routeStatus(refs...)} +} + +func gatewayParentRef(namespace, name string) v1alpha2.ParentRef { + group := v1alpha2.Group("gateway.networking.k8s.io") + kind := v1alpha2.Kind("Gateway") + return v1alpha2.ParentRef{ + Group: &group, + Kind: &kind, + Name: v1alpha2.ObjectName(name), + Namespace: (*v1alpha2.Namespace)(&namespace), + } +} + +func newTestEndpoint(dnsName, recordType string, targets ...string) *endpoint.Endpoint { + return newTestEndpointWithTTL(dnsName, recordType, 0, targets...) +} + +func newTestEndpointWithTTL(dnsName, recordType string, ttl int64, targets ...string) *endpoint.Endpoint { + return &endpoint.Endpoint{ + DNSName: dnsName, + Targets: append([]string(nil), targets...), // clone targets + RecordType: recordType, + RecordTTL: endpoint.TTL(ttl), + } +} + +func joinTargets(targets ...[]string) []string { + var s []string + for _, v := range targets { + s = append(s, v...) + } + return s +} + +func TestGatewayHTTPRouteSourceEndpoints(t *testing.T) { + t.Parallel() + + objectMeta := func(namespace, name string) metav1.ObjectMeta { + return metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + } + } + namespaces := func(names ...string) []*corev1.Namespace { + v := make([]*corev1.Namespace, len(names)) + for i, name := range names { + v[i] = &corev1.Namespace{ObjectMeta: objectMeta("", name)} + } + return v + } + hostnames := func(names ...v1alpha2.Hostname) []v1alpha2.Hostname { return names } + + tests := []struct { + title string + config Config + namespaces []*corev1.Namespace + gateways []*v1alpha2.Gateway + routes []*v1alpha2.HTTPRoute + endpoints []*endpoint.Endpoint + }{ + { + title: "GatewayNamespace", + config: Config{ + GatewayNamespace: "gateway-namespace", + }, + namespaces: namespaces("gateway-namespace", "not-gateway-namespace", "route-namespace"), + gateways: []*v1alpha2.Gateway{ + { + ObjectMeta: objectMeta("gateway-namespace", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }, + { + ObjectMeta: objectMeta("not-gateway-namespace", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("2.3.4.5"), + }, + }, + routes: []*v1alpha2.HTTPRoute{{ + ObjectMeta: objectMeta("route-namespace", "test"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("test.example.internal"), + }, + Status: httpRouteStatus( // The route is attached to both gateways. + gatewayParentRef("gateway-namespace", "test"), + gatewayParentRef("not-gateway-namespace", "test"), + ), + }}, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("test.example.internal", "A", "1.2.3.4"), + }, + }, + { + title: "RouteNamespace", + config: Config{ + Namespace: "route-namespace", + }, + namespaces: namespaces("gateway-namespace", "route-namespace", "not-route-namespace"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("gateway-namespace", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{ + { + ObjectMeta: objectMeta("route-namespace", "test"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("route-namespace.example.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("gateway-namespace", "test")), + }, + { + ObjectMeta: objectMeta("not-route-namespace", "test"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("not-route-namespace.example.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("gateway-namespace", "test")), + }, + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("route-namespace.example.internal", "A", "1.2.3.4"), + }, + }, + { + title: "GatewayLabelFilter", + config: Config{ + GatewayLabelFilter: "foo=bar", + }, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "labels-match", + Namespace: "default", + Labels: map[string]string{"foo": "bar"}, + }, + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "labels-dont-match", + Namespace: "default", + Labels: map[string]string{"foo": "qux"}, + }, + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("2.3.4.5"), + }, + }, + routes: []*v1alpha2.HTTPRoute{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("test.example.internal"), + }, + Status: httpRouteStatus( // The route is attached to both gateways. + gatewayParentRef("default", "labels-match"), + gatewayParentRef("default", "labels-dont-match"), + ), + }}, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("test.example.internal", "A", "1.2.3.4"), + }, + }, + { + title: "RouteLabelFilter", + config: Config{ + LabelFilter: mustGetLabelSelector("foo=bar"), + }, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "labels-match", + Namespace: "default", + Labels: map[string]string{"foo": "bar"}, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("labels-match.example.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "labels-dont-match", + Namespace: "default", + Labels: map[string]string{"foo": "qux"}, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("labels-dont-match.example.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("labels-match.example.internal", "A", "1.2.3.4"), + }, + }, + { + title: "RouteAnnotationFilter", + config: Config{ + AnnotationFilter: "foo=bar", + }, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "annotations-match", + Namespace: "default", + Annotations: map[string]string{"foo": "bar"}, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("annotations-match.example.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "annotations-dont-match", + Namespace: "default", + Annotations: map[string]string{"foo": "qux"}, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("annotations-dont-match.example.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("annotations-match.example.internal", "A", "1.2.3.4"), + }, + }, + { + title: "SkipControllerAnnotation", + config: Config{}, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api", + Namespace: "default", + Annotations: map[string]string{ + controllerAnnotationKey: "something-else", + }, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("api.example.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }}, + endpoints: nil, + }, + { + title: "MultipleGateways", + config: Config{}, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{ + { + ObjectMeta: objectMeta("default", "one"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }, + { + ObjectMeta: objectMeta("default", "two"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("2.3.4.5"), + }, + }, + routes: []*v1alpha2.HTTPRoute{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("test.example.internal"), + }, + Status: httpRouteStatus( + gatewayParentRef("default", "one"), + gatewayParentRef("default", "two"), + ), + }}, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("test.example.internal", "A", "1.2.3.4", "2.3.4.5"), + }, + }, + { + title: "NoGateways", + config: Config{}, + namespaces: namespaces("default"), + gateways: nil, + routes: []*v1alpha2.HTTPRoute{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("example.internal"), + }, + Status: httpRouteStatus(), + }}, + endpoints: nil, + }, + { + title: "NoHostnames", + config: Config{}, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{{ + ObjectMeta: objectMeta("default", "no-hostame"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: nil, + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }}, + endpoints: nil, + }, + { + title: "HostnameAnnotation", + config: Config{}, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "without-hostame", + Namespace: "default", + Annotations: map[string]string{ + hostnameAnnotationKey: "annotation.without-hostname.internal", + }, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: nil, + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "with-hostame", + Namespace: "default", + Annotations: map[string]string{ + hostnameAnnotationKey: "annotation.with-hostname.internal", + }, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("with-hostname.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("annotation.without-hostname.internal", "A", "1.2.3.4"), + newTestEndpoint("annotation.with-hostname.internal", "A", "1.2.3.4"), + newTestEndpoint("with-hostname.internal", "A", "1.2.3.4"), + }, + }, + { + title: "IgnoreHostnameAnnotation", + config: Config{ + IgnoreHostnameAnnotation: true, + }, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "with-hostame", + Namespace: "default", + Annotations: map[string]string{ + hostnameAnnotationKey: "annotation.with-hostname.internal", + }, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("with-hostname.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }}, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("with-hostname.internal", "A", "1.2.3.4"), + }, + }, + { + title: "FQDNTemplate", + config: Config{ + FQDNTemplate: "{{.Name}}.zero.internal, {{.Name}}.one.internal. , {{.Name}}.two.internal ", + }, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{ + { + ObjectMeta: objectMeta("default", "fqdn-with-hostnames"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("fqdn-with-hostnames.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + { + ObjectMeta: objectMeta("default", "fqdn-without-hostnames"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: nil, + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("fqdn-without-hostnames.zero.internal", "A", "1.2.3.4"), + newTestEndpoint("fqdn-without-hostnames.one.internal", "A", "1.2.3.4"), + newTestEndpoint("fqdn-without-hostnames.two.internal", "A", "1.2.3.4"), + newTestEndpoint("fqdn-with-hostnames.internal", "A", "1.2.3.4"), + }, + }, + { + title: "CombineFQDN", + config: Config{ + FQDNTemplate: "combine-{{.Name}}.internal", + CombineFQDNAndAnnotation: true, + }, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{{ + ObjectMeta: objectMeta("default", "fqdn-with-hostnames"), + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("fqdn-with-hostnames.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }}, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("fqdn-with-hostnames.internal", "A", "1.2.3.4"), + newTestEndpoint("combine-fqdn-with-hostnames.internal", "A", "1.2.3.4"), + }, + }, + { + title: "TTL", + config: Config{}, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-ttl", + Namespace: "default", + Annotations: map[string]string{ttlAnnotationKey: "15s"}, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("valid-ttl.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-ttl", + Namespace: "default", + Annotations: map[string]string{ttlAnnotationKey: "abc"}, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("invalid-ttl.internal"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }, + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("invalid-ttl.internal", "A", "1.2.3.4"), + newTestEndpointWithTTL("valid-ttl.internal", "A", 15, "1.2.3.4"), + }, + }, + { + title: "ProviderAnnotations", + config: Config{}, + namespaces: namespaces("default"), + gateways: []*v1alpha2.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1alpha2.GatewaySpec{ + Listeners: []v1alpha2.Listener{{Protocol: v1alpha2.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1alpha2.HTTPRoute{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "provider-annotations", + Namespace: "default", + Annotations: map[string]string{ + SetIdentifierKey: "test-set-identifier", + aliasAnnotationKey: "true", + }, + }, + Spec: v1alpha2.HTTPRouteSpec{ + Hostnames: hostnames("provider-annotations.com"), + }, + Status: httpRouteStatus(gatewayParentRef("default", "test")), + }}, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("provider-annotations.com", "A", "1.2.3.4"). + WithProviderSpecific("alias", "true"). + WithSetIdentifier("test-set-identifier"), + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.title, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + gwClient := gatewayfake.NewSimpleClientset() + for _, gw := range tt.gateways { + _, err := gwClient.GatewayV1alpha2().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create Gateway") + + } + for _, rt := range tt.routes { + _, err := gwClient.GatewayV1alpha2().HTTPRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create HTTPRoute") + } + kubeClient := kubefake.NewSimpleClientset() + for _, ns := range tt.namespaces { + _, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create Namespace") + } + + clients := new(MockClientGenerator) + clients.On("GatewayClient").Return(gwClient, nil) + clients.On("KubeClient").Return(kubeClient, nil) + + src, err := NewGatewayHTTPRouteSource(clients, &tt.config) + require.NoError(t, err, "failed to create Gateway HTTPRoute Source") + + endpoints, err := src.Endpoints(ctx) + require.NoError(t, err, "failed to get Endpoints") + validateEndpoints(t, endpoints, tt.endpoints) + }) + } +} + +func strPtr(val string) *string { return &val } + +func hostnamePtr(val v1alpha2.Hostname) *v1alpha2.Hostname { return &val } diff --git a/source/store.go b/source/store.go index 721e8daed..713982bf0 100644 --- a/source/store.go +++ b/source/store.go @@ -51,6 +51,8 @@ type Config struct { IgnoreHostnameAnnotation bool IgnoreIngressTLSSpec bool IgnoreIngressRulesSpec bool + GatewayNamespace string + GatewayLabelFilter string Compatibility string PublishInternal bool PublishHostIP bool @@ -225,6 +227,8 @@ func BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg return nil, err } return NewPodSource(ctx, client, cfg.Namespace, cfg.Compatibility) + case "gateway-httproute": + return NewGatewayHTTPRouteSource(p, cfg) case "istio-gateway": kubernetesClient, err := p.KubeClient() if err != nil {