mirror of
https://github.com/tailscale/tailscale.git
synced 2026-02-11 02:31:20 +01:00
This file was never truly necessary and has never actually been used in the history of Tailscale's open source releases. A Brief History of AUTHORS files --- The AUTHORS file was a pattern developed at Google, originally for Chromium, then adopted by Go and a bunch of other projects. The problem was that Chromium originally had a copyright line only recognizing Google as the copyright holder. Because Google (and most open source projects) do not require copyright assignemnt for contributions, each contributor maintains their copyright. Some large corporate contributors then tried to add their own name to the copyright line in the LICENSE file or in file headers. This quickly becomes unwieldy, and puts a tremendous burden on anyone building on top of Chromium, since the license requires that they keep all copyright lines intact. The compromise was to create an AUTHORS file that would list all of the copyright holders. The LICENSE file and source file headers would then include that list by reference, listing the copyright holder as "The Chromium Authors". This also become cumbersome to simply keep the file up to date with a high rate of new contributors. Plus it's not always obvious who the copyright holder is. Sometimes it is the individual making the contribution, but many times it may be their employer. There is no way for the proejct maintainer to know. Eventually, Google changed their policy to no longer recommend trying to keep the AUTHORS file up to date proactively, and instead to only add to it when requested: https://opensource.google/docs/releasing/authors. They are also clear that: > Adding contributors to the AUTHORS file is entirely within the > project's discretion and has no implications for copyright ownership. It was primarily added to appease a small number of large contributors that insisted that they be recognized as copyright holders (which was entirely their right to do). But it's not truly necessary, and not even the most accurate way of identifying contributors and/or copyright holders. In practice, we've never added anyone to our AUTHORS file. It only lists Tailscale, so it's not really serving any purpose. It also causes confusion because Tailscalars put the "Tailscale Inc & AUTHORS" header in other open source repos which don't actually have an AUTHORS file, so it's ambiguous what that means. Instead, we just acknowledge that the contributors to Tailscale (whoever they are) are copyright holders for their individual contributions. We also have the benefit of using the DCO (developercertificate.org) which provides some additional certification of their right to make the contribution. The source file changes were purely mechanical with: git ls-files | xargs sed -i -e 's/\(Tailscale Inc &\) AUTHORS/\1 contributors/g' Updates #cleanup Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d Signed-off-by: Will Norris <will@tailscale.com>
946 lines
30 KiB
Go
946 lines
30 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
networkingv1 "k8s.io/api/networking/v1"
|
|
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
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/ipn"
|
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
|
"tailscale.com/kube/kubetypes"
|
|
"tailscale.com/tstest"
|
|
"tailscale.com/types/ptr"
|
|
"tailscale.com/util/mak"
|
|
)
|
|
|
|
func TestTailscaleIngress(t *testing.T) {
|
|
fc := fake.NewFakeClient(ingressClass())
|
|
ft := &fakeTSClient{}
|
|
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
|
zl, err := zap.NewDevelopment()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ingR := &IngressReconciler{
|
|
Client: fc,
|
|
ingressClassName: "tailscale",
|
|
ssr: &tailscaleSTSReconciler{
|
|
Client: fc,
|
|
tsClient: ft,
|
|
tsnetServer: fakeTsnetServer,
|
|
defaultTags: []string{"tag:k8s"},
|
|
operatorNamespace: "operator-ns",
|
|
proxyImage: "tailscale/tailscale",
|
|
},
|
|
logger: zl.Sugar(),
|
|
}
|
|
|
|
// 1. Resources get created for regular Ingress
|
|
mustCreate(t, fc, ingress())
|
|
mustCreate(t, fc, service())
|
|
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
|
|
opts := configOpts{
|
|
replicas: ptr.To[int32](1),
|
|
stsName: shortName,
|
|
secretName: fullName,
|
|
namespace: "default",
|
|
parentType: "ingress",
|
|
hostname: "default-test",
|
|
app: kubetypes.AppIngressResource,
|
|
serveConfig: &ipn.ServeConfig{
|
|
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{
|
|
"/": {Proxy: "http://1.2.3.4:8080/"},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
expectEqual(t, fc, expectedSecret(t, fc, opts))
|
|
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"))
|
|
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeResourceReqs)
|
|
|
|
// 2. Ingress status gets updated with ingress proxy's MagicDNS name
|
|
// once that becomes available.
|
|
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
|
|
mak.Set(&secret.Data, "device_id", []byte("1234"))
|
|
mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net"))
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
// Get the ingress and update it with expected changes
|
|
ing := ingress()
|
|
ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer")
|
|
ing.Status.LoadBalancer = networkingv1.IngressLoadBalancerStatus{
|
|
Ingress: []networkingv1.IngressLoadBalancerIngress{
|
|
{Hostname: "foo.tailnetxyz.ts.net", Ports: []networkingv1.IngressPortStatus{{Port: 443, Protocol: "TCP"}}},
|
|
},
|
|
}
|
|
expectEqual(t, fc, ing)
|
|
|
|
// 3. Resources get created for Ingress that should allow forwarding
|
|
// cluster traffic
|
|
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
|
|
mak.Set(&ing.ObjectMeta.Annotations, AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy, "true")
|
|
})
|
|
opts.shouldEnableForwardingClusterTrafficViaIngress = true
|
|
expectReconciled(t, ingR, "default", "test")
|
|
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
|
|
|
|
// 4. Resources get cleaned up when Ingress class is unset
|
|
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
|
|
ing.Spec.IngressClassName = ptr.To("nginx")
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
expectReconciled(t, ingR, "default", "test") // deleting Ingress STS requires two reconciles
|
|
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
|
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
|
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
|
}
|
|
|
|
func TestTailscaleIngressHostname(t *testing.T) {
|
|
fc := fake.NewFakeClient(ingressClass())
|
|
ft := &fakeTSClient{}
|
|
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
|
zl, err := zap.NewDevelopment()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ingR := &IngressReconciler{
|
|
Client: fc,
|
|
ingressClassName: "tailscale",
|
|
ssr: &tailscaleSTSReconciler{
|
|
Client: fc,
|
|
tsClient: ft,
|
|
tsnetServer: fakeTsnetServer,
|
|
defaultTags: []string{"tag:k8s"},
|
|
operatorNamespace: "operator-ns",
|
|
proxyImage: "tailscale/tailscale",
|
|
},
|
|
logger: zl.Sugar(),
|
|
}
|
|
|
|
// 1. Resources get created for regular Ingress
|
|
mustCreate(t, fc, ingress())
|
|
mustCreate(t, fc, service())
|
|
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
|
|
mustCreate(t, fc, &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: fullName,
|
|
Namespace: "operator-ns",
|
|
UID: "test-uid",
|
|
},
|
|
})
|
|
opts := configOpts{
|
|
stsName: shortName,
|
|
secretName: fullName,
|
|
namespace: "default",
|
|
parentType: "ingress",
|
|
hostname: "default-test",
|
|
app: kubetypes.AppIngressResource,
|
|
serveConfig: &ipn.ServeConfig{
|
|
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{
|
|
"/": {Proxy: "http://1.2.3.4:8080/"},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
expectEqual(t, fc, expectedSecret(t, fc, opts))
|
|
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"))
|
|
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeResourceReqs)
|
|
|
|
// 2. Ingress proxy with capability version >= 110 does not have an HTTPS endpoint set
|
|
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
|
|
mak.Set(&secret.Data, "device_id", []byte("1234"))
|
|
mak.Set(&secret.Data, "tailscale_capver", []byte("110"))
|
|
mak.Set(&secret.Data, "pod_uid", []byte("test-uid"))
|
|
mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net"))
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
// Get the ingress and update it with expected changes
|
|
ing := ingress()
|
|
ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer")
|
|
expectEqual(t, fc, ing)
|
|
|
|
// 3. Ingress proxy with capability version >= 110 advertises HTTPS endpoint
|
|
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
|
|
mak.Set(&secret.Data, "device_id", []byte("1234"))
|
|
mak.Set(&secret.Data, "tailscale_capver", []byte("110"))
|
|
mak.Set(&secret.Data, "pod_uid", []byte("test-uid"))
|
|
mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net"))
|
|
mak.Set(&secret.Data, "https_endpoint", []byte("foo.tailnetxyz.ts.net"))
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
ing.Status.LoadBalancer = networkingv1.IngressLoadBalancerStatus{
|
|
Ingress: []networkingv1.IngressLoadBalancerIngress{
|
|
{Hostname: "foo.tailnetxyz.ts.net", Ports: []networkingv1.IngressPortStatus{{Port: 443, Protocol: "TCP"}}},
|
|
},
|
|
}
|
|
expectEqual(t, fc, ing)
|
|
|
|
// 4. Ingress proxy with capability version >= 110 does not have an HTTPS endpoint ready
|
|
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
|
|
mak.Set(&secret.Data, "device_id", []byte("1234"))
|
|
mak.Set(&secret.Data, "tailscale_capver", []byte("110"))
|
|
mak.Set(&secret.Data, "pod_uid", []byte("test-uid"))
|
|
mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net"))
|
|
mak.Set(&secret.Data, "https_endpoint", []byte("no-https"))
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
ing.Status.LoadBalancer.Ingress = nil
|
|
expectEqual(t, fc, ing)
|
|
|
|
// 5. Ingress proxy's state has https_endpoints set, but its capver is not matching Pod UID (downgrade)
|
|
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
|
|
mak.Set(&secret.Data, "device_id", []byte("1234"))
|
|
mak.Set(&secret.Data, "tailscale_capver", []byte("110"))
|
|
mak.Set(&secret.Data, "pod_uid", []byte("not-the-right-uid"))
|
|
mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net"))
|
|
mak.Set(&secret.Data, "https_endpoint", []byte("bar.tailnetxyz.ts.net"))
|
|
})
|
|
ing.Status.LoadBalancer = networkingv1.IngressLoadBalancerStatus{
|
|
Ingress: []networkingv1.IngressLoadBalancerIngress{
|
|
{Hostname: "foo.tailnetxyz.ts.net", Ports: []networkingv1.IngressPortStatus{{Port: 443, Protocol: "TCP"}}},
|
|
},
|
|
}
|
|
expectReconciled(t, ingR, "default", "test")
|
|
expectEqual(t, fc, ing)
|
|
}
|
|
|
|
func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
|
// Setup
|
|
pc := &tsapi.ProxyClass{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"},
|
|
Spec: tsapi.ProxyClassSpec{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"},
|
|
TailscaleContainer: &tsapi.Container{
|
|
Resources: corev1.ResourceRequirements{
|
|
Requests: corev1.ResourceList{
|
|
corev1.ResourceCPU: resource.MustParse("500m"),
|
|
corev1.ResourceMemory: resource.MustParse("28Mi"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}},
|
|
}
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithObjects(pc, ingressClass()).
|
|
WithStatusSubresource(pc).
|
|
Build()
|
|
ft := &fakeTSClient{}
|
|
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
|
zl, err := zap.NewDevelopment()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ingR := &IngressReconciler{
|
|
Client: fc,
|
|
ingressClassName: "tailscale",
|
|
ssr: &tailscaleSTSReconciler{
|
|
Client: fc,
|
|
tsClient: ft,
|
|
tsnetServer: fakeTsnetServer,
|
|
defaultTags: []string{"tag:k8s"},
|
|
operatorNamespace: "operator-ns",
|
|
proxyImage: "tailscale/tailscale",
|
|
},
|
|
logger: zl.Sugar(),
|
|
}
|
|
|
|
// 1. Ingress is created with no ProxyClass specified, default proxy
|
|
// resources get configured.
|
|
mustCreate(t, fc, ingress())
|
|
mustCreate(t, fc, service())
|
|
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
|
|
opts := configOpts{
|
|
stsName: shortName,
|
|
secretName: fullName,
|
|
namespace: "default",
|
|
parentType: "ingress",
|
|
hostname: "default-test",
|
|
app: kubetypes.AppIngressResource,
|
|
serveConfig: &ipn.ServeConfig{
|
|
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{
|
|
"/": {Proxy: "http://1.2.3.4:8080/"},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
expectEqual(t, fc, expectedSecret(t, fc, opts))
|
|
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"))
|
|
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
|
|
|
|
// 2. Ingress is updated to specify a ProxyClass, ProxyClass is not yet
|
|
// ready, so proxy resource configuration does not change.
|
|
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
|
|
mak.Set(&ing.ObjectMeta.Labels, LabelAnnotationProxyClass, "custom-metadata")
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
|
|
|
|
// 3. ProxyClass is set to Ready by proxy-class reconciler. Ingress get
|
|
// reconciled and configuration from the ProxyClass is applied to the
|
|
// created proxy resources.
|
|
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, ingR, "default", "test")
|
|
opts.proxyClass = pc.Name
|
|
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
|
|
|
|
// 4. tailscale.com/proxy-class label is removed from the Ingress, the
|
|
// Ingress gets reconciled and the custom ProxyClass configuration is
|
|
// removed from the proxy resources.
|
|
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
|
|
delete(ing.ObjectMeta.Labels, LabelAnnotationProxyClass)
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
opts.proxyClass = ""
|
|
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeResourceReqs)
|
|
}
|
|
|
|
func TestTailscaleIngressWithServiceMonitor(t *testing.T) {
|
|
pc := &tsapi.ProxyClass{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "metrics", Generation: 1},
|
|
Spec: tsapi.ProxyClassSpec{},
|
|
Status: tsapi.ProxyClassStatus{
|
|
Conditions: []metav1.Condition{{
|
|
Status: metav1.ConditionTrue,
|
|
Type: string(tsapi.ProxyClassReady),
|
|
ObservedGeneration: 1,
|
|
}},
|
|
},
|
|
}
|
|
crd := &apiextensionsv1.CustomResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: serviceMonitorCRD}}
|
|
|
|
// Create fake client with ProxyClass, IngressClass, Ingress with metrics ProxyClass, and Service
|
|
ing := ingress()
|
|
ing.Labels = map[string]string{
|
|
LabelAnnotationProxyClass: "metrics",
|
|
}
|
|
fc := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme).
|
|
WithObjects(pc, ingressClass(), ing, service()).
|
|
WithStatusSubresource(pc).
|
|
Build()
|
|
|
|
ft := &fakeTSClient{}
|
|
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
|
zl, err := zap.NewDevelopment()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ingR := &IngressReconciler{
|
|
Client: fc,
|
|
ingressClassName: "tailscale",
|
|
ssr: &tailscaleSTSReconciler{
|
|
Client: fc,
|
|
tsClient: ft,
|
|
tsnetServer: fakeTsnetServer,
|
|
defaultTags: []string{"tag:k8s"},
|
|
operatorNamespace: "operator-ns",
|
|
proxyImage: "tailscale/tailscale",
|
|
},
|
|
logger: zl.Sugar(),
|
|
}
|
|
expectReconciled(t, ingR, "default", "test")
|
|
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
|
|
opts := configOpts{
|
|
stsName: shortName,
|
|
secretName: fullName,
|
|
namespace: "default",
|
|
tailscaleNamespace: "operator-ns",
|
|
parentType: "ingress",
|
|
hostname: "default-test",
|
|
app: kubetypes.AppIngressResource,
|
|
namespaced: true,
|
|
proxyType: proxyTypeIngressResource,
|
|
serveConfig: &ipn.ServeConfig{
|
|
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{
|
|
"/": {Proxy: "http://1.2.3.4:8080/"},
|
|
}},
|
|
},
|
|
},
|
|
resourceVersion: "1",
|
|
}
|
|
|
|
// 1. Enable metrics- expect metrics Service to be created
|
|
mustUpdate(t, fc, "", "metrics", func(proxyClass *tsapi.ProxyClass) {
|
|
proxyClass.Spec.Metrics = &tsapi.Metrics{Enable: true}
|
|
})
|
|
opts.enableMetrics = true
|
|
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
expectEqual(t, fc, expectedMetricsService(opts))
|
|
|
|
// 2. Enable ServiceMonitor - should not error when there is no ServiceMonitor CRD in cluster
|
|
mustUpdate(t, fc, "", "metrics", func(pc *tsapi.ProxyClass) {
|
|
pc.Spec.Metrics.ServiceMonitor = &tsapi.ServiceMonitor{Enable: true, Labels: tsapi.Labels{"foo": "bar"}}
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
expectEqual(t, fc, expectedMetricsService(opts))
|
|
|
|
// 3. Create ServiceMonitor CRD and reconcile- ServiceMonitor should get created
|
|
mustCreate(t, fc, crd)
|
|
expectReconciled(t, ingR, "default", "test")
|
|
opts.serviceMonitorLabels = tsapi.Labels{"foo": "bar"}
|
|
expectEqual(t, fc, expectedMetricsService(opts))
|
|
expectEqualUnstructured(t, fc, expectedServiceMonitor(t, opts))
|
|
|
|
// 4. Update ServiceMonitor CRD and reconcile- ServiceMonitor should get updated
|
|
mustUpdate(t, fc, pc.Namespace, pc.Name, func(proxyClass *tsapi.ProxyClass) {
|
|
proxyClass.Spec.Metrics.ServiceMonitor.Labels = nil
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
opts.serviceMonitorLabels = nil
|
|
opts.resourceVersion = "2"
|
|
expectEqual(t, fc, expectedMetricsService(opts))
|
|
expectEqualUnstructured(t, fc, expectedServiceMonitor(t, opts))
|
|
|
|
// 5. Disable metrics - metrics resources should get deleted.
|
|
mustUpdate(t, fc, pc.Namespace, pc.Name, func(proxyClass *tsapi.ProxyClass) {
|
|
proxyClass.Spec.Metrics = nil
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
expectMissing[corev1.Service](t, fc, "operator-ns", metricsResourceName(shortName))
|
|
// ServiceMonitor gets garbage collected when the Service is deleted - we cannot test that here.
|
|
}
|
|
|
|
func TestIngressProxyClassAnnotation(t *testing.T) {
|
|
cl := tstest.NewClock(tstest.ClockOpts{})
|
|
zl := zap.Must(zap.NewDevelopment())
|
|
|
|
pcLEStaging, pcLEStagingFalse, _ := proxyClassesForLEStagingTest()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
proxyClassAnnotation string
|
|
proxyClassLabel string
|
|
proxyClassDefault string
|
|
expectedProxyClass string
|
|
expectEvents []string
|
|
}{
|
|
{
|
|
name: "via_label",
|
|
proxyClassLabel: pcLEStaging.Name,
|
|
expectedProxyClass: pcLEStaging.Name,
|
|
},
|
|
{
|
|
name: "via_annotation",
|
|
proxyClassAnnotation: pcLEStaging.Name,
|
|
expectedProxyClass: pcLEStaging.Name,
|
|
},
|
|
{
|
|
name: "via_default",
|
|
proxyClassDefault: pcLEStaging.Name,
|
|
expectedProxyClass: pcLEStaging.Name,
|
|
},
|
|
{
|
|
name: "via_label_override_annotation",
|
|
proxyClassLabel: pcLEStaging.Name,
|
|
proxyClassAnnotation: pcLEStagingFalse.Name,
|
|
expectedProxyClass: pcLEStaging.Name,
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
builder := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme)
|
|
|
|
builder = builder.WithObjects(pcLEStaging, pcLEStagingFalse).
|
|
WithStatusSubresource(pcLEStaging, pcLEStagingFalse)
|
|
|
|
fc := builder.Build()
|
|
|
|
if tt.proxyClassAnnotation != "" || tt.proxyClassLabel != "" || tt.proxyClassDefault != "" {
|
|
name := tt.proxyClassDefault
|
|
if name == "" {
|
|
name = tt.proxyClassLabel
|
|
if name == "" {
|
|
name = tt.proxyClassAnnotation
|
|
}
|
|
}
|
|
setProxyClassReady(t, fc, cl, name)
|
|
}
|
|
|
|
mustCreate(t, fc, ingressClass())
|
|
mustCreate(t, fc, service())
|
|
ing := ingress()
|
|
if tt.proxyClassLabel != "" {
|
|
ing.Labels = map[string]string{
|
|
LabelAnnotationProxyClass: tt.proxyClassLabel,
|
|
}
|
|
}
|
|
if tt.proxyClassAnnotation != "" {
|
|
ing.Annotations = map[string]string{
|
|
LabelAnnotationProxyClass: tt.proxyClassAnnotation,
|
|
}
|
|
}
|
|
mustCreate(t, fc, ing)
|
|
|
|
ingR := &IngressReconciler{
|
|
Client: fc,
|
|
ingressClassName: "tailscale",
|
|
ssr: &tailscaleSTSReconciler{
|
|
Client: fc,
|
|
tsClient: &fakeTSClient{},
|
|
tsnetServer: &fakeTSNetServer{certDomains: []string{"test-host"}},
|
|
defaultTags: []string{"tag:test"},
|
|
operatorNamespace: "operator-ns",
|
|
proxyImage: "tailscale/tailscale:test",
|
|
},
|
|
logger: zl.Sugar(),
|
|
defaultProxyClass: tt.proxyClassDefault,
|
|
}
|
|
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
_, shortName := findGenName(t, fc, "default", "test", "ingress")
|
|
sts := &appsv1.StatefulSet{}
|
|
if err := fc.Get(context.Background(), client.ObjectKey{Namespace: "operator-ns", Name: shortName}, sts); err != nil {
|
|
t.Fatalf("failed to get StatefulSet: %v", err)
|
|
}
|
|
|
|
switch tt.expectedProxyClass {
|
|
case pcLEStaging.Name:
|
|
verifyEnvVar(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL", letsEncryptStagingEndpoint)
|
|
case pcLEStagingFalse.Name:
|
|
verifyEnvVarNotPresent(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL")
|
|
default:
|
|
t.Fatalf("unexpected expected ProxyClass %q", tt.expectedProxyClass)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIngressLetsEncryptStaging(t *testing.T) {
|
|
cl := tstest.NewClock(tstest.ClockOpts{})
|
|
zl := zap.Must(zap.NewDevelopment())
|
|
|
|
pcLEStaging, pcLEStagingFalse, pcOther := proxyClassesForLEStagingTest()
|
|
|
|
testCases := testCasesForLEStagingTests()
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
builder := fake.NewClientBuilder().
|
|
WithScheme(tsapi.GlobalScheme)
|
|
|
|
builder = builder.WithObjects(pcLEStaging, pcLEStagingFalse, pcOther).
|
|
WithStatusSubresource(pcLEStaging, pcLEStagingFalse, pcOther)
|
|
|
|
fc := builder.Build()
|
|
|
|
if tt.proxyClassPerResource != "" || tt.defaultProxyClass != "" {
|
|
name := tt.proxyClassPerResource
|
|
if name == "" {
|
|
name = tt.defaultProxyClass
|
|
}
|
|
setProxyClassReady(t, fc, cl, name)
|
|
}
|
|
|
|
mustCreate(t, fc, ingressClass())
|
|
mustCreate(t, fc, service())
|
|
ing := ingress()
|
|
if tt.proxyClassPerResource != "" {
|
|
ing.Labels = map[string]string{
|
|
LabelAnnotationProxyClass: tt.proxyClassPerResource,
|
|
}
|
|
}
|
|
mustCreate(t, fc, ing)
|
|
|
|
ingR := &IngressReconciler{
|
|
Client: fc,
|
|
ingressClassName: "tailscale",
|
|
ssr: &tailscaleSTSReconciler{
|
|
Client: fc,
|
|
tsClient: &fakeTSClient{},
|
|
tsnetServer: &fakeTSNetServer{certDomains: []string{"test-host"}},
|
|
defaultTags: []string{"tag:test"},
|
|
operatorNamespace: "operator-ns",
|
|
proxyImage: "tailscale/tailscale:test",
|
|
},
|
|
logger: zl.Sugar(),
|
|
defaultProxyClass: tt.defaultProxyClass,
|
|
}
|
|
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
_, shortName := findGenName(t, fc, "default", "test", "ingress")
|
|
sts := &appsv1.StatefulSet{}
|
|
if err := fc.Get(context.Background(), client.ObjectKey{Namespace: "operator-ns", Name: shortName}, sts); err != nil {
|
|
t.Fatalf("failed to get StatefulSet: %v", err)
|
|
}
|
|
|
|
if tt.useLEStagingEndpoint {
|
|
verifyEnvVar(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL", letsEncryptStagingEndpoint)
|
|
} else {
|
|
verifyEnvVarNotPresent(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmptyPath(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
paths []networkingv1.HTTPIngressPath
|
|
expectedEvents []string
|
|
}{
|
|
{
|
|
name: "empty_path_with_prefix_type",
|
|
paths: []networkingv1.HTTPIngressPath{
|
|
{
|
|
PathType: ptrPathType(networkingv1.PathTypePrefix),
|
|
Path: "",
|
|
Backend: *backend(),
|
|
},
|
|
},
|
|
expectedEvents: []string{
|
|
"Normal PathUndefined configured backend is missing a path, defaulting to '/'",
|
|
},
|
|
},
|
|
{
|
|
name: "empty_path_with_implementation_specific_type",
|
|
paths: []networkingv1.HTTPIngressPath{
|
|
{
|
|
PathType: ptrPathType(networkingv1.PathTypeImplementationSpecific),
|
|
Path: "",
|
|
Backend: *backend(),
|
|
},
|
|
},
|
|
expectedEvents: []string{
|
|
"Normal PathUndefined configured backend is missing a path, defaulting to '/'",
|
|
},
|
|
},
|
|
{
|
|
name: "empty_path_with_exact_type",
|
|
paths: []networkingv1.HTTPIngressPath{
|
|
{
|
|
PathType: ptrPathType(networkingv1.PathTypeExact),
|
|
Path: "",
|
|
Backend: *backend(),
|
|
},
|
|
},
|
|
expectedEvents: []string{
|
|
"Warning UnsupportedPathTypeExact Exact path type strict matching is currently not supported and requests will be routed as for Prefix path type. This behaviour might change in the future.",
|
|
"Normal PathUndefined configured backend is missing a path, defaulting to '/'",
|
|
},
|
|
},
|
|
{
|
|
name: "two_competing_but_not_identical_paths_including_one_empty",
|
|
paths: []networkingv1.HTTPIngressPath{
|
|
{
|
|
PathType: ptrPathType(networkingv1.PathTypeImplementationSpecific),
|
|
Path: "",
|
|
Backend: *backend(),
|
|
},
|
|
{
|
|
PathType: ptrPathType(networkingv1.PathTypeImplementationSpecific),
|
|
Path: "/",
|
|
Backend: *backend(),
|
|
},
|
|
},
|
|
expectedEvents: []string{
|
|
"Normal PathUndefined configured backend is missing a path, defaulting to '/'",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
fc := fake.NewFakeClient(ingressClass())
|
|
ft := &fakeTSClient{}
|
|
fr := record.NewFakeRecorder(3) // bump this if you expect a test case to throw more events
|
|
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
|
zl, err := zap.NewDevelopment()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ingR := &IngressReconciler{
|
|
recorder: fr,
|
|
Client: fc,
|
|
ingressClassName: "tailscale",
|
|
ssr: &tailscaleSTSReconciler{
|
|
Client: fc,
|
|
tsClient: ft,
|
|
tsnetServer: fakeTsnetServer,
|
|
defaultTags: []string{"tag:k8s"},
|
|
operatorNamespace: "operator-ns",
|
|
proxyImage: "tailscale/tailscale",
|
|
},
|
|
logger: zl.Sugar(),
|
|
}
|
|
|
|
// 1. Resources get created for regular Ingress
|
|
mustCreate(t, fc, ingressWithPaths(tt.paths))
|
|
mustCreate(t, fc, service())
|
|
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
|
|
mustCreate(t, fc, &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: fullName,
|
|
Namespace: "operator-ns",
|
|
UID: "test-uid",
|
|
},
|
|
})
|
|
opts := configOpts{
|
|
stsName: shortName,
|
|
secretName: fullName,
|
|
namespace: "default",
|
|
parentType: "ingress",
|
|
hostname: "foo",
|
|
app: kubetypes.AppIngressResource,
|
|
serveConfig: &ipn.ServeConfig{
|
|
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{
|
|
"/": {Proxy: "http://1.2.3.4:8080/"},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
expectEqual(t, fc, expectedSecret(t, fc, opts))
|
|
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"))
|
|
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeResourceReqs)
|
|
|
|
expectEvents(t, fr, tt.expectedEvents)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ptrPathType is a helper function to return a pointer to the pathtype string (required for TestEmptyPath)
|
|
func ptrPathType(p networkingv1.PathType) *networkingv1.PathType {
|
|
return &p
|
|
}
|
|
|
|
func ingressClass() *networkingv1.IngressClass {
|
|
return &networkingv1.IngressClass{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
|
|
Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"},
|
|
}
|
|
}
|
|
|
|
func service() *corev1.Service {
|
|
return &corev1.Service{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test",
|
|
Namespace: "default",
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
ClusterIP: "1.2.3.4",
|
|
Ports: []corev1.ServicePort{
|
|
{
|
|
Port: 8080,
|
|
Name: "http",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func ingress() *networkingv1.Ingress {
|
|
return &networkingv1.Ingress{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test",
|
|
Namespace: "default",
|
|
UID: "1234-UID",
|
|
},
|
|
Spec: networkingv1.IngressSpec{
|
|
IngressClassName: ptr.To("tailscale"),
|
|
DefaultBackend: backend(),
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"default-test"}},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func ingressWithPaths(paths []networkingv1.HTTPIngressPath) *networkingv1.Ingress {
|
|
return &networkingv1.Ingress{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test",
|
|
Namespace: "default",
|
|
UID: types.UID("1234-UID"),
|
|
},
|
|
Spec: networkingv1.IngressSpec{
|
|
IngressClassName: ptr.To("tailscale"),
|
|
Rules: []networkingv1.IngressRule{
|
|
{
|
|
Host: "foo.tailnetxyz.ts.net",
|
|
IngressRuleValue: networkingv1.IngressRuleValue{
|
|
HTTP: &networkingv1.HTTPIngressRuleValue{
|
|
Paths: paths,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
TLS: []networkingv1.IngressTLS{
|
|
{Hosts: []string{"foo.tailnetxyz.ts.net"}},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func backend() *networkingv1.IngressBackend {
|
|
return &networkingv1.IngressBackend{
|
|
Service: &networkingv1.IngressServiceBackend{
|
|
Name: "test",
|
|
Port: networkingv1.ServiceBackendPort{
|
|
Number: 8080,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestTailscaleIngressWithHTTPRedirect(t *testing.T) {
|
|
fc := fake.NewFakeClient(ingressClass())
|
|
ft := &fakeTSClient{}
|
|
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
|
zl, err := zap.NewDevelopment()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ingR := &IngressReconciler{
|
|
Client: fc,
|
|
ingressClassName: "tailscale",
|
|
ssr: &tailscaleSTSReconciler{
|
|
Client: fc,
|
|
tsClient: ft,
|
|
tsnetServer: fakeTsnetServer,
|
|
defaultTags: []string{"tag:k8s"},
|
|
operatorNamespace: "operator-ns",
|
|
proxyImage: "tailscale/tailscale",
|
|
},
|
|
logger: zl.Sugar(),
|
|
}
|
|
|
|
// 1. Create Ingress with HTTP redirect annotation
|
|
ing := ingress()
|
|
mak.Set(&ing.Annotations, AnnotationHTTPRedirect, "true")
|
|
mustCreate(t, fc, ing)
|
|
mustCreate(t, fc, service())
|
|
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
|
|
opts := configOpts{
|
|
replicas: ptr.To[int32](1),
|
|
stsName: shortName,
|
|
secretName: fullName,
|
|
namespace: "default",
|
|
parentType: "ingress",
|
|
hostname: "default-test",
|
|
app: kubetypes.AppIngressResource,
|
|
serveConfig: &ipn.ServeConfig{
|
|
TCP: map[uint16]*ipn.TCPPortHandler{
|
|
443: {HTTPS: true},
|
|
80: {HTTP: true},
|
|
},
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{
|
|
"/": {Proxy: "http://1.2.3.4:8080/"},
|
|
}},
|
|
"${TS_CERT_DOMAIN}:80": {Handlers: map[string]*ipn.HTTPHandler{
|
|
"/": {Redirect: "301:https://${HOST}${REQUEST_URI}"},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
expectEqual(t, fc, expectedSecret(t, fc, opts))
|
|
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"))
|
|
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeResourceReqs)
|
|
|
|
// 2. Update device info to get status updated
|
|
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
|
|
mak.Set(&secret.Data, "device_id", []byte("1234"))
|
|
mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net"))
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
// Verify Ingress status includes both ports 80 and 443
|
|
ing = &networkingv1.Ingress{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{Name: "test", Namespace: "default"}, ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantPorts := []networkingv1.IngressPortStatus{
|
|
{Port: 443, Protocol: "TCP"},
|
|
{Port: 80, Protocol: "TCP"},
|
|
}
|
|
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress[0].Ports, wantPorts) {
|
|
t.Errorf("incorrect status ports: got %v, want %v", ing.Status.LoadBalancer.Ingress[0].Ports, wantPorts)
|
|
}
|
|
|
|
// 3. Remove HTTP redirect annotation
|
|
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
|
|
delete(ing.Annotations, AnnotationHTTPRedirect)
|
|
})
|
|
expectReconciled(t, ingR, "default", "test")
|
|
|
|
// 4. Verify Ingress status no longer includes port 80
|
|
ing = &networkingv1.Ingress{}
|
|
if err := fc.Get(context.Background(), types.NamespacedName{Name: "test", Namespace: "default"}, ing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantPorts = []networkingv1.IngressPortStatus{
|
|
{Port: 443, Protocol: "TCP"},
|
|
}
|
|
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress[0].Ports, wantPorts) {
|
|
t.Errorf("incorrect status ports after removing redirect: got %v, want %v", ing.Status.LoadBalancer.Ingress[0].Ports, wantPorts)
|
|
}
|
|
}
|