mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-16 02:28:41 +00:00

This commit modifies the recorder node reconciler to include the environment variable added in https://github.com/tailscale/corp/pull/30058 which allows for configuration of the coordination server. Updates https://github.com/tailscale/corp/issues/29847 Signed-off-by: David Bond <davidsbond93@gmail.com>
259 lines
8.6 KiB
Go
259 lines
8.6 KiB
Go
// 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"
|
|
tsLoginServer = "example.tailscale.com"
|
|
)
|
|
|
|
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,
|
|
loginServer: tsLoginServer,
|
|
}
|
|
|
|
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, tsLoginServer)
|
|
|
|
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)
|
|
}
|
|
}
|