mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 20:26:47 +02:00
This commit modifies the kubernetes operator to use the `tailscale-client-go-v2` package instead of the internal tailscale client it was previously using. This now gives us the ability to expand out custom resources and features as they become available via the API module. The tailnet reconciler has also been modified to manage clients as tailnets are created and removed, providing each subsequent reconciler with a single `ClientProvider` that obtains a tailscale client for the respective tailnet by name, or the operator's default when presented with a blank string. Fixes: https://github.com/tailscale/corp/issues/38418 Signed-off-by: David Bond <davidsbond93@gmail.com>
1226 lines
36 KiB
Go
1226 lines
36 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"go.uber.org/zap"
|
|
corev1 "k8s.io/api/core/v1"
|
|
networkingv1 "k8s.io/api/networking/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"
|
|
"tailscale.com/client/tailscale/v2"
|
|
|
|
"tailscale.com/ipn"
|
|
tsoperator "tailscale.com/k8s-operator"
|
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
|
"tailscale.com/k8s-operator/tsclient"
|
|
"tailscale.com/kube/kubetypes"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
func TestIngressPGReconciler(t *testing.T) {
|
|
ingPGR, fc, ft := setupIngressTest(t)
|
|
|
|
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: new("tailscale"),
|
|
DefaultBackend: &networkingv1.IngressBackend{
|
|
Service: &networkingv1.IngressServiceBackend{
|
|
Name: "test",
|
|
Port: networkingv1.ServiceBackendPort{
|
|
Number: 8080,
|
|
},
|
|
},
|
|
},
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"my-svc"}},
|
|
},
|
|
},
|
|
}
|
|
mustCreate(t, fc, ing)
|
|
|
|
// Verify initial reconciliation
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
populateTLSSecret(t, fc, "test-pg", "my-svc.ts.net")
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
verifyServeConfig(t, fc, "svc:my-svc", false)
|
|
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:443"})
|
|
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:my-svc"})
|
|
|
|
// Verify that Role and RoleBinding have been created for the first Ingress.
|
|
// Do not verify the cert Secret as that was already verified implicitly above.
|
|
pg := &tsapi.ProxyGroup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pg",
|
|
},
|
|
}
|
|
expectEqual(t, fc, certSecretRole("test-pg", "operator-ns", "my-svc.ts.net"))
|
|
expectEqual(t, fc, certSecretRoleBinding(pg, "operator-ns", "my-svc.ts.net"))
|
|
|
|
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 Tailscale Service uses custom tags
|
|
tsSvc, err := ft.VIPServices().Get(t.Context(), "svc:my-svc")
|
|
if err != nil {
|
|
t.Fatalf("getting Tailscale Service: %v", err)
|
|
}
|
|
if tsSvc == nil {
|
|
t.Fatal("Tailscale Service not created")
|
|
}
|
|
wantTags := []string{"tag:custom", "tag:test"} // custom tags only
|
|
gotTags := slices.Clone(tsSvc.Tags)
|
|
slices.Sort(gotTags)
|
|
slices.Sort(wantTags)
|
|
if !slices.Equal(gotTags, wantTags) {
|
|
t.Errorf("incorrect Tailscale Service tags: got %v, want %v", gotTags, wantTags)
|
|
}
|
|
|
|
// Create second Ingress
|
|
ing2 := &networkingv1.Ingress{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-other-ingress",
|
|
Namespace: "default",
|
|
UID: types.UID("5678-UID"),
|
|
Annotations: map[string]string{
|
|
"tailscale.com/proxy-group": "test-pg",
|
|
},
|
|
},
|
|
Spec: networkingv1.IngressSpec{
|
|
IngressClassName: new("tailscale"),
|
|
DefaultBackend: &networkingv1.IngressBackend{
|
|
Service: &networkingv1.IngressServiceBackend{
|
|
Name: "test",
|
|
Port: networkingv1.ServiceBackendPort{
|
|
Number: 8080,
|
|
},
|
|
},
|
|
},
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"my-other-svc.tailnetxyz.ts.net"}},
|
|
},
|
|
},
|
|
}
|
|
mustCreate(t, fc, ing2)
|
|
|
|
// Verify second Ingress reconciliation
|
|
expectReconciled(t, ingPGR, "default", "my-other-ingress")
|
|
populateTLSSecret(t, fc, "test-pg", "my-other-svc.ts.net")
|
|
expectReconciled(t, ingPGR, "default", "my-other-ingress")
|
|
verifyServeConfig(t, fc, "svc:my-other-svc", false)
|
|
verifyTailscaleService(t, ft, "svc:my-other-svc", []string{"tcp:443"})
|
|
|
|
// Verify that Role and RoleBinding have been created for the second Ingress.
|
|
// Do not verify the cert Secret as that was already verified implicitly above.
|
|
expectEqual(t, fc, certSecretRole("test-pg", "operator-ns", "my-other-svc.ts.net"))
|
|
expectEqual(t, fc, certSecretRoleBinding(pg, "operator-ns", "my-other-svc.ts.net"))
|
|
|
|
// Verify first Ingress is still working
|
|
verifyServeConfig(t, fc, "svc:my-svc", false)
|
|
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:443"})
|
|
|
|
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:my-svc", "svc:my-other-svc"})
|
|
|
|
// Delete second Ingress
|
|
if err := fc.Delete(t.Context(), ing2); err != nil {
|
|
t.Fatalf("deleting second Ingress: %v", err)
|
|
}
|
|
expectReconciled(t, ingPGR, "default", "my-other-ingress")
|
|
|
|
// Verify second Ingress cleanup
|
|
cm := &corev1.ConfigMap{}
|
|
if err := fc.Get(t.Context(), 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)
|
|
}
|
|
|
|
// Verify first Ingress is still configured
|
|
if cfg.Services["svc:my-svc"] == nil {
|
|
t.Error("first Ingress service config was incorrectly removed")
|
|
}
|
|
// Verify second Ingress was cleaned up
|
|
if cfg.Services["svc:my-other-svc"] != nil {
|
|
t.Error("second Ingress service config was not cleaned up")
|
|
}
|
|
|
|
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:my-svc"})
|
|
expectMissing[corev1.Secret](t, fc, "operator-ns", "my-other-svc.ts.net")
|
|
expectMissing[rbacv1.Role](t, fc, "operator-ns", "my-other-svc.ts.net")
|
|
expectMissing[rbacv1.RoleBinding](t, fc, "operator-ns", "my-other-svc.ts.net")
|
|
|
|
// Test Ingress ProxyGroup change
|
|
createPGResources(t, fc, "test-pg-second")
|
|
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
|
|
ing.Annotations["tailscale.com/proxy-group"] = "test-pg-second"
|
|
})
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
expectEqual(t, fc, certSecretRole("test-pg-second", "operator-ns", "my-svc.ts.net"))
|
|
pg = &tsapi.ProxyGroup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pg-second",
|
|
},
|
|
}
|
|
expectEqual(t, fc, certSecretRoleBinding(pg, "operator-ns", "my-svc.ts.net"))
|
|
|
|
// Delete the first Ingress and verify cleanup
|
|
if err := fc.Delete(t.Context(), 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(t.Context(), types.NamespacedName{
|
|
Name: "test-pg-second-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")
|
|
}
|
|
verifyTailscaledConfig(t, fc, "test-pg-second", nil)
|
|
|
|
// Add verification that cert resources were cleaned up
|
|
expectMissing[corev1.Secret](t, fc, "operator-ns", "my-svc.ts.net")
|
|
expectMissing[rbacv1.Role](t, fc, "operator-ns", "my-svc.ts.net")
|
|
expectMissing[rbacv1.RoleBinding](t, fc, "operator-ns", "my-svc.ts.net")
|
|
|
|
// Create a third ingress
|
|
ing3 := &networkingv1.Ingress{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-other-ingress",
|
|
Namespace: "default",
|
|
UID: types.UID("5678-UID"),
|
|
Annotations: map[string]string{
|
|
"tailscale.com/proxy-group": "test-pg",
|
|
},
|
|
},
|
|
Spec: networkingv1.IngressSpec{
|
|
IngressClassName: new("tailscale"),
|
|
DefaultBackend: &networkingv1.IngressBackend{
|
|
Service: &networkingv1.IngressServiceBackend{
|
|
Name: "test",
|
|
Port: networkingv1.ServiceBackendPort{
|
|
Number: 8080,
|
|
},
|
|
},
|
|
},
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"my-other-svc.tailnetxyz.ts.net"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
mustCreate(t, fc, ing3)
|
|
expectReconciled(t, ingPGR, ing3.Namespace, ing3.Name)
|
|
|
|
// Delete the service from "control"
|
|
ft.vipServices = make(map[string]tailscale.VIPService)
|
|
|
|
// Delete the ingress and confirm we don't get stuck due to the VIP service not existing.
|
|
if err = fc.Delete(t.Context(), ing3); err != nil {
|
|
t.Fatalf("deleting Ingress: %v", err)
|
|
}
|
|
|
|
expectReconciled(t, ingPGR, ing3.Namespace, ing3.Name)
|
|
expectMissing[networkingv1.Ingress](t, fc, ing3.Namespace, ing3.Name)
|
|
}
|
|
|
|
func TestIngressPGReconciler_UpdateIngressHostname(t *testing.T) {
|
|
ingPGR, fc, ft := setupIngressTest(t)
|
|
|
|
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: new("tailscale"),
|
|
DefaultBackend: &networkingv1.IngressBackend{
|
|
Service: &networkingv1.IngressServiceBackend{
|
|
Name: "test",
|
|
Port: networkingv1.ServiceBackendPort{
|
|
Number: 8080,
|
|
},
|
|
},
|
|
},
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"my-svc"}},
|
|
},
|
|
},
|
|
}
|
|
mustCreate(t, fc, ing)
|
|
|
|
// Verify initial reconciliation
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
populateTLSSecret(t, fc, "test-pg", "my-svc.ts.net")
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
verifyServeConfig(t, fc, "svc:my-svc", false)
|
|
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:443"})
|
|
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:my-svc"})
|
|
|
|
// Update the Ingress hostname and make sure the original Tailscale Service is deleted.
|
|
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
|
|
ing.Spec.TLS[0].Hosts[0] = "updated-svc"
|
|
})
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
populateTLSSecret(t, fc, "test-pg", "updated-svc.ts.net")
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
verifyServeConfig(t, fc, "svc:updated-svc", false)
|
|
verifyTailscaleService(t, ft, "svc:updated-svc", []string{"tcp:443"})
|
|
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:updated-svc"})
|
|
|
|
_, err := ft.VIPServices().Get(context.Background(), "svc:my-svc")
|
|
if err == nil {
|
|
t.Fatalf("svc:my-svc not cleaned up")
|
|
}
|
|
if !tailscale.IsNotFound(err) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateIngress(t *testing.T) {
|
|
baseIngress := &networkingv1.Ingress{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-ingress",
|
|
Namespace: "default",
|
|
UID: types.UID("1234-UID"),
|
|
Annotations: map[string]string{
|
|
AnnotationProxyGroup: "test-pg",
|
|
},
|
|
},
|
|
Spec: networkingv1.IngressSpec{
|
|
IngressClassName: new("tailscale"),
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"test"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
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.ProxyGroupAvailable),
|
|
Status: metav1.ConditionTrue,
|
|
ObservedGeneration: 1,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
ing *networkingv1.Ingress
|
|
pg *tsapi.ProxyGroup
|
|
existingIngs []networkingv1.Ingress
|
|
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: "Ingress contains invalid tags: 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.ProxyGroupAvailable),
|
|
Status: metav1.ConditionFalse,
|
|
ObservedGeneration: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "ProxyGroup \"test-pg\" is not ready",
|
|
},
|
|
{
|
|
name: "duplicate_hostname",
|
|
ing: baseIngress,
|
|
pg: readyProxyGroup,
|
|
existingIngs: []networkingv1.Ingress{{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "existing-ingress",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
AnnotationProxyGroup: "test-pg",
|
|
},
|
|
},
|
|
Spec: networkingv1.IngressSpec{
|
|
IngressClassName: new("tailscale"),
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"test"}},
|
|
},
|
|
},
|
|
}},
|
|
wantErr: `found duplicate Ingress "default/existing-ingress" for hostname "test" - multiple Ingresses for the same hostname in the same cluster are not allowed`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithObjects(tt.ing).
|
|
WithLists(&networkingv1.IngressList{Items: tt.existingIngs}).
|
|
Build()
|
|
|
|
r := &HAIngressReconciler{Client: fc}
|
|
if tt.ing.Spec.IngressClassName != nil {
|
|
r.ingressClassName = *tt.ing.Spec.IngressClassName
|
|
}
|
|
|
|
err := r.validateIngress(context.Background(), 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIngressPGReconciler_HTTPEndpoint(t *testing.T) {
|
|
ingPGR, fc, ft := setupIngressTest(t)
|
|
|
|
// Create test Ingress with HTTP endpoint enabled
|
|
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",
|
|
"tailscale.com/http-endpoint": "enabled",
|
|
},
|
|
},
|
|
Spec: networkingv1.IngressSpec{
|
|
IngressClassName: new("tailscale"),
|
|
DefaultBackend: &networkingv1.IngressBackend{
|
|
Service: &networkingv1.IngressServiceBackend{
|
|
Name: "test",
|
|
Port: networkingv1.ServiceBackendPort{
|
|
Number: 8080,
|
|
},
|
|
},
|
|
},
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"my-svc"}},
|
|
},
|
|
},
|
|
}
|
|
if err := fc.Create(context.Background(), ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify initial reconciliation with HTTP enabled
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
populateTLSSecret(t, fc, "test-pg", "my-svc.ts.net")
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:80", "tcp:443"})
|
|
verifyServeConfig(t, fc, "svc:my-svc", true)
|
|
|
|
// Verify Ingress status
|
|
ing = &networkingv1.Ingress{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: "test-ingress",
|
|
Namespace: "default",
|
|
}, ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Status will be empty until the Tailscale Service shows up in prefs.
|
|
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress, []networkingv1.IngressLoadBalancerIngress(nil)) {
|
|
t.Errorf("incorrect Ingress status: got %v, want empty",
|
|
ing.Status.LoadBalancer.Ingress)
|
|
}
|
|
|
|
// Add the Tailscale Service to prefs to have the Ingress recognised as ready.
|
|
mustUpdate(t, fc, "operator-ns", "test-pg-0", func(o *corev1.Secret) {
|
|
var p prefs
|
|
var err error
|
|
if err = json.Unmarshal(o.Data["test"], &p); err != nil {
|
|
t.Errorf("failed to unmarshal preferences: %v", err)
|
|
}
|
|
|
|
p.AdvertiseServices = []string{"svc:my-svc"}
|
|
o.Data["test"], err = json.Marshal(p)
|
|
if err != nil {
|
|
t.Errorf("failed to marshal preferences: %v", err)
|
|
}
|
|
})
|
|
|
|
// Reconcile and re-fetch Ingress.
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
if err := fc.Get(context.Background(), client.ObjectKeyFromObject(ing), ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantStatus := []networkingv1.IngressPortStatus{
|
|
{Port: 443, Protocol: "TCP"},
|
|
{Port: 80, Protocol: "TCP"},
|
|
}
|
|
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus) {
|
|
t.Errorf("incorrect status ports: got %v, want %v",
|
|
ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus)
|
|
}
|
|
|
|
// Remove HTTP endpoint annotation
|
|
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
|
|
delete(ing.Annotations, "tailscale.com/http-endpoint")
|
|
})
|
|
|
|
// Verify reconciliation after removing HTTP
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:443"})
|
|
verifyServeConfig(t, fc, "svc:my-svc", false)
|
|
|
|
// Verify Ingress status
|
|
ing = &networkingv1.Ingress{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: "test-ingress",
|
|
Namespace: "default",
|
|
}, ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantStatus = []networkingv1.IngressPortStatus{
|
|
{Port: 443, Protocol: "TCP"},
|
|
}
|
|
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus) {
|
|
t.Errorf("incorrect status ports: got %v, want %v",
|
|
ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus)
|
|
}
|
|
}
|
|
|
|
func TestIngressPGReconciler_HTTPRedirect(t *testing.T) {
|
|
ingPGR, fc, ft := setupIngressTest(t)
|
|
|
|
// Create backend Service that the Ingress will route to
|
|
backendSvc := &corev1.Service{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test",
|
|
Namespace: "default",
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
ClusterIP: "10.0.0.1",
|
|
Ports: []corev1.ServicePort{
|
|
{
|
|
Port: 8080,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
mustCreate(t, fc, backendSvc)
|
|
|
|
// Create test Ingress with HTTP redirect enabled
|
|
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",
|
|
"tailscale.com/http-redirect": "true",
|
|
},
|
|
},
|
|
Spec: networkingv1.IngressSpec{
|
|
IngressClassName: new("tailscale"),
|
|
DefaultBackend: &networkingv1.IngressBackend{
|
|
Service: &networkingv1.IngressServiceBackend{
|
|
Name: "test",
|
|
Port: networkingv1.ServiceBackendPort{
|
|
Number: 8080,
|
|
},
|
|
},
|
|
},
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"my-svc"}},
|
|
},
|
|
},
|
|
}
|
|
if err := fc.Create(context.Background(), ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify initial reconciliation with HTTP redirect enabled
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
populateTLSSecret(t, fc, "test-pg", "my-svc.ts.net")
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
|
|
// Verify Tailscale Service includes both tcp:80 and tcp:443
|
|
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:80", "tcp:443"})
|
|
|
|
// Verify Ingress status includes port 80
|
|
ing = &networkingv1.Ingress{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: "test-ingress",
|
|
Namespace: "default",
|
|
}, ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Add the Tailscale Service to prefs to have the Ingress recognised as ready.
|
|
mustUpdate(t, fc, "operator-ns", "test-pg-0", func(o *corev1.Secret) {
|
|
var p prefs
|
|
var err error
|
|
if err = json.Unmarshal(o.Data["test"], &p); err != nil {
|
|
t.Errorf("failed to unmarshal preferences: %v", err)
|
|
}
|
|
|
|
p.AdvertiseServices = []string{"svc:my-svc"}
|
|
o.Data["test"], err = json.Marshal(p)
|
|
if err != nil {
|
|
t.Errorf("failed to marshal preferences: %v", err)
|
|
}
|
|
})
|
|
|
|
// Reconcile and re-fetch Ingress
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
if err := fc.Get(context.Background(), client.ObjectKeyFromObject(ing), ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantStatus := []networkingv1.IngressPortStatus{
|
|
{Port: 443, Protocol: "TCP"},
|
|
{Port: 80, Protocol: "TCP"},
|
|
}
|
|
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus) {
|
|
t.Errorf("incorrect status ports: got %v, want %v",
|
|
ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus)
|
|
}
|
|
|
|
// Remove HTTP redirect annotation
|
|
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
|
|
delete(ing.Annotations, "tailscale.com/http-redirect")
|
|
})
|
|
|
|
// Verify reconciliation after removing HTTP redirect
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:443"})
|
|
|
|
// Verify Ingress status no longer includes port 80
|
|
ing = &networkingv1.Ingress{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: "test-ingress",
|
|
Namespace: "default",
|
|
}, ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantStatus = []networkingv1.IngressPortStatus{
|
|
{Port: 443, Protocol: "TCP"},
|
|
}
|
|
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus) {
|
|
t.Errorf("incorrect status ports after removing redirect: got %v, want %v",
|
|
ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus)
|
|
}
|
|
}
|
|
|
|
func TestIngressPGReconciler_HTTPEndpointAndRedirectConflict(t *testing.T) {
|
|
ingPGR, fc, ft := setupIngressTest(t)
|
|
|
|
// Create backend Service that the Ingress will route to
|
|
backendSvc := &corev1.Service{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test",
|
|
Namespace: "default",
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
ClusterIP: "10.0.0.1",
|
|
Ports: []corev1.ServicePort{
|
|
{
|
|
Port: 8080,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
mustCreate(t, fc, backendSvc)
|
|
|
|
// Create test Ingress with both HTTP endpoint and HTTP redirect enabled
|
|
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",
|
|
"tailscale.com/http-endpoint": "enabled",
|
|
"tailscale.com/http-redirect": "true",
|
|
},
|
|
},
|
|
Spec: networkingv1.IngressSpec{
|
|
IngressClassName: new("tailscale"),
|
|
DefaultBackend: &networkingv1.IngressBackend{
|
|
Service: &networkingv1.IngressServiceBackend{
|
|
Name: "test",
|
|
Port: networkingv1.ServiceBackendPort{
|
|
Number: 8080,
|
|
},
|
|
},
|
|
},
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"my-svc"}},
|
|
},
|
|
},
|
|
}
|
|
if err := fc.Create(context.Background(), ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify initial reconciliation - HTTP endpoint should take precedence
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
populateTLSSecret(t, fc, "test-pg", "my-svc.ts.net")
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
|
|
// Verify Tailscale Service includes both tcp:80 and tcp:443
|
|
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:80", "tcp:443"})
|
|
|
|
// Verify the serve config has HTTP endpoint handlers on port 80, NOT redirect handlers
|
|
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)
|
|
}
|
|
|
|
// Verify Ingress status includes port 80
|
|
ing = &networkingv1.Ingress{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{
|
|
Name: "test-ingress",
|
|
Namespace: "default",
|
|
}, ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Add the Tailscale Service to prefs to have the Ingress recognised as ready.
|
|
mustUpdate(t, fc, "operator-ns", "test-pg-0", func(o *corev1.Secret) {
|
|
var p prefs
|
|
var err error
|
|
if err = json.Unmarshal(o.Data["test"], &p); err != nil {
|
|
t.Errorf("failed to unmarshal preferences: %v", err)
|
|
}
|
|
|
|
p.AdvertiseServices = []string{"svc:my-svc"}
|
|
o.Data["test"], err = json.Marshal(p)
|
|
if err != nil {
|
|
t.Errorf("failed to marshal preferences: %v", err)
|
|
}
|
|
})
|
|
|
|
// Reconcile and re-fetch Ingress
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
if err := fc.Get(context.Background(), client.ObjectKeyFromObject(ing), ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantStatus := []networkingv1.IngressPortStatus{
|
|
{Port: 443, Protocol: "TCP"},
|
|
{Port: 80, Protocol: "TCP"},
|
|
}
|
|
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus) {
|
|
t.Errorf("incorrect status ports: got %v, want %v",
|
|
ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus)
|
|
}
|
|
}
|
|
|
|
func TestIngressPGReconciler_MultiCluster(t *testing.T) {
|
|
ingPGR, fc, ft := setupIngressTest(t)
|
|
ingPGR.operatorID = "operator-1"
|
|
|
|
// Create initial Ingress
|
|
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: new("tailscale"),
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"my-svc"}},
|
|
},
|
|
},
|
|
}
|
|
mustCreate(t, fc, ing)
|
|
|
|
// Simulate existing Tailscale Service from another cluster
|
|
existingVIPSvc := tailscale.VIPService{
|
|
Name: "svc:my-svc",
|
|
Annotations: map[string]string{
|
|
ownerAnnotation: `{"ownerrefs":[{"operatorID":"operator-2"}]}`,
|
|
},
|
|
}
|
|
ft.VIPServices().CreateOrUpdate(t.Context(), existingVIPSvc)
|
|
|
|
// Verify reconciliation adds our operator reference
|
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
|
|
|
tsSvc, err := ft.VIPServices().Get(context.Background(), "svc:my-svc")
|
|
if err != nil {
|
|
t.Fatalf("getting Tailscale Service: %v", err)
|
|
}
|
|
if tsSvc == nil {
|
|
t.Fatal("Tailscale Service not found")
|
|
}
|
|
|
|
o, err := parseOwnerAnnotation(tsSvc)
|
|
if err != nil {
|
|
t.Fatalf("parsing owner annotation: %v", err)
|
|
}
|
|
|
|
wantOwnerRefs := []OwnerRef{
|
|
{OperatorID: "operator-2"},
|
|
{OperatorID: "operator-1"},
|
|
}
|
|
if !reflect.DeepEqual(o.OwnerRefs, wantOwnerRefs) {
|
|
t.Errorf("incorrect owner refs\ngot: %+v\nwant: %+v", o.OwnerRefs, wantOwnerRefs)
|
|
}
|
|
|
|
// Delete the Ingress and verify Tailscale Service still exists with one owner ref
|
|
if err := fc.Delete(context.Background(), ing); err != nil {
|
|
t.Fatalf("deleting Ingress: %v", err)
|
|
}
|
|
expectRequeue(t, ingPGR, "default", "test-ingress")
|
|
|
|
tsSvc, err = ft.VIPServices().Get(context.Background(), "svc:my-svc")
|
|
if err != nil {
|
|
t.Fatalf("getting Tailscale Service after deletion: %v", err)
|
|
}
|
|
if tsSvc == nil {
|
|
t.Fatal("Tailscale Service was incorrectly deleted")
|
|
}
|
|
|
|
o, err = parseOwnerAnnotation(tsSvc)
|
|
if err != nil {
|
|
t.Fatalf("parsing owner annotation: %v", err)
|
|
}
|
|
|
|
wantOwnerRefs = []OwnerRef{
|
|
{OperatorID: "operator-2"},
|
|
}
|
|
if !reflect.DeepEqual(o.OwnerRefs, wantOwnerRefs) {
|
|
t.Errorf("incorrect owner refs after deletion\ngot: %+v\nwant: %+v", o.OwnerRefs, wantOwnerRefs)
|
|
}
|
|
}
|
|
|
|
func TestOwnerAnnotations(t *testing.T) {
|
|
singleSelfOwner := map[string]string{
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id"}]}`,
|
|
}
|
|
|
|
for name, tc := range map[string]struct {
|
|
svc *tailscale.VIPService
|
|
wantAnnotations map[string]string
|
|
wantErr string
|
|
}{
|
|
"no_svc": {
|
|
svc: nil,
|
|
wantAnnotations: singleSelfOwner,
|
|
},
|
|
"empty_svc": {
|
|
svc: &tailscale.VIPService{},
|
|
wantErr: "likely a resource created by something other than the Tailscale Kubernetes operator",
|
|
},
|
|
"already_owner": {
|
|
svc: &tailscale.VIPService{
|
|
Annotations: singleSelfOwner,
|
|
},
|
|
wantAnnotations: singleSelfOwner,
|
|
},
|
|
"add_owner": {
|
|
svc: &tailscale.VIPService{
|
|
Annotations: map[string]string{
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"operator-2"}]}`,
|
|
},
|
|
},
|
|
wantAnnotations: map[string]string{
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"operator-2"},{"operatorID":"self-id"}]}`,
|
|
},
|
|
},
|
|
"owned_by_proxygroup": {
|
|
svc: &tailscale.VIPService{
|
|
Annotations: map[string]string{
|
|
ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"test-pg","uid":"1234-UID"}}]}`,
|
|
},
|
|
},
|
|
wantErr: "owned by another resource",
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
got, err := ownerAnnotations("self-id", tc.svc)
|
|
if tc.wantErr != "" && !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Errorf("ownerAnnotations() error = %v, wantErr %v", err, tc.wantErr)
|
|
}
|
|
if diff := cmp.Diff(tc.wantAnnotations, got); diff != "" {
|
|
t.Errorf("ownerAnnotations() mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func populateTLSSecret(t *testing.T, c client.Client, pgName, domain string) {
|
|
t.Helper()
|
|
|
|
secret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: domain,
|
|
Namespace: "operator-ns",
|
|
Labels: map[string]string{
|
|
kubetypes.LabelManaged: "true",
|
|
labelProxyGroup: pgName,
|
|
labelDomain: domain,
|
|
kubetypes.LabelSecretType: kubetypes.LabelSecretTypeCerts,
|
|
},
|
|
},
|
|
Type: corev1.SecretTypeTLS,
|
|
Data: map[string][]byte{
|
|
corev1.TLSCertKey: []byte("fake-cert"),
|
|
corev1.TLSPrivateKeyKey: []byte("fake-key"),
|
|
},
|
|
}
|
|
|
|
_, err := createOrUpdate(t.Context(), c, "operator-ns", secret, func(s *corev1.Secret) {
|
|
s.Data = secret.Data
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to populate TLS secret: %v", err)
|
|
}
|
|
}
|
|
|
|
func verifyTailscaleService(t *testing.T, ft *fakeTSClient, serviceName string, wantPorts []string) {
|
|
t.Helper()
|
|
tsSvc, err := ft.VIPServices().Get(context.Background(), serviceName)
|
|
if err != nil {
|
|
t.Fatalf("getting Tailscale Service %q: %v", serviceName, err)
|
|
}
|
|
if tsSvc == nil {
|
|
t.Fatalf("Tailscale Service %q not created", serviceName)
|
|
}
|
|
gotPorts := slices.Clone(tsSvc.Ports)
|
|
slices.Sort(gotPorts)
|
|
slices.Sort(wantPorts)
|
|
if !slices.Equal(gotPorts, wantPorts) {
|
|
t.Errorf("incorrect ports for Tailscale Service %q: got %v, want %v", serviceName, gotPorts, wantPorts)
|
|
}
|
|
}
|
|
|
|
func verifyServeConfig(t *testing.T, fc client.Client, serviceName string, wantHTTP bool) {
|
|
t.Helper()
|
|
|
|
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["serve-config.json"], cfg); err != nil {
|
|
t.Fatalf("unmarshaling serve config: %v", err)
|
|
}
|
|
|
|
t.Logf("Looking for service %q in config: %+v", serviceName, cfg)
|
|
|
|
svc := cfg.Services[tailcfg.ServiceName(serviceName)]
|
|
if svc == nil {
|
|
t.Fatalf("service %q not found in serve config, services: %+v", serviceName, maps.Keys(cfg.Services))
|
|
}
|
|
|
|
wantHandlers := 1
|
|
if wantHTTP {
|
|
wantHandlers = 2
|
|
}
|
|
|
|
// Check TCP handlers
|
|
if len(svc.TCP) != wantHandlers {
|
|
t.Errorf("incorrect number of TCP handlers for service %q: got %d, want %d", serviceName, len(svc.TCP), wantHandlers)
|
|
}
|
|
if wantHTTP {
|
|
if h, ok := svc.TCP[uint16(80)]; !ok {
|
|
t.Errorf("HTTP (port 80) handler not found for service %q", serviceName)
|
|
} else if !h.HTTP {
|
|
t.Errorf("HTTP not enabled for port 80 handler for service %q", serviceName)
|
|
}
|
|
}
|
|
if h, ok := svc.TCP[uint16(443)]; !ok {
|
|
t.Errorf("HTTPS (port 443) handler not found for service %q", serviceName)
|
|
} else if !h.HTTPS {
|
|
t.Errorf("HTTPS not enabled for port 443 handler for service %q", serviceName)
|
|
}
|
|
|
|
// Check Web handlers
|
|
if len(svc.Web) != wantHandlers {
|
|
t.Errorf("incorrect number of Web handlers for service %q: got %d, want %d", serviceName, len(svc.Web), wantHandlers)
|
|
}
|
|
}
|
|
|
|
func verifyTailscaledConfig(t *testing.T, fc client.Client, pgName string, expectedServices []string) {
|
|
t.Helper()
|
|
var expected string
|
|
if expectedServices != nil && len(expectedServices) > 0 {
|
|
expectedServicesJSON, err := json.Marshal(expectedServices)
|
|
if err != nil {
|
|
t.Fatalf("marshaling expected services: %v", err)
|
|
}
|
|
expected = fmt.Sprintf(`,"AdvertiseServices":%s`, expectedServicesJSON)
|
|
}
|
|
expectEqual(t, fc, &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: pgConfigSecretName(pgName, 0),
|
|
Namespace: "operator-ns",
|
|
Labels: pgSecretLabels(pgName, kubetypes.LabelSecretTypeConfig),
|
|
},
|
|
Data: map[string][]byte{
|
|
tsoperator.TailscaledConfigFileName(pgMinCapabilityVersion): fmt.Appendf(nil, `{"Version":""%s}`, expected),
|
|
},
|
|
})
|
|
}
|
|
|
|
func createPGResources(t *testing.T, fc client.Client, pgName string) {
|
|
t.Helper()
|
|
|
|
// Pre-create the ProxyGroup
|
|
pg := &tsapi.ProxyGroup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: pgName,
|
|
Generation: 1,
|
|
},
|
|
Spec: tsapi.ProxyGroupSpec{
|
|
Type: tsapi.ProxyGroupTypeIngress,
|
|
},
|
|
}
|
|
mustCreate(t, fc, pg)
|
|
|
|
// Pre-create the ConfigMap for the ProxyGroup
|
|
pgConfigMap := &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: fmt.Sprintf("%s-ingress-config", pgName),
|
|
Namespace: "operator-ns",
|
|
},
|
|
BinaryData: map[string][]byte{
|
|
"serve-config.json": []byte(`{"Services":{}}`),
|
|
},
|
|
}
|
|
mustCreate(t, fc, pgConfigMap)
|
|
|
|
// Pre-create a config Secret for the ProxyGroup
|
|
pgCfgSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: pgConfigSecretName(pgName, 0),
|
|
Namespace: "operator-ns",
|
|
Labels: pgSecretLabels(pgName, kubetypes.LabelSecretTypeConfig),
|
|
},
|
|
Data: map[string][]byte{
|
|
tsoperator.TailscaledConfigFileName(pgMinCapabilityVersion): []byte("{}"),
|
|
},
|
|
}
|
|
mustCreate(t, fc, pgCfgSecret)
|
|
|
|
pr := prefs{}
|
|
pr.Config.UserProfile.LoginName = "test.ts.net"
|
|
pr.Config.NodeID = "test"
|
|
|
|
p, err := json.Marshal(pr)
|
|
if err != nil {
|
|
t.Fatalf("marshaling prefs: %v", err)
|
|
}
|
|
|
|
// Pre-create a state secret for the ProxyGroup
|
|
pgStateSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: pgStateSecretName(pgName, 0),
|
|
Namespace: "operator-ns",
|
|
Labels: pgSecretLabels(pgName, kubetypes.LabelSecretTypeState),
|
|
},
|
|
Data: map[string][]byte{
|
|
currentProfileKey: []byte("test"),
|
|
"test": p,
|
|
},
|
|
}
|
|
mustCreate(t, fc, pgStateSecret)
|
|
|
|
pg.Status.Conditions = []metav1.Condition{
|
|
{
|
|
Type: string(tsapi.ProxyGroupAvailable),
|
|
Status: metav1.ConditionTrue,
|
|
ObservedGeneration: 1,
|
|
},
|
|
}
|
|
if err := fc.Status().Update(context.Background(), pg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func setupIngressTest(t *testing.T) (*HAIngressReconciler, client.Client, *fakeTSClient) {
|
|
tsIngressClass := &networkingv1.IngressClass{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
|
|
Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"},
|
|
}
|
|
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithObjects(tsIngressClass).
|
|
WithStatusSubresource(&tsapi.ProxyGroup{}).
|
|
Build()
|
|
|
|
createPGResources(t, fc, "test-pg")
|
|
|
|
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
|
|
|
ft := &fakeTSClient{
|
|
vipServices: make(map[string]tailscale.VIPService),
|
|
}
|
|
zl, err := zap.NewDevelopment()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ingPGR := &HAIngressReconciler{
|
|
Client: fc,
|
|
clients: tsclient.NewProvider(ft),
|
|
defaultTags: []string{"tag:k8s"},
|
|
tsNamespace: "operator-ns",
|
|
tsnetServer: fakeTsnetServer,
|
|
logger: zl.Sugar(),
|
|
recorder: record.NewFakeRecorder(10),
|
|
ingressClassName: tsIngressClass.Name,
|
|
}
|
|
|
|
return ingPGR, fc, ft
|
|
}
|