mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 11:05:45 +00:00
e48cddfbb3
Implements the controller for the new ProxyGroup CRD, designed for running proxies in a high availability configuration. Each proxy gets its own config and state Secret, and its own tailscale node ID. We are currently mounting all of the config secrets into the container, but will stop mounting them and instead read them directly from the kube API once #13578 is implemented. Updates #13406 Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
163 lines
5.2 KiB
Go
163 lines
5.2 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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(1)
|
|
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, nil)
|
|
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})
|
|
})
|
|
|
|
t.Run("observe Ready=true status condition for a valid spec", func(t *testing.T) {
|
|
tsr.Spec.EnableUI = true
|
|
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, nil)
|
|
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("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, nil)
|
|
})
|
|
|
|
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, nil)
|
|
expectEqual(t, fc, state, nil)
|
|
expectEqual(t, fc, role, nil)
|
|
expectEqual(t, fc, roleBinding, nil)
|
|
expectEqual(t, fc, serviceAccount, nil)
|
|
expectEqual(t, fc, statefulSet, nil)
|
|
} 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)
|
|
}
|
|
}
|