tailscale/cmd/k8s-operator/svc-for-pg_test.go
David Bond 85d6ba9473
cmd/k8s-operator: migrate to tailscale-client-go-v2 (#19010)
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>
2026-04-09 14:39:46 +01:00

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
}