mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 19:15:34 +00:00
19b31ac9a6
cmd/k8s-operator: optionally update dnsrecords Configmap with DNS records for proxies. This commit adds functionality to automatically populate DNS records for the in-cluster ts.net nameserver to allow cluster workloads to resolve MagicDNS names associated with operator's proxies. The records are created as follows: * For tailscale Ingress proxies there will be a record mapping the MagicDNS name of the Ingress device and each proxy Pod's IP address. * For cluster egress proxies, configured via tailscale.com/tailnet-fqdn annotation, there will be a record for each proxy Pod, mapping the MagicDNS name of the exposed tailnet workload to the proxy Pod's IP. No records will be created for any other proxy types. Records will only be created if users have configured the operator to deploy an in-cluster ts.net nameserver by applying tailscale.com/v1alpha1.DNSConfig. It is user's responsibility to add the ts.net nameserver as a stub nameserver for ts.net DNS names. https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns#upstream_nameservers See also https://github.com/tailscale/tailscale/pull/11017 Updates tailscale/tailscale#10499 Signed-off-by: Irbe Krumina <irbe@tailscale.com>
199 lines
7.0 KiB
Go
199 lines
7.0 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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")
|
|
mustCreate(t, fc, egressSvcFQDN)
|
|
mustCreate(t, fc, headlessForEgressSvcFQDN)
|
|
mustCreate(t, fc, ep)
|
|
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"}}
|
|
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")
|
|
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")
|
|
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)
|
|
}
|
|
|
|
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) *discoveryv1.EndpointSlice {
|
|
return &discoveryv1.EndpointSlice{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: svc.Name,
|
|
Namespace: svc.Namespace,
|
|
Labels: map[string]string{discoveryv1.LabelServiceName: svc.Name},
|
|
},
|
|
Endpoints: []discoveryv1.Endpoint{{
|
|
Addresses: []string{ip},
|
|
}},
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|