mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 11:05:45 +00:00
adbab25bac
* cmd/k8s-operator: fix DNS reconciler for dual-stack clusters This fixes a bug where DNS reconciler logic was always assuming that no more than one EndpointSlice exists for a Service. In fact, there can be multiple, for example, in dual-stack clusters, but also in other cases this is valid (as per kube docs). This PR: - allows for multiple EndpointSlices - picks out the ones for IPv4 family - deduplicates addresses Updates tailscale/tailscale#13056 Signed-off-by: Irbe Krumina <irbe@tailscale.com> Co-authored-by: Tom Proctor <tomhjp@users.noreply.github.com>
220 lines
7.9 KiB
Go
220 lines
7.9 KiB
Go
// 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)
|
|
}
|
|
}
|