mirror of
https://github.com/tailscale/tailscale.git
synced 2025-08-14 23:17:29 +00:00
cmd/k8s-operator,k8s-operator: allow the operator to deploy exit nodes via Connector custom resource (#10724)
cmd/k8s-operator/deploy/crds,k8s-operator/apis/v1alpha1: allow to define an exit node via Connector CR. Make it possible to define an exit node to be deployed to a Kubernetes cluster via Connector Custom resource. Also changes to Connector API so that one Connector corresponds to one Tailnet node that can be either a subnet router or an exit node or both. The Kubernetes operator parses Connector custom resource and, if .spec.isExitNode is set, configures that Tailscale node deployed for that connector as an exit node. Signed-off-by: Irbe Krumina <irbe@tailscale.com> Co-authored-by: Anton Tolchanov <anton@tailscale.com>
This commit is contained in:
@@ -17,10 +17,11 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
func TestConnector(t *testing.T) {
|
||||
// Create a Connector that defines a Tailscale node that advertises
|
||||
// 10.40.0.0/14 route and acts as an exit node.
|
||||
cn := &tsapi.Connector{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
@@ -32,8 +33,9 @@ func TestConnector(t *testing.T) {
|
||||
},
|
||||
Spec: tsapi.ConnectorSpec{
|
||||
SubnetRouter: &tsapi.SubnetRouter{
|
||||
Routes: []tsapi.Route{"10.40.0.0/14"},
|
||||
AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"},
|
||||
},
|
||||
ExitNode: true,
|
||||
},
|
||||
}
|
||||
fc := fake.NewClientBuilder().
|
||||
@@ -48,7 +50,6 @@ func TestConnector(t *testing.T) {
|
||||
}
|
||||
|
||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||
// Create a Connector with a subnet router definition
|
||||
cr := &ConnectorReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -63,26 +64,61 @@ func TestConnector(t *testing.T) {
|
||||
}
|
||||
|
||||
expectReconciled(t, cr, "", "test")
|
||||
fullName, shortName := findGenName(t, fc, "", "test", "subnetrouter")
|
||||
fullName, shortName := findGenName(t, fc, "", "test", "connector")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName, "", "subnetrouter"))
|
||||
expectEqual(t, fc, expectedConnectorSTS(shortName, fullName, "10.40.0.0/14"))
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
parentType: "connector",
|
||||
hostname: "test-connector",
|
||||
shouldUseDeclarativeConfig: true,
|
||||
isExitNode: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Add another CIDR
|
||||
// Add another route to be advertised.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter.Routes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"}
|
||||
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"}
|
||||
})
|
||||
opts.subnetRoutes = "10.40.0.0/14,10.44.0.0/20"
|
||||
opts.confFileHash = "fb6c4daf67425f983985750cd8d6f2beae77e614fcb34176604571f5623d6862"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedConnectorSTS(shortName, fullName, "10.40.0.0/14,10.44.0.0/20"))
|
||||
|
||||
// Remove a CIDR
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Remove a route.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter.Routes = []tsapi.Route{"10.44.0.0/20"}
|
||||
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.44.0.0/20"}
|
||||
})
|
||||
opts.subnetRoutes = "10.44.0.0/20"
|
||||
opts.confFileHash = "bacba177bcfe3849065cf6fee53d658a9bb4144197ac5b861727d69ea99742bb"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedConnectorSTS(shortName, fullName, "10.44.0.0/20"))
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Delete the Connector
|
||||
// Remove the subnet router.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter = nil
|
||||
})
|
||||
opts.subnetRoutes = ""
|
||||
opts.confFileHash = "7c421a99128eb80e79a285a82702f19f8f720615542a15bd794858a6275d8079"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Re-add the subnet router.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter = &tsapi.SubnetRouter{
|
||||
AdvertiseRoutes: []tsapi.Route{"10.44.0.0/20"},
|
||||
}
|
||||
})
|
||||
opts.subnetRoutes = "10.44.0.0/20"
|
||||
opts.confFileHash = "bacba177bcfe3849065cf6fee53d658a9bb4144197ac5b861727d69ea99742bb"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Delete the Connector.
|
||||
if err = fc.Delete(context.Background(), cn); err != nil {
|
||||
t.Fatalf("error deleting Connector: %v", err)
|
||||
}
|
||||
@@ -93,72 +129,57 @@ func TestConnector(t *testing.T) {
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
|
||||
}
|
||||
|
||||
func expectedConnectorSTS(stsName, secretName, routes string) *appsv1.StatefulSet {
|
||||
return &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "StatefulSet",
|
||||
APIVersion: "apps/v1",
|
||||
},
|
||||
// Create a Connector that advertises a route and is not an exit node.
|
||||
cn = &tsapi.Connector{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: stsName,
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "",
|
||||
"tailscale.com/parent-resource-type": "subnetrouter",
|
||||
},
|
||||
Name: "test",
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
Replicas: ptr.To[int32](1),
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
ServiceName: stsName,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
DeletionGracePeriodSeconds: ptr.To[int64](10),
|
||||
Labels: map[string]string{"app": "1234-UID"},
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/operator-last-set-hostname": "test-subnetrouter",
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
ServiceAccountName: "proxies",
|
||||
InitContainers: []corev1.Container{
|
||||
{
|
||||
Name: "sysctler",
|
||||
Image: "tailscale/tailscale",
|
||||
Command: []string{"/bin/sh"},
|
||||
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Privileged: ptr.To(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "tailscale",
|
||||
Image: "tailscale/tailscale",
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "TS_USERSPACE", Value: "false"},
|
||||
{Name: "TS_AUTH_ONCE", Value: "true"},
|
||||
{Name: "TS_KUBE_SECRET", Value: secretName},
|
||||
{Name: "TS_HOSTNAME", Value: "test-subnetrouter"},
|
||||
{Name: "TS_ROUTES", Value: routes},
|
||||
},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Capabilities: &corev1.Capabilities{
|
||||
Add: []corev1.Capability{"NET_ADMIN"},
|
||||
},
|
||||
},
|
||||
ImagePullPolicy: "Always",
|
||||
},
|
||||
},
|
||||
},
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: tsapi.ConnectorKind,
|
||||
APIVersion: "tailscale.io/v1alpha1",
|
||||
},
|
||||
Spec: tsapi.ConnectorSpec{
|
||||
SubnetRouter: &tsapi.SubnetRouter{
|
||||
AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"},
|
||||
},
|
||||
},
|
||||
}
|
||||
opts.subnetRoutes = "10.44.0.0/14"
|
||||
opts.isExitNode = false
|
||||
mustCreate(t, fc, cn)
|
||||
expectReconciled(t, cr, "", "test")
|
||||
fullName, shortName = findGenName(t, fc, "", "test", "connector")
|
||||
|
||||
opts = configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
parentType: "connector",
|
||||
shouldUseDeclarativeConfig: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
hostname: "test-connector",
|
||||
confFileHash: "57d922331890c9b1c8c6ae664394cb254334c551d9cd9db14537b5d9da9fb17e",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Add an exit node.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.ExitNode = true
|
||||
})
|
||||
opts.isExitNode = true
|
||||
opts.confFileHash = "1499b591fd97a50f0330db6ec09979792c49890cf31f5da5bb6a3f50dba1e77a"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Delete the Connector.
|
||||
if err = fc.Delete(context.Background(), cn); err != nil {
|
||||
t.Fatalf("error deleting Connector: %v", err)
|
||||
}
|
||||
|
||||
expectRequeue(t, cr, "", "test")
|
||||
expectReconciled(t, cr, "", "test")
|
||||
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
}
|
||||
|
Reference in New Issue
Block a user