cmd/k8s-operator: allow specifying replicas for connectors (#16721)

This commit adds a `replicas` field to the `Connector` custom resource that
allows users to specify the number of desired replicas deployed for their
connectors.

This allows users to deploy exit nodes, subnet routers and app connectors
in a highly available fashion.

Fixes #14020

Signed-off-by: David Bond <davidsbond93@gmail.com>
This commit is contained in:
David Bond
2025-09-02 13:10:03 +01:00
committed by GitHub
parent d05e6dc09e
commit 12ad630128
13 changed files with 665 additions and 202 deletions

View File

@@ -7,6 +7,8 @@ package main
import (
"context"
"strconv"
"strings"
"testing"
"time"
@@ -20,6 +22,7 @@ import (
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstest"
"tailscale.com/types/ptr"
"tailscale.com/util/mak"
)
@@ -36,6 +39,7 @@ func TestConnector(t *testing.T) {
APIVersion: "tailscale.com/v1alpha1",
},
Spec: tsapi.ConnectorSpec{
Replicas: ptr.To[int32](1),
SubnetRouter: &tsapi.SubnetRouter{
AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"},
},
@@ -55,7 +59,8 @@ func TestConnector(t *testing.T) {
cl := tstest.NewClock(tstest.ClockOpts{})
cr := &ConnectorReconciler{
Client: fc,
Client: fc,
recorder: record.NewFakeRecorder(10),
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
@@ -78,6 +83,7 @@ func TestConnector(t *testing.T) {
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
app: kubetypes.AppConnector,
replicas: cn.Spec.Replicas,
}
expectEqual(t, fc, expectedSecret(t, fc, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
@@ -94,6 +100,10 @@ func TestConnector(t *testing.T) {
cn.Status.IsExitNode = cn.Spec.ExitNode
cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify()
cn.Status.Hostname = hostname
cn.Status.Devices = []tsapi.ConnectorDevice{{
Hostname: hostname,
TailnetIPs: []string{"127.0.0.1", "::1"},
}}
cn.Status.TailnetIPs = []string{"127.0.0.1", "::1"}
expectEqual(t, fc, cn, func(o *tsapi.Connector) {
o.Status.Conditions = nil
@@ -156,6 +166,7 @@ func TestConnector(t *testing.T) {
APIVersion: "tailscale.io/v1alpha1",
},
Spec: tsapi.ConnectorSpec{
Replicas: ptr.To[int32](1),
SubnetRouter: &tsapi.SubnetRouter{
AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"},
},
@@ -174,6 +185,7 @@ func TestConnector(t *testing.T) {
subnetRoutes: "10.40.0.0/14",
hostname: "test-connector",
app: kubetypes.AppConnector,
replicas: cn.Spec.Replicas,
}
expectEqual(t, fc, expectedSecret(t, fc, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
@@ -217,9 +229,11 @@ func TestConnectorWithProxyClass(t *testing.T) {
APIVersion: "tailscale.io/v1alpha1",
},
Spec: tsapi.ConnectorSpec{
Replicas: ptr.To[int32](1),
SubnetRouter: &tsapi.SubnetRouter{
AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"},
},
ExitNode: true,
},
}
@@ -260,6 +274,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
app: kubetypes.AppConnector,
replicas: cn.Spec.Replicas,
}
expectEqual(t, fc, expectedSecret(t, fc, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
@@ -311,6 +326,7 @@ func TestConnectorWithAppConnector(t *testing.T) {
APIVersion: "tailscale.io/v1alpha1",
},
Spec: tsapi.ConnectorSpec{
Replicas: ptr.To[int32](1),
AppConnector: &tsapi.AppConnector{},
},
}
@@ -340,7 +356,7 @@ func TestConnectorWithAppConnector(t *testing.T) {
recorder: fr,
}
// 1. Connector with app connnector is created and becomes ready
// 1. Connector with app connector is created and becomes ready
expectReconciled(t, cr, "", "test")
fullName, shortName := findGenName(t, fc, "", "test", "connector")
opts := configOpts{
@@ -350,6 +366,7 @@ func TestConnectorWithAppConnector(t *testing.T) {
hostname: "test-connector",
app: kubetypes.AppConnector,
isAppConnector: true,
replicas: cn.Spec.Replicas,
}
expectEqual(t, fc, expectedSecret(t, fc, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
@@ -357,6 +374,7 @@ func TestConnectorWithAppConnector(t *testing.T) {
cn.ObjectMeta.Finalizers = append(cn.ObjectMeta.Finalizers, "tailscale.com/finalizer")
cn.Status.IsAppConnector = true
cn.Status.Devices = []tsapi.ConnectorDevice{}
cn.Status.Conditions = []metav1.Condition{{
Type: string(tsapi.ConnectorReady),
Status: metav1.ConditionTrue,
@@ -368,9 +386,9 @@ func TestConnectorWithAppConnector(t *testing.T) {
// 2. Connector with invalid app connector routes has status set to invalid
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("1.2.3.4/5")}
conn.Spec.AppConnector.Routes = tsapi.Routes{"1.2.3.4/5"}
})
cn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("1.2.3.4/5")}
cn.Spec.AppConnector.Routes = tsapi.Routes{"1.2.3.4/5"}
expectReconciled(t, cr, "", "test")
cn.Status.Conditions = []metav1.Condition{{
Type: string(tsapi.ConnectorReady),
@@ -383,9 +401,9 @@ func TestConnectorWithAppConnector(t *testing.T) {
// 3. Connector with valid app connnector routes becomes ready
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("10.88.2.21/32")}
conn.Spec.AppConnector.Routes = tsapi.Routes{"10.88.2.21/32"}
})
cn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("10.88.2.21/32")}
cn.Spec.AppConnector.Routes = tsapi.Routes{"10.88.2.21/32"}
cn.Status.Conditions = []metav1.Condition{{
Type: string(tsapi.ConnectorReady),
Status: metav1.ConditionTrue,
@@ -395,3 +413,94 @@ func TestConnectorWithAppConnector(t *testing.T) {
}}
expectReconciled(t, cr, "", "test")
}
func TestConnectorWithMultipleReplicas(t *testing.T) {
cn := &tsapi.Connector{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
UID: types.UID("1234-UID"),
},
TypeMeta: metav1.TypeMeta{
Kind: tsapi.ConnectorKind,
APIVersion: "tailscale.io/v1alpha1",
},
Spec: tsapi.ConnectorSpec{
Replicas: ptr.To[int32](3),
AppConnector: &tsapi.AppConnector{},
HostnamePrefix: "test-connector",
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(cn).
WithStatusSubresource(cn).
Build()
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
cl := tstest.NewClock(tstest.ClockOpts{})
fr := record.NewFakeRecorder(1)
cr := &ConnectorReconciler{
Client: fc,
clock: cl,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
recorder: fr,
}
// 1. Ensure that our connector resource is reconciled.
expectReconciled(t, cr, "", "test")
// 2. Ensure we have a number of secrets matching the number of replicas.
names := findGenNames(t, fc, "", "test", "connector")
if int32(len(names)) != *cn.Spec.Replicas {
t.Fatalf("expected %d secrets, got %d", *cn.Spec.Replicas, len(names))
}
// 3. Ensure each device has the correct hostname prefix and ordinal suffix.
for i, name := range names {
expected := expectedSecret(t, fc, configOpts{
secretName: name,
hostname: string(cn.Spec.HostnamePrefix) + "-" + strconv.Itoa(i),
isAppConnector: true,
parentType: "connector",
namespace: cr.tsnamespace,
})
expectEqual(t, fc, expected)
}
// 4. Ensure the generated stateful set has the matching number of replicas
shortName := strings.TrimSuffix(names[0], "-0")
var sts appsv1.StatefulSet
if err = fc.Get(t.Context(), types.NamespacedName{Namespace: "operator-ns", Name: shortName}, &sts); err != nil {
t.Fatalf("failed to get StatefulSet %q: %v", shortName, err)
}
if sts.Spec.Replicas == nil {
t.Fatalf("actual StatefulSet %q does not have replicas set", shortName)
}
if *sts.Spec.Replicas != *cn.Spec.Replicas {
t.Fatalf("expected %d replicas, got %d", *cn.Spec.Replicas, *sts.Spec.Replicas)
}
// 5. We'll scale the connector down by 1 replica and make sure its secret is cleaned up
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.Replicas = ptr.To[int32](2)
})
expectReconciled(t, cr, "", "test")
names = findGenNames(t, fc, "", "test", "connector")
if len(names) != 2 {
t.Fatalf("expected 2 secrets, got %d", len(names))
}
}