You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
tailscale/cmd/k8s-operator/svc_test.go

223 lines
7.1 KiB

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"slices"
"testing"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/k8s-operator/tsclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstest"
)
func TestService_DefaultProxyClassInitiallyNotReady(t *testing.T) {
pc := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"},
Spec: tsapi.ProxyClassSpec{
TailscaleConfig: &tsapi.TailscaleConfig{
AcceptRoutes: true,
},
StatefulSet: &tsapi.StatefulSet{
Labels: tsapi.Labels{"foo": "bar"},
Annotations: map[string]string{"bar.io/foo": "some-val"},
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}},
},
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pc).
WithStatusSubresource(pc).
Build()
ft := &fakeTSClient{}
zl := zap.Must(zap.NewDevelopment())
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
clients: tsclient.NewProvider(ft),
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
defaultProxyClass: "custom-metadata",
logger: zl.Sugar(),
clock: clock,
}
// 1. A new tailscale LoadBalancer Service is created but the default
// ProxyClass is not ready yet.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeLoadBalancer,
LoadBalancerClass: new("tailscale"),
},
})
expectReconciled(t, sr, "default", "test")
labels := map[string]string{
kubetypes.LabelManaged: "true",
LabelParentName: "test",
LabelParentNamespace: "operator-ns",
LabelParentType: "svc",
}
s, err := getSingleObject[corev1.Secret](context.Background(), fc, "operator-ns", labels)
if err != nil {
t.Fatalf("finding Secret for %q: %v", "test", err)
}
if s != nil {
t.Fatalf("expected no Secret to be created when default ProxyClass is not ready, but found one: %v", s)
}
// 2. ProxyClass is set to Ready, the Service can become ready now.
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
pc.Status = tsapi.ProxyClassStatus{
Conditions: []metav1.Condition{{
Status: metav1.ConditionTrue,
Type: string(tsapi.ProxyClassReady),
ObservedGeneration: pc.Generation,
}},
}
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
opts := configOpts{
replicas: new(int32(1)),
stsName: shortName,
secretName: fullName,
namespace: "default",
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
proxyClass: pc.Name,
}
expectEqual(t, fc, expectedSecret(t, fc, opts))
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
}
func TestProxyClassHandlerForSvc(t *testing.T) {
svc := func(name string, annotations, labels map[string]string) *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "default",
Annotations: annotations,
Labels: labels,
},
Spec: corev1.ServiceSpec{
ClusterIP: "1.2.3.4",
},
}
}
lbSvc := func(name string, annotations map[string]string, class *string) *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "foo",
Annotations: annotations,
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeLoadBalancer,
LoadBalancerClass: class,
ClusterIP: "1.2.3.4",
},
}
}
const (
defaultPCName = "default-proxyclass"
otherPCName = "other-proxyclass"
unreferencedPCName = "unreferenced-proxyclass"
)
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithIndex(&corev1.Service{}, indexServiceProxyClass, indexProxyClass).
WithIndex(&corev1.Service{}, indexServiceExposed, indexExposed).
WithIndex(&corev1.Service{}, indexServiceType, indexType).
WithObjects(
svc("not-exposed", nil, nil),
svc("exposed-default", map[string]string{AnnotationExpose: "true"}, nil),
svc("exposed-other", map[string]string{AnnotationExpose: "true", LabelAnnotationProxyClass: otherPCName}, nil),
svc("annotated", map[string]string{LabelAnnotationProxyClass: defaultPCName}, nil),
svc("labelled", nil, map[string]string{LabelAnnotationProxyClass: defaultPCName}),
lbSvc("lb-svc", nil, new("tailscale")),
lbSvc("lb-svc-no-class", nil, nil),
lbSvc("lb-svc-other-class", nil, new("other")),
lbSvc("lb-svc-other-pc", map[string]string{LabelAnnotationProxyClass: otherPCName}, nil),
).
Build()
zl := zap.Must(zap.NewDevelopment())
mapFunc := proxyClassHandlerForSvc(fc, zl.Sugar(), defaultPCName, true)
for _, tc := range []struct {
name string
proxyClassName string
expected []reconcile.Request
}{
{
name: "default_ProxyClass",
proxyClassName: defaultPCName,
expected: []reconcile.Request{
{NamespacedName: types.NamespacedName{Namespace: "default", Name: "exposed-default"}},
{NamespacedName: types.NamespacedName{Namespace: "default", Name: "annotated"}},
{NamespacedName: types.NamespacedName{Namespace: "default", Name: "labelled"}},
{NamespacedName: types.NamespacedName{Namespace: "foo", Name: "lb-svc"}},
{NamespacedName: types.NamespacedName{Namespace: "foo", Name: "lb-svc-no-class"}},
},
},
{
name: "other_ProxyClass",
proxyClassName: otherPCName,
expected: []reconcile.Request{
{NamespacedName: types.NamespacedName{Namespace: "default", Name: "exposed-other"}},
{NamespacedName: types.NamespacedName{Namespace: "foo", Name: "lb-svc-other-pc"}},
},
},
{
name: "unreferenced_ProxyClass",
proxyClassName: unreferencedPCName,
expected: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
reqs := mapFunc(t.Context(), &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{
Name: tc.proxyClassName,
},
})
if len(reqs) != len(tc.expected) {
t.Fatalf("expected %d requests, got %d: %v", len(tc.expected), len(reqs), reqs)
}
for _, expected := range tc.expected {
if !slices.Contains(reqs, expected) {
t.Errorf("expected request for Service %q not found in results: %v", expected.Name, reqs)
}
}
})
}
}