// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause //go:build !plan9 package main import ( "context" "encoding/json" "fmt" "testing" "github.com/google/go-cmp/cmp" "go.uber.org/zap" corev1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" operatorutils "tailscale.com/k8s-operator" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/tstest" "tailscale.com/types/ptr" ) func TestDNSRecordsReconciler(t *testing.T) { // Preconfigure a cluster with a DNSConfig dnsConfig := &tsapi.DNSConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, TypeMeta: metav1.TypeMeta{Kind: "DNSConfig"}, Spec: tsapi.DNSConfigSpec{ Nameserver: &tsapi.Nameserver{}, }} ing := &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: "ts-ingress", Namespace: "test", }, Spec: networkingv1.IngressSpec{ IngressClassName: ptr.To("tailscale"), }, Status: networkingv1.IngressStatus{ LoadBalancer: networkingv1.IngressLoadBalancerStatus{ Ingress: []networkingv1.IngressLoadBalancerIngress{{ Hostname: "cluster.ingress.ts.net"}}, }, }, } cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsrecords", Namespace: "tailscale"}} fc := fake.NewClientBuilder(). WithScheme(tsapi.GlobalScheme). WithObjects(cm). WithObjects(dnsConfig). WithObjects(ing). WithStatusSubresource(dnsConfig, ing). Build() zl, err := zap.NewDevelopment() if err != nil { t.Fatal(err) } cl := tstest.NewClock(tstest.ClockOpts{}) // Set the ready condition of the DNSConfig mustUpdateStatus[tsapi.DNSConfig](t, fc, "", "test", func(c *tsapi.DNSConfig) { operatorutils.SetDNSConfigCondition(c, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated, 0, cl, zl.Sugar()) }) dnsRR := &dnsRecordsReconciler{ Client: fc, logger: zl.Sugar(), tsNamespace: "tailscale", } // 1. DNS record is created for an egress proxy configured via // tailscale.com/tailnet-fqdn annotation egressSvcFQDN := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "egress-fqdn", Namespace: "test", Annotations: map[string]string{"tailscale.com/tailnet-fqdn": "foo.bar.ts.net"}, }, Spec: corev1.ServiceSpec{ ExternalName: "unused", Type: corev1.ServiceTypeExternalName, }, } headlessForEgressSvcFQDN := headlessSvcForParent(egressSvcFQDN, "svc") // create the proxy headless Service ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7", discoveryv1.AddressTypeIPv4) epv6 := endpointSliceForService(headlessForEgressSvcFQDN, "2600:1900:4011:161:0:d:0:d", discoveryv1.AddressTypeIPv6) mustCreate(t, fc, egressSvcFQDN) mustCreate(t, fc, headlessForEgressSvcFQDN) mustCreate(t, fc, ep) mustCreate(t, fc, epv6) expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service // ConfigMap should now have a record for foo.bar.ts.net -> 10.8.8.7 wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}} // IPv6 endpoint is currently ignored expectHostsRecords(t, fc, wantHosts) // 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's // value changes mustUpdate(t, fc, "test", "egress-fqdn", func(svc *corev1.Service) { svc.Annotations["tailscale.com/tailnet-fqdn"] = "baz.bar.ts.net" }) expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service wantHosts = map[string][]string{"baz.bar.ts.net": {"10.9.8.7"}} expectHostsRecords(t, fc, wantHosts) // 3. DNS record is updated if the IP address of the proxy Pod changes. ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4", discoveryv1.AddressTypeIPv4) mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) { ep.Endpoints[0].Addresses = []string{"10.6.5.4"} }) expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service wantHosts = map[string][]string{"baz.bar.ts.net": {"10.6.5.4"}} expectHostsRecords(t, fc, wantHosts) // 4. DNS record is created for an ingress proxy configured via Ingress headlessForIngress := headlessSvcForParent(ing, "ingress") ep = endpointSliceForService(headlessForIngress, "10.9.8.7", discoveryv1.AddressTypeIPv4) mustCreate(t, fc, headlessForIngress) mustCreate(t, fc, ep) expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service wantHosts["cluster.ingress.ts.net"] = []string{"10.9.8.7"} expectHostsRecords(t, fc, wantHosts) // 5. DNS records are updated if Ingress's MagicDNS name changes (i.e users changed spec.tls.hosts[0]) t.Log("test case 5") mustUpdateStatus(t, fc, "test", "ts-ingress", func(ing *networkingv1.Ingress) { ing.Status.LoadBalancer.Ingress[0].Hostname = "another.ingress.ts.net" }) expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service delete(wantHosts, "cluster.ingress.ts.net") wantHosts["another.ingress.ts.net"] = []string{"10.9.8.7"} expectHostsRecords(t, fc, wantHosts) // 6. DNS records are updated if Ingress proxy's Pod IP changes mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) { ep.Endpoints[0].Addresses = []string{"7.8.9.10"} }) expectReconciled(t, dnsRR, "tailscale", "ts-ingress") wantHosts["another.ingress.ts.net"] = []string{"7.8.9.10"} expectHostsRecords(t, fc, wantHosts) // 7. A not-ready Endpoint is removed from DNS config. mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) { ep.Endpoints[0].Conditions.Ready = ptr.To(false) ep.Endpoints = append(ep.Endpoints, discoveryv1.Endpoint{ Addresses: []string{"1.2.3.4"}, }) }) expectReconciled(t, dnsRR, "tailscale", "ts-ingress") wantHosts["another.ingress.ts.net"] = []string{"1.2.3.4"} expectHostsRecords(t, fc, wantHosts) } func headlessSvcForParent(o client.Object, typ string) *corev1.Service { return &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: o.GetName(), Namespace: "tailscale", Labels: map[string]string{ LabelManaged: "true", LabelParentName: o.GetName(), LabelParentNamespace: o.GetNamespace(), LabelParentType: typ, }, }, Spec: corev1.ServiceSpec{ ClusterIP: "None", Type: corev1.ServiceTypeClusterIP, Selector: map[string]string{"foo": "bar"}, }, } } func endpointSliceForService(svc *corev1.Service, ip string, fam discoveryv1.AddressType) *discoveryv1.EndpointSlice { return &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("%s-%s", svc.Name, string(fam)), Namespace: svc.Namespace, Labels: map[string]string{discoveryv1.LabelServiceName: svc.Name}, }, AddressType: fam, Endpoints: []discoveryv1.Endpoint{{ Addresses: []string{ip}, Conditions: discoveryv1.EndpointConditions{ Ready: ptr.To(true), Serving: ptr.To(true), Terminating: ptr.To(false), }, }}, } } func expectHostsRecords(t *testing.T, cl client.Client, wantsHosts map[string][]string) { t.Helper() cm := new(corev1.ConfigMap) if err := cl.Get(context.Background(), types.NamespacedName{Name: "dnsrecords", Namespace: "tailscale"}, cm); err != nil { t.Fatalf("getting dnsconfig ConfigMap: %v", err) } if cm.Data == nil { t.Fatal("dnsconfig ConfigMap has no data") } dnsConfigString, ok := cm.Data[operatorutils.DNSRecordsCMKey] if !ok { t.Fatal("dnsconfig ConfigMap does not contain dnsconfig") } dnsConfig := &operatorutils.Records{} if err := json.Unmarshal([]byte(dnsConfigString), dnsConfig); err != nil { t.Fatalf("unmarshaling dnsconfig: %v", err) } if diff := cmp.Diff(dnsConfig.IP4, wantsHosts); diff != "" { t.Fatalf("unexpected dns config (-got +want):\n%s", diff) } }