diff --git a/docs/sources/f5-transportserver.md b/docs/sources/f5-transportserver.md new file mode 100644 index 0000000000..4a682d5190 --- /dev/null +++ b/docs/sources/f5-transportserver.md @@ -0,0 +1,106 @@ +# F5 Networks TransportServer Source + +This tutorial describes how to configure ExternalDNS to use the F5 Networks TransportServer Source. It is meant to supplement the other provider-specific setup tutorials. + +The F5 Networks TransportServer CRD is part of [this](https://github.com/F5Networks/k8s-bigip-ctlr) project. See more in-depth info regarding the TransportServer CRD [here](https://github.com/F5Networks/k8s-bigip-ctlr/tree/master/docs/cis-20.x/config_examples/customResource/TransportServer). + +## Start with ExternalDNS with the F5 Networks TransportServer source + +1. Make sure that you have the `k8s-bigip-ctlr` installed in your cluster. The needed CRDs are bundled within the controller. + +2. In your Helm `values.yaml` add: +``` +sources: + - ... + - f5-transportserver + - ... +``` +or add it in your `Deployment` if you aren't installing `external-dns` via Helm: +``` +args: +- --source=f5-transportserver +``` + +Note that, in case you're not installing via Helm, you'll need the following in the `ClusterRole` bound to the service account of `external-dns`: +``` +- apiGroups: + - cis.f5.com + resources: + - transportservers + verbs: + - get + - list + - watch +``` + +### Example TransportServer CR w/ host in spec + +``` +apiVersion: cis.f5.com/v1 +kind: TransportServer +metadata: + labels: + f5cr: 'true' + name: test-ts + namespace: test-ns +spec: + bigipRouteDomain: 0 + host: test.example.com + ipamLabel: vips + mode: standard + pool: + service: test-service + servicePort: 4222 + virtualServerPort: 4222 +``` + +### Example TransportServer CR w/ target annotation set + +If the `external-dns.alpha.kubernetes.io/target` annotation is set, the record created will reflect that and everything else will be ignored. + +``` +apiVersion: cis.f5.com/v1 +kind: TransportServer +metadata: + annotations: + external-dns.alpha.kubernetes.io/target: 10.172.1.12 + labels: + f5cr: 'true' + name: test-ts + namespace: test-ns +spec: + bigipRouteDomain: 0 + host: test.example.com + ipamLabel: vips + mode: standard + pool: + service: test-service + servicePort: 4222 + virtualServerPort: 4222 +``` + +### Example TransportServer CR w/ VirtualServerAddress set + +If `virtualServerAddress` is set, the record created will reflect that. `external-dns.alpha.kubernetes.io/target` will take precedence though. + +``` +apiVersion: cis.f5.com/v1 +kind: TransportServer +metadata: + labels: + f5cr: 'true' + name: test-ts + namespace: test-ns +spec: + bigipRouteDomain: 0 + host: test.example.com + ipamLabel: vips + mode: standard + pool: + service: test-service + servicePort: 4222 + virtualServerPort: 4222 + virtualServerAddress: 10.172.1.123 +``` + +If there is no target annotation or `virtualServerAddress` field set, then it'll use the `VSAddress` field from the created TransportServer status to create the record. diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 6cd78ee593..196427716e 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 0000000000..0454747f40 --- /dev/null +++ b/source/f5_transportserver.go @@ -0,0 +1,207 @@ +/* +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" + + "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 + } + + 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 0000000000..8f8820ce86 --- /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 f67091d315..b74be9fe15 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 0a39237288..d65889328d 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() {