tailscale/cmd/k8s-operator/ingress-for-pg_test.go
Adrian Dewhurst 0fa7b4a236 tailcfg: add ServiceName
Rather than using a string everywhere and needing to clarify that the
string should have the svc: prefix, create a separate type for Service
names.

Updates tailscale/corp#24607

Change-Id: I720e022f61a7221644bb60955b72cacf42f59960
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2025-01-22 15:27:46 -05:00

338 lines
8.9 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"encoding/json"
"testing"
"slices"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/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/fake"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/types/ptr"
)
func TestIngressPGReconciler(t *testing.T) {
tsIngressClass := &networkingv1.IngressClass{
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"},
}
// Pre-create the ProxyGroup
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg",
Generation: 1,
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
},
}
// Pre-create the ConfigMap for the ProxyGroup
pgConfigMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg-ingress-config",
Namespace: "operator-ns",
},
BinaryData: map[string][]byte{
"serve-config.json": []byte(`{"Services":{}}`),
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pg, pgConfigMap, tsIngressClass).
WithStatusSubresource(pg).
Build()
mustUpdateStatus(t, fc, "", pg.Name, func(pg *tsapi.ProxyGroup) {
pg.Status.Conditions = []metav1.Condition{
{
Type: string(tsapi.ProxyGroupReady),
Status: metav1.ConditionTrue,
ObservedGeneration: 1,
},
}
})
ft := &fakeTSClient{}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
lc := &fakeLocalClient{
status: &ipnstate.Status{
CurrentTailnet: &ipnstate.TailnetStatus{
MagicDNSSuffix: "ts.net",
},
},
}
ingPGR := &IngressPGReconciler{
Client: fc,
tsClient: ft,
tsnetServer: fakeTsnetServer,
defaultTags: []string{"tag:k8s"},
tsNamespace: "operator-ns",
logger: zl.Sugar(),
recorder: record.NewFakeRecorder(10),
lc: lc,
}
// Test 1: Default tags
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/proxy-group": "test-pg",
},
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"my-svc.tailnetxyz.ts.net"}},
},
},
}
mustCreate(t, fc, ing)
// Verify initial reconciliation
expectReconciled(t, ingPGR, "default", "test-ingress")
// Get and verify the ConfigMap was updated
cm := &corev1.ConfigMap{}
if err := fc.Get(context.Background(), types.NamespacedName{
Name: "test-pg-ingress-config",
Namespace: "operator-ns",
}, cm); err != nil {
t.Fatalf("getting ConfigMap: %v", err)
}
cfg := &ipn.ServeConfig{}
if err := json.Unmarshal(cm.BinaryData[serveConfigKey], cfg); err != nil {
t.Fatalf("unmarshaling serve config: %v", err)
}
if cfg.Services["svc:my-svc"] == nil {
t.Error("expected serve config to contain VIPService configuration")
}
// Verify VIPService uses default tags
vipSvc, err := ft.getVIPServiceByName(context.Background(), "my-svc")
if err != nil {
t.Fatalf("getting VIPService: %v", err)
}
if vipSvc == nil {
t.Fatal("VIPService not created")
}
wantTags := []string{"tag:k8s"} // default tags
if !slices.Equal(vipSvc.Tags, wantTags) {
t.Errorf("incorrect VIPService tags: got %v, want %v", vipSvc.Tags, wantTags)
}
// Test 2: Custom tags
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
ing.Annotations["tailscale.com/tags"] = "tag:custom,tag:test"
})
expectReconciled(t, ingPGR, "default", "test-ingress")
// Verify VIPService uses custom tags
vipSvc, err = ft.getVIPServiceByName(context.Background(), "my-svc")
if err != nil {
t.Fatalf("getting VIPService: %v", err)
}
if vipSvc == nil {
t.Fatal("VIPService not created")
}
wantTags = []string{"tag:custom", "tag:test"} // custom tags only
gotTags := slices.Clone(vipSvc.Tags)
slices.Sort(gotTags)
slices.Sort(wantTags)
if !slices.Equal(gotTags, wantTags) {
t.Errorf("incorrect VIPService tags: got %v, want %v", gotTags, wantTags)
}
// Delete the Ingress and verify cleanup
if err := fc.Delete(context.Background(), ing); err != nil {
t.Fatalf("deleting Ingress: %v", err)
}
expectReconciled(t, ingPGR, "default", "test-ingress")
// Verify the ConfigMap was cleaned up
cm = &corev1.ConfigMap{}
if err := fc.Get(context.Background(), types.NamespacedName{
Name: "test-pg-ingress-config",
Namespace: "operator-ns",
}, cm); err != nil {
t.Fatalf("getting ConfigMap: %v", err)
}
cfg = &ipn.ServeConfig{}
if err := json.Unmarshal(cm.BinaryData[serveConfigKey], cfg); err != nil {
t.Fatalf("unmarshaling serve config: %v", err)
}
if len(cfg.Services) > 0 {
t.Error("serve config not cleaned up")
}
}
func TestValidateIngress(t *testing.T) {
baseIngress := &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
},
}
readyProxyGroup := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg",
Generation: 1,
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
},
Status: tsapi.ProxyGroupStatus{
Conditions: []metav1.Condition{
{
Type: string(tsapi.ProxyGroupReady),
Status: metav1.ConditionTrue,
ObservedGeneration: 1,
},
},
},
}
tests := []struct {
name string
ing *networkingv1.Ingress
pg *tsapi.ProxyGroup
wantErr string
}{
{
name: "valid_ingress_with_hostname",
ing: &networkingv1.Ingress{
ObjectMeta: baseIngress.ObjectMeta,
Spec: networkingv1.IngressSpec{
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"test.example.com"}},
},
},
},
pg: readyProxyGroup,
},
{
name: "valid_ingress_with_default_hostname",
ing: baseIngress,
pg: readyProxyGroup,
},
{
name: "invalid_tags",
ing: &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: baseIngress.Name,
Namespace: baseIngress.Namespace,
Annotations: map[string]string{
AnnotationTags: "tag:invalid!",
},
},
},
pg: readyProxyGroup,
wantErr: "tailscale.com/tags annotation contains invalid tag \"tag:invalid!\": tag names can only contain numbers, letters, or dashes",
},
{
name: "multiple_TLS_entries",
ing: &networkingv1.Ingress{
ObjectMeta: baseIngress.ObjectMeta,
Spec: networkingv1.IngressSpec{
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"test1.example.com"}},
{Hosts: []string{"test2.example.com"}},
},
},
},
pg: readyProxyGroup,
wantErr: "Ingress contains invalid TLS block [{[test1.example.com] } {[test2.example.com] }]: only a single TLS entry with a single host is allowed",
},
{
name: "multiple_hosts_in_TLS_entry",
ing: &networkingv1.Ingress{
ObjectMeta: baseIngress.ObjectMeta,
Spec: networkingv1.IngressSpec{
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"test1.example.com", "test2.example.com"}},
},
},
},
pg: readyProxyGroup,
wantErr: "Ingress contains invalid TLS block [{[test1.example.com test2.example.com] }]: only a single TLS entry with a single host is allowed",
},
{
name: "wrong_proxy_group_type",
ing: baseIngress,
pg: &tsapi.ProxyGroup{
ObjectMeta: readyProxyGroup.ObjectMeta,
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupType("foo"),
},
Status: readyProxyGroup.Status,
},
wantErr: "ProxyGroup \"test-pg\" is of type \"foo\" but must be of type \"ingress\"",
},
{
name: "proxy_group_not_ready",
ing: baseIngress,
pg: &tsapi.ProxyGroup{
ObjectMeta: readyProxyGroup.ObjectMeta,
Spec: readyProxyGroup.Spec,
Status: tsapi.ProxyGroupStatus{
Conditions: []metav1.Condition{
{
Type: string(tsapi.ProxyGroupReady),
Status: metav1.ConditionFalse,
ObservedGeneration: 1,
},
},
},
},
wantErr: "ProxyGroup \"test-pg\" is not ready",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &IngressPGReconciler{}
err := r.validateIngress(tt.ing, tt.pg)
if (err == nil && tt.wantErr != "") || (err != nil && err.Error() != tt.wantErr) {
t.Errorf("validateIngress() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}