tailscale/cmd/k8s-operator/tsrecorder_test.go

255 lines
8.5 KiB
Go
Raw Normal View History

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"encoding/json"
"strings"
"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()
fr := record.NewFakeRecorder(2)
cl := tstest.NewClock(tstest.ClockOpts{})
reconciler := &RecorderReconciler{
tsNamespace: tsNamespace,
Client: fc,
tsClient: tsClient,
recorder: fr,
l: zl.Sugar(),
clock: cl,
}
t.Run("invalid_spec_gives_an_error_condition", func(t *testing.T) {
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())
expectEqual(t, fc, tsr)
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
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 = ""
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())
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)
})
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) {
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)
tsr.Status.Devices = []tsapi.RecorderTailnetDevice{
{
Hostname: "hostname-nodeid-123",
TailnetIPs: []string{"1.2.3.4", "::1"},
URL: "https://test-0.example.ts.net",
},
}
expectEqual(t, fc, tsr)
})
t.Run("delete_the_Recorder_and_observe_cleanup", func(t *testing.T) {
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 {
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)
} 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)
}
}