mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-29 23:33:45 +00:00

Adds a new IDP (Identity Provider) Custom Resource Definition to the Tailscale Kubernetes operator. This allows users to deploy and manage tsidp instances as Kubernetes resources. Updates #16666 Signed-off-by: Raj Singh <raj@tailscale.com>
607 lines
14 KiB
Go
607 lines
14 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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/apimachinery/pkg/types"
|
|
"k8s.io/client-go/tools/record"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
|
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
|
"tailscale.com/tstime"
|
|
)
|
|
|
|
func TestIDPReconciler_BasicFlow(t *testing.T) {
|
|
// Test basic creation flow similar to Recorder
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithStatusSubresource(&tsapi.IDP{}).
|
|
Build()
|
|
|
|
idp := &tsapi.IDP{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-idp",
|
|
Namespace: "default",
|
|
},
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
Tags: tsapi.Tags{"tag:k8s"},
|
|
},
|
|
}
|
|
|
|
r := &IDPReconciler{
|
|
Client: fc,
|
|
l: zap.L().Sugar(),
|
|
recorder: record.NewFakeRecorder(100),
|
|
tsNamespace: "tailscale",
|
|
clock: tstime.DefaultClock{},
|
|
tsClient: &fakeTSClient{},
|
|
}
|
|
|
|
if err := fc.Create(context.Background(), idp); err != nil {
|
|
t.Fatalf("failed to create IDP: %v", err)
|
|
}
|
|
|
|
req := reconcile.Request{
|
|
NamespacedName: types.NamespacedName{
|
|
Name: "test-idp",
|
|
Namespace: "default",
|
|
},
|
|
}
|
|
|
|
_, err := r.Reconcile(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("reconciliation failed: %v", err)
|
|
}
|
|
|
|
// Verify resources were created
|
|
verifyResourcesCreated(t, fc, "test-idp", "tailscale")
|
|
}
|
|
|
|
func TestTSIDPEnv(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
idp *tsapi.IDP
|
|
wantEnv map[string]string
|
|
}{
|
|
{
|
|
name: "basic",
|
|
idp: &tsapi.IDP{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-idp"},
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
Port: 443,
|
|
},
|
|
},
|
|
wantEnv: map[string]string{
|
|
"TS_STATE": "kube:test-idp-state",
|
|
"TSIDP_VERBOSE": "true",
|
|
"TS_HOSTNAME": "idp-test",
|
|
"TSIDP_PORT": "443",
|
|
"TSIDP_FUNNEL_CLIENTS_STORE": "kube:test-idp-funnel-clients",
|
|
},
|
|
},
|
|
{
|
|
name: "with-funnel-and-local-port",
|
|
idp: &tsapi.IDP{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-idp"},
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-mcp",
|
|
Port: 8443,
|
|
EnableFunnel: true,
|
|
LocalPort: &[]int32{9080}[0],
|
|
},
|
|
},
|
|
wantEnv: map[string]string{
|
|
"TS_STATE": "kube:test-idp-state",
|
|
"TSIDP_VERBOSE": "true",
|
|
"TS_HOSTNAME": "idp-mcp",
|
|
"TSIDP_PORT": "8443",
|
|
"TSIDP_FUNNEL": "true",
|
|
"TSIDP_LOCAL_PORT": "9080",
|
|
"TSIDP_FUNNEL_CLIENTS_STORE": "kube:test-idp-funnel-clients",
|
|
},
|
|
},
|
|
{
|
|
name: "with-custom-env",
|
|
idp: &tsapi.IDP{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-idp"},
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-mcp",
|
|
Port: 8443,
|
|
EnableFunnel: true,
|
|
StatefulSet: tsapi.IDPStatefulSet{
|
|
Pod: tsapi.IDPPod{
|
|
Container: tsapi.IDPContainer{
|
|
Env: []tsapi.Env{
|
|
{Name: tsapi.Name("CUSTOM_VAR"), Value: "custom-value"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantEnv: map[string]string{
|
|
"TS_STATE": "kube:test-idp-state",
|
|
"TSIDP_VERBOSE": "true",
|
|
"TS_HOSTNAME": "idp-mcp",
|
|
"TSIDP_PORT": "8443",
|
|
"TSIDP_FUNNEL": "true",
|
|
"TSIDP_FUNNEL_CLIENTS_STORE": "kube:test-idp-funnel-clients",
|
|
"CUSTOM_VAR": "custom-value",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
env := idpEnv(tt.idp, "")
|
|
|
|
envMap := make(map[string]string)
|
|
for _, e := range env {
|
|
if e.Value != "" {
|
|
envMap[e.Name] = e.Value
|
|
}
|
|
}
|
|
|
|
for key, expected := range tt.wantEnv {
|
|
if got, exists := envMap[key]; !exists {
|
|
t.Errorf("expected env var %s not found", key)
|
|
} else if got != expected {
|
|
t.Errorf("env var %s: expected %q, got %q", key, expected, got)
|
|
}
|
|
}
|
|
|
|
var hasAuthKey bool
|
|
for _, e := range env {
|
|
if e.Name == "TS_AUTHKEY" && e.ValueFrom != nil && e.ValueFrom.SecretKeyRef != nil {
|
|
hasAuthKey = true
|
|
break
|
|
}
|
|
}
|
|
if !hasAuthKey {
|
|
t.Error("expected TS_AUTHKEY to be set via secret reference")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIDPStatusConditions(t *testing.T) {
|
|
// Test that invalid specs produce proper status conditions
|
|
idp := &tsapi.IDP{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-idp",
|
|
Namespace: "default",
|
|
Finalizers: []string{FinalizerName},
|
|
},
|
|
Spec: tsapi.IDPSpec{
|
|
Tags: tsapi.Tags{"invalid-tag"}, // Missing tag: prefix
|
|
},
|
|
}
|
|
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithObjects(idp).
|
|
WithStatusSubresource(idp).
|
|
Build()
|
|
|
|
fr := record.NewFakeRecorder(10)
|
|
|
|
r := &IDPReconciler{
|
|
Client: fc,
|
|
l: zap.L().Sugar(),
|
|
recorder: fr,
|
|
tsNamespace: "tailscale",
|
|
clock: tstime.DefaultClock{},
|
|
tsClient: &fakeTSClient{},
|
|
}
|
|
|
|
expectReconciled(t, r, idp.Namespace, idp.Name)
|
|
|
|
updatedIDP := &tsapi.IDP{}
|
|
if err := fc.Get(context.Background(), client.ObjectKey{Name: idp.Name, Namespace: idp.Namespace}, updatedIDP); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(updatedIDP.Status.Conditions) != 1 {
|
|
t.Fatalf("expected 1 condition, got %d", len(updatedIDP.Status.Conditions))
|
|
}
|
|
|
|
cond := updatedIDP.Status.Conditions[0]
|
|
if cond.Type != string(tsapi.IDPReady) || cond.Status != metav1.ConditionFalse || cond.Reason != reasonIDPInvalid {
|
|
t.Fatalf("expected condition IDPReady false with reason IDPInvalid, got %v", cond)
|
|
}
|
|
|
|
if !strings.Contains(cond.Message, "must start with 'tag:'") {
|
|
t.Errorf("expected validation error in condition message, got %q", cond.Message)
|
|
}
|
|
|
|
select {
|
|
case event := <-fr.Events:
|
|
if !strings.Contains(event, "IDPInvalid") {
|
|
t.Errorf("expected IDPInvalid event, got %q", event)
|
|
}
|
|
default:
|
|
t.Error("expected event to be recorded")
|
|
}
|
|
}
|
|
|
|
func TestIDPValidation(t *testing.T) {
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
Build()
|
|
|
|
r := &IDPReconciler{
|
|
Client: fc,
|
|
l: zap.L().Sugar(),
|
|
recorder: record.NewFakeRecorder(100),
|
|
tsNamespace: "tailscale",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
idp *tsapi.IDP
|
|
wantErr bool
|
|
errMsg string
|
|
}{
|
|
{
|
|
name: "valid",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
Tags: tsapi.Tags{"tag:k8s", "tag:mcp"},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid-tag-missing-prefix",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
Tags: tsapi.Tags{"invalid-tag"},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
errMsg: "must start with 'tag:'",
|
|
},
|
|
{
|
|
name: "invalid-tag-empty-name",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
Tags: tsapi.Tags{"tag:"},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
errMsg: "tag names must not be empty",
|
|
},
|
|
{
|
|
name: "invalid-tag-special-chars",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
Tags: tsapi.Tags{"tag:test@123"},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
errMsg: "tag names can only contain numbers, letters, or dashes",
|
|
},
|
|
{
|
|
name: "hostname-too-long",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "this-hostname-is-way-too-long-and-exceeds-the-63-character-limit-for-dns-names",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
errMsg: "must be 63 characters or less",
|
|
},
|
|
{
|
|
name: "hostname-invalid-chars",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp_test",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
errMsg: "must be a valid DNS label",
|
|
},
|
|
{
|
|
name: "hostname-starts-with-dash",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "-idp-test",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
errMsg: "must be a valid DNS label",
|
|
},
|
|
{
|
|
name: "invalid-port-zero",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
Port: 0,
|
|
},
|
|
},
|
|
wantErr: false, // Port 0 means default (443)
|
|
},
|
|
{
|
|
name: "invalid-port-too-high",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
Port: 65536,
|
|
},
|
|
},
|
|
wantErr: true,
|
|
errMsg: "out of valid range",
|
|
},
|
|
{
|
|
name: "funnel-with-non-443-port",
|
|
idp: &tsapi.IDP{
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
EnableFunnel: true,
|
|
Port: 8443,
|
|
},
|
|
},
|
|
wantErr: true,
|
|
errMsg: "port must be 443 or unset",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := r.validate(context.Background(), tt.idp)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
|
t.Errorf("validate() error = %v, expected to contain %q", err, tt.errMsg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIDPServiceAccountHandling(t *testing.T) {
|
|
// Test custom ServiceAccount name works
|
|
t.Run("custom_service_account_name", func(t *testing.T) {
|
|
idp := &tsapi.IDP{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-idp",
|
|
Namespace: "default",
|
|
},
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
StatefulSet: tsapi.IDPStatefulSet{
|
|
Pod: tsapi.IDPPod{
|
|
ServiceAccount: tsapi.IDPServiceAccount{
|
|
Name: "custom-sa",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithStatusSubresource(&tsapi.IDP{}).
|
|
Build()
|
|
|
|
r := &IDPReconciler{
|
|
Client: fc,
|
|
l: zap.L().Sugar(),
|
|
recorder: record.NewFakeRecorder(100),
|
|
tsNamespace: "tailscale",
|
|
clock: tstime.DefaultClock{},
|
|
tsClient: &fakeTSClient{},
|
|
}
|
|
|
|
if err := fc.Create(context.Background(), idp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
expectReconciled(t, r, idp.Namespace, idp.Name)
|
|
|
|
// Verify custom ServiceAccount was created
|
|
sa := &corev1.ServiceAccount{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: "custom-sa",
|
|
Namespace: "tailscale",
|
|
}, sa); err != nil {
|
|
t.Errorf("expected custom ServiceAccount to be created: %v", err)
|
|
}
|
|
})
|
|
|
|
// Test ServiceAccount conflict detection
|
|
t.Run("service_account_conflict", func(t *testing.T) {
|
|
existingSA := &corev1.ServiceAccount{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "existing-sa",
|
|
Namespace: "tailscale",
|
|
OwnerReferences: []metav1.OwnerReference{
|
|
{
|
|
APIVersion: "v1",
|
|
Kind: "Pod",
|
|
Name: "other-pod",
|
|
UID: "12345",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
idp := &tsapi.IDP{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-idp",
|
|
Namespace: "default",
|
|
},
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
StatefulSet: tsapi.IDPStatefulSet{
|
|
Pod: tsapi.IDPPod{
|
|
ServiceAccount: tsapi.IDPServiceAccount{
|
|
Name: "existing-sa",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithStatusSubresource(&tsapi.IDP{}).
|
|
WithObjects(existingSA).
|
|
Build()
|
|
|
|
r := &IDPReconciler{
|
|
Client: fc,
|
|
l: zap.L().Sugar(),
|
|
recorder: record.NewFakeRecorder(100),
|
|
tsNamespace: "tailscale",
|
|
clock: tstime.DefaultClock{},
|
|
tsClient: &fakeTSClient{},
|
|
}
|
|
|
|
if err := fc.Create(context.Background(), idp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req := reconcile.Request{
|
|
NamespacedName: types.NamespacedName{
|
|
Name: idp.Name,
|
|
Namespace: idp.Namespace,
|
|
},
|
|
}
|
|
|
|
_, err := r.Reconcile(context.Background(), req)
|
|
if err == nil {
|
|
t.Error("expected error for ServiceAccount conflict")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIDPDeletion(t *testing.T) {
|
|
// Test deletion flow - similar to Recorder
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithStatusSubresource(&tsapi.IDP{}).
|
|
Build()
|
|
|
|
idp := &tsapi.IDP{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-idp",
|
|
Namespace: "default",
|
|
Finalizers: []string{FinalizerName},
|
|
},
|
|
Spec: tsapi.IDPSpec{
|
|
Hostname: "idp-test",
|
|
},
|
|
}
|
|
|
|
r := &IDPReconciler{
|
|
Client: fc,
|
|
l: zap.L().Sugar(),
|
|
recorder: record.NewFakeRecorder(100),
|
|
tsNamespace: "tailscale",
|
|
clock: tstime.DefaultClock{},
|
|
tsClient: &fakeTSClient{},
|
|
}
|
|
|
|
if err := fc.Create(context.Background(), idp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create resources
|
|
expectReconciled(t, r, idp.Namespace, idp.Name)
|
|
|
|
// Delete IDP
|
|
if err := fc.Delete(context.Background(), idp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Reconcile deletion
|
|
expectReconciled(t, r, idp.Namespace, idp.Name)
|
|
}
|
|
|
|
func verifyResourcesCreated(t *testing.T, fc client.Client, name, namespace string) {
|
|
t.Helper()
|
|
|
|
sa := &corev1.ServiceAccount{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
}, sa); err != nil {
|
|
t.Errorf("expected ServiceAccount to be created: %v", err)
|
|
}
|
|
|
|
role := &rbacv1.Role{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
}, role); err != nil {
|
|
t.Errorf("expected Role to be created: %v", err)
|
|
}
|
|
|
|
rb := &rbacv1.RoleBinding{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
}, rb); err != nil {
|
|
t.Errorf("expected RoleBinding to be created: %v", err)
|
|
}
|
|
|
|
sts := &appsv1.StatefulSet{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
}, sts); err != nil {
|
|
t.Errorf("expected StatefulSet to be created: %v", err)
|
|
}
|
|
|
|
svc := &corev1.Service{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
}, svc); err != nil {
|
|
t.Errorf("expected Service to be created: %v", err)
|
|
}
|
|
|
|
authSecret := &corev1.Secret{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
}, authSecret); err != nil {
|
|
t.Errorf("expected auth Secret to be created: %v", err)
|
|
}
|
|
|
|
funnelSecret := &corev1.Secret{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: name + "-funnel-clients",
|
|
Namespace: namespace,
|
|
}, funnelSecret); err != nil {
|
|
t.Errorf("expected funnel clients Secret to be created: %v", err)
|
|
} else {
|
|
if data, ok := funnelSecret.Data["funnel-clients"]; !ok {
|
|
t.Error("expected funnel-clients data key in secret")
|
|
} else if string(data) != "{}" {
|
|
t.Errorf("expected funnel-clients data to be '{}', got '%s'", string(data))
|
|
}
|
|
}
|
|
}
|