2024-09-11 12:19:29 +01:00
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"encoding/json"
2025-05-19 11:35:05 +01:00
"strings"
2024-09-11 12:19:29 +01:00
"testing"
"github.com/google/go-cmp/cmp"
"go.uber.org/zap"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstest"
)
const tsNamespace = "tailscale"
func TestRecorder ( t * testing . T ) {
tsr := & tsapi . Recorder {
ObjectMeta : metav1 . ObjectMeta {
Name : "test" ,
Finalizers : [ ] string { "tailscale.com/finalizer" } ,
} ,
}
fc := fake . NewClientBuilder ( ) .
WithScheme ( tsapi . GlobalScheme ) .
WithObjects ( tsr ) .
WithStatusSubresource ( tsr ) .
Build ( )
tsClient := & fakeTSClient { }
zl , _ := zap . NewDevelopment ( )
2025-05-19 11:35:05 +01:00
fr := record . NewFakeRecorder ( 2 )
2024-09-11 12:19:29 +01:00
cl := tstest . NewClock ( tstest . ClockOpts { } )
reconciler := & RecorderReconciler {
tsNamespace : tsNamespace ,
Client : fc ,
tsClient : tsClient ,
recorder : fr ,
l : zl . Sugar ( ) ,
clock : cl ,
}
2025-05-19 11:35:05 +01:00
t . Run ( "invalid_spec_gives_an_error_condition" , func ( t * testing . T ) {
2024-09-11 12:19:29 +01:00
expectReconciled ( t , reconciler , "" , tsr . Name )
msg := "Recorder is invalid: must either enable UI or use S3 storage to ensure recordings are accessible"
tsoperator . SetRecorderCondition ( tsr , tsapi . RecorderReady , metav1 . ConditionFalse , reasonRecorderInvalid , msg , 0 , cl , zl . Sugar ( ) )
2025-01-17 05:37:53 +00:00
expectEqual ( t , fc , tsr )
2024-09-11 12:19:29 +01:00
if expected := 0 ; reconciler . recorders . Len ( ) != expected {
t . Fatalf ( "expected %d recorders, got %d" , expected , reconciler . recorders . Len ( ) )
}
expectRecorderResources ( t , fc , tsr , false )
expectedEvent := "Warning RecorderInvalid Recorder is invalid: must either enable UI or use S3 storage to ensure recordings are accessible"
expectEvents ( t , fr , [ ] string { expectedEvent } )
tsr . Spec . EnableUI = true
2025-05-19 11:35:05 +01:00
tsr . Spec . StatefulSet . Pod . ServiceAccount . Annotations = map [ string ] string {
"invalid space characters" : "test" ,
}
mustUpdate ( t , fc , "" , "test" , func ( t * tsapi . Recorder ) {
t . Spec = tsr . Spec
} )
expectReconciled ( t , reconciler , "" , tsr . Name )
// Only check part of this error message, because it's defined in an
// external package and may change.
if err := fc . Get ( context . Background ( ) , client . ObjectKey {
Name : tsr . Name ,
} , tsr ) ; err != nil {
t . Fatal ( err )
}
if len ( tsr . Status . Conditions ) != 1 {
t . Fatalf ( "expected 1 condition, got %d" , len ( tsr . Status . Conditions ) )
}
cond := tsr . Status . Conditions [ 0 ]
if cond . Type != string ( tsapi . RecorderReady ) || cond . Status != metav1 . ConditionFalse || cond . Reason != reasonRecorderInvalid {
t . Fatalf ( "expected condition RecorderReady false due to RecorderInvalid, got %v" , cond )
}
for _ , msg := range [ ] string { cond . Message , <- fr . Events } {
if ! strings . Contains ( msg , ` "invalid space characters" ` ) {
t . Fatalf ( "expected invalid annotation key in error message, got %q" , cond . Message )
}
}
} )
t . Run ( "conflicting_service_account_config_marked_as_invalid" , func ( t * testing . T ) {
mustCreate ( t , fc , & corev1 . ServiceAccount {
ObjectMeta : metav1 . ObjectMeta {
Name : "pre-existing-sa" ,
Namespace : tsNamespace ,
} ,
} )
tsr . Spec . StatefulSet . Pod . ServiceAccount . Annotations = nil
tsr . Spec . StatefulSet . Pod . ServiceAccount . Name = "pre-existing-sa"
mustUpdate ( t , fc , "" , "test" , func ( t * tsapi . Recorder ) {
t . Spec = tsr . Spec
} )
expectReconciled ( t , reconciler , "" , tsr . Name )
msg := ` Recorder is invalid: custom ServiceAccount name "pre-existing-sa" specified but conflicts with a pre-existing ServiceAccount in the tailscale namespace `
tsoperator . SetRecorderCondition ( tsr , tsapi . RecorderReady , metav1 . ConditionFalse , reasonRecorderInvalid , msg , 0 , cl , zl . Sugar ( ) )
expectEqual ( t , fc , tsr )
if expected := 0 ; reconciler . recorders . Len ( ) != expected {
t . Fatalf ( "expected %d recorders, got %d" , expected , reconciler . recorders . Len ( ) )
}
expectedEvent := "Warning RecorderInvalid " + msg
expectEvents ( t , fr , [ ] string { expectedEvent } )
} )
t . Run ( "observe_Ready_true_status_condition_for_a_valid_spec" , func ( t * testing . T ) {
tsr . Spec . StatefulSet . Pod . ServiceAccount . Name = ""
2024-09-11 12:19:29 +01:00
mustUpdate ( t , fc , "" , "test" , func ( t * tsapi . Recorder ) {
t . Spec = tsr . Spec
} )
expectReconciled ( t , reconciler , "" , tsr . Name )
tsoperator . SetRecorderCondition ( tsr , tsapi . RecorderReady , metav1 . ConditionTrue , reasonRecorderCreated , reasonRecorderCreated , 0 , cl , zl . Sugar ( ) )
2025-01-17 05:37:53 +00:00
expectEqual ( t , fc , tsr )
2024-09-11 12:19:29 +01:00
if expected := 1 ; reconciler . recorders . Len ( ) != expected {
t . Fatalf ( "expected %d recorders, got %d" , expected , reconciler . recorders . Len ( ) )
}
expectRecorderResources ( t , fc , tsr , true )
} )
2025-05-19 11:35:05 +01:00
t . Run ( "valid_service_account_config" , func ( t * testing . T ) {
tsr . Spec . StatefulSet . Pod . ServiceAccount . Name = "test-sa"
tsr . Spec . StatefulSet . Pod . ServiceAccount . Annotations = map [ string ] string {
"test" : "test" ,
}
mustUpdate ( t , fc , "" , "test" , func ( t * tsapi . Recorder ) {
t . Spec = tsr . Spec
} )
expectReconciled ( t , reconciler , "" , tsr . Name )
expectEqual ( t , fc , tsr )
if expected := 1 ; reconciler . recorders . Len ( ) != expected {
t . Fatalf ( "expected %d recorders, got %d" , expected , reconciler . recorders . Len ( ) )
}
expectRecorderResources ( t , fc , tsr , true )
// Get the service account and check the annotations.
sa := & corev1 . ServiceAccount { }
if err := fc . Get ( context . Background ( ) , client . ObjectKey {
Name : tsr . Spec . StatefulSet . Pod . ServiceAccount . Name ,
Namespace : tsNamespace ,
} , sa ) ; err != nil {
t . Fatal ( err )
}
if diff := cmp . Diff ( sa . Annotations , tsr . Spec . StatefulSet . Pod . ServiceAccount . Annotations ) ; diff != "" {
t . Fatalf ( "unexpected service account annotations (-got +want):\n%s" , diff )
}
if sa . Name != tsr . Spec . StatefulSet . Pod . ServiceAccount . Name {
t . Fatalf ( "unexpected service account name: got %q, want %q" , sa . Name , tsr . Spec . StatefulSet . Pod . ServiceAccount . Name )
}
expectMissing [ corev1 . ServiceAccount ] ( t , fc , tsNamespace , tsr . Name )
} )
t . Run ( "populate_node_info_in_state_secret_and_see_it_appear_in_status" , func ( t * testing . T ) {
2024-09-11 12:19:29 +01:00
bytes , err := json . Marshal ( map [ string ] any {
"Config" : map [ string ] any {
"NodeID" : "nodeid-123" ,
"UserProfile" : map [ string ] any {
"LoginName" : "test-0.example.ts.net" ,
} ,
} ,
} )
if err != nil {
t . Fatal ( err )
}
const key = "profile-abc"
mustUpdate ( t , fc , tsNamespace , "test-0" , func ( s * corev1 . Secret ) {
s . Data = map [ string ] [ ] byte {
currentProfileKey : [ ] byte ( key ) ,
key : bytes ,
}
} )
expectReconciled ( t , reconciler , "" , tsr . Name )
2024-09-27 01:05:56 +01:00
tsr . Status . Devices = [ ] tsapi . RecorderTailnetDevice {
2024-09-11 12:19:29 +01:00
{
2024-10-07 14:58:45 +01:00
Hostname : "hostname-nodeid-123" ,
2024-09-11 12:19:29 +01:00
TailnetIPs : [ ] string { "1.2.3.4" , "::1" } ,
URL : "https://test-0.example.ts.net" ,
} ,
}
2025-01-17 05:37:53 +00:00
expectEqual ( t , fc , tsr )
2024-09-11 12:19:29 +01:00
} )
2025-05-19 11:35:05 +01:00
t . Run ( "delete_the_Recorder_and_observe_cleanup" , func ( t * testing . T ) {
2024-09-11 12:19:29 +01:00
if err := fc . Delete ( context . Background ( ) , tsr ) ; err != nil {
t . Fatal ( err )
}
expectReconciled ( t , reconciler , "" , tsr . Name )
expectMissing [ tsapi . Recorder ] ( t , fc , "" , tsr . Name )
if expected := 0 ; reconciler . recorders . Len ( ) != expected {
t . Fatalf ( "expected %d recorders, got %d" , expected , reconciler . recorders . Len ( ) )
}
if diff := cmp . Diff ( tsClient . deleted , [ ] string { "nodeid-123" } ) ; diff != "" {
t . Fatalf ( "unexpected deleted devices (-got +want):\n%s" , diff )
}
// The fake client does not clean up objects whose owner has been
// deleted, so we can't test for the owned resources getting deleted.
} )
}
func expectRecorderResources ( t * testing . T , fc client . WithWatch , tsr * tsapi . Recorder , shouldExist bool ) {
t . Helper ( )
auth := tsrAuthSecret ( tsr , tsNamespace , "secret-authkey" )
state := tsrStateSecret ( tsr , tsNamespace )
role := tsrRole ( tsr , tsNamespace )
roleBinding := tsrRoleBinding ( tsr , tsNamespace )
serviceAccount := tsrServiceAccount ( tsr , tsNamespace )
statefulSet := tsrStatefulSet ( tsr , tsNamespace )
if shouldExist {
2025-01-17 05:37:53 +00:00
expectEqual ( t , fc , auth )
expectEqual ( t , fc , state )
expectEqual ( t , fc , role )
expectEqual ( t , fc , roleBinding )
expectEqual ( t , fc , serviceAccount )
expectEqual ( t , fc , statefulSet , removeResourceReqs )
2024-09-11 12:19:29 +01:00
} else {
expectMissing [ corev1 . Secret ] ( t , fc , auth . Namespace , auth . Name )
expectMissing [ corev1 . Secret ] ( t , fc , state . Namespace , state . Name )
expectMissing [ rbacv1 . Role ] ( t , fc , role . Namespace , role . Name )
expectMissing [ rbacv1 . RoleBinding ] ( t , fc , roleBinding . Namespace , roleBinding . Name )
expectMissing [ corev1 . ServiceAccount ] ( t , fc , serviceAccount . Namespace , serviceAccount . Name )
expectMissing [ appsv1 . StatefulSet ] ( t , fc , statefulSet . Namespace , statefulSet . Name )
}
}