mirror of
https://github.com/tailscale/tailscale.git
synced 2025-10-30 00:29:48 +00:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user