diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 6cd78ee59..196427716 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -422,7 +422,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, pod, fake, connector, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, cloudfoundry, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, traefik-proxy)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "pod", "gateway-httproute", "gateway-grpcroute", "gateway-tlsroute", "gateway-tcproute", "gateway-udproute", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-httpproxy", "gloo-proxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host", "kong-tcpingress", "f5-virtualserver", "traefik-proxy") + app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, pod, fake, connector, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, cloudfoundry, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, f5-transportserver, traefik-proxy)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "pod", "gateway-httproute", "gateway-grpcroute", "gateway-tlsroute", "gateway-tcproute", "gateway-udproute", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-httpproxy", "gloo-proxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host", "kong-tcpingress", "f5-virtualserver", "f5-transportserver", "traefik-proxy") 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 resources queried for endpoints to a specific namespace (default: all namespaces)").Default(defaultConfig.Namespace).StringVar(&cfg.Namespace) app.Flag("annotation-filter", "Filter resources queried for endpoints by annotation, using label selector semantics").Default(defaultConfig.AnnotationFilter).StringVar(&cfg.AnnotationFilter) diff --git a/source/f5_transportserver.go b/source/f5_transportserver.go new file mode 100644 index 000000000..3ab57ce2b --- /dev/null +++ b/source/f5_transportserver.go @@ -0,0 +1,213 @@ +/* +Copyright 2022 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" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/cache" + + f5 "github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1" + + "sigs.k8s.io/external-dns/endpoint" +) + +var f5TransportServerGVR = schema.GroupVersionResource{ + Group: "cis.f5.com", + Version: "v1", + Resource: "transportservers", +} + +// transportServerSource is an implementation of Source for F5 TransportServer objects. +type f5TransportServerSource struct { + dynamicKubeClient dynamic.Interface + transportServerInformer informers.GenericInformer + kubeClient kubernetes.Interface + annotationFilter string + namespace string + unstructuredConverter *unstructuredConverter +} + +func NewF5TransportServerSource( + ctx context.Context, + dynamicKubeClient dynamic.Interface, + kubeClient kubernetes.Interface, + namespace string, + annotationFilter string, +) (Source, error) { + informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, namespace, nil) + transportServerInformer := informerFactory.ForResource(f5TransportServerGVR) + + transportServerInformer.Informer().AddEventHandler( + cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + }, + }, + ) + + informerFactory.Start(ctx.Done()) + + // wait for the local cache to be populated. + if err := waitForDynamicCacheSync(context.Background(), informerFactory); err != nil { + return nil, err + } + + uc, err := newTSUnstructuredConverter() + if err != nil { + return nil, errors.Wrapf(err, "failed to setup unstructured converter") + } + + return &f5TransportServerSource{ + dynamicKubeClient: dynamicKubeClient, + transportServerInformer: transportServerInformer, + kubeClient: kubeClient, + namespace: namespace, + annotationFilter: annotationFilter, + unstructuredConverter: uc, + }, nil +} + +// Endpoints returns endpoint objects for each host-target combination that should be processed. +// Retrieves all TransportServers in the source's namespace(s). +func (ts *f5TransportServerSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { + transportServerObjects, err := ts.transportServerInformer.Lister().ByNamespace(ts.namespace).List(labels.Everything()) + if err != nil { + return nil, err + } + + var transportServers []*f5.TransportServer + for _, tsObj := range transportServerObjects { + unstructuredHost, ok := tsObj.(*unstructured.Unstructured) + if !ok { + return nil, errors.New("could not convert") + } + + transportServer := &f5.TransportServer{} + err := ts.unstructuredConverter.scheme.Convert(unstructuredHost, transportServer, nil) + if err != nil { + return nil, err + } + transportServers = append(transportServers, transportServer) + } + + transportServers, err = ts.filterByAnnotations(transportServers) + if err != nil { + return nil, errors.Wrap(err, "failed to filter TransportServers") + } + + endpoints, err := ts.endpointsFromTransportServers(transportServers) + if err != nil { + return nil, err + } + + // Sort endpoints + for _, ep := range endpoints { + sort.Sort(ep.Targets) + } + + return endpoints, nil +} + +func (ts *f5TransportServerSource) AddEventHandler(ctx context.Context, handler func()) { + log.Debug("Adding event handler for TransportServer") + + ts.transportServerInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) +} + +// endpointsFromTransportServers extracts the endpoints from a slice of TransportServers +func (ts *f5TransportServerSource) endpointsFromTransportServers(transportServers []*f5.TransportServer) ([]*endpoint.Endpoint, error) { + var endpoints []*endpoint.Endpoint + + for _, transportServer := range transportServers { + resource := fmt.Sprintf("f5-transportserver/%s/%s", transportServer.Namespace, transportServer.Name) + + ttl := getTTLFromAnnotations(transportServer.Annotations, resource) + + targets := getTargetsFromTargetAnnotation(transportServer.Annotations) + if len(targets) == 0 && transportServer.Spec.VirtualServerAddress != "" { + targets = append(targets, transportServer.Spec.VirtualServerAddress) + } + if len(targets) == 0 && transportServer.Status.VSAddress != "" { + targets = append(targets, transportServer.Status.VSAddress) + } + + endpoints = append(endpoints, endpointsForHostname(transportServer.Spec.Host, targets, ttl, nil, "", resource)...) + } + + return endpoints, nil +} + +// newUnstructuredConverter returns a new unstructuredConverter initialized +func newTSUnstructuredConverter() (*unstructuredConverter, error) { + uc := &unstructuredConverter{ + scheme: runtime.NewScheme(), + } + + // Add the core types we need + uc.scheme.AddKnownTypes(f5TransportServerGVR.GroupVersion(), &f5.TransportServer{}, &f5.TransportServerList{}) + if err := scheme.AddToScheme(uc.scheme); err != nil { + return nil, err + } + + return uc, nil +} + +// filterByAnnotations filters a list of TransportServers by a given annotation selector. +func (ts *f5TransportServerSource) filterByAnnotations(transportServers []*f5.TransportServer) ([]*f5.TransportServer, error) { + labelSelector, err := metav1.ParseToLabelSelector(ts.annotationFilter) + if err != nil { + return nil, err + } + + selector, err := metav1.LabelSelectorAsSelector(labelSelector) + if err != nil { + return nil, err + } + + // empty filter returns original list + if selector.Empty() { + return transportServers, nil + } + + filteredList := []*f5.TransportServer{} + + for _, ts := range transportServers { + // convert the TransportServer's annotations to an equivalent label selector + annotations := labels.Set(ts.Annotations) + + // include TransportServer if its annotations match the selector + if selector.Matches(annotations) { + filteredList = append(filteredList, ts) + } + } + + return filteredList, nil +} diff --git a/source/f5_transportserver_test.go b/source/f5_transportserver_test.go new file mode 100644 index 000000000..8f8820ce8 --- /dev/null +++ b/source/f5_transportserver_test.go @@ -0,0 +1,285 @@ +/* +Copyright 2022 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" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + fakeDynamic "k8s.io/client-go/dynamic/fake" + fakeKube "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/endpoint" + + f5 "github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1" +) + +const defaultF5TransportServerNamespace = "transportserver" + +func TestF5TransportServerEndpoints(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + annotationFilter string + transportServer f5.TransportServer + expected []*endpoint.Endpoint + }{ + { + name: "F5 TransportServer with target annotation", + annotationFilter: "", + transportServer: f5.TransportServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: f5TransportServerGVR.GroupVersion().String(), + Kind: "TransportServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vs", + Namespace: defaultF5TransportServerNamespace, + Annotations: map[string]string{ + targetAnnotationKey: "192.168.1.150", + }, + }, + Spec: f5.TransportServerSpec{ + Host: "www.example.com", + VirtualServerAddress: "192.168.1.100", + }, + Status: f5.TransportServerStatus{ + VSAddress: "192.168.1.200", + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "www.example.com", + Targets: []string{"192.168.1.150"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + Labels: endpoint.Labels{ + "resource": "f5-transportserver/transportserver/test-vs", + }, + }, + }, + }, + { + name: "F5 TransportServer with host and VirtualServerAddress set", + annotationFilter: "", + transportServer: f5.TransportServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: f5TransportServerGVR.GroupVersion().String(), + Kind: "TransportServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vs", + Namespace: defaultF5TransportServerNamespace, + }, + Spec: f5.TransportServerSpec{ + Host: "www.example.com", + VirtualServerAddress: "192.168.1.100", + }, + Status: f5.TransportServerStatus{ + VSAddress: "192.168.1.200", + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "www.example.com", + Targets: []string{"192.168.1.100"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + Labels: endpoint.Labels{ + "resource": "f5-transportserver/transportserver/test-vs", + }, + }, + }, + }, + { + name: "F5 TransportServer with host set and IP address from the status field", + annotationFilter: "", + transportServer: f5.TransportServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: f5TransportServerGVR.GroupVersion().String(), + Kind: "TransportServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vs", + Namespace: defaultF5TransportServerNamespace, + }, + Spec: f5.TransportServerSpec{ + Host: "www.example.com", + }, + Status: f5.TransportServerStatus{ + VSAddress: "192.168.1.100", + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "www.example.com", + Targets: []string{"192.168.1.100"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + Labels: endpoint.Labels{ + "resource": "f5-transportserver/transportserver/test-vs", + }, + }, + }, + }, + { + name: "F5 TransportServer with no IP address set", + annotationFilter: "", + transportServer: f5.TransportServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: f5TransportServerGVR.GroupVersion().String(), + Kind: "TransportServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vs", + Namespace: defaultF5TransportServerNamespace, + }, + Spec: f5.TransportServerSpec{ + Host: "www.example.com", + }, + Status: f5.TransportServerStatus{ + VSAddress: "", + }, + }, + expected: nil, + }, + { + name: "F5 TransportServer with matching annotation filter", + annotationFilter: "foo=bar", + transportServer: f5.TransportServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: f5TransportServerGVR.GroupVersion().String(), + Kind: "TransportServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vs", + Namespace: defaultF5TransportServerNamespace, + Annotations: map[string]string{ + "foo": "bar", + }, + }, + Spec: f5.TransportServerSpec{ + Host: "www.example.com", + VirtualServerAddress: "192.168.1.100", + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "www.example.com", + Targets: []string{"192.168.1.100"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + Labels: endpoint.Labels{ + "resource": "f5-transportserver/transportserver/test-vs", + }, + }, + }, + }, + { + name: "F5 TransportServer with non-matching annotation filter", + annotationFilter: "foo=bar", + transportServer: f5.TransportServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: f5TransportServerGVR.GroupVersion().String(), + Kind: "TransportServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vs", + Namespace: defaultF5TransportServerNamespace, + Annotations: map[string]string{ + "bar": "foo", + }, + }, + Spec: f5.TransportServerSpec{ + Host: "www.example.com", + VirtualServerAddress: "192.168.1.100", + }, + }, + expected: nil, + }, + { + name: "F5 TransportServer TTL annotation", + transportServer: f5.TransportServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: f5TransportServerGVR.GroupVersion().String(), + Kind: "TransportServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vs", + Namespace: defaultF5TransportServerNamespace, + Annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/ttl": "600", + }, + }, + Spec: f5.TransportServerSpec{ + Host: "www.example.com", + VirtualServerAddress: "192.168.1.100", + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "www.example.com", + Targets: []string{"192.168.1.100"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 600, + Labels: endpoint.Labels{ + "resource": "f5-transportserver/transportserver/test-vs", + }, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fakeKubernetesClient := fakeKube.NewSimpleClientset() + scheme := runtime.NewScheme() + scheme.AddKnownTypes(f5TransportServerGVR.GroupVersion(), &f5.TransportServer{}, &f5.TransportServerList{}) + fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) + + transportServer := unstructured.Unstructured{} + + transportServerJSON, err := json.Marshal(tc.transportServer) + require.NoError(t, err) + assert.NoError(t, transportServer.UnmarshalJSON(transportServerJSON)) + + // Create TransportServer resources + _, err = fakeDynamicClient.Resource(f5TransportServerGVR).Namespace(defaultF5TransportServerNamespace).Create(context.Background(), &transportServer, metav1.CreateOptions{}) + assert.NoError(t, err) + + source, err := NewF5TransportServerSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, defaultF5TransportServerNamespace, tc.annotationFilter) + require.NoError(t, err) + assert.NotNil(t, source) + + count := &unstructured.UnstructuredList{} + for len(count.Items) < 1 { + count, _ = fakeDynamicClient.Resource(f5TransportServerGVR).Namespace(defaultF5TransportServerNamespace).List(context.Background(), metav1.ListOptions{}) + } + + endpoints, err := source.Endpoints(context.Background()) + require.NoError(t, err) + assert.Len(t, endpoints, len(tc.expected)) + assert.Equal(t, endpoints, tc.expected) + }) + } +} diff --git a/source/store.go b/source/store.go index f67091d31..b74be9fe1 100644 --- a/source/store.go +++ b/source/store.go @@ -354,6 +354,16 @@ func BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg return nil, err } return NewF5VirtualServerSource(ctx, dynamicClient, kubernetesClient, cfg.Namespace, cfg.AnnotationFilter) + case "f5-transportserver": + kubernetesClient, err := p.KubeClient() + if err != nil { + return nil, err + } + dynamicClient, err := p.DynamicKubernetesClient() + if err != nil { + return nil, err + } + return NewF5TransportServerSource(ctx, dynamicClient, kubernetesClient, cfg.Namespace, cfg.AnnotationFilter) } return nil, ErrSourceNotFound diff --git a/source/store_test.go b/source/store_test.go index 0a3923728..d65889328 100644 --- a/source/store_test.go +++ b/source/store_test.go @@ -130,6 +130,11 @@ func (suite *ByNamesTestSuite) TestAllInitialized() { Version: "v1", Resource: "virtualservers", }: "VirtualServersList", + { + Group: "cis.f5.com", + Version: "v1", + Resource: "transportservers", + }: "TransportServersList", { Group: "traefik.containo.us", Version: "v1alpha1", @@ -162,9 +167,9 @@ func (suite *ByNamesTestSuite) TestAllInitialized() { }: "IngressRouteUDPList", }), nil) - sources, err := ByNames(context.TODO(), mockClientGenerator, []string{"service", "ingress", "istio-gateway", "contour-httpproxy", "kong-tcpingress", "f5-virtualserver", "traefik-proxy", "fake"}, &Config{}) + sources, err := ByNames(context.TODO(), mockClientGenerator, []string{"service", "ingress", "istio-gateway", "contour-httpproxy", "kong-tcpingress", "f5-virtualserver", "f5-transportserver", "traefik-proxy", "fake"}, &Config{}) suite.NoError(err, "should not generate errors") - suite.Len(sources, 8, "should generate all eight sources") + suite.Len(sources, 9, "should generate all nine sources") } func (suite *ByNamesTestSuite) TestOnlyFake() {