// Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause //go:build !plan9 package main import ( "context" "slices" "testing" "go.uber.org/zap" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/k8s-operator/tsclient" "tailscale.com/kube/kubetypes" "tailscale.com/tstest" ) func TestService_DefaultProxyClassInitiallyNotReady(t *testing.T) { pc := &tsapi.ProxyClass{ ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"}, Spec: tsapi.ProxyClassSpec{ TailscaleConfig: &tsapi.TailscaleConfig{ AcceptRoutes: true, }, StatefulSet: &tsapi.StatefulSet{ Labels: tsapi.Labels{"foo": "bar"}, Annotations: map[string]string{"bar.io/foo": "some-val"}, Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}, }, }, } fc := fake.NewClientBuilder(). WithScheme(tsapi.GlobalScheme). WithObjects(pc). WithStatusSubresource(pc). Build() ft := &fakeTSClient{} zl := zap.Must(zap.NewDevelopment()) clock := tstest.NewClock(tstest.ClockOpts{}) sr := &ServiceReconciler{ Client: fc, ssr: &tailscaleSTSReconciler{ Client: fc, clients: tsclient.NewProvider(ft), defaultTags: []string{"tag:k8s"}, operatorNamespace: "operator-ns", proxyImage: "tailscale/tailscale", }, defaultProxyClass: "custom-metadata", logger: zl.Sugar(), clock: clock, } // 1. A new tailscale LoadBalancer Service is created but the default // ProxyClass is not ready yet. mustCreate(t, fc, &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"), }, Spec: corev1.ServiceSpec{ ClusterIP: "10.20.30.40", Type: corev1.ServiceTypeLoadBalancer, LoadBalancerClass: new("tailscale"), }, }) expectReconciled(t, sr, "default", "test") labels := map[string]string{ kubetypes.LabelManaged: "true", LabelParentName: "test", LabelParentNamespace: "operator-ns", LabelParentType: "svc", } s, err := getSingleObject[corev1.Secret](context.Background(), fc, "operator-ns", labels) if err != nil { t.Fatalf("finding Secret for %q: %v", "test", err) } if s != nil { t.Fatalf("expected no Secret to be created when default ProxyClass is not ready, but found one: %v", s) } // 2. ProxyClass is set to Ready, the Service can become ready now. mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) { pc.Status = tsapi.ProxyClassStatus{ Conditions: []metav1.Condition{{ Status: metav1.ConditionTrue, Type: string(tsapi.ProxyClassReady), ObservedGeneration: pc.Generation, }}, } }) expectReconciled(t, sr, "default", "test") fullName, shortName := findGenName(t, fc, "default", "test", "svc") opts := configOpts{ replicas: new(int32(1)), stsName: shortName, secretName: fullName, namespace: "default", parentType: "svc", hostname: "default-test", clusterTargetIP: "10.20.30.40", app: kubetypes.AppIngressProxy, proxyClass: pc.Name, } expectEqual(t, fc, expectedSecret(t, fc, opts)) expectEqual(t, fc, expectedHeadlessService(shortName, "svc")) expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs) } func TestProxyClassHandlerForSvc(t *testing.T) { svc := func(name string, annotations, labels map[string]string) *corev1.Service { return &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "default", Annotations: annotations, Labels: labels, }, Spec: corev1.ServiceSpec{ ClusterIP: "1.2.3.4", }, } } lbSvc := func(name string, annotations map[string]string, class *string) *corev1.Service { return &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "foo", Annotations: annotations, }, Spec: corev1.ServiceSpec{ Type: corev1.ServiceTypeLoadBalancer, LoadBalancerClass: class, ClusterIP: "1.2.3.4", }, } } const ( defaultPCName = "default-proxyclass" otherPCName = "other-proxyclass" unreferencedPCName = "unreferenced-proxyclass" ) fc := fake.NewClientBuilder(). WithScheme(tsapi.GlobalScheme). WithIndex(&corev1.Service{}, indexServiceProxyClass, indexProxyClass). WithIndex(&corev1.Service{}, indexServiceExposed, indexExposed). WithIndex(&corev1.Service{}, indexServiceType, indexType). WithObjects( svc("not-exposed", nil, nil), svc("exposed-default", map[string]string{AnnotationExpose: "true"}, nil), svc("exposed-other", map[string]string{AnnotationExpose: "true", LabelAnnotationProxyClass: otherPCName}, nil), svc("annotated", map[string]string{LabelAnnotationProxyClass: defaultPCName}, nil), svc("labelled", nil, map[string]string{LabelAnnotationProxyClass: defaultPCName}), lbSvc("lb-svc", nil, new("tailscale")), lbSvc("lb-svc-no-class", nil, nil), lbSvc("lb-svc-other-class", nil, new("other")), lbSvc("lb-svc-other-pc", map[string]string{LabelAnnotationProxyClass: otherPCName}, nil), ). Build() zl := zap.Must(zap.NewDevelopment()) mapFunc := proxyClassHandlerForSvc(fc, zl.Sugar(), defaultPCName, true) for _, tc := range []struct { name string proxyClassName string expected []reconcile.Request }{ { name: "default_ProxyClass", proxyClassName: defaultPCName, expected: []reconcile.Request{ {NamespacedName: types.NamespacedName{Namespace: "default", Name: "exposed-default"}}, {NamespacedName: types.NamespacedName{Namespace: "default", Name: "annotated"}}, {NamespacedName: types.NamespacedName{Namespace: "default", Name: "labelled"}}, {NamespacedName: types.NamespacedName{Namespace: "foo", Name: "lb-svc"}}, {NamespacedName: types.NamespacedName{Namespace: "foo", Name: "lb-svc-no-class"}}, }, }, { name: "other_ProxyClass", proxyClassName: otherPCName, expected: []reconcile.Request{ {NamespacedName: types.NamespacedName{Namespace: "default", Name: "exposed-other"}}, {NamespacedName: types.NamespacedName{Namespace: "foo", Name: "lb-svc-other-pc"}}, }, }, { name: "unreferenced_ProxyClass", proxyClassName: unreferencedPCName, expected: nil, }, } { t.Run(tc.name, func(t *testing.T) { reqs := mapFunc(t.Context(), &tsapi.ProxyClass{ ObjectMeta: metav1.ObjectMeta{ Name: tc.proxyClassName, }, }) if len(reqs) != len(tc.expected) { t.Fatalf("expected %d requests, got %d: %v", len(tc.expected), len(reqs), reqs) } for _, expected := range tc.expected { if !slices.Contains(reqs, expected) { t.Errorf("expected request for Service %q not found in results: %v", expected.Name, reqs) } } }) } }