diff --git a/docs/sources/sources.md b/docs/sources/sources.md index b15662491..5f262bb40 100644 --- a/docs/sources/sources.md +++ b/docs/sources/sources.md @@ -2,7 +2,7 @@ | Source | Resources | annotation-filter | label-filter | |---------------------------------|-------------------------------------------------------------------------------|-------------------|--------------| -| ambassador-host | Host.getambassador.io | | | +| ambassador-host | Host.getambassador.io | Yes | Yes | | connector | | | | | contour-httpproxy | HttpProxy.projectcontour.io | Yes | | | cloudfoundry | | | | diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 423229a8b..0687521b7 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -430,7 +430,7 @@ func (cfg *Config) ParseFlags(args []string) error { 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) - app.Flag("label-filter", "Filter resources queried for endpoints by label selector; currently supported by source types crd, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, ingress, node, openshift-route, and service").Default(defaultConfig.LabelFilter).StringVar(&cfg.LabelFilter) + app.Flag("label-filter", "Filter resources queried for endpoints by label selector; currently supported by source types crd, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, ingress, node, openshift-route, service and ambassador-host").Default(defaultConfig.LabelFilter).StringVar(&cfg.LabelFilter) app.Flag("ingress-class", "Require an Ingress to have this class name (defaults to any class; specify multiple times to allow more than one class)").StringsVar(&cfg.IngressClassNames) app.Flag("fqdn-template", "A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Accepts comma separated list for multiple global FQDN.").Default(defaultConfig.FQDNTemplate).StringVar(&cfg.FQDNTemplate) app.Flag("combine-fqdn-annotation", "Combine FQDN template and Annotations instead of overwriting").BoolVar(&cfg.CombineFQDNAndAnnotation) diff --git a/source/ambassador_host.go b/source/ambassador_host.go index 5d8b9e56e..cb0501210 100644 --- a/source/ambassador_host.go +++ b/source/ambassador_host.go @@ -57,8 +57,10 @@ type ambassadorHostSource struct { dynamicKubeClient dynamic.Interface kubeClient kubernetes.Interface namespace string + annotationFilter string ambassadorHostInformer informers.GenericInformer unstructuredConverter *unstructuredConverter + labelSelector labels.Selector } // NewAmbassadorHostSource creates a new ambassadorHostSource with the given config. @@ -67,6 +69,8 @@ func NewAmbassadorHostSource( dynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface, namespace string, + annotationFilter string, + labelSelector labels.Selector, ) (Source, error) { var err error @@ -85,6 +89,7 @@ func NewAmbassadorHostSource( informerFactory.Start(ctx.Done()) + // wait for the local cache to be populated. if err := waitForDynamicCacheSync(context.Background(), informerFactory); err != nil { return nil, err } @@ -98,20 +103,23 @@ func NewAmbassadorHostSource( dynamicKubeClient: dynamicKubeClient, kubeClient: kubeClient, namespace: namespace, + annotationFilter: annotationFilter, ambassadorHostInformer: ambassadorHostInformer, unstructuredConverter: uc, + labelSelector: labelSelector, }, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all Hosts in the source's namespace(s). func (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { - hosts, err := sc.ambassadorHostInformer.Lister().ByNamespace(sc.namespace).List(labels.Everything()) + hosts, err := sc.ambassadorHostInformer.Lister().ByNamespace(sc.namespace).List(sc.labelSelector) if err != nil { return nil, err } - endpoints := []*endpoint.Endpoint{} + // Get a list of Ambassador Host resources + ambassadorHosts := []*ambassador.Host{} for _, hostObj := range hosts { unstructuredHost, ok := hostObj.(*unstructured.Unstructured) if !ok { @@ -123,7 +131,18 @@ func (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endp if err != nil { return nil, err } + ambassadorHosts = append(ambassadorHosts, host) + } + // Filter Ambassador Hosts + ambassadorHosts, err = sc.filterByAnnotations(ambassadorHosts) + if err != nil { + return nil, errors.Wrap(err, "failed to filter Ambassador Hosts by annotation") + } + + endpoints := []*endpoint.Endpoint{} + + for _, host := range ambassadorHosts { fullname := fmt.Sprintf("%s/%s", host.Namespace, host.Name) // look for the "exernal-dns.ambassador-service" annotation. If it is not there then just ignore this `Host` @@ -269,3 +288,35 @@ func newUnstructuredConverter() (*unstructuredConverter, error) { return uc, nil } + +// Filter a list of Ambassador Host Resources to only return the ones that +// contain the required External-DNS annotation filter +func (sc *ambassadorHostSource) filterByAnnotations(ambassadorHosts []*ambassador.Host) ([]*ambassador.Host, error) { + // External-DNS Annotation Filter + labelSelector, err := metav1.ParseToLabelSelector(sc.annotationFilter) + if err != nil { + return nil, err + } + + selector, err := metav1.LabelSelectorAsSelector(labelSelector) + if err != nil { + return nil, err + } + + // empty filter returns original list of Ambassador Hosts + if selector.Empty() { + return ambassadorHosts, nil + } + + // Return a filtered list of Ambassador Hosts + filteredList := []*ambassador.Host{} + for _, host := range ambassadorHosts { + annotations := labels.Set(host.Annotations) + // include Ambassador Host if its annotations match the annotation filter + if selector.Matches(annotations) { + filteredList = append(filteredList, host) + } + } + + return filteredList, nil +} diff --git a/source/ambassador_host_test.go b/source/ambassador_host_test.go index 1f0091146..970bb2a19 100644 --- a/source/ambassador_host_test.go +++ b/source/ambassador_host_test.go @@ -17,6 +17,7 @@ limitations under the License. package source import ( + "context" "fmt" "testing" @@ -24,10 +25,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "golang.org/x/net/context" v1 "k8s.io/api/core/v1" 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" fakeDynamic "k8s.io/client-go/dynamic/fake" fakeKube "k8s.io/client-go/kubernetes/fake" @@ -37,6 +38,9 @@ import ( const defaultAmbassadorNamespace = "ambassador" const defaultAmbassadorServiceName = "ambassador" +// This is a compile-time validation that ambassadorHostSource is a Source. +var _ Source = &ambassadorHostSource{} + type AmbassadorSuite struct { suite.Suite } @@ -57,13 +61,16 @@ func TestAmbassadorHostSource(t *testing.T) { hostAnnotation := fmt.Sprintf("%s/%s", defaultAmbassadorNamespace, defaultAmbassadorServiceName) for _, ti := range []struct { - title string - host ambassador.Host - service v1.Service - expected []*endpoint.Endpoint + title string + annotationFilter string + labelSelector labels.Selector + host ambassador.Host + service v1.Service + expected []*endpoint.Endpoint }{ { - title: "Simple host", + title: "Simple host", + labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", @@ -95,7 +102,8 @@ func TestAmbassadorHostSource(t *testing.T) { }, }, }, { - title: "Service with load balancer hostname", + title: "Service with load balancer hostname", + labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", @@ -127,7 +135,8 @@ func TestAmbassadorHostSource(t *testing.T) { }, }, }, { - title: "Service with external IP", + title: "Service with external IP", + labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "service-external-ip", @@ -162,7 +171,8 @@ func TestAmbassadorHostSource(t *testing.T) { }, }, }, { - title: "Host with target annotation", + title: "Host with target annotation", + labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", @@ -195,7 +205,8 @@ func TestAmbassadorHostSource(t *testing.T) { }, }, }, { - title: "Host with TTL annotation", + title: "Host with TTL annotation", + labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", @@ -229,7 +240,8 @@ func TestAmbassadorHostSource(t *testing.T) { }, }, }, { - title: "Host with provider specific annotation", + title: "Host with provider specific annotation", + labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", @@ -266,7 +278,8 @@ func TestAmbassadorHostSource(t *testing.T) { }, }, }, { - title: "Host with missing Ambassador annotation", + title: "Host with missing Ambassador annotation", + labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", @@ -288,6 +301,316 @@ func TestAmbassadorHostSource(t *testing.T) { }, }, expected: []*endpoint.Endpoint{}, + }, { + title: "valid matching annotation filter expression", + annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", + labelSelector: labels.Everything(), + host: ambassador.Host{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-host", + Annotations: map[string]string{ + ambHostAnnotation: hostAnnotation, + "kubernetes.io/ingress.class": "external-ingress", + }, + }, + Spec: &ambassador.HostSpec{ + Hostname: "www.example.org", + }, + }, + service: v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAmbassadorServiceName, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{ + IP: "1.1.1.1", + }}, + }, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "www.example.org", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"1.1.1.1"}, + }, + }, + }, { + title: "valid non-matching annotation filter expression", + annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", + labelSelector: labels.Everything(), + host: ambassador.Host{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-host", + Annotations: map[string]string{ + ambHostAnnotation: hostAnnotation, + "kubernetes.io/ingress.class": "internal-ingress", + }, + }, + Spec: &ambassador.HostSpec{ + Hostname: "www.example.org", + }, + }, + service: v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAmbassadorServiceName, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{ + IP: "1.1.1.1", + }}, + }, + }, + }, + expected: []*endpoint.Endpoint{}, + }, { + title: "invalid annotation filter expression", + annotationFilter: "kubernetes.io/ingress.class in (invalid-ingress)", + labelSelector: labels.Everything(), + host: ambassador.Host{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-host", + Annotations: map[string]string{ + ambHostAnnotation: hostAnnotation, + "kubernetes.io/ingress.class": "external-ingress", + }, + }, + Spec: &ambassador.HostSpec{ + Hostname: "www.example.org", + }, + }, + service: v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAmbassadorServiceName, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{ + IP: "1.1.1.1", + }}, + }, + }, + }, + expected: []*endpoint.Endpoint{}, + }, { + title: "valid non-matching annotation filter label", + annotationFilter: "kubernetes.io/ingress.class=external-ingress", + labelSelector: labels.Everything(), + host: ambassador.Host{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-host", + Annotations: map[string]string{ + ambHostAnnotation: hostAnnotation, + "kubernetes.io/ingress.class": "internal-ingress", + }, + }, + Spec: &ambassador.HostSpec{ + Hostname: "www.example.org", + }, + }, + service: v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAmbassadorServiceName, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{ + IP: "1.1.1.1", + }}, + }, + }, + }, + expected: []*endpoint.Endpoint{}, + }, + { + title: "valid non-matching label filter expression", + labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), + host: ambassador.Host{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-host", + Annotations: map[string]string{ + ambHostAnnotation: hostAnnotation, + "kubernetes.io/ingress.class": "internal-ingress", + }, + Labels: map[string]string{ + "kubernetes.io/ingress.class": "internal-ingress", + }, + }, + Spec: &ambassador.HostSpec{ + Hostname: "www.example.org", + }, + }, + service: v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAmbassadorServiceName, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{ + IP: "1.1.1.1", + }}, + }, + }, + }, + expected: []*endpoint.Endpoint{}, + }, + { + title: "valid matching label filter expression for single host", + labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), + host: ambassador.Host{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-host", + Annotations: map[string]string{ + ambHostAnnotation: hostAnnotation, + }, + Labels: map[string]string{ + "kubernetes.io/ingress.class": "external-ingress", + }, + }, + Spec: &ambassador.HostSpec{ + Hostname: "www.example.org", + }, + }, + service: v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAmbassadorServiceName, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{ + IP: "1.1.1.1", + Hostname: "dns.google", + }}, + }, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "www.example.org", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"1.1.1.1"}, + }, + { + DNSName: "www.example.org", + RecordType: endpoint.RecordTypeCNAME, + Targets: endpoint.Targets{"dns.google"}, + }, + }, + }, + { + title: "valid matching label filter expression and matching annotation filter", + annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", + labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), + host: ambassador.Host{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-host", + Annotations: map[string]string{ + ambHostAnnotation: hostAnnotation, + "kubernetes.io/ingress.class": "external-ingress", + }, + Labels: map[string]string{ + "kubernetes.io/ingress.class": "external-ingress", + }, + }, + Spec: &ambassador.HostSpec{ + Hostname: "www.example.org", + }, + }, + service: v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAmbassadorServiceName, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{ + IP: "1.1.1.1", + Hostname: "dns.google", + }}, + }, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "www.example.org", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"1.1.1.1"}, + }, + { + DNSName: "www.example.org", + RecordType: endpoint.RecordTypeCNAME, + Targets: endpoint.Targets{"dns.google"}, + }, + }, + }, + { + title: "valid non matching label filter expression and valid matching annotation filter", + annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", + labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), + host: ambassador.Host{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-host", + Annotations: map[string]string{ + ambHostAnnotation: hostAnnotation, + "kubernetes.io/ingress.class": "external-ingress", + }, + Labels: map[string]string{ + "kubernetes.io/ingress.class": "internal-ingress", + }, + }, + Spec: &ambassador.HostSpec{ + Hostname: "www.example.org", + }, + }, + service: v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAmbassadorServiceName, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{ + IP: "1.1.1.1", + Hostname: "dns.google", + }}, + }, + }, + }, + expected: []*endpoint.Endpoint{}, + }, + { + title: "valid matching label filter expression and non matching annotation filter", + annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", + labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), + host: ambassador.Host{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-host", + Annotations: map[string]string{ + ambHostAnnotation: hostAnnotation, + "kubernetes.io/ingress.class": "internal-ingress", + }, + Labels: map[string]string{ + "kubernetes.io/ingress.class": "external-ingress", + }, + }, + Spec: &ambassador.HostSpec{ + Hostname: "www.example.org", + }, + }, + service: v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAmbassadorServiceName, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{ + IP: "1.1.1.1", + Hostname: "dns.google", + }}, + }, + }, + }, + expected: []*endpoint.Endpoint{}, }, } { ti := ti @@ -312,7 +635,7 @@ func TestAmbassadorHostSource(t *testing.T) { _, err = fakeDynamicClient.Resource(ambHostGVR).Namespace(namespace).Create(context.Background(), host, metav1.CreateOptions{}) assert.NoError(t, err) - source, err := NewAmbassadorHostSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, namespace) + source, err := NewAmbassadorHostSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, namespace, ti.annotationFilter, ti.labelSelector) assert.NoError(t, err) assert.NotNil(t, source) diff --git a/source/store.go b/source/store.go index cdb993b6b..f67091d31 100644 --- a/source/store.go +++ b/source/store.go @@ -276,7 +276,7 @@ func BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg if err != nil { return nil, err } - return NewAmbassadorHostSource(ctx, dynamicClient, kubernetesClient, cfg.Namespace) + return NewAmbassadorHostSource(ctx, dynamicClient, kubernetesClient, cfg.Namespace, cfg.AnnotationFilter, cfg.LabelFilter) case "contour-httpproxy": dynamicClient, err := p.DynamicKubernetesClient() if err != nil {