mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 20:26:47 +02:00
This commit modifies the kubernetes operator to use the `tailscale-client-go-v2` package instead of the internal tailscale client it was previously using. This now gives us the ability to expand out custom resources and features as they become available via the API module. The tailnet reconciler has also been modified to manage clients as tailnets are created and removed, providing each subsequent reconciler with a single `ClientProvider` that obtains a tailscale client for the respective tailnet by name, or the operator's default when presented with a blank string. Fixes: https://github.com/tailscale/corp/issues/38418 Signed-off-by: David Bond <davidsbond93@gmail.com>
417 lines
12 KiB
Go
417 lines
12 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"net/netip"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
corev1 "k8s.io/api/core/v1"
|
|
discoveryv1 "k8s.io/api/discovery/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/tools/record"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"tailscale.com/client/tailscale/v2"
|
|
|
|
tsoperator "tailscale.com/k8s-operator"
|
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
|
"tailscale.com/k8s-operator/tsclient"
|
|
"tailscale.com/kube/ingressservices"
|
|
"tailscale.com/kube/kubetypes"
|
|
"tailscale.com/tstest"
|
|
"tailscale.com/util/mak"
|
|
)
|
|
|
|
func TestServicePGReconciler(t *testing.T) {
|
|
svcPGR, stateSecret, fc, ft, _ := setupServiceTest(t)
|
|
svcs := []*corev1.Service{}
|
|
config := []string{}
|
|
for i := range 4 {
|
|
svc, _ := setupTestService(t, fmt.Sprintf("test-svc-%d", i), "", fmt.Sprintf("1.2.3.%d", i), fc, stateSecret)
|
|
svcs = append(svcs, svc)
|
|
|
|
// Verify initial reconciliation
|
|
expectReconciled(t, svcPGR, "default", svc.Name)
|
|
|
|
config = append(config, fmt.Sprintf("svc:default-%s", svc.Name))
|
|
verifyTailscaleService(t, ft, fmt.Sprintf("svc:default-%s", svc.Name), []string{"do-not-validate"})
|
|
verifyTailscaledConfig(t, fc, "test-pg", config)
|
|
}
|
|
|
|
for i, svc := range svcs {
|
|
if err := fc.Delete(context.Background(), svc); err != nil {
|
|
t.Fatalf("deleting Service: %v", err)
|
|
}
|
|
|
|
expectReconciled(t, svcPGR, "default", svc.Name)
|
|
|
|
// Verify the ConfigMap was cleaned up
|
|
cm := &corev1.ConfigMap{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: "test-pg-ingress-config",
|
|
Namespace: "operator-ns",
|
|
}, cm); err != nil {
|
|
t.Fatalf("getting ConfigMap: %v", err)
|
|
}
|
|
|
|
cfgs := ingressservices.Configs{}
|
|
if err := json.Unmarshal(cm.BinaryData[ingressservices.IngressConfigKey], &cfgs); err != nil {
|
|
t.Fatalf("unmarshaling serve config: %v", err)
|
|
}
|
|
|
|
if len(cfgs) > len(svcs)-(i+1) {
|
|
t.Error("serve config not cleaned up")
|
|
}
|
|
|
|
config = removeEl(config, fmt.Sprintf("svc:default-%s", svc.Name))
|
|
verifyTailscaledConfig(t, fc, "test-pg", config)
|
|
}
|
|
}
|
|
|
|
func TestServicePGReconciler_UpdateHostname(t *testing.T) {
|
|
svcPGR, stateSecret, fc, ft, _ := setupServiceTest(t)
|
|
|
|
cip := "4.1.6.7"
|
|
svc, _ := setupTestService(t, "test-service", "", cip, fc, stateSecret)
|
|
|
|
expectReconciled(t, svcPGR, "default", svc.Name)
|
|
|
|
verifyTailscaleService(t, ft, fmt.Sprintf("svc:default-%s", svc.Name), []string{"do-not-validate"})
|
|
verifyTailscaledConfig(t, fc, "test-pg", []string{fmt.Sprintf("svc:default-%s", svc.Name)})
|
|
|
|
hostname := "foobarbaz"
|
|
mustUpdate(t, fc, svc.Namespace, svc.Name, func(s *corev1.Service) {
|
|
mak.Set(&s.Annotations, AnnotationHostname, hostname)
|
|
})
|
|
|
|
// NOTE: we need to update the ingress config Secret because there is no containerboot in the fake proxy Pod
|
|
updateIngressConfigSecret(t, fc, stateSecret, hostname, cip)
|
|
expectReconciled(t, svcPGR, "default", svc.Name)
|
|
|
|
verifyTailscaleService(t, ft, fmt.Sprintf("svc:%s", hostname), []string{"do-not-validate"})
|
|
verifyTailscaledConfig(t, fc, "test-pg", []string{fmt.Sprintf("svc:%s", hostname)})
|
|
|
|
_, err := ft.VIPServices().Get(context.Background(), fmt.Sprintf("svc:default-%s", svc.Name))
|
|
if err == nil {
|
|
t.Fatalf("svc:default-%s not cleaned up", svc.Name)
|
|
}
|
|
if !tailscale.IsNotFound(err) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func setupServiceTest(t *testing.T) (*HAServiceReconciler, *corev1.Secret, client.Client, *fakeTSClient, *tstest.Clock) {
|
|
// Pre-create the ProxyGroup
|
|
pg := &tsapi.ProxyGroup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pg",
|
|
Generation: 1,
|
|
},
|
|
Spec: tsapi.ProxyGroupSpec{
|
|
Type: tsapi.ProxyGroupTypeIngress,
|
|
},
|
|
}
|
|
|
|
// Pre-create the ConfigMap for the ProxyGroup
|
|
pgConfigMap := &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pg-ingress-config",
|
|
Namespace: "operator-ns",
|
|
},
|
|
BinaryData: map[string][]byte{
|
|
"serve-config.json": []byte(`{"Services":{}}`),
|
|
},
|
|
}
|
|
|
|
// Pre-create a config Secret for the ProxyGroup
|
|
pgCfgSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: pgConfigSecretName("test-pg", 0),
|
|
Namespace: "operator-ns",
|
|
Labels: pgSecretLabels("test-pg", kubetypes.LabelSecretTypeConfig),
|
|
},
|
|
Data: map[string][]byte{
|
|
tsoperator.TailscaledConfigFileName(pgMinCapabilityVersion): []byte(`{"Version":""}`),
|
|
},
|
|
}
|
|
|
|
pgStateSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pg-0",
|
|
Namespace: "operator-ns",
|
|
},
|
|
Data: map[string][]byte{},
|
|
}
|
|
|
|
pgPod := &corev1.Pod{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pg-0",
|
|
Namespace: "operator-ns",
|
|
},
|
|
Status: corev1.PodStatus{
|
|
PodIPs: []corev1.PodIP{
|
|
{
|
|
IP: "4.3.2.1",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithObjects(pg, pgCfgSecret, pgConfigMap, pgPod, pgStateSecret).
|
|
WithStatusSubresource(pg).
|
|
WithIndex(new(corev1.Service), indexIngressProxyGroup, indexPGIngresses).
|
|
Build()
|
|
|
|
// Set ProxyGroup status to ready
|
|
pg.Status.Conditions = []metav1.Condition{
|
|
{
|
|
Type: string(tsapi.ProxyGroupAvailable),
|
|
Status: metav1.ConditionTrue,
|
|
ObservedGeneration: 1,
|
|
},
|
|
}
|
|
if err := fc.Status().Update(context.Background(), pg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ft := &fakeTSClient{
|
|
vipServices: make(map[string]tailscale.VIPService),
|
|
}
|
|
zl, err := zap.NewDevelopment()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cl := tstest.NewClock(tstest.ClockOpts{})
|
|
svcPGR := &HAServiceReconciler{
|
|
Client: fc,
|
|
clients: tsclient.NewProvider(ft),
|
|
clock: cl,
|
|
defaultTags: []string{"tag:k8s"},
|
|
tsNamespace: "operator-ns",
|
|
logger: zl.Sugar(),
|
|
recorder: record.NewFakeRecorder(10),
|
|
}
|
|
|
|
return svcPGR, pgStateSecret, fc, ft, cl
|
|
}
|
|
|
|
func TestValidateService(t *testing.T) {
|
|
// Test that no more than one Kubernetes Service in a cluster refers to the same Tailscale Service.
|
|
pgr, _, lc, _, cl := setupServiceTest(t)
|
|
svc := &corev1.Service{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-app",
|
|
Namespace: "ns-1",
|
|
UID: types.UID("1234-UID"),
|
|
Annotations: map[string]string{
|
|
"tailscale.com/proxy-group": "test-pg",
|
|
"tailscale.com/hostname": "my-app",
|
|
},
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
ClusterIP: "1.2.3.4",
|
|
Type: corev1.ServiceTypeLoadBalancer,
|
|
LoadBalancerClass: new("tailscale"),
|
|
},
|
|
}
|
|
svc2 := &corev1.Service{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-app2",
|
|
Namespace: "ns-2",
|
|
UID: types.UID("1235-UID"),
|
|
Annotations: map[string]string{
|
|
"tailscale.com/proxy-group": "test-pg",
|
|
"tailscale.com/hostname": "my-app",
|
|
},
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
ClusterIP: "1.2.3.5",
|
|
Type: corev1.ServiceTypeLoadBalancer,
|
|
LoadBalancerClass: new("tailscale"),
|
|
},
|
|
}
|
|
wantSvc := &corev1.Service{
|
|
ObjectMeta: svc.ObjectMeta,
|
|
TypeMeta: svc.TypeMeta,
|
|
Spec: svc.Spec,
|
|
Status: corev1.ServiceStatus{
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: string(tsapi.IngressSvcValid),
|
|
Status: metav1.ConditionFalse,
|
|
Reason: reasonIngressSvcInvalid,
|
|
LastTransitionTime: metav1.NewTime(cl.Now().Truncate(time.Second)),
|
|
Message: `found duplicate Service "ns-2/my-app2" for hostname "my-app" - multiple HA Services for the same hostname in the same cluster are not allowed`,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
mustCreate(t, lc, svc)
|
|
mustCreate(t, lc, svc2)
|
|
expectReconciled(t, pgr, svc.Namespace, svc.Name)
|
|
expectEqual(t, lc, wantSvc)
|
|
}
|
|
|
|
func TestServicePGReconciler_MultiCluster(t *testing.T) {
|
|
var ft *fakeTSClient
|
|
for i := 0; i <= 10; i++ {
|
|
pgr, stateSecret, fc, fti, _ := setupServiceTest(t)
|
|
if i == 0 {
|
|
ft = fti
|
|
} else {
|
|
pgr.clients = tsclient.NewProvider(ft)
|
|
}
|
|
|
|
svc, _ := setupTestService(t, "test-multi-cluster", "", "4.3.2.1", fc, stateSecret)
|
|
expectReconciled(t, pgr, "default", svc.Name)
|
|
|
|
tsSvcs, err := ft.VIPServices().List(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("getting Tailscale Service: %v", err)
|
|
}
|
|
|
|
if len(tsSvcs) != 1 {
|
|
t.Fatalf("unexpected number of Tailscale Services (%d)", len(tsSvcs))
|
|
}
|
|
|
|
for _, svc := range tsSvcs {
|
|
t.Logf("found Tailscale Service with name %q", svc.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIgnoreRegularService(t *testing.T) {
|
|
pgr, _, fc, ft, _ := setupServiceTest(t)
|
|
|
|
svc := &corev1.Service{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test",
|
|
Namespace: "default",
|
|
// The apiserver is supposed to set the UID, but the fake client
|
|
// doesn't. So, set it explicitly because other code later depends
|
|
// on it being set.
|
|
UID: types.UID("1234-UID"),
|
|
Annotations: map[string]string{
|
|
"tailscale.com/expose": "true",
|
|
},
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
ClusterIP: "10.20.30.40",
|
|
Type: corev1.ServiceTypeClusterIP,
|
|
},
|
|
}
|
|
|
|
mustCreate(t, fc, svc)
|
|
expectReconciled(t, pgr, "default", "test")
|
|
|
|
verifyTailscaledConfig(t, fc, "test-pg", nil)
|
|
|
|
tsSvcs, err := ft.VIPServices().List(t.Context())
|
|
if err == nil {
|
|
if len(tsSvcs) > 0 {
|
|
t.Fatal("unexpected Tailscale Services found")
|
|
}
|
|
}
|
|
}
|
|
|
|
func removeEl(s []string, value string) []string {
|
|
result := s[:0]
|
|
for _, v := range s {
|
|
if v != value {
|
|
result = append(result, v)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func updateIngressConfigSecret(t *testing.T, fc client.Client, stateSecret *corev1.Secret, serviceName string, clusterIP string) {
|
|
ingressConfig := ingressservices.Configs{
|
|
fmt.Sprintf("svc:%s", serviceName): ingressservices.Config{
|
|
IPv4Mapping: &ingressservices.Mapping{
|
|
TailscaleServiceIP: netip.MustParseAddr(vipTestIP),
|
|
ClusterIP: netip.MustParseAddr(clusterIP),
|
|
},
|
|
},
|
|
}
|
|
|
|
ingressStatus := ingressservices.Status{
|
|
Configs: ingressConfig,
|
|
PodIPv4: "4.3.2.1",
|
|
}
|
|
|
|
icJson, err := json.Marshal(ingressStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to json marshal ingress config: %s", err.Error())
|
|
}
|
|
|
|
mustUpdate(t, fc, stateSecret.Namespace, stateSecret.Name, func(sec *corev1.Secret) {
|
|
mak.Set(&sec.Data, ingressservices.IngressConfigKey, icJson)
|
|
})
|
|
}
|
|
|
|
func setupTestService(t *testing.T, svcName string, hostname string, clusterIP string, fc client.Client, stateSecret *corev1.Secret) (svc *corev1.Service, eps *discoveryv1.EndpointSlice) {
|
|
uid := rand.IntN(100)
|
|
svc = &corev1.Service{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: svcName,
|
|
Namespace: "default",
|
|
UID: types.UID(fmt.Sprintf("%d-UID", uid)),
|
|
Annotations: map[string]string{
|
|
"tailscale.com/proxy-group": "test-pg",
|
|
},
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
Type: corev1.ServiceTypeLoadBalancer,
|
|
LoadBalancerClass: new("tailscale"),
|
|
ClusterIP: clusterIP,
|
|
ClusterIPs: []string{clusterIP},
|
|
},
|
|
}
|
|
|
|
eps = &discoveryv1.EndpointSlice{
|
|
TypeMeta: metav1.TypeMeta{Kind: "EndpointSlice", APIVersion: "v1"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: svcName,
|
|
Namespace: "default",
|
|
Labels: map[string]string{
|
|
discoveryv1.LabelServiceName: svcName,
|
|
},
|
|
},
|
|
AddressType: discoveryv1.AddressTypeIPv4,
|
|
Endpoints: []discoveryv1.Endpoint{
|
|
{
|
|
Addresses: []string{"4.3.2.1"},
|
|
Conditions: discoveryv1.EndpointConditions{
|
|
Ready: new(true),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
updateIngressConfigSecret(t, fc, stateSecret, fmt.Sprintf("default-%s", svcName), clusterIP)
|
|
|
|
mustCreate(t, fc, svc)
|
|
mustCreate(t, fc, eps)
|
|
|
|
return svc, eps
|
|
}
|