diff --git a/docs/tutorials/istio.md b/docs/tutorials/istio.md index 16f992020..1282e3561 100644 --- a/docs/tutorials/istio.md +++ b/docs/tutorials/istio.md @@ -1,4 +1,4 @@ -# Configuring ExternalDNS to use the Istio Gateway Source +# Configuring ExternalDNS to use the Istio Gateway and/or Istio Virtual Service Source This tutorial describes how to configure ExternalDNS to use the Istio Gateway source. It is meant to supplement the other provider-specific setup tutorials. @@ -32,7 +32,8 @@ spec: args: - --source=service - --source=ingress - - --source=istio-gateway + - --source=istio-gateway # choose one + - --source=istio-virtualservice # or both - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization @@ -63,7 +64,7 @@ rules: resources: ["nodes"] verbs: ["list"] - apiGroups: ["networking.istio.io"] - resources: ["gateways"] + resources: ["gateways", "virtualservices"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 @@ -102,6 +103,7 @@ spec: - --source=service - --source=ingress - --source=istio-gateway + - --source=istio-virtualservice - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization @@ -130,7 +132,7 @@ kubectl patch clusterrole external-dns --type='json' \ -p='[{"op": "add", "path": "/rules/4", "value": { "apiGroups": [ "networking.istio.io"], "resources": ["gateways"],"verbs": ["get", "watch", "list" ]} }]' ``` -### Verify ExternalDNS works (Gateway example) +### Verify that Istio Gateway/VirtualService Source works Follow the [Istio ingress traffic tutorial](https://istio.io/docs/tasks/traffic-management/ingress/) to deploy a sample service that will be exposed outside of the service mesh. @@ -147,7 +149,8 @@ Otherwise: $ kubectl apply -f <(istioctl kube-inject -f https://raw.githubusercontent.com/istio/istio/release-1.6/samples/httpbin/httpbin.yaml) ``` -#### Create an Istio Gateway: +#### Using a Gateway as a source +##### Create an Istio Gateway: ```bash $ cat < 0 { + return } - services, err := sc.serviceInformer.Lister().Services(sc.namespace).List(selector) + services, err := sc.serviceInformer.Lister().Services(sc.namespace).List(labels.Everything()) if err != nil { log.Error(err) return } for _, service := range services { + if !gatewaySelectorMatchesServiceSelector(gateway.Spec.Selector, service.Spec.Selector) { + continue + } + for _, lb := range service.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) @@ -262,7 +264,7 @@ func (sc *gatewaySource) endpointsFromGateway(hostnames []string, gateway networ targets := getTargetsFromTargetAnnotation(annotations) if len(targets) == 0 { - targets, err = sc.targetsFromGatewayConfig(gateway) + targets, err = sc.targetsFromGateway(gateway) if err != nil { return nil, err } @@ -315,3 +317,12 @@ func (sc *gatewaySource) hostNamesFromTemplate(gateway networkingv1alpha3.Gatewa hostnames := strings.Split(strings.Replace(buf.String(), " ", "", -1), ",") return hostnames, nil } + +func gatewaySelectorMatchesServiceSelector(gwSelector, svcSelector map[string]string) bool { + for k, v := range gwSelector { + if lbl, ok := svcSelector[k]; !ok || lbl != v { + return false + } + } + return true +} diff --git a/source/gateway_test.go b/source/gateway_test.go index 9324fcc05..9a4dd144a 100644 --- a/source/gateway_test.go +++ b/source/gateway_test.go @@ -1151,6 +1151,7 @@ type fakeIngressGatewayService struct { hostnames []string namespace string name string + selector map[string]string } func (ig fakeIngressGatewayService) Service() *v1.Service { @@ -1164,6 +1165,9 @@ func (ig fakeIngressGatewayService) Service() *v1.Service { Ingress: []v1.LoadBalancerIngress{}, }, }, + Spec: v1.ServiceSpec{ + Selector: ig.selector, + }, } for _, ip := range ig.ips { diff --git a/source/source_test.go b/source/source_test.go index dbf6c90c7..04c3b4612 100644 --- a/source/source_test.go +++ b/source/source_test.go @@ -17,15 +17,12 @@ limitations under the License. package source import ( - "context" "fmt" "testing" - "time" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" - "sigs.k8s.io/external-dns/internal/testutils" ) func TestGetTTLFromAnnotations(t *testing.T) { @@ -108,35 +105,3 @@ func TestSuitableType(t *testing.T) { } } } - -// TestSourceEventHandler that AddEventHandler calls provided handler -func TestSourceEventHandler(t *testing.T) { - source := new(testutils.MockSource) - - handlerCh := make(chan bool) - - ctx, cancel := context.WithCancel(context.Background()) - - // Define and register a simple handler that sends a message to a channel to show it was called. - handler := func() { - handlerCh <- true - } - // Example of preventing handler from being called more than once every 5 seconds. - source.AddEventHandler(ctx, handler) - - // Send timeout message after 10 seconds to fail test if handler is not called. - go func() { - time.Sleep(10 * time.Second) - cancel() - }() - - // Wait until we either receive a message from handlerCh or timeoutCh channel after 10 seconds. - select { - case msg := <-handlerCh: - assert.True(t, msg) - case <-ctx.Done(): - assert.Fail(t, "timed out waiting for event handler to be called") - } - - close(handlerCh) -} diff --git a/source/store.go b/source/store.go index 070ef9725..8a33b641e 100644 --- a/source/store.go +++ b/source/store.go @@ -196,6 +196,16 @@ func BuildWithConfig(source string, p ClientGenerator, cfg *Config) (Source, err return nil, err } return NewIstioGatewaySource(kubernetesClient, istioClient, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) + case "istio-virtualservice": + kubernetesClient, err := p.KubeClient() + if err != nil { + return nil, err + } + istioClient, err := p.IstioClient() + if err != nil { + return nil, err + } + return NewIstioVirtualServiceSource(kubernetesClient, istioClient, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) case "cloudfoundry": cfClient, err := p.CloudFoundryClient(cfg.CFAPIEndpoint, cfg.CFUsername, cfg.CFPassword) if err != nil { diff --git a/source/virtualservice.go b/source/virtualservice.go new file mode 100644 index 000000000..999dc449c --- /dev/null +++ b/source/virtualservice.go @@ -0,0 +1,449 @@ +/* +Copyright 2020 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 ( + "bytes" + "context" + "fmt" + "sort" + "strings" + "text/template" + "time" + + networkingv1alpha3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + + log "github.com/sirupsen/logrus" + istioclient "istio.io/client-go/pkg/clientset/versioned" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/wait" + kubeinformers "k8s.io/client-go/informers" + coreinformers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + + "sigs.k8s.io/external-dns/endpoint" +) + +// IstioMeshGateway is the built in gateway for all sidecars +const IstioMeshGateway = "mesh" + +// virtualServiceSource is an implementation of Source for Istio VirtualService objects. +// The implementation uses the spec.hosts values for the hostnames. +// Use targetAnnotationKey to explicitly set Endpoint. +type virtualServiceSource struct { + kubeClient kubernetes.Interface + istioClient istioclient.Interface + namespace string + annotationFilter string + fqdnTemplate *template.Template + combineFQDNAnnotation bool + ignoreHostnameAnnotation bool + serviceInformer coreinformers.ServiceInformer +} + +// NewIstioVirtualServiceSource creates a new virtualServiceSource with the given config. +func NewIstioVirtualServiceSource( + kubeClient kubernetes.Interface, + istioClient istioclient.Interface, + namespace string, + annotationFilter string, + fqdnTemplate string, + combineFQDNAnnotation bool, + ignoreHostnameAnnotation bool, +) (Source, error) { + var ( + tmpl *template.Template + err error + ) + + if fqdnTemplate != "" { + tmpl, err = template.New("endpoint").Funcs(template.FuncMap{ + "trimPrefix": strings.TrimPrefix, + }).Parse(fqdnTemplate) + if err != nil { + return nil, err + } + } + + // Use shared informers to listen for add/update/delete of services/pods/nodes in the specified namespace. + // Set resync period to 0, to prevent processing when nothing has changed + informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace)) + serviceInformer := informerFactory.Core().V1().Services() + + // Add default resource event handlers to properly initialize informer. + serviceInformer.Informer().AddEventHandler( + cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + log.Debug("service added") + }, + }, + ) + + // TODO informer is not explicitly stopped since controller is not passing in its channel. + informerFactory.Start(wait.NeverStop) + + // wait for the local cache to be populated. + err = wait.Poll(time.Second, 60*time.Second, func() (bool, error) { + return serviceInformer.Informer().HasSynced(), nil + }) + if err != nil { + return nil, fmt.Errorf("failed to sync cache: %v", err) + } + + return &virtualServiceSource{ + kubeClient: kubeClient, + istioClient: istioClient, + namespace: namespace, + annotationFilter: annotationFilter, + fqdnTemplate: tmpl, + combineFQDNAnnotation: combineFQDNAnnotation, + ignoreHostnameAnnotation: ignoreHostnameAnnotation, + serviceInformer: serviceInformer, + }, nil +} + +// Endpoints returns endpoint objects for each host-target combination that should be processed. +// Retrieves all VirtualService resources in the source's namespace(s). +func (sc *virtualServiceSource) Endpoints() ([]*endpoint.Endpoint, error) { + virtualServiceList, err := sc.istioClient.NetworkingV1alpha3().VirtualServices(sc.namespace).List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + virtualServices := virtualServiceList.Items + virtualServices, err = sc.filterByAnnotations(virtualServices) + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + + for _, virtualService := range virtualServices { + // Check controller annotation to see if we are responsible. + controller, ok := virtualService.Annotations[controllerAnnotationKey] + if ok && controller != controllerAnnotationValue { + log.Debugf("Skipping VirtualService %s/%s because controller value does not match, found: %s, required: %s", + virtualService.Namespace, virtualService.Name, controller, controllerAnnotationValue) + continue + } + + gwEndpoints, err := sc.endpointsFromVirtualService(virtualService) + if err != nil { + return nil, err + } + + // apply template if host is missing on VirtualService + if (sc.combineFQDNAnnotation || len(gwEndpoints) == 0) && sc.fqdnTemplate != nil { + iEndpoints, err := sc.endpointsFromTemplate(virtualService) + if err != nil { + return nil, err + } + + if sc.combineFQDNAnnotation { + gwEndpoints = append(gwEndpoints, iEndpoints...) + } else { + gwEndpoints = iEndpoints + } + } + + if len(gwEndpoints) == 0 { + log.Debugf("No endpoints could be generated from VirtualService %s/%s", virtualService.Namespace, virtualService.Name) + continue + } + + log.Debugf("Endpoints generated from VirtualService: %s/%s: %v", virtualService.Namespace, virtualService.Name, gwEndpoints) + sc.setResourceLabel(virtualService, gwEndpoints) + endpoints = append(endpoints, gwEndpoints...) + } + + for _, ep := range endpoints { + sort.Sort(ep.Targets) + } + + return endpoints, nil +} + +// TODO(tariq1890): Implement this once we have evaluated and tested VirtualServiceInformers +// AddEventHandler adds an event handler that should be triggered if the watched Istio VirtualService changes. +func (sc *virtualServiceSource) AddEventHandler(ctx context.Context, handler func()) { +} + +func (sc *virtualServiceSource) getGateway(gatewayStr string, virtualService networkingv1alpha3.VirtualService) *networkingv1alpha3.Gateway { + if gatewayStr == "" || gatewayStr == IstioMeshGateway { + // This refers to "all sidecars in the mesh"; ignore. + return nil + } + + namespace, name, err := parseGateway(gatewayStr) + if err != nil { + log.Debugf("Failed parsing gatewayStr %s of VirtualService %s/%s", gatewayStr, virtualService.Namespace, virtualService.Name) + return nil + } + if namespace == "" { + namespace = virtualService.Namespace + } + + gateway, err := sc.istioClient.NetworkingV1alpha3().Gateways(namespace).Get(name, metav1.GetOptions{}) + if err != nil { + log.Errorf("Failed retrieving gateway %s referenced by VirtualService %s/%s: %v", gatewayStr, virtualService.Namespace, virtualService.Name, err) + return nil + } + if gateway == nil { + log.Debugf("Gateway %s referenced by VirtualService %s/%s not found: %v", gatewayStr, virtualService.Namespace, virtualService.Name, err) + return nil + } + + return gateway +} + +func (sc *virtualServiceSource) endpointsFromTemplate(virtualService networkingv1alpha3.VirtualService) ([]*endpoint.Endpoint, error) { + // Process the whole template string + var buf bytes.Buffer + err := sc.fqdnTemplate.Execute(&buf, virtualService) + if err != nil { + return nil, fmt.Errorf("failed to apply template on istio config %v: %v", virtualService, err) + } + + hostnamesTemplate := buf.String() + + ttl, err := getTTLFromAnnotations(virtualService.Annotations) + if err != nil { + log.Warn(err) + } + + var endpoints []*endpoint.Endpoint + + providerSpecific, setIdentifier := getProviderSpecificAnnotations(virtualService.Annotations) + + // splits the FQDN template and removes the trailing periods + hostnames := strings.Split(strings.Replace(hostnamesTemplate, " ", "", -1), ",") + for _, hostname := range hostnames { + hostname = strings.TrimSuffix(hostname, ".") + targets, err := sc.targetsFromVirtualService(virtualService, hostname) + if err != nil { + return endpoints, err + } + endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier)...) + } + return endpoints, nil +} + +// filterByAnnotations filters a list of configs by a given annotation selector. +func (sc *virtualServiceSource) filterByAnnotations(virtualservices []networkingv1alpha3.VirtualService) ([]networkingv1alpha3.VirtualService, error) { + 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 + if selector.Empty() { + return virtualservices, nil + } + + var filteredList []networkingv1alpha3.VirtualService + + for _, virtualservice := range virtualservices { + // convert the annotations to an equivalent label selector + annotations := labels.Set(virtualservice.Annotations) + + // include if the annotations match the selector + if selector.Matches(annotations) { + filteredList = append(filteredList, virtualservice) + } + } + + return filteredList, nil +} + +func (sc *virtualServiceSource) setResourceLabel(virtualservice networkingv1alpha3.VirtualService, endpoints []*endpoint.Endpoint) { + for _, ep := range endpoints { + ep.Labels[endpoint.ResourceLabelKey] = fmt.Sprintf("virtualservice/%s/%s", virtualservice.Namespace, virtualservice.Name) + } +} + +func (sc *virtualServiceSource) targetsFromVirtualService(virtualService networkingv1alpha3.VirtualService, vsHost string) ([]string, error) { + var targets []string + // for each host we need to iterate through the gateways because each host might match for only one of the gateways + for _, gateway := range virtualService.Spec.Gateways { + gateway := sc.getGateway(gateway, virtualService) + if gateway == nil { + continue + } + if !virtualServiceBindsToGateway(&virtualService, gateway, vsHost) { + continue + } + tgs, err := sc.targetsFromGateway(gateway) + if err != nil { + return targets, err + } + targets = append(targets, tgs...) + } + + return targets, nil +} + +// endpointsFromVirtualService extracts the endpoints from an Istio VirtualService Config object +func (sc *virtualServiceSource) endpointsFromVirtualService(virtualservice networkingv1alpha3.VirtualService) ([]*endpoint.Endpoint, error) { + var endpoints []*endpoint.Endpoint + + ttl, err := getTTLFromAnnotations(virtualservice.Annotations) + if err != nil { + log.Warn(err) + } + + targetsFromAnnotation := getTargetsFromTargetAnnotation(virtualservice.Annotations) + + providerSpecific, setIdentifier := getProviderSpecificAnnotations(virtualservice.Annotations) + + for _, host := range virtualservice.Spec.Hosts { + if host == "" || host == "*" { + continue + } + + parts := strings.Split(host, "/") + + // If the input hostname is of the form my-namespace/foo.bar.com, remove the namespace + // before appending it to the list of endpoints to create + if len(parts) == 2 { + host = parts[1] + } + + targets := targetsFromAnnotation + if len(targets) == 0 { + targets, err = sc.targetsFromVirtualService(virtualservice, host) + if err != nil { + return endpoints, err + } + } + + endpoints = append(endpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier)...) + } + + // Skip endpoints if we do not want entries from annotations + if !sc.ignoreHostnameAnnotation { + hostnameList := getHostnamesFromAnnotations(virtualservice.Annotations) + for _, hostname := range hostnameList { + targets := targetsFromAnnotation + if len(targets) == 0 { + targets, err = sc.targetsFromVirtualService(virtualservice, hostname) + if err != nil { + return endpoints, err + } + } + endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier)...) + } + } + + return endpoints, nil +} + +// checks if the given VirtualService should actually bind to the given gateway +// see requirements here: https://istio.io/docs/reference/config/networking/gateway/#Server +func virtualServiceBindsToGateway(virtualService *networkingv1alpha3.VirtualService, gateway *networkingv1alpha3.Gateway, vsHost string) bool { + isValid := false + if len(virtualService.Spec.ExportTo) == 0 { + isValid = true + } else { + for _, ns := range virtualService.Spec.ExportTo { + if ns == "*" || ns == gateway.Namespace || (ns == "." && gateway.Namespace == virtualService.Namespace) { + isValid = true + } + } + } + if !isValid { + return false + } + + for _, server := range gateway.Spec.Servers { + for _, host := range server.Hosts { + namespace := "*" + parts := strings.Split(host, "/") + if len(parts) == 2 { + namespace = parts[0] + host = parts[1] + } else if len(parts) != 1 { + log.Debugf("Gateway %s/%s has invalid host %s", gateway.Namespace, gateway.Name, host) + continue + } + + if namespace == "*" || namespace == virtualService.Namespace || (namespace == "." && virtualService.Namespace == gateway.Namespace) { + if host == "*" { + return true + } + + suffixMatch := false + if strings.HasPrefix(host, "*.") { + suffixMatch = true + } + + if host == vsHost || (suffixMatch && strings.HasSuffix(vsHost, host[1:])) { + return true + } + } + } + } + + return false +} + +func parseGateway(gateway string) (namespace, name string, err error) { + parts := strings.Split(gateway, "/") + if len(parts) == 2 { + namespace, name = parts[0], parts[1] + } else if len(parts) == 1 { + name = parts[0] + } else { + err = fmt.Errorf("invalid gateway name (name or namespace/name) found '%v'", gateway) + } + + return +} + +func (sc *virtualServiceSource) targetsFromGateway(gateway *networkingv1alpha3.Gateway) (targets endpoint.Targets, err error) { + targets = getTargetsFromTargetAnnotation(gateway.Annotations) + if len(targets) > 0 { + return + } + + services, err := sc.serviceInformer.Lister().Services(sc.namespace).List(labels.Everything()) + if err != nil { + log.Error(err) + return + } + + for _, service := range services { + if !gatewaySelectorMatchesServiceSelector(gateway.Spec.Selector, service.Spec.Selector) { + continue + } + + for _, lb := range service.Status.LoadBalancer.Ingress { + if lb.IP != "" { + targets = append(targets, lb.IP) + } else if lb.Hostname != "" { + targets = append(targets, lb.Hostname) + } + } + } + + return +} diff --git a/source/virtualservice_test.go b/source/virtualservice_test.go new file mode 100644 index 000000000..8bb34c546 --- /dev/null +++ b/source/virtualservice_test.go @@ -0,0 +1,1554 @@ +/* +Copyright 2020 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 ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + istionetworking "istio.io/api/networking/v1alpha3" + networkingv1alpha3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + v1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/fake" + + "sigs.k8s.io/external-dns/endpoint" +) + +// This is a compile-time validation that istioVirtualServiceSource is a Source. +var _ Source = &virtualServiceSource{} + +type VirtualServiceSuite struct { + suite.Suite + source Source + lbServices []*v1.Service + gwconfig networkingv1alpha3.Gateway + vsconfig networkingv1alpha3.VirtualService +} + +func (suite *VirtualServiceSuite) SetupTest() { + fakeKubernetesClient := fake.NewSimpleClientset() + fakeIstioClient := NewFakeConfigStore() + var err error + + suite.lbServices = []*v1.Service{ + (fakeIngressGatewayService{ + ips: []string{"8.8.8.8"}, + hostnames: []string{"v1"}, + namespace: "istio-system", + name: "istio-gateway1", + }).Service(), + (fakeIngressGatewayService{ + ips: []string{"1.1.1.1"}, + hostnames: []string{"v42"}, + namespace: "istio-system", + name: "istio-gateway2", + }).Service(), + } + + for _, service := range suite.lbServices { + _, err = fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(service) + suite.NoError(err, "should succeed") + } + + suite.source, err = NewIstioVirtualServiceSource( + fakeKubernetesClient, + fakeIstioClient, + "", + "", + "{{.Name}}", + false, + false, + ) + suite.NoError(err, "should initialize virtualservice source") + + suite.gwconfig = (fakeGatewayConfig{ + name: "foo-gateway-with-targets", + namespace: "istio-system", + dnsnames: [][]string{{"*"}}, + }).Config() + _, err = fakeIstioClient.NetworkingV1alpha3().Gateways(suite.gwconfig.Namespace).Create(&suite.gwconfig) + suite.NoError(err, "should succeed") + + suite.vsconfig = (fakeVirtualServiceConfig{ + name: "foo-virtualservice", + namespace: "istio-other", + gateways: []string{"istio-system/foo-gateway-with-targets"}, + dnsnames: []string{"foo"}, + }).Config() + _, err = fakeIstioClient.NetworkingV1alpha3().VirtualServices(suite.vsconfig.Namespace).Create(&suite.vsconfig) + suite.NoError(err, "should succeed") +} + +func (suite *VirtualServiceSuite) TestResourceLabelIsSet() { + endpoints, err := suite.source.Endpoints() + suite.NoError(err, "should succeed") + suite.Equal(len(endpoints), 2, "should return the correct number of endpoints") + for _, ep := range endpoints { + suite.Equal("virtualservice/istio-other/foo-virtualservice", ep.Labels[endpoint.ResourceLabelKey], "should set correct resource label") + } +} + +func TestVirtualService(t *testing.T) { + suite.Run(t, new(VirtualServiceSuite)) + t.Run("virtualServiceBindsToGateway", testVirtualServiceBindsToGateway) + t.Run("endpointsFromVirtualServiceConfig", testEndpointsFromVirtualServiceConfig) + t.Run("Endpoints", testVirtualServiceEndpoints) + t.Run("gatewaySelectorMatchesService", testGatewaySelectorMatchesService) +} + +func TestNewIstioVirtualServiceSource(t *testing.T) { + for _, ti := range []struct { + title string + annotationFilter string + fqdnTemplate string + combineFQDNAndAnnotation bool + expectError bool + }{ + { + title: "invalid template", + expectError: true, + fqdnTemplate: "{{.Name", + }, + { + title: "valid empty template", + expectError: false, + }, + { + title: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", + }, + { + title: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", + }, + { + title: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", + combineFQDNAndAnnotation: true, + }, + { + title: "non-empty annotation filter label", + expectError: false, + annotationFilter: "kubernetes.io/gateway.class=nginx", + }, + } { + t.Run(ti.title, func(t *testing.T) { + _, err := NewIstioVirtualServiceSource( + fake.NewSimpleClientset(), + NewFakeConfigStore(), + "", + ti.annotationFilter, + ti.fqdnTemplate, + ti.combineFQDNAndAnnotation, + false, + ) + if ti.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func testVirtualServiceBindsToGateway(t *testing.T) { + for _, ti := range []struct { + title string + gwconfig fakeGatewayConfig + vsconfig fakeVirtualServiceConfig + vsHost string + expected bool + }{ + { + title: "matching host *", + gwconfig: fakeGatewayConfig{ + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{}, + vsHost: "foo.bar", + expected: true, + }, + { + title: "matching host *.", + gwconfig: fakeGatewayConfig{ + dnsnames: [][]string{{"*.foo.bar"}}, + }, + vsconfig: fakeVirtualServiceConfig{}, + vsHost: "baz.foo.bar", + expected: true, + }, + { + title: "not matching host *.", + gwconfig: fakeGatewayConfig{ + dnsnames: [][]string{{"*.foo.bar"}}, + }, + vsconfig: fakeVirtualServiceConfig{}, + vsHost: "foo.bar", + expected: false, + }, + { + title: "not matching host *.", + gwconfig: fakeGatewayConfig{ + dnsnames: [][]string{{"*.foo.bar"}}, + }, + vsconfig: fakeVirtualServiceConfig{}, + vsHost: "bazfoo.bar", + expected: false, + }, + { + title: "not matching host *.", + gwconfig: fakeGatewayConfig{ + dnsnames: [][]string{{"*.foo.bar"}}, + }, + vsconfig: fakeVirtualServiceConfig{}, + vsHost: "*foo.bar", + expected: false, + }, + { + title: "matching host */*", + gwconfig: fakeGatewayConfig{ + dnsnames: [][]string{{"*/*"}}, + }, + vsconfig: fakeVirtualServiceConfig{}, + vsHost: "foo.bar", + expected: true, + }, + { + title: "matching host /*", + gwconfig: fakeGatewayConfig{ + namespace: "istio-system", + dnsnames: [][]string{{"myns/*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + namespace: "myns", + }, + vsHost: "foo.bar", + expected: true, + }, + { + title: "matching host ./*", + gwconfig: fakeGatewayConfig{ + namespace: "istio-system", + dnsnames: [][]string{{"./*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + namespace: "istio-system", + }, + vsHost: "foo.bar", + expected: true, + }, + { + title: "not matching host ./*", + gwconfig: fakeGatewayConfig{ + namespace: "istio-system", + dnsnames: [][]string{{"./*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + namespace: "myns", + }, + vsHost: "foo.bar", + expected: false, + }, + { + title: "not matching host /*", + gwconfig: fakeGatewayConfig{ + namespace: "istio-system", + dnsnames: [][]string{{"myns/*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + namespace: "otherns", + }, + vsHost: "foo.bar", + expected: false, + }, + { + title: "not matching host /*", + gwconfig: fakeGatewayConfig{ + namespace: "istio-system", + dnsnames: [][]string{{"myns/*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + namespace: "otherns", + }, + vsHost: "foo.bar", + expected: false, + }, + { + title: "matching exportTo *", + gwconfig: fakeGatewayConfig{ + namespace: "istio-system", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + namespace: "otherns", + exportTo: "*", + }, + vsHost: "foo.bar", + expected: true, + }, + { + title: "matching exportTo ", + gwconfig: fakeGatewayConfig{ + namespace: "istio-system", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + namespace: "otherns", + exportTo: "istio-system", + }, + vsHost: "foo.bar", + expected: true, + }, + { + title: "not matching exportTo ", + gwconfig: fakeGatewayConfig{ + namespace: "istio-system", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + namespace: "otherns", + exportTo: "myns", + }, + vsHost: "foo.bar", + expected: false, + }, + { + title: "not matching exportTo .", + gwconfig: fakeGatewayConfig{ + namespace: "istio-system", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + namespace: "otherns", + exportTo: ".", + }, + vsHost: "foo.bar", + expected: false, + }, + } { + t.Run(ti.title, func(t *testing.T) { + vsconfig := ti.vsconfig.Config() + gwconfig := ti.gwconfig.Config() + require.Equal(t, ti.expected, virtualServiceBindsToGateway(&vsconfig, &gwconfig, ti.vsHost)) + }) + } +} + +func testEndpointsFromVirtualServiceConfig(t *testing.T) { + for _, ti := range []struct { + title string + lbServices []fakeIngressGatewayService + gwconfig fakeGatewayConfig + vsconfig fakeVirtualServiceConfig + expected []*endpoint.Endpoint + }{ + { + title: "one rule.host one lb.hostname", + lbServices: []fakeIngressGatewayService{ + { + hostnames: []string{"lb.com"}, // Kubernetes omits the trailing dot + }, + }, + gwconfig: fakeGatewayConfig{ + name: "mygw", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + gateways: []string{"mygw"}, + dnsnames: []string{"foo.bar"}, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "foo.bar", + Targets: endpoint.Targets{"lb.com"}, + }, + }, + }, + { + title: "one rule.host one lb.IP", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, + }, + gwconfig: fakeGatewayConfig{ + name: "mygw", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + gateways: []string{"mygw"}, + dnsnames: []string{"foo.bar"}, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "foo.bar", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + }, + }, + { + title: "one rule.host two lb.IP and two lb.Hostname", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8", "127.0.0.1"}, + hostnames: []string{"elb.com", "alb.com"}, + }, + }, + gwconfig: fakeGatewayConfig{ + name: "mygw", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + gateways: []string{"mygw"}, + dnsnames: []string{"foo.bar"}, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "foo.bar", + Targets: endpoint.Targets{"8.8.8.8", "127.0.0.1"}, + }, + { + DNSName: "foo.bar", + Targets: endpoint.Targets{"elb.com", "alb.com"}, + }, + }, + }, + { + title: "no rule.host", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8", "127.0.0.1"}, + hostnames: []string{"elb.com", "alb.com"}, + }, + }, + gwconfig: fakeGatewayConfig{ + name: "mygw", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + gateways: []string{"mygw"}, + dnsnames: []string{}, + }, + expected: []*endpoint.Endpoint{}, + }, + { + title: "no rule.gateway", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8", "127.0.0.1"}, + hostnames: []string{"elb.com", "alb.com"}, + }, + }, + gwconfig: fakeGatewayConfig{ + name: "mygw", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + gateways: []string{}, + dnsnames: []string{"foo.bar"}, + }, + expected: []*endpoint.Endpoint{}, + }, + { + title: "one empty rule.host", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8", "127.0.0.1"}, + hostnames: []string{"elb.com", "alb.com"}, + }, + }, + gwconfig: fakeGatewayConfig{ + dnsnames: [][]string{ + {""}, + }, + }, + expected: []*endpoint.Endpoint{}, + }, + { + title: "no targets", + lbServices: []fakeIngressGatewayService{{}}, + gwconfig: fakeGatewayConfig{ + name: "mygw", + dnsnames: [][]string{{"*"}}, + }, + vsconfig: fakeVirtualServiceConfig{ + gateways: []string{}, + dnsnames: []string{"foo.bar"}, + }, + expected: []*endpoint.Endpoint{}, + }, + { + title: "matching selectors for service and gateway", + lbServices: []fakeIngressGatewayService{ + { + name: "service1", + selector: map[string]string{ + "app": "myservice", + }, + hostnames: []string{"elb.com", "alb.com"}, + }, + { + name: "service2", + selector: map[string]string{ + "app": "otherservice", + }, + ips: []string{"8.8.8.8", "127.0.0.1"}, + }, + }, + gwconfig: fakeGatewayConfig{ + name: "mygw", + dnsnames: [][]string{{"*"}}, + selector: map[string]string{ + "app": "myservice", + }, + }, + vsconfig: fakeVirtualServiceConfig{ + gateways: []string{"mygw"}, + dnsnames: []string{"foo.bar"}, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "foo.bar", + Targets: endpoint.Targets{"elb.com", "alb.com"}, + }, + }, + }, + } { + t.Run(ti.title, func(t *testing.T) { + if source, err := newTestVirtualServiceSource(ti.lbServices, []fakeGatewayConfig{ti.gwconfig}); err != nil { + require.NoError(t, err) + } else if endpoints, err := source.endpointsFromVirtualService(ti.vsconfig.Config()); err != nil { + require.NoError(t, err) + } else { + validateEndpoints(t, endpoints, ti.expected) + } + }) + } +} + +func testVirtualServiceEndpoints(t *testing.T) { + namespace := "testing" + for _, ti := range []struct { + title string + targetNamespace string + annotationFilter string + lbServices []fakeIngressGatewayService + gwConfigs []fakeGatewayConfig + vsConfigs []fakeVirtualServiceConfig + expected []*endpoint.Endpoint + expectError bool + fqdnTemplate string + combineFQDNAndAnnotation bool + ignoreHostnameAnnotation bool + }{ + { + title: "two simple virtualservices with one gateway each, one ingressgateway loadbalancer service", + lbServices: []fakeIngressGatewayService{ + { + namespace: namespace, + ips: []string{"8.8.8.8"}, + hostnames: []string{"lb.com"}, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"example.org"}}, + }, + { + name: "fake2", + namespace: namespace, + dnsnames: [][]string{{"new.org"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + dnsnames: []string{"example.org"}, + }, + { + name: "vs2", + namespace: namespace, + gateways: []string{"fake2"}, + dnsnames: []string{"new.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "example.org", + Targets: endpoint.Targets{"lb.com"}, + }, + { + DNSName: "new.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "new.org", + Targets: endpoint.Targets{"lb.com"}, + }, + }, + }, + { + title: "two simple virtualservices on different namespaces with the same target gateway, one ingressgateway loadbalancer service", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + hostnames: []string{"lb.com"}, + namespace: "istio-system", + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: "istio-system", + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: "testing1", + gateways: []string{"istio-system/fake1"}, + dnsnames: []string{"example.org"}, + }, + { + name: "vs2", + namespace: "testing2", + gateways: []string{"istio-system/fake1"}, + dnsnames: []string{"new.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "example.org", + Targets: endpoint.Targets{"lb.com"}, + }, + { + DNSName: "new.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "new.org", + Targets: endpoint.Targets{"lb.com"}, + }, + }, + }, + { + title: "two simple virtualservices with one gateway on different namespaces and a target namespace, one ingressgateway loadbalancer service", + targetNamespace: "testing1", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + hostnames: []string{"lb.com"}, + namespace: "testing1", + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: "testing1", + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: "testing1", + gateways: []string{"testing1/fake1"}, + dnsnames: []string{"example.org"}, + }, + { + name: "vs2", + namespace: "testing2", + gateways: []string{"testing1/fake1"}, + dnsnames: []string{"new.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "example.org", + Targets: endpoint.Targets{"lb.com"}, + }, + }, + }, + { + title: "valid matching annotation filter expression", + annotationFilter: "kubernetes.io/virtualservice.class in (alb, nginx)", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + annotations: map[string]string{ + "kubernetes.io/virtualservice.class": "nginx", + }, + gateways: []string{"fake1"}, + dnsnames: []string{"example.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + }, + }, + { + title: "valid non-matching annotation filter expression", + annotationFilter: "kubernetes.io/gateway.class in (alb, nginx)", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + annotations: map[string]string{ + "kubernetes.io/virtualservice.class": "tectonic", + }, + gateways: []string{"fake1"}, + dnsnames: []string{"example.org"}, + }, + }, + expected: []*endpoint.Endpoint{}, + }, + { + title: "invalid annotation filter expression", + annotationFilter: "kubernetes.io/gateway.name in (a b)", + expected: []*endpoint.Endpoint{}, + expectError: true, + }, + { + title: "our controller type is dns-controller", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + annotations: map[string]string{ + controllerAnnotationKey: controllerAnnotationValue, + }, + gateways: []string{"fake1"}, + dnsnames: []string{"example.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + }, + }, + { + title: "different controller types are ignored", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + annotations: map[string]string{ + controllerAnnotationKey: "some-other-tool", + }, + gateways: []string{"fake1"}, + dnsnames: []string{"example.org"}, + }, + }, + expected: []*endpoint.Endpoint{}, + }, + { + title: "template for virtualservice if host is missing", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + hostnames: []string{"elb.com"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + dnsnames: []string{""}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "vs1.ext-dns.test.com", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "vs1.ext-dns.test.com", + Targets: endpoint.Targets{"elb.com"}, + }, + }, + fqdnTemplate: "{{.Name}}.ext-dns.test.com", + }, + { + title: "multiple FQDN template hostnames", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + dnsnames: []string{""}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "vs1.ext-dns.test.com", + Targets: endpoint.Targets{"8.8.8.8"}, + RecordType: endpoint.RecordTypeA, + }, + { + DNSName: "vs1.ext-dna.test.com", + Targets: endpoint.Targets{"8.8.8.8"}, + RecordType: endpoint.RecordTypeA, + }, + }, + fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", + }, + { + title: "multiple FQDN template hostnames with restricted gw.hosts", + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + annotations: map[string]string{ + targetAnnotationKey: "gateway-target.com", + }, + dnsnames: [][]string{{"*.org", "*.ext-dns.test.com"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + dnsnames: []string{"example.org"}, + }, + { + name: "vs2", + namespace: namespace, + gateways: []string{"fake1"}, + dnsnames: []string{}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "vs1.ext-dns.test.com", + Targets: endpoint.Targets{"gateway-target.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "vs2.ext-dns.test.com", + Targets: endpoint.Targets{"gateway-target.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "example.org", + Targets: endpoint.Targets{"gateway-target.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + }, + fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", + combineFQDNAndAnnotation: true, + }, + { + title: "virtualservice with target annotation", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + namespace: namespace, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + annotations: map[string]string{ + targetAnnotationKey: "virtualservice-target.com", + }, + dnsnames: []string{"example.org"}, + }, + { + name: "vs2", + namespace: namespace, + gateways: []string{"fake1"}, + annotations: map[string]string{ + targetAnnotationKey: "virtualservice-target.com", + }, + dnsnames: []string{"example2.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"virtualservice-target.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "example2.org", + Targets: endpoint.Targets{"virtualservice-target.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + }, + }, + { + title: "virtualservice; gateway with target annotation", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + annotations: map[string]string{ + targetAnnotationKey: "gateway-target.com", + }, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + dnsnames: []string{"example.org"}, + }, + { + name: "vs2", + namespace: namespace, + gateways: []string{"fake1"}, + dnsnames: []string{"example2.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"gateway-target.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "example2.org", + Targets: endpoint.Targets{"gateway-target.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + }, + }, + { + title: "virtualservice with hostname annotation", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"1.2.3.4"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + annotations: map[string]string{ + hostnameAnnotationKey: "dns-through-hostname.com", + }, + dnsnames: []string{"example.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + { + DNSName: "dns-through-hostname.com", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + }, + }, + { + title: "virtualservice with hostname annotation having multiple hostnames, restricted by gw.hosts", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"1.2.3.4"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*.bar.com"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + annotations: map[string]string{ + hostnameAnnotationKey: "foo.bar.com, another-dns-through-hostname.com", + }, + dnsnames: []string{"baz.bar.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "foo.bar.com", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + }, + }, + { + title: "virtualservices with annotation and custom TTL", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + annotations: map[string]string{ + ttlAnnotationKey: "6", + }, + dnsnames: []string{"example.org"}, + }, + { + name: "vs2", + namespace: namespace, + gateways: []string{"fake1"}, + annotations: map[string]string{ + ttlAnnotationKey: "1", + }, + dnsnames: []string{"example2.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + RecordTTL: endpoint.TTL(6), + }, + { + DNSName: "example2.org", + Targets: endpoint.Targets{"8.8.8.8"}, + RecordTTL: endpoint.TTL(1), + }, + }, + }, + { + title: "template for virtualservice; gateway with target annotation", + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + annotations: map[string]string{ + targetAnnotationKey: "gateway-target.com", + }, + dnsnames: [][]string{{"*"}}, + }, + { + name: "fake2", + namespace: namespace, + annotations: map[string]string{ + targetAnnotationKey: "gateway-target.com", + }, + dnsnames: [][]string{{"*"}}, + }, + { + name: "fake3", + namespace: namespace, + annotations: map[string]string{ + targetAnnotationKey: "1.2.3.4", + }, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + dnsnames: []string{}, + }, + { + name: "vs2", + namespace: namespace, + gateways: []string{"fake2"}, + dnsnames: []string{}, + }, + { + name: "vs3", + namespace: namespace, + gateways: []string{"fake3"}, + dnsnames: []string{}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "vs1.ext-dns.test.com", + Targets: endpoint.Targets{"gateway-target.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "vs2.ext-dns.test.com", + Targets: endpoint.Targets{"gateway-target.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "vs3.ext-dns.test.com", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + }, + fqdnTemplate: "{{.Name}}.ext-dns.test.com", + }, + { + title: "ignore hostname annotations", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + hostnames: []string{"lb.com"}, + namespace: namespace, + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + dnsnames: [][]string{{"*"}}, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: namespace, + gateways: []string{"fake1"}, + annotations: map[string]string{ + hostnameAnnotationKey: "ignore.me", + }, + dnsnames: []string{"example.org"}, + }, + { + name: "vs2", + namespace: namespace, + gateways: []string{"fake1"}, + annotations: map[string]string{ + hostnameAnnotationKey: "ignore.me.too", + }, + dnsnames: []string{"new.org"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "example.org", + Targets: endpoint.Targets{"lb.com"}, + }, + { + DNSName: "new.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "new.org", + Targets: endpoint.Targets{"lb.com"}, + }, + }, + ignoreHostnameAnnotation: true, + }, + { + title: "complex setup with multiple gateways and multiple vs.hosts only matching some of the gateway", + lbServices: []fakeIngressGatewayService{ + { + name: "svc1", + selector: map[string]string{ + "app": "igw1", + }, + hostnames: []string{"target1.com"}, + namespace: "istio-system", + }, + { + name: "svc2", + selector: map[string]string{ + "app": "igw2", + }, + hostnames: []string{"target2.com"}, + namespace: "testing1", + }, + { + name: "svc3", + selector: map[string]string{ + "app": "igw3", + }, + hostnames: []string{"target3.com"}, + namespace: "testing2", + }, + }, + gwConfigs: []fakeGatewayConfig{ + { + name: "fake1", + namespace: "istio-system", + dnsnames: [][]string{{"*"}}, + selector: map[string]string{ + "app": "igw1", + }, + }, + { + name: "fake2", + namespace: "testing1", + dnsnames: [][]string{{"*.baz.com"}, {"*.bar.com"}}, + selector: map[string]string{ + "app": "igw2", + }, + }, + { + name: "fake3", + namespace: "testing2", + dnsnames: [][]string{{"*.bax.com", "*.bar.com"}}, + selector: map[string]string{ + "app": "igw3", + }, + }, + }, + vsConfigs: []fakeVirtualServiceConfig{ + { + name: "vs1", + namespace: "testing3", + gateways: []string{"istio-system/fake1", "testing1/fake2"}, + dnsnames: []string{"somedomain.com", "foo.bar.com"}, + }, + { + name: "vs2", + namespace: "testing2", + gateways: []string{"testing1/fake2", "fake3"}, + dnsnames: []string{"hello.bar.com", "hello.bax.com", "hello.bak.com"}, + }, + { + name: "vs3", + namespace: "testing1", + gateways: []string{"istio-system/fake1", "testing2/fake3"}, + dnsnames: []string{"world.bax.com", "world.bak.com"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "somedomain.com", + Targets: endpoint.Targets{"target1.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "foo.bar.com", + Targets: endpoint.Targets{"target1.com", "target2.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "hello.bar.com", + Targets: endpoint.Targets{"target2.com", "target3.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "hello.bax.com", + Targets: endpoint.Targets{"target3.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "world.bak.com", + Targets: endpoint.Targets{"target1.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "world.bax.com", + Targets: endpoint.Targets{"target1.com", "target3.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + }, + fqdnTemplate: "{{.Name}}.ext-dns.test.com", + }, + } { + t.Run(ti.title, func(t *testing.T) { + var gateways []networkingv1alpha3.Gateway + var virtualservices []networkingv1alpha3.VirtualService + + for _, gwItem := range ti.gwConfigs { + gateways = append(gateways, gwItem.Config()) + } + for _, vsItem := range ti.vsConfigs { + virtualservices = append(virtualservices, vsItem.Config()) + } + + fakeKubernetesClient := fake.NewSimpleClientset() + + for _, lb := range ti.lbServices { + service := lb.Service() + _, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(service) + require.NoError(t, err) + } + + fakeIstioClient := NewFakeConfigStore() + + for _, gateway := range gateways { + _, err := fakeIstioClient.NetworkingV1alpha3().Gateways(gateway.Namespace).Create(&gateway) + require.NoError(t, err) + } + + for _, virtualservice := range virtualservices { + _, err := fakeIstioClient.NetworkingV1alpha3().VirtualServices(virtualservice.Namespace).Create(&virtualservice) + require.NoError(t, err) + } + + virtualServiceSource, err := NewIstioVirtualServiceSource( + fakeKubernetesClient, + fakeIstioClient, + ti.targetNamespace, + ti.annotationFilter, + ti.fqdnTemplate, + ti.combineFQDNAndAnnotation, + ti.ignoreHostnameAnnotation, + ) + require.NoError(t, err) + + res, err := virtualServiceSource.Endpoints() + if ti.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + validateEndpoints(t, res, ti.expected) + }) + } +} + +func testGatewaySelectorMatchesService(t *testing.T) { + for _, ti := range []struct { + title string + gwSelector map[string]string + lbSelector map[string]string + expected bool + }{ + { + title: "gw selector matches lb selector", + gwSelector: map[string]string{"istio": "ingressgateway"}, + lbSelector: map[string]string{"istio": "ingressgateway"}, + expected: true, + }, + { + title: "gw selector matches lb selector partially", + gwSelector: map[string]string{"istio": "ingressgateway"}, + lbSelector: map[string]string{"release": "istio", "istio": "ingressgateway"}, + expected: true, + }, + { + title: "gw selector does not match lb selector", + gwSelector: map[string]string{"app": "mytest"}, + lbSelector: map[string]string{"istio": "ingressgateway"}, + expected: false, + }, + } { + t.Run(ti.title, func(t *testing.T) { + require.Equal(t, ti.expected, gatewaySelectorMatchesServiceSelector(ti.gwSelector, ti.lbSelector)) + }) + } +} + +func newTestVirtualServiceSource(loadBalancerList []fakeIngressGatewayService, gwList []fakeGatewayConfig) (*virtualServiceSource, error) { + fakeKubernetesClient := fake.NewSimpleClientset() + fakeIstioClient := NewFakeConfigStore() + + for _, lb := range loadBalancerList { + service := lb.Service() + _, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(service) + if err != nil { + return nil, err + } + } + + for _, gw := range gwList { + gwObj := gw.Config() + _, err := fakeIstioClient.NetworkingV1alpha3().Gateways(gw.namespace).Create(&gwObj) + if err != nil { + return nil, err + } + } + + src, err := NewIstioVirtualServiceSource( + fakeKubernetesClient, + fakeIstioClient, + "", + "", + "{{.Name}}", + false, + false, + ) + if err != nil { + return nil, err + } + + vssrc, ok := src.(*virtualServiceSource) + if !ok { + return nil, errors.New("underlying source type was not virtualservice") + } + + return vssrc, nil +} + +type fakeVirtualServiceConfig struct { + namespace string + name string + gateways []string + annotations map[string]string + dnsnames []string + exportTo string +} + +func (c fakeVirtualServiceConfig) Config() networkingv1alpha3.VirtualService { + vs := istionetworking.VirtualService{ + Gateways: c.gateways, + Hosts: c.dnsnames, + } + if c.exportTo != "" { + vs.ExportTo = []string{c.exportTo} + } + + config := networkingv1alpha3.VirtualService{ + ObjectMeta: metav1.ObjectMeta{ + Name: c.name, + Namespace: c.namespace, + Annotations: c.annotations, + }, + Spec: vs, + } + + return config +}