// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause //go:build !plan9 package main import ( "context" "testing" "time" "go.uber.org/zap" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/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/fake" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/kube/kubetypes" "tailscale.com/tstest" "tailscale.com/util/mak" ) func TestConnector(t *testing.T) { // Create a Connector that defines a Tailscale node that advertises // 10.40.0.0/14 route and acts as an exit node. cn := &tsapi.Connector{ ObjectMeta: metav1.ObjectMeta{ Name: "test", UID: types.UID("1234-UID"), }, TypeMeta: metav1.TypeMeta{ Kind: tsapi.ConnectorKind, APIVersion: "tailscale.com/v1alpha1", }, Spec: tsapi.ConnectorSpec{ SubnetRouter: &tsapi.SubnetRouter{ AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"}, }, ExitNode: true, }, } fc := fake.NewClientBuilder(). WithScheme(tsapi.GlobalScheme). WithObjects(cn). WithStatusSubresource(cn). Build() ft := &fakeTSClient{} zl, err := zap.NewDevelopment() if err != nil { t.Fatal(err) } cl := tstest.NewClock(tstest.ClockOpts{}) cr := &ConnectorReconciler{ Client: fc, ssr: &tailscaleSTSReconciler{ Client: fc, tsClient: ft, defaultTags: []string{"tag:k8s"}, operatorNamespace: "operator-ns", proxyImage: "tailscale/tailscale", }, clock: cl, logger: zl.Sugar(), } expectReconciled(t, cr, "", "test") fullName, shortName := findGenName(t, fc, "", "test", "connector") opts := configOpts{ stsName: shortName, secretName: fullName, parentType: "connector", hostname: "test-connector", isExitNode: true, subnetRoutes: "10.40.0.0/14", app: kubetypes.AppConnector, } expectEqual(t, fc, expectedSecret(t, fc, opts), nil) expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // Connector status should get updated with the IP/hostname info when available. const hostname = "foo.tailnetxyz.ts.net" mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) { mak.Set(&secret.Data, "device_id", []byte("1234")) mak.Set(&secret.Data, "device_fqdn", []byte(hostname)) mak.Set(&secret.Data, "device_ips", []byte(`["127.0.0.1", "::1"]`)) }) expectReconciled(t, cr, "", "test") cn.Finalizers = append(cn.Finalizers, "tailscale.com/finalizer") cn.Status.IsExitNode = cn.Spec.ExitNode cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify() cn.Status.Hostname = hostname cn.Status.TailnetIPs = []string{"127.0.0.1", "::1"} expectEqual(t, fc, cn, func(o *tsapi.Connector) { o.Status.Conditions = nil }) // Add another route to be advertised. mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"} }) opts.subnetRoutes = "10.40.0.0/14,10.44.0.0/20" expectReconciled(t, cr, "", "test") expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // Remove a route. mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.44.0.0/20"} }) opts.subnetRoutes = "10.44.0.0/20" expectReconciled(t, cr, "", "test") expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // Remove the subnet router. mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { conn.Spec.SubnetRouter = nil }) opts.subnetRoutes = "" expectReconciled(t, cr, "", "test") expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // Re-add the subnet router. mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { conn.Spec.SubnetRouter = &tsapi.SubnetRouter{ AdvertiseRoutes: []tsapi.Route{"10.44.0.0/20"}, } }) opts.subnetRoutes = "10.44.0.0/20" expectReconciled(t, cr, "", "test") expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // Delete the Connector. if err = fc.Delete(context.Background(), cn); err != nil { t.Fatalf("error deleting Connector: %v", err) } expectRequeue(t, cr, "", "test") expectReconciled(t, cr, "", "test") expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) // Create a Connector that advertises a route and is not an exit node. cn = &tsapi.Connector{ ObjectMeta: metav1.ObjectMeta{ Name: "test", UID: types.UID("1234-UID"), }, TypeMeta: metav1.TypeMeta{ Kind: tsapi.ConnectorKind, APIVersion: "tailscale.io/v1alpha1", }, Spec: tsapi.ConnectorSpec{ SubnetRouter: &tsapi.SubnetRouter{ AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"}, }, }, } opts.subnetRoutes = "10.44.0.0/14" opts.isExitNode = false mustCreate(t, fc, cn) expectReconciled(t, cr, "", "test") fullName, shortName = findGenName(t, fc, "", "test", "connector") opts = configOpts{ stsName: shortName, secretName: fullName, parentType: "connector", subnetRoutes: "10.40.0.0/14", hostname: "test-connector", app: kubetypes.AppConnector, } expectEqual(t, fc, expectedSecret(t, fc, opts), nil) expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // Add an exit node. mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { conn.Spec.ExitNode = true }) opts.isExitNode = true expectReconciled(t, cr, "", "test") expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // Delete the Connector. if err = fc.Delete(context.Background(), cn); err != nil { t.Fatalf("error deleting Connector: %v", err) } expectRequeue(t, cr, "", "test") expectReconciled(t, cr, "", "test") expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) } func TestConnectorWithProxyClass(t *testing.T) { // Setup pc := &tsapi.ProxyClass{ ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"}, Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{ Labels: map[string]string{"foo": "bar"}, Annotations: map[string]string{"bar.io/foo": "some-val"}, Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}}, } cn := &tsapi.Connector{ ObjectMeta: metav1.ObjectMeta{ Name: "test", UID: types.UID("1234-UID"), }, TypeMeta: metav1.TypeMeta{ Kind: tsapi.ConnectorKind, APIVersion: "tailscale.io/v1alpha1", }, Spec: tsapi.ConnectorSpec{ SubnetRouter: &tsapi.SubnetRouter{ AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"}, }, ExitNode: true, }, } fc := fake.NewClientBuilder(). WithScheme(tsapi.GlobalScheme). WithObjects(pc, cn). WithStatusSubresource(pc, cn). Build() ft := &fakeTSClient{} zl, err := zap.NewDevelopment() if err != nil { t.Fatal(err) } cl := tstest.NewClock(tstest.ClockOpts{}) cr := &ConnectorReconciler{ Client: fc, clock: cl, ssr: &tailscaleSTSReconciler{ Client: fc, tsClient: ft, defaultTags: []string{"tag:k8s"}, operatorNamespace: "operator-ns", proxyImage: "tailscale/tailscale", }, logger: zl.Sugar(), } // 1. Connector is created with no ProxyClass specified, create // resources with the default configuration. expectReconciled(t, cr, "", "test") fullName, shortName := findGenName(t, fc, "", "test", "connector") opts := configOpts{ stsName: shortName, secretName: fullName, parentType: "connector", hostname: "test-connector", isExitNode: true, subnetRoutes: "10.40.0.0/14", app: kubetypes.AppConnector, } expectEqual(t, fc, expectedSecret(t, fc, opts), nil) expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // 2. Update Connector to specify a ProxyClass. ProxyClass is not yet // ready, so its configuration is NOT applied to the Connector // resources. mustUpdate(t, fc, "", "test", func(conn *tsapi.Connector) { conn.Spec.ProxyClass = "custom-metadata" }) expectReconciled(t, cr, "", "test") expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // 3. ProxyClass is set to Ready by proxy-class reconciler. Connector // get reconciled and configuration from the ProxyClass is applied to // its resources. 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, }}} }) opts.proxyClass = pc.Name expectReconciled(t, cr, "", "test") expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // 4. Connector.spec.proxyClass field is unset, Connector gets // reconciled and configuration from the ProxyClass is removed from the // cluster resources for the Connector. mustUpdate(t, fc, "", "test", func(conn *tsapi.Connector) { conn.Spec.ProxyClass = "" }) opts.proxyClass = "" expectReconciled(t, cr, "", "test") expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) } func TestConnectorWithAppConnector(t *testing.T) { // Setup cn := &tsapi.Connector{ ObjectMeta: metav1.ObjectMeta{ Name: "test", UID: types.UID("1234-UID"), }, TypeMeta: metav1.TypeMeta{ Kind: tsapi.ConnectorKind, APIVersion: "tailscale.io/v1alpha1", }, Spec: tsapi.ConnectorSpec{ AppConnector: &tsapi.AppConnector{}, }, } fc := fake.NewClientBuilder(). WithScheme(tsapi.GlobalScheme). WithObjects(cn). WithStatusSubresource(cn). Build() ft := &fakeTSClient{} zl, err := zap.NewDevelopment() if err != nil { t.Fatal(err) } cl := tstest.NewClock(tstest.ClockOpts{}) fr := record.NewFakeRecorder(1) cr := &ConnectorReconciler{ Client: fc, clock: cl, ssr: &tailscaleSTSReconciler{ Client: fc, tsClient: ft, defaultTags: []string{"tag:k8s"}, operatorNamespace: "operator-ns", proxyImage: "tailscale/tailscale", }, logger: zl.Sugar(), recorder: fr, } // 1. Connector with app connnector is created and becomes ready expectReconciled(t, cr, "", "test") fullName, shortName := findGenName(t, fc, "", "test", "connector") opts := configOpts{ stsName: shortName, secretName: fullName, parentType: "connector", hostname: "test-connector", app: kubetypes.AppConnector, isAppConnector: true, } expectEqual(t, fc, expectedSecret(t, fc, opts), nil) expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) // Connector's ready condition should be set to true cn.ObjectMeta.Finalizers = append(cn.ObjectMeta.Finalizers, "tailscale.com/finalizer") cn.Status.IsAppConnector = true cn.Status.Conditions = []metav1.Condition{{ Type: string(tsapi.ConnectorReady), Status: metav1.ConditionTrue, LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, Reason: reasonConnectorCreated, Message: reasonConnectorCreated, }} expectEqual(t, fc, cn, nil) // 2. Connector with invalid app connector routes has status set to invalid mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { conn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("1.2.3.4/5")} }) cn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("1.2.3.4/5")} expectReconciled(t, cr, "", "test") cn.Status.Conditions = []metav1.Condition{{ Type: string(tsapi.ConnectorReady), Status: metav1.ConditionFalse, LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, Reason: reasonConnectorInvalid, Message: "Connector is invalid: route 1.2.3.4/5 has non-address bits set; expected 0.0.0.0/5", }} expectEqual(t, fc, cn, nil) // 3. Connector with valid app connnector routes becomes ready mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { conn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("10.88.2.21/32")} }) cn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("10.88.2.21/32")} cn.Status.Conditions = []metav1.Condition{{ Type: string(tsapi.ConnectorReady), Status: metav1.ConditionTrue, LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, Reason: reasonConnectorCreated, Message: reasonConnectorCreated, }} expectReconciled(t, cr, "", "test") }