mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-29 00:36:33 -04:00
cmd/k8s-operator/e2e: add egress tests (#20648)
Add end-to-end tests for egress to a tailnet target via its FQDN, IPv4 and IPv6 addresses. Update the kind cluster used in tests to support dual-stack (to allow testing egress to both IPv4 and IPv6 tailnet targets). For pre-existing clusters, detect on test setup which IP families the cluster supports. IPv4/IPv6 subtests are skipped if not supported. Run the end-to-end tests in parallel to improve the speed of test runs. To enable this, run TestProxyGroupPolicy in a dedicated namespace so its ValidatingAdmissionPolicies don't affect other parallel tests sharing the same namespace. Updates tailscale/corp#34834 Signed-off-by: Becky Pauley <becky@tailscale.com>
This commit is contained in:
227
cmd/k8s-operator/e2e/egress_test.go
Normal file
227
cmd/k8s-operator/e2e/egress_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
kube "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
const egressPort = 80
|
||||
|
||||
// See [TestMain] for test requirements.
|
||||
func TestEgress(t *testing.T) {
|
||||
if tnClient == nil {
|
||||
t.Skip("TestEgress requires a working tailnet client")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
t.Run("IPv4", func(t *testing.T) {
|
||||
if !clusterIPv4Support {
|
||||
t.Skip("cluster does not support IPv4")
|
||||
}
|
||||
svc := egressService(generateName("test-egress"), map[string]string{
|
||||
"tailscale.com/tailnet-ip": tnTarget.ipv4,
|
||||
})
|
||||
createAndCleanup(t, kubeClient, svc)
|
||||
waitForEgress(t, svc.Name, kube.SvcIsReady)
|
||||
testEgressIsReachable(t, ns, svc.Name)
|
||||
})
|
||||
|
||||
t.Run("IPv6", func(t *testing.T) {
|
||||
if !clusterIPv6Support {
|
||||
t.Skip("cluster does not support IPv6")
|
||||
}
|
||||
svc := egressService(generateName("test-egress"), map[string]string{
|
||||
"tailscale.com/tailnet-ip": tnTarget.ipv6,
|
||||
})
|
||||
createAndCleanup(t, kubeClient, svc)
|
||||
waitForEgress(t, svc.Name, kube.SvcIsReady)
|
||||
testEgressIsReachable(t, ns, svc.Name)
|
||||
})
|
||||
|
||||
t.Run("FQDN", func(t *testing.T) {
|
||||
svc := egressService(generateName("test-egress"), map[string]string{
|
||||
"tailscale.com/tailnet-fqdn": tnTarget.fqdn,
|
||||
})
|
||||
createAndCleanup(t, kubeClient, svc)
|
||||
waitForEgress(t, svc.Name, kube.SvcIsReady)
|
||||
testEgressIsReachable(t, ns, svc.Name)
|
||||
})
|
||||
}
|
||||
|
||||
// See [TestMain] for test requirements.
|
||||
func TestHAEgress(t *testing.T) {
|
||||
if tnClient == nil {
|
||||
t.Skip("TestHAEgress requires a working tailnet client")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
pg := &tsapi.ProxyGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: generateName("egress"),
|
||||
},
|
||||
Spec: tsapi.ProxyGroupSpec{
|
||||
Type: tsapi.ProxyGroupTypeEgress,
|
||||
},
|
||||
}
|
||||
createAndCleanup(t, kubeClient, pg)
|
||||
|
||||
t.Run("IPv4", func(t *testing.T) {
|
||||
if !clusterIPv4Support {
|
||||
t.Skip("cluster does not support IPv4")
|
||||
}
|
||||
svc := egressService(generateName("test-egress"), map[string]string{
|
||||
"tailscale.com/tailnet-ip": tnTarget.ipv4,
|
||||
"tailscale.com/proxy-group": pg.Name,
|
||||
})
|
||||
createAndCleanup(t, kubeClient, svc)
|
||||
waitForEgress(t, svc.Name, pgEgressReady)
|
||||
testEgressIsReachable(t, ns, svc.Name)
|
||||
})
|
||||
|
||||
t.Run("IPv6", func(t *testing.T) {
|
||||
if !clusterIPv6Support {
|
||||
t.Skip("cluster does not support IPv6")
|
||||
}
|
||||
svc := egressService(generateName("test-egress"), map[string]string{
|
||||
"tailscale.com/tailnet-ip": tnTarget.ipv6,
|
||||
"tailscale.com/proxy-group": pg.Name,
|
||||
})
|
||||
createAndCleanup(t, kubeClient, svc)
|
||||
waitForEgress(t, svc.Name, pgEgressReady)
|
||||
testEgressIsReachable(t, ns, svc.Name)
|
||||
})
|
||||
|
||||
t.Run("FQDN", func(t *testing.T) {
|
||||
svc := egressService(generateName("test-egress"), map[string]string{
|
||||
"tailscale.com/tailnet-fqdn": tnTarget.fqdn,
|
||||
"tailscale.com/proxy-group": pg.Name,
|
||||
})
|
||||
createAndCleanup(t, kubeClient, svc)
|
||||
waitForEgress(t, svc.Name, pgEgressReady)
|
||||
testEgressIsReachable(t, ns, svc.Name)
|
||||
})
|
||||
}
|
||||
|
||||
// See [TestMain] for test requirements.
|
||||
func TestHAEgressMultiTailnet(t *testing.T) {
|
||||
if tnClient == nil || secondTNClient == nil {
|
||||
t.Skip("TestHAEgressMultiTailnet requires a working tailnet client for a first and second tailnet")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
pg := &tsapi.ProxyGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: generateName("egress"),
|
||||
},
|
||||
Spec: tsapi.ProxyGroupSpec{
|
||||
Type: tsapi.ProxyGroupTypeEgress,
|
||||
Tailnet: "second-tailnet",
|
||||
},
|
||||
}
|
||||
createAndCleanup(t, kubeClient, pg)
|
||||
if err := verifyProxyGroupTailnet(t, pg, secondTNClient); err != nil {
|
||||
t.Fatalf("verifying ProxyGroup %s is registered to the correct tailnet: %v", pg.Name, err)
|
||||
}
|
||||
|
||||
svc := egressService(generateName("test-egress"), map[string]string{
|
||||
"tailscale.com/tailnet-fqdn": secondTNTarget.fqdn,
|
||||
"tailscale.com/proxy-group": pg.Name,
|
||||
})
|
||||
createAndCleanup(t, kubeClient, svc)
|
||||
waitForEgress(t, svc.Name, pgEgressReady)
|
||||
testEgressIsReachable(t, ns, svc.Name)
|
||||
}
|
||||
|
||||
func egressService(name string, annotations map[string]string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
Annotations: annotations,
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ExternalName: "placeholder",
|
||||
Type: corev1.ServiceTypeExternalName,
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
Name: "http",
|
||||
Protocol: corev1.ProtocolTCP,
|
||||
Port: egressPort,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pgEgressReady(svc *corev1.Service) bool {
|
||||
cond := kube.GetServiceCondition(svc, tsapi.EgressSvcReady)
|
||||
return cond != nil && cond.Status == metav1.ConditionTrue
|
||||
}
|
||||
|
||||
func waitForEgress(t *testing.T, svcName string, ready func(*corev1.Service) bool) {
|
||||
t.Helper()
|
||||
if err := tstest.WaitFor(5*time.Minute, func() error {
|
||||
svc := &corev1.Service{ObjectMeta: objectMeta(ns, svcName)}
|
||||
if err := get(t.Context(), kubeClient, svc); err != nil {
|
||||
return err
|
||||
}
|
||||
if ready(svc) {
|
||||
t.Logf("Service %s is ready", svcName)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("Service %s is not ready yet", svcName)
|
||||
}); err != nil {
|
||||
t.Fatalf("error waiting for Service %s to become ready: %v", svcName, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testEgressIsReachable(t *testing.T, namespace, svcName string) {
|
||||
t.Helper()
|
||||
url := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", svcName, namespace, egressPort)
|
||||
pod := &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: generateName("curl"),
|
||||
Namespace: namespace,
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "curl",
|
||||
Image: "curlimages/curl",
|
||||
Command: []string{"sh", "-c", fmt.Sprintf(
|
||||
`for i in $(seq 1 10); do `+
|
||||
`code=$(curl -s -o /dev/null -w "%%{http_code}" --max-time 5 %q); `+
|
||||
`[ "$code" = "200" ] && exit 0; sleep 2; done; exit 1`, url)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
createAndCleanup(t, kubeClient, pod)
|
||||
|
||||
if err := tstest.WaitFor(2*time.Minute, func() error {
|
||||
p := &corev1.Pod{ObjectMeta: objectMeta(namespace, pod.Name)}
|
||||
if err := get(t.Context(), kubeClient, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Status.Phase == corev1.PodSucceeded {
|
||||
t.Logf("curl pod %s succeeded", pod.Name)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("curl pod %s phase: %s", pod.Name, p.Status.Phase)
|
||||
}); err != nil {
|
||||
t.Fatalf("egress service %s/%s not reachable: %v",
|
||||
namespace, svcName, err)
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,15 @@
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
func generateName(prefix string) string {
|
||||
@@ -29,3 +35,53 @@ func newHTTPClient(cl *tsnet.Server) *http.Client {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// verifyProxyGroupTailnet verifies that a ProxyGroup is registered to the correct tailnet.
|
||||
// This is done by getting the expected tailnet domain for the tailnet client,
|
||||
// and comparing this with the actual device fqdn in the ProxyGroup state secret.
|
||||
func verifyProxyGroupTailnet(t *testing.T, pg *tsapi.ProxyGroup, cl *tsnet.Server) error {
|
||||
t.Helper()
|
||||
// Determine the expected tailnet Magic DNS Name.
|
||||
lc, err := cl.LocalClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := lc.Status(t.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, expectedTailnet, ok := strings.Cut(strings.TrimSuffix(status.Self.DNSName, "."), ".")
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected DNSName format %q", status.Self.DNSName)
|
||||
}
|
||||
// Read the device FQDN from the first state secret for the ProxyGroup,
|
||||
// and verify that this matches the expected tailnet.
|
||||
if err := tstest.WaitFor(3*time.Minute, func() error {
|
||||
var secrets corev1.SecretList
|
||||
if err := kubeClient.List(t.Context(), &secrets,
|
||||
client.InNamespace("tailscale"),
|
||||
client.MatchingLabels{
|
||||
kubetypes.LabelSecretType: kubetypes.LabelSecretTypeState,
|
||||
"tailscale.com/parent-resource-type": "proxygroup",
|
||||
"tailscale.com/parent-resource": pg.Name,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(secrets.Items) == 0 {
|
||||
return fmt.Errorf("no state secrets found for ProxyGroup %q yet", pg.Name)
|
||||
}
|
||||
fqdn := strings.TrimSuffix(string(secrets.Items[0].Data[kubetypes.KeyDeviceFQDN]), ".")
|
||||
_, tailnet, ok := strings.Cut(fqdn, ".")
|
||||
if !ok {
|
||||
return fmt.Errorf("ProxyGroup %q: device FQDN %q has no domain yet", pg.Name, fqdn)
|
||||
}
|
||||
if tailnet != expectedTailnet {
|
||||
return fmt.Errorf("ProxyGroup %q on wrong tailnet: got domain %q, want %q", pg.Name, tailnet, expectedTailnet)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("ProxyGroup %q not on expected tailnet: %v", pg.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
kube "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
@@ -31,6 +30,7 @@ func TestL3Ingress(t *testing.T) {
|
||||
if tnClient == nil {
|
||||
t.Skip("TestL3Ingress requires a working tailnet client")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
// Apply nginx
|
||||
nginx := nginxDeployment(ns)
|
||||
@@ -109,6 +109,7 @@ func TestL3HAIngress(t *testing.T) {
|
||||
if tnClient == nil {
|
||||
t.Skip("TestL3HAIngress requires a working tailnet client")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
// Apply nginx.
|
||||
nginx := nginxDeployment(ns)
|
||||
@@ -187,6 +188,7 @@ func TestL7Ingress(t *testing.T) {
|
||||
if tnClient == nil {
|
||||
t.Skip("TestL7Ingress requires a working tailnet client")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
// Apply nginx Deployment and Service.
|
||||
nginx := nginxDeployment(ns)
|
||||
@@ -229,6 +231,7 @@ func TestL7HAIngress(t *testing.T) {
|
||||
if tnClient == nil {
|
||||
t.Skip("TestL7HAIngress requires a working tailnet client")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
// Apply nginx Deployment and Service.
|
||||
nginx := nginxDeployment(ns)
|
||||
@@ -282,6 +285,7 @@ func TestL7HAIngressMultiTailnet(t *testing.T) {
|
||||
if tnClient == nil || secondTNClient == nil {
|
||||
t.Skip("TestL7HAIngressMultiTailnet requires a working tailnet client for a first and second tailnet")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
// Apply nginx Deployment and Service.
|
||||
nginx := nginxDeployment(ns)
|
||||
@@ -486,59 +490,6 @@ func testIngressIsReachable(t *testing.T, httpClient *http.Client, url string) e
|
||||
}); err != nil {
|
||||
return fmt.Errorf("error trying to reach %s: %w", url, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status from %s: %d", url, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyProxyGroupTailnet verifies that a ProxyGroup is registered to the correct tailnet.
|
||||
// This is done by getting the expected tailnet domain for the tailnet client,
|
||||
// and comparing this with the actual device fqdn in the ProxyGroup state secret.
|
||||
func verifyProxyGroupTailnet(t *testing.T, pg *tsapi.ProxyGroup, cl *tsnet.Server) error {
|
||||
t.Helper()
|
||||
// Determine the expected tailnet Magic DNS Name.
|
||||
lc, err := cl.LocalClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := lc.Status(t.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, expectedTailnet, ok := strings.Cut(strings.TrimSuffix(status.Self.DNSName, "."), ".")
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected DNSName format %q", status.Self.DNSName)
|
||||
}
|
||||
// Read the device FQDN from the first state secret for the ProxyGroup,
|
||||
// and verify that this matches the expected tailnet.
|
||||
if err := tstest.WaitFor(3*time.Minute, func() error {
|
||||
var secrets corev1.SecretList
|
||||
if err := kubeClient.List(t.Context(), &secrets,
|
||||
client.InNamespace("tailscale"),
|
||||
client.MatchingLabels{
|
||||
kubetypes.LabelSecretType: kubetypes.LabelSecretTypeState,
|
||||
"tailscale.com/parent-resource-type": "proxygroup",
|
||||
"tailscale.com/parent-resource": pg.Name,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(secrets.Items) == 0 {
|
||||
return fmt.Errorf("no state secrets found for ProxyGroup %q yet", pg.Name)
|
||||
}
|
||||
fqdn := strings.TrimSuffix(string(secrets.Items[0].Data[kubetypes.KeyDeviceFQDN]), ".")
|
||||
_, tailnet, ok := strings.Cut(fqdn, ".")
|
||||
if !ok {
|
||||
return fmt.Errorf("ProxyGroup %q: device FQDN %q has no domain yet", pg.Name, fqdn)
|
||||
}
|
||||
if tailnet != expectedTailnet {
|
||||
return fmt.Errorf("ProxyGroup %q on wrong tailnet: got domain %q, want %q", pg.Name, tailnet, expectedTailnet)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("ProxyGroup %q not on expected tailnet: %v", pg.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestProxy(t *testing.T) {
|
||||
if tnClient == nil {
|
||||
t.Skip("TestProxy requires a working tailnet client")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
// Create role and role binding to allow a group we'll impersonate to do stuff.
|
||||
createAndCleanup(t, kubeClient, &rbacv1.Role{
|
||||
|
||||
@@ -20,12 +20,21 @@ func TestProxyGroupPolicy(t *testing.T) {
|
||||
if tnClient == nil {
|
||||
t.Skip("TestProxyGroupPolicy requires a working tailnet client")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
// Run in a dedicated namespace so the ValidatingAdmissionPolicies the
|
||||
// operator generates for our ProxyGroupPolicies don't affect Services/Ingresses
|
||||
// created by the other parallel tests in the default namespace.
|
||||
pgPolicyNs := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: generateName("test-pgpolicy")},
|
||||
}
|
||||
createAndCleanup(t, kubeClient, pgPolicyNs)
|
||||
|
||||
// Apply deny-all policy
|
||||
denyAllPolicy := &tsapi.ProxyGroupPolicy{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "deny-all",
|
||||
Namespace: metav1.NamespaceDefault,
|
||||
Namespace: pgPolicyNs.Name,
|
||||
},
|
||||
Spec: tsapi.ProxyGroupPolicySpec{
|
||||
Ingress: []string{},
|
||||
@@ -36,12 +45,12 @@ func TestProxyGroupPolicy(t *testing.T) {
|
||||
createAndCleanup(t, kubeClient, denyAllPolicy)
|
||||
<-time.After(time.Second * 2)
|
||||
|
||||
// Attempt to create an egress Service within the default namespace, the above policy should
|
||||
// Attempt to create an egress Service within the namespace, the above policy should
|
||||
// reject it.
|
||||
egressService := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "egress-to-proxy-group",
|
||||
Namespace: metav1.NamespaceDefault,
|
||||
Namespace: pgPolicyNs.Name,
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/tailnet-fqdn": "test.something.ts.net",
|
||||
"tailscale.com/proxy-group": "test",
|
||||
@@ -69,12 +78,12 @@ func TestProxyGroupPolicy(t *testing.T) {
|
||||
t.Fatal("expected error when creating egress service")
|
||||
}
|
||||
|
||||
// Attempt to create an ingress Service within the default namespace, the above policy should
|
||||
// Attempt to create an ingress Service within the namespace, the above policy should
|
||||
// reject it.
|
||||
ingressService := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "ingress-to-proxy-group",
|
||||
Namespace: metav1.NamespaceDefault,
|
||||
Namespace: pgPolicyNs.Name,
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/proxy-group": "test",
|
||||
},
|
||||
@@ -101,11 +110,11 @@ func TestProxyGroupPolicy(t *testing.T) {
|
||||
t.Fatal("expected error when creating ingress service")
|
||||
}
|
||||
|
||||
// Attempt to create an Ingress within the default namespace, the above policy should reject it
|
||||
// Attempt to create an Ingress within the namespace, the above policy should reject it
|
||||
ingress := &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "ingress-to-proxy-group",
|
||||
Namespace: metav1.NamespaceDefault,
|
||||
Namespace: pgPolicyNs.Name,
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/proxy-group": "test",
|
||||
},
|
||||
@@ -142,7 +151,7 @@ func TestProxyGroupPolicy(t *testing.T) {
|
||||
allowTestPolicy := &tsapi.ProxyGroupPolicy{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "allow-test",
|
||||
Namespace: metav1.NamespaceDefault,
|
||||
Namespace: pgPolicyNs.Name,
|
||||
},
|
||||
Spec: tsapi.ProxyGroupPolicySpec{
|
||||
Ingress: []string{"test"},
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
"crypto/x509"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -50,6 +53,7 @@
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
klog "sigs.k8s.io/controller-runtime/pkg/log"
|
||||
kzap "sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||
"sigs.k8s.io/kind/pkg/apis/config/v1alpha4"
|
||||
"sigs.k8s.io/kind/pkg/cluster"
|
||||
"sigs.k8s.io/kind/pkg/cluster/nodeutils"
|
||||
"sigs.k8s.io/kind/pkg/cmd"
|
||||
@@ -73,11 +77,15 @@
|
||||
var (
|
||||
tsClient *tailscale.Client // For API calls to control.
|
||||
tnClient *tsnet.Server // For testing real tailnet traffic on first tailnet.
|
||||
tnTarget tailnetTarget // Egress target on the first tailnet.
|
||||
secondTSClient *tailscale.Client // For API calls to the secondary tailnet (_second_tailnet).
|
||||
secondTNClient *tsnet.Server // For testing real tailnet traffic on second tailnet.
|
||||
secondTNTarget tailnetTarget // Egress target on the second tailnet.
|
||||
restCfg *rest.Config // For constructing a client-go client if necessary.
|
||||
kubeClient client.WithWatch // For k8s API calls.
|
||||
clusterLoginServer string
|
||||
clusterIPv4Support bool // whether the test cluster supports IPv4.
|
||||
clusterIPv6Support bool // whether the test cluster supports IPv6.
|
||||
|
||||
//go:embed certs/pebble.minica.crt
|
||||
pebbleMiniCACert []byte
|
||||
@@ -136,6 +144,11 @@ func runTests(m *testing.M) (int, error) {
|
||||
|
||||
if !slices.Contains(clusters, kindClusterName) {
|
||||
if err := kindProvider.Create(kindClusterName,
|
||||
cluster.CreateWithV1Alpha4Config(&v1alpha4.Cluster{
|
||||
Networking: v1alpha4.Networking{
|
||||
IPFamily: v1alpha4.DualStackFamily,
|
||||
},
|
||||
}),
|
||||
cluster.CreateWithWaitForReady(5*time.Minute),
|
||||
cluster.CreateWithKubeconfigPath(kubeconfig),
|
||||
cluster.CreateWithNodeImage("kindest/node:v1.35.0"),
|
||||
@@ -161,6 +174,10 @@ func runTests(m *testing.M) (int, error) {
|
||||
return 0, fmt.Errorf("error creating Kubernetes client: %w", err)
|
||||
}
|
||||
|
||||
if err := detectClusterIPFamilies(ctx, logger, kubeClient); err != nil {
|
||||
return 0, fmt.Errorf("error detecting cluster IP families: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
clientID, clientSecret string // OAuth client for the first tailnet (for the operator to use).
|
||||
caPaths []string // Extra CA cert file paths to add to images.
|
||||
@@ -514,6 +531,10 @@ func runTests(m *testing.M) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
defer tnClient.Close()
|
||||
tnTarget, err = startTailnetHTTPServer(ctx, tnClient)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to start tailnet HTTP server on first tailnet: %w", err)
|
||||
}
|
||||
|
||||
secondTNClient = &tsnet.Server{
|
||||
ControlURL: secondTSClient.BaseURL.String(),
|
||||
@@ -527,6 +548,10 @@ func runTests(m *testing.M) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
defer secondTNClient.Close()
|
||||
secondTNTarget, err = startTailnetHTTPServer(ctx, secondTNClient)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to start tailnet HTTP server on second tailnet: %w", err)
|
||||
}
|
||||
|
||||
// Create the tailnet Secret in the tailscale namespace.
|
||||
secret := &corev1.Secret{
|
||||
@@ -842,6 +867,88 @@ func createOrUpdate(ctx context.Context, cl client.Client, obj client.Object) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectClusterIPFamilies determines which IP families the cluster supports by
|
||||
// creating a throwaway ClusterIP Service with PreferDualStack and reading back
|
||||
// the IP families the API server assigns.
|
||||
func detectClusterIPFamilies(ctx context.Context, logger *zap.SugaredLogger, cl client.Client) error {
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: generateName("ipfamily-probe"),
|
||||
Namespace: ns,
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
IPFamilyPolicy: new(corev1.IPFamilyPolicyPreferDualStack),
|
||||
Ports: []corev1.ServicePort{
|
||||
{Name: "probe", Protocol: corev1.ProtocolTCP, Port: 80},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := cl.Create(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to create IP family Service: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := cl.Delete(ctx, svc); err != nil {
|
||||
logger.Warnf("failed to clean up IP family Service %s/%s: %v", svc.Namespace, svc.Name, err)
|
||||
}
|
||||
}()
|
||||
|
||||
for _, ip := range svc.Spec.IPFamilies {
|
||||
switch ip {
|
||||
case corev1.IPv4Protocol:
|
||||
clusterIPv4Support = true
|
||||
case corev1.IPv6Protocol:
|
||||
clusterIPv6Support = true
|
||||
}
|
||||
}
|
||||
if !clusterIPv4Support && !clusterIPv6Support {
|
||||
return fmt.Errorf("Service %s/%s reported no IP families", svc.Namespace, svc.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailnetTarget holds the FQDN, IPv4, and IPv6 addresses of the tailnet
|
||||
// HTTP server used as the egress target.
|
||||
type tailnetTarget struct {
|
||||
fqdn, ipv4, ipv6 string
|
||||
}
|
||||
|
||||
// startTailnetHTTPServer starts an HTTP server that returns the tailnet FQDN, IPv4,
|
||||
// and IPv6 addresses of the created node. Used as an egress target in tests.
|
||||
func startTailnetHTTPServer(ctx context.Context, cl *tsnet.Server) (tailnetTarget, error) {
|
||||
ln, err := cl.Listen("tcp", ":80")
|
||||
if err != nil {
|
||||
return tailnetTarget{}, fmt.Errorf("failed to listen on tailnet: %w", err)
|
||||
}
|
||||
go func() {
|
||||
if err := http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("tailnet HTTP server exited: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
lc, err := cl.LocalClient()
|
||||
if err != nil {
|
||||
return tailnetTarget{}, fmt.Errorf("failed to get local client: %w", err)
|
||||
}
|
||||
status, err := lc.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return tailnetTarget{}, fmt.Errorf("failed to get status: %w", err)
|
||||
}
|
||||
target := tailnetTarget{fqdn: strings.TrimSuffix(status.Self.DNSName, ".")}
|
||||
for _, ip := range status.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
target.ipv4 = ip.String()
|
||||
} else {
|
||||
target.ipv6 = ip.String()
|
||||
}
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// createTailnet creates a new tailnet and returns a tailscale.Client
|
||||
// authenticated against it using the bootstrap credentials included in the
|
||||
// creation response.
|
||||
|
||||
Reference in New Issue
Block a user