cmd/k8s-operator,k8s-operator: Kubernetes Peer Relays (#20495)

This commit contains the Kubernetes implementation of peer relays via the new `PeerRelay` CRD. It's a mega branch consisting of the commits of other PRs gone into this work:

1. https://github.com/tailscale/tailscale/pull/20211
2. https://github.com/tailscale/tailscale/pull/20329
3. https://github.com/tailscale/tailscale/pull/20423
4. https://github.com/tailscale/tailscale/pull/20503

An instance of the `PeerRelay` CRD deploys a `StatefulSet` of containerboot instances configured to advertise themselves as peer relays using the IP addresses configured via `LoadBalancer` services on each cloud provider (with some AWS specifics as it's less automatic than its competing cloud providers). 

Per replica, a `LoadBalancer` type `Service` resource is provisioned and its IP address is used to configure the respective relay.

This has been tested with success in AWS, GCP & Azure and provides additional modification to `Service` resources via the CRD for any other kinds of deployment environments. It also contains some work that may appear to be duplication of what already exists within `cmd/k8s-operator` so we can start building an appropriate migration path for `Connector`, `ProxyGroup` etc into respective `k8s-operator/reconciler/*` packages.

Closes https://github.com/tailscale/corp/issues/34524
This commit is contained in:
David Bond
2026-07-20 16:37:15 +01:00
committed by GitHub
parent 2dd5d82f56
commit be0e460a20
22 changed files with 3823 additions and 1 deletions
@@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package peerrelay
import (
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
)
func (r *Reconciler) peerRelayTags(pr *tsapi.PeerRelay) []string {
tags := pr.Spec.Tags.Stringify()
if len(tags) == 0 {
return r.defaultTags
}
return tags
}
@@ -0,0 +1,66 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package peerrelay
import (
"context"
"errors"
"fmt"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
tailscaleclient "tailscale.com/client/tailscale/v2"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/k8s-operator/reconciler/tailscaled"
"tailscale.com/kube/kubetypes"
)
func (r *Reconciler) deleteDevicesFrom(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay, fromIdx int32) error {
if r.tsClients == nil {
return nil
}
tsc, err := r.tsClients.For(pr.Spec.Tailnet)
if err != nil {
return fmt.Errorf("failed to resolve Tailscale API client for tailnet %q: %w", pr.Spec.Tailnet, err)
}
labels := peerRelayLabels(pr.Name)
labels[kubetypes.LabelSecretType] = kubetypes.LabelSecretTypeState
var list corev1.SecretList
if err = r.List(ctx, &list, client.InNamespace(r.tailscaleNamespace), client.MatchingLabels(labels)); err != nil {
return fmt.Errorf("failed to list state Secrets: %w", err)
}
var errs []error
for i := range list.Items {
s := &list.Items[i]
idx, ok := replicaIndexFromLabels(s.Labels)
if !ok || idx < fromIdx {
continue
}
if deviceID := tailscaled.DeviceIDFromStateSecret(s); deviceID != "" {
logger.Debugf("deleting tailnet device %q", deviceID)
if err = tsc.Devices().Delete(ctx, deviceID); err != nil && !tailscaleclient.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to delete tailnet device %q: %w", deviceID, err))
continue
}
}
logger.Debugf("deleting state Secret %q", s.Name)
if err = r.Delete(ctx, s); err != nil && !apierrors.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to delete state Secret %q: %w", s.Name, err))
}
}
return errors.Join(errs...)
}
@@ -0,0 +1,572 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
// Package peerrelay provides reconciliation logic for the PeerRelay custom resource definition. It is responsible
// for managing the lifecycle of PeerRelay devices, including the StatefulSet and Service resources used to expose
// them.
package peerrelay
import (
"cmp"
"context"
"errors"
"fmt"
"net"
"net/netip"
"reflect"
"slices"
"sync"
"time"
"go.uber.org/zap"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
operatorutils "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/k8s-operator/reconciler"
"tailscale.com/k8s-operator/reconciler/tailscaled"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
type (
// The Reconciler type is a reconcile.TypedReconciler implementation used to manage the reconciliation of
// PeerRelay custom resources.
Reconciler struct {
client.Client
tailscaleNamespace string
proxyImage string
defaultTags []string
tsClients tailscaled.ClientProvider
resolver func(ctx context.Context, network, host string) ([]netip.Addr, error)
logger *zap.SugaredLogger
clock tstime.Clock
// Metrics related fields
mu sync.Mutex
peerRelays set.Slice[types.UID]
}
// The ReconcilerOptions type contains configuration values for the Reconciler.
ReconcilerOptions struct {
// The client for interacting with the Kubernetes API.
Client client.Client
// The namespace the operator is installed in. PeerRelay-managed resources (Services, StatefulSets, etc.)
// are created within this namespace.
TailscaleNamespace string
// ProxyImage is the container image used for the tailscaled pods that back each peer relay replica.
ProxyImage string
// DefaultTags is the tag list applied to freshly minted auth keys when a PeerRelay hasn't set its own
// spec.tags. Must be non-empty at construction time.
DefaultTags []string
// Clients resolves the Tailscale API client for a given tailnet name. Used to mint auth keys for each
// replica. Blank tailnet returns the operator's default client.
Clients tailscaled.ClientProvider
// Resolver is used to convert LoadBalancer Service hostnames to concrete IPs when the cloud
// controller doesn't populate Ingress[].IP directly (e.g. AWS NLBs). Defaults to a resolver backed by
// net.DefaultResolver when unset.
Resolver func(ctx context.Context, network string, host string) ([]netip.Addr, error)
// The logger to use for this Reconciler.
Logger *zap.SugaredLogger
// Clock is used to stamp condition transitions. Defaults to a real clock when unset.
Clock tstime.Clock
}
)
const (
reconcilerName = "peerrelay-reconciler"
fieldOwner client.FieldOwner = "peerrelay-reconciler"
)
// Constants for condition reasons.
const (
ReasonEndpointsPending = "EndpointsPending"
ReasonPodsPending = "PodsPending"
ReasonAWSConfigInvalid = "AWSConfigInvalid"
ReasonTailnetUnavailable = "TailnetUnavailable"
ReasonReady = "PeerRelayReady"
)
var (
// gaugePeerRelayResources tracks the overall number of PeerRelay resources currently managed by this operator
// instance.
gaugePeerRelayResources = clientmetric.NewGauge(kubetypes.MetricPeerRelayCount)
)
// NewReconciler returns a new instance of the Reconciler type. It watches specifically for changes to PeerRelay
// custom resources. The ReconcilerOptions can be used to modify the behaviour of the Reconciler.
func NewReconciler(options ReconcilerOptions) *Reconciler {
clock := options.Clock
if clock == nil {
clock = tstime.DefaultClock{}
}
resolver := options.Resolver
if resolver == nil {
resolver = net.DefaultResolver.LookupNetIP
}
return &Reconciler{
Client: options.Client,
tailscaleNamespace: options.TailscaleNamespace,
proxyImage: options.ProxyImage,
defaultTags: options.DefaultTags,
tsClients: options.Clients,
resolver: resolver,
logger: options.Logger.Named(reconcilerName),
clock: clock,
}
}
// Register the Reconciler onto the given manager.Manager implementation. It watches PeerRelay resources directly,
// the child resources it manages (Services, StatefulSets, Secrets) so external drift or cloud controller updates
// enqueue a reconcile for the owning PeerRelay, and ProxyClass so config changes propagate to referring
// PeerRelays.
func (r *Reconciler) Register(mgr manager.Manager) error {
enqueue := handler.EnqueueRequestsFromMapFunc(reconciler.EnqueueForChild(parentTypePeerRelay))
return builder.
ControllerManagedBy(mgr).
For(&tsapi.PeerRelay{}).
Watches(&corev1.Service{}, enqueue).
Watches(&appsv1.StatefulSet{}, enqueue).
Watches(&corev1.Secret{}, enqueue).
Watches(&tsapi.ProxyClass{}, handler.EnqueueRequestsFromMapFunc(r.enqueuePeerRelaysForProxyClass)).
Named(reconcilerName).
Complete(r)
}
func (r *Reconciler) enqueuePeerRelaysForProxyClass(ctx context.Context, o client.Object) []reconcile.Request {
pc, ok := o.(*tsapi.ProxyClass)
if !ok {
return nil
}
var list tsapi.PeerRelayList
if err := r.List(ctx, &list); err != nil {
r.logger.Errorf("failed to list PeerRelays for ProxyClass %q change: %v", pc.Name, err)
return nil
}
var reqs []reconcile.Request
for _, pr := range list.Items {
if pr.Spec.ProxyClass == pc.Name {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Name: pr.Name}})
}
}
return reqs
}
// Reconcile is invoked when a change occurs to PeerRelay resources within the cluster. On create/update, it ensures
// one LoadBalancer Service exists per replica. On delete, all managed Services are removed before the finalizer is
// released.
func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
logger := r.logger.With("PeerRelay", req.Name)
logger.Debug("starting reconcile")
defer logger.Debug("reconcile finished")
var pr tsapi.PeerRelay
err := r.Get(ctx, req.NamespacedName, &pr)
switch {
case apierrors.IsNotFound(err):
logger.Debug("PeerRelay not found, assuming it was deleted")
return reconcile.Result{}, nil
case err != nil:
return reconcile.Result{}, fmt.Errorf("failed to get PeerRelay %q: %w", req.NamespacedName, err)
}
if r.tsClients != nil {
if _, err = r.tsClients.For(pr.Spec.Tailnet); err != nil {
return r.reportTailnetUnavailable(ctx, logger, &pr, err)
}
}
if !pr.DeletionTimestamp.IsZero() {
return r.delete(ctx, logger, &pr)
}
return r.createOrUpdate(ctx, logger, &pr)
}
func (r *Reconciler) reportTailnetUnavailable(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay, tsErr error) (reconcile.Result, error) {
operatorutils.SetPeerRelayCondition(pr, tsapi.PeerRelayReady, metav1.ConditionFalse, ReasonTailnetUnavailable, tsErr.Error(), r.clock, logger)
if err := r.Status().Update(ctx, pr); err != nil {
return reconcile.Result{}, errors.Join(tsErr, fmt.Errorf("failed to update PeerRelay status: %w", err))
}
return reconcile.Result{}, tsErr
}
func (r *Reconciler) createOrUpdate(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay) (reconcile.Result, error) {
if !slices.Contains(pr.Finalizers, reconciler.FinalizerName) {
reconciler.SetFinalizer(pr)
if err := r.Update(ctx, pr); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to add finalizer to PeerRelay %q: %w", pr.Name, err)
}
}
r.mu.Lock()
if !r.peerRelays.Contains(pr.UID) {
r.peerRelays.Add(pr.UID)
logger.Infof("now managing PeerRelay %q", pr.Name)
}
r.mu.Unlock()
gaugePeerRelayResources.Set(int64(r.peerRelays.Len()))
replicas := int32(1)
if pr.Spec.Replicas != nil {
replicas = *pr.Spec.Replicas
}
// Belt-and-braces: CEL on the CRD enforces this at admission, but we also validate here to guard against older
// clusters without CEL, resources created before the CRD schema landed, or hand-edited status paths. If the user
// hasn't supplied enough EIPs for the requested replica count we refuse to touch existing state and surface the
// condition so they can fix the spec.
if pr.Spec.AWS != nil && int32(len(pr.Spec.AWS.ElasticIPs)) < replicas {
message := fmt.Sprintf("spec.aws.elasticIPs has %d entries but spec.replicas is %d", len(pr.Spec.AWS.ElasticIPs), replicas)
operatorutils.SetPeerRelayCondition(pr, tsapi.PeerRelayReady, metav1.ConditionFalse, ReasonAWSConfigInvalid, message, r.clock, logger)
if err := r.Status().Update(ctx, pr); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to update PeerRelay status for %q: %w", pr.Name, err)
}
return reconcile.Result{}, nil
}
for i := int32(0); i < replicas; i++ {
desired := r.peerRelayService(pr, i)
if err := r.ensureService(ctx, logger, desired); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to apply Service %q: %w", desired.Name, err)
}
}
// Read the LB addresses assigned by the cloud so each pod's config file can advertise its own public endpoint
// via RelayServerStaticEndpoints. On first reconcile the LBs aren't provisioned yet , endpointsByReplica ends
// up empty and the configs are written without static endpoints; the Watches-triggered reconcile that fires
// when the LB IP lands will fill them in.
endpoints, err := r.readEndpoints(ctx, logger, pr)
if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to read endpoints for PeerRelay %q: %w", pr.Name, err)
}
endpointsByReplica := make(map[int32]tsapi.PeerRelayEndpoint, len(endpoints))
for _, ep := range endpoints {
endpointsByReplica[ep.Replica] = ep
}
for i := int32(0); i < replicas; i++ {
var endpoint *tsapi.PeerRelayEndpoint
if ep, ok := endpointsByReplica[i]; ok {
endpoint = &ep
}
if err = r.ensureStateSecret(ctx, logger, pr, i); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to apply state Secret for PeerRelay %q replica %d: %w", pr.Name, i, err)
}
if err = r.ensureConfigSecret(ctx, logger, pr, i, endpoint); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to apply config Secret for PeerRelay %q replica %d: %w", pr.Name, i, err)
}
}
ss, err := r.ensureStatefulSet(ctx, logger, pr, replicas)
if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to apply StatefulSet for PeerRelay %q: %w", pr.Name, err)
}
if err = r.deleteDevicesFrom(ctx, logger, pr, replicas); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to clean up scaled-down tailnet devices for PeerRelay %q: %w", pr.Name, err)
}
if err = r.deleteServicesFrom(ctx, logger, pr, replicas); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to clean up scaled-down Services for PeerRelay %q: %w", pr.Name, err)
}
if err = r.deleteConfigSecretsFrom(ctx, logger, pr, replicas); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to clean up scaled-down config Secrets for PeerRelay %q: %w", pr.Name, err)
}
if err = r.writeStatus(ctx, logger, pr, endpoints, replicas, ss); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to update PeerRelay status for %q: %w", pr.Name, err)
}
if !peerRelayReady(pr) {
return reconcile.Result{RequeueAfter: 30 * time.Second}, nil
}
return reconcile.Result{}, nil
}
func peerRelayReady(pr *tsapi.PeerRelay) bool {
for _, c := range pr.Status.Conditions {
if c.Type == string(tsapi.PeerRelayReady) {
return c.Status == metav1.ConditionTrue
}
}
return false
}
func (r *Reconciler) readEndpoints(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay) ([]tsapi.PeerRelayEndpoint, error) {
var list corev1.ServiceList
if err := r.List(ctx, &list, client.InNamespace(r.tailscaleNamespace), client.MatchingLabels(peerRelayLabels(pr.Name))); err != nil {
return nil, fmt.Errorf("failed to list Services: %w", err)
}
prevByReplica := make(map[int32]tsapi.PeerRelayEndpoint, len(pr.Status.Endpoints))
for _, ep := range pr.Status.Endpoints {
prevByReplica[ep.Replica] = ep
}
var endpoints []tsapi.PeerRelayEndpoint
for i := range list.Items {
svc := &list.Items[i]
var prev *tsapi.PeerRelayEndpoint
if idx, ok := replicaIndexFromLabels(svc.Labels); ok {
if ep, ok := prevByReplica[idx]; ok {
prev = &ep
}
}
if endpoint := r.peerRelayEndpoint(ctx, logger, svc, prev); endpoint != nil {
endpoints = append(endpoints, *endpoint)
}
}
slices.SortFunc(endpoints, func(a, b tsapi.PeerRelayEndpoint) int {
return cmp.Compare(a.Replica, b.Replica)
})
return endpoints, nil
}
func (r *Reconciler) writeStatus(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay, endpoints []tsapi.PeerRelayEndpoint, replicas int32, ss *appsv1.StatefulSet) error {
prevStatus := pr.Status.DeepCopy()
pr.Status.Endpoints = endpoints
var readyReplicas int32
if ss != nil {
readyReplicas = ss.Status.ReadyReplicas
}
switch {
case int32(len(endpoints)) < replicas:
message := fmt.Sprintf("%d of %d replicas have a public IP", len(endpoints), replicas)
operatorutils.SetPeerRelayCondition(pr, tsapi.PeerRelayReady, metav1.ConditionFalse, ReasonEndpointsPending, message, r.clock, logger)
case readyReplicas < replicas:
message := fmt.Sprintf("%d of %d pods are ready", readyReplicas, replicas)
operatorutils.SetPeerRelayCondition(pr, tsapi.PeerRelayReady, metav1.ConditionFalse, ReasonPodsPending, message, r.clock, logger)
default:
operatorutils.SetPeerRelayCondition(pr, tsapi.PeerRelayReady, metav1.ConditionTrue, ReasonReady, ReasonReady, r.clock, logger)
}
if reflect.DeepEqual(prevStatus, &pr.Status) {
return nil
}
if err := r.Status().Update(ctx, pr); err != nil {
return fmt.Errorf("failed to update PeerRelay status: %w", err)
}
return nil
}
func (r *Reconciler) delete(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay) (reconcile.Result, error) {
logger.Infof("deleting PeerRelay %q", pr.Name)
if err := r.deleteDevicesFrom(ctx, logger, pr, 0); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to delete tailnet devices for PeerRelay %q: %w", pr.Name, err)
}
if err := r.deleteStatefulSet(ctx, logger, pr); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to delete StatefulSet for PeerRelay %q: %w", pr.Name, err)
}
if err := r.deleteConfigSecretsFrom(ctx, logger, pr, 0); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to delete config Secrets for PeerRelay %q: %w", pr.Name, err)
}
if err := r.deleteServicesFrom(ctx, logger, pr, 0); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to delete Services for PeerRelay %q: %w", pr.Name, err)
}
reconciler.RemoveFinalizer(pr)
if err := r.Update(ctx, pr); err != nil {
return reconcile.Result{}, fmt.Errorf("failed to remove finalizer from PeerRelay %q: %w", pr.Name, err)
}
r.mu.Lock()
r.peerRelays.Remove(pr.UID)
r.mu.Unlock()
gaugePeerRelayResources.Set(int64(r.peerRelays.Len()))
return reconcile.Result{}, nil
}
func (r *Reconciler) ensureService(ctx context.Context, logger *zap.SugaredLogger, desired *corev1.Service) error {
logger.Debugf("applying Service %q", desired.Name)
if err := r.Patch(ctx, desired, client.Apply, fieldOwner, client.ForceOwnership); err != nil {
return fmt.Errorf("failed to apply Service: %w", err)
}
return nil
}
func (r *Reconciler) deleteServicesFrom(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay, fromIdx int32) error {
var list corev1.ServiceList
if err := r.List(ctx, &list, client.InNamespace(r.tailscaleNamespace), client.MatchingLabels(peerRelayLabels(pr.Name))); err != nil {
return fmt.Errorf("failed to list Services: %w", err)
}
for i := range list.Items {
svc := &list.Items[i]
idx, ok := replicaIndexFromLabels(svc.Labels)
if !ok || idx < fromIdx {
continue
}
logger.Debugf("deleting Service %q", svc.Name)
if err := r.Delete(ctx, svc); err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("failed to delete Service %q: %w", svc.Name, err)
}
}
return nil
}
func (r *Reconciler) ensureConfigSecret(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay, idx int32, endpoint *tsapi.PeerRelayEndpoint) error {
authKey, err := r.reuseOrMintAuthKey(ctx, pr, idx)
if err != nil {
return err
}
desired, err := r.peerRelayConfigSecret(pr, idx, endpoint, authKey)
if err != nil {
return fmt.Errorf("failed to build config Secret: %w", err)
}
logger.Debugf("applying config Secret %q", desired.Name)
if err = r.Patch(ctx, desired, client.Apply, fieldOwner, client.ForceOwnership); err != nil {
return fmt.Errorf("failed to apply config Secret: %w", err)
}
return nil
}
func (r *Reconciler) reuseOrMintAuthKey(ctx context.Context, pr *tsapi.PeerRelay, idx int32) (*string, error) {
var existing corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: r.tailscaleNamespace, Name: configSecretName(pr.Name, idx)}, &existing)
switch {
case apierrors.IsNotFound(err):
key, err := r.mintAuthKey(ctx, pr)
if err != nil {
return nil, err
}
return &key, nil
case err != nil:
return nil, fmt.Errorf("failed to get config Secret: %w", err)
}
if existingKey := tailscaled.AuthKeyFromConfigSecret(&existing); existingKey != nil {
return existingKey, nil
}
key, err := r.mintAuthKey(ctx, pr)
if err != nil {
return nil, err
}
return &key, nil
}
func (r *Reconciler) mintAuthKey(ctx context.Context, pr *tsapi.PeerRelay) (string, error) {
client, err := r.tsClients.For(pr.Spec.Tailnet)
if err != nil {
return "", fmt.Errorf("failed to resolve Tailscale API client for tailnet %q: %w", pr.Spec.Tailnet, err)
}
return tailscaled.NewAuthKey(ctx, client, r.peerRelayTags(pr))
}
func (r *Reconciler) ensureStateSecret(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay, idx int32) error {
desired := tailscaled.NewStateSecret(tailscaled.StateSecretOptions{
Name: replicaName(pr.Name, idx),
Namespace: r.tailscaleNamespace,
Labels: peerRelayServiceLabels(pr.Name, idx),
})
logger.Debugf("applying state Secret %q", desired.Name)
if err := r.Patch(ctx, desired, client.Apply, fieldOwner, client.ForceOwnership); err != nil {
return fmt.Errorf("failed to apply state Secret: %w", err)
}
return nil
}
func (r *Reconciler) deleteConfigSecretsFrom(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay, fromIdx int32) error {
labels := peerRelayLabels(pr.Name)
labels[kubetypes.LabelSecretType] = kubetypes.LabelSecretTypeConfig
var list corev1.SecretList
if err := r.List(ctx, &list, client.InNamespace(r.tailscaleNamespace), client.MatchingLabels(labels)); err != nil {
return fmt.Errorf("failed to list config Secrets: %w", err)
}
for i := range list.Items {
secret := &list.Items[i]
idx, ok := replicaIndexFromLabels(secret.Labels)
if !ok || idx < fromIdx {
continue
}
logger.Debugf("deleting config Secret %q", secret.Name)
if err := r.Delete(ctx, secret); err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("failed to delete config Secret %q: %w", secret.Name, err)
}
}
return nil
}
func (r *Reconciler) ensureStatefulSet(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay, replicas int32) (*appsv1.StatefulSet, error) {
pc, err := r.getProxyClass(ctx, pr)
if err != nil {
return nil, err
}
desired := r.peerRelayStatefulSet(pr, replicas, pc)
logger.Debugf("applying StatefulSet %q", desired.Name)
if err = r.Patch(ctx, desired, client.Apply, fieldOwner, client.ForceOwnership); err != nil {
return nil, fmt.Errorf("failed to apply StatefulSet: %w", err)
}
var current appsv1.StatefulSet
if err = r.Get(ctx, types.NamespacedName{Namespace: desired.Namespace, Name: desired.Name}, &current); err != nil {
return nil, fmt.Errorf("failed to get StatefulSet: %w", err)
}
return &current, nil
}
func (r *Reconciler) deleteStatefulSet(ctx context.Context, logger *zap.SugaredLogger, pr *tsapi.PeerRelay) error {
ss := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{Name: resourceName(pr.Name), Namespace: r.tailscaleNamespace},
}
logger.Debugf("deleting StatefulSet %q", ss.Name)
if err := r.Delete(ctx, ss); err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("failed to delete StatefulSet: %w", err)
}
return nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,189 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package peerrelay
import (
"context"
"fmt"
"maps"
"net/netip"
"slices"
"strconv"
"time"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/k8s-operator/reconciler"
)
const (
// labelReplicaIndex stores the replica index of a managed Service so it can be matched back to a specific
// peer relay instance.
labelReplicaIndex = "tailscale.com/peer-relay-replica"
// parentTypePeerRelay is the value used for reconciler.LabelParentType on PeerRelay-managed resources.
parentTypePeerRelay = "peerrelay"
// servicePortName names the UDP port exposed by each Service. Mostly cosmetic, but Kubernetes requires a name
// once a Service has more than one port; using a stable name keeps the door open for that.
servicePortName = "peerrelay"
// servicePort is the UDP port that each peer relay container will listen on and that the LoadBalancer Service
// exposes externally.
servicePort = 41641
annotationEIPAllocations = "service.beta.kubernetes.io/aws-load-balancer-eip-allocations"
annotationSubnets = "service.beta.kubernetes.io/aws-load-balancer-subnets"
)
// cloudAnnotations are the cloud-provider-specific annotations applied to every generated LoadBalancer Service to
// ensure the Service is provisioned with a publicly addressable IP rather than a DNS name.
var cloudAnnotations = map[string]string{
// AWS: provision an internet-facing NLB in IP target mode via the AWS Load Balancer Controller.
"service.beta.kubernetes.io/aws-load-balancer-type": "external",
"service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip",
"service.beta.kubernetes.io/aws-load-balancer-scheme": "internet-facing",
"service.beta.kubernetes.io/aws-load-balancer-ip-address-type": "ipv4",
// Azure: pin the LB to external.
"service.beta.kubernetes.io/azure-load-balancer-internal": "false",
}
func peerRelayLabels(prName string) map[string]string {
return reconciler.Labels(parentTypePeerRelay, prName, "")
}
func peerRelayServiceLabels(prName string, idx int32) map[string]string {
labels := peerRelayLabels(prName)
labels[labelReplicaIndex] = strconv.FormatInt(int64(idx), 10)
return labels
}
func resourceName(prName string) string {
return "peerrelay-" + prName
}
func replicaName(prName string, idx int32) string {
return fmt.Sprintf("%s-%d", resourceName(prName), idx)
}
func peerRelayServiceAnnotations(pr *tsapi.PeerRelay, idx int32) map[string]string {
annotations := make(map[string]string, len(cloudAnnotations))
if pr.Spec.Service != nil {
maps.Copy(annotations, pr.Spec.Service.Annotations)
}
maps.Copy(annotations, cloudAnnotations)
// Per-replica AWS pinning always wins over anything in spec.service.annotations or the cloud defaults so users
// can rely on spec.aws.elasticIPs being the single source of truth for each replica's EIP + subnet.
if pr.Spec.AWS != nil && int(idx) < len(pr.Spec.AWS.ElasticIPs) {
eip := pr.Spec.AWS.ElasticIPs[idx]
annotations[annotationEIPAllocations] = eip.AllocationID
annotations[annotationSubnets] = eip.SubnetID
}
return annotations
}
func (r *Reconciler) peerRelayService(pr *tsapi.PeerRelay, idx int32) *corev1.Service {
name := replicaName(pr.Name, idx)
return &corev1.Service{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Service",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: r.tailscaleNamespace,
Labels: peerRelayServiceLabels(pr.Name, idx),
Annotations: peerRelayServiceAnnotations(pr, idx),
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeLoadBalancer,
// The Service targets the specific StatefulSet pod for this replica. The StatefulSet controller
// automatically sets this label on each pod.
Selector: map[string]string{
"statefulset.kubernetes.io/pod-name": name,
},
Ports: []corev1.ServicePort{
{
Name: servicePortName,
Protocol: corev1.ProtocolUDP,
Port: servicePort,
TargetPort: intstr.FromInt32(servicePort),
},
},
},
}
}
func replicaIndexFromLabels(labels map[string]string) (int32, bool) {
raw, ok := labels[labelReplicaIndex]
if !ok {
return 0, false
}
n, err := strconv.ParseInt(raw, 10, 32)
if err != nil {
return 0, false
}
return int32(n), true
}
func (r *Reconciler) peerRelayEndpoint(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service, prev *tsapi.PeerRelayEndpoint) *tsapi.PeerRelayEndpoint {
idx, ok := replicaIndexFromLabels(svc.Labels)
if !ok {
return nil
}
for _, ing := range svc.Status.LoadBalancer.Ingress {
if ing.IP != "" {
return &tsapi.PeerRelayEndpoint{Replica: idx, Address: ing.IP, Port: servicePort}
}
}
// Just return nil if we're not dealing with AWS fun.
if _, ok = svc.Annotations[annotationEIPAllocations]; !ok {
return nil
}
// If we were not able to obtain an IP address, we fall back to an IPv4 lookup. This is specifically for the case
// of AWS where NLB-backed Service resources are only ever given hostnames. We expect users to also provide
// an annotation with their elastic IP allocations so that there is only ever 1 IP address behind the hostname, so
// we perform a lookup so that the user doesn't also need to provide that IP address.
for _, ing := range svc.Status.LoadBalancer.Ingress {
if ing.Hostname == "" {
continue
}
resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
addrs, err := r.resolver(resolveCtx, "ip4", ing.Hostname)
if err != nil || len(addrs) == 0 {
logger.Warnf("failed to resolve LoadBalancer hostname %q for Service %q: %v", ing.Hostname, svc.Name, err)
// Preserve the previously-known endpoint (if any) so that a failure here doesn't erase status.endpoints.
if prev != nil {
return prev
}
continue
}
slices.SortFunc(addrs, netip.Addr.Compare)
return &tsapi.PeerRelayEndpoint{Replica: idx, Address: addrs[0].String(), Port: servicePort}
}
return nil
}
@@ -0,0 +1,101 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package peerrelay
import (
"context"
"fmt"
"net/netip"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"tailscale.com/ipn"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/k8s-operator/reconciler"
"tailscale.com/k8s-operator/reconciler/tailscaled"
"tailscale.com/kube/kubetypes"
)
func configSecretName(prName string, idx int32) string {
return replicaName(prName, idx) + "-config"
}
func peerRelayHostname(pr *tsapi.PeerRelay, idx int32) string {
prefix := string(pr.Spec.HostnamePrefix)
if prefix == "" {
prefix = pr.Name
}
return fmt.Sprintf("%s-%d", prefix, idx)
}
func peerRelayTailscaledConfig(pr *tsapi.PeerRelay, idx int32, endpoint *tsapi.PeerRelayEndpoint, authKey *string) ipn.ConfigVAlpha {
conf := ipn.ConfigVAlpha{
Version: "alpha0",
AcceptDNS: "false",
AcceptRoutes: "false",
Locked: "false",
Hostname: new(peerRelayHostname(pr, idx)),
RelayServerPort: new(uint16(servicePort)),
AuthKey: authKey,
}
if endpoint != nil {
if addr, err := netip.ParseAddr(endpoint.Address); err == nil {
conf.RelayServerStaticEndpoints = []netip.AddrPort{
netip.AddrPortFrom(addr, uint16(endpoint.Port)),
}
}
}
return conf
}
func (r *Reconciler) peerRelayConfigSecret(pr *tsapi.PeerRelay, idx int32, endpoint *tsapi.PeerRelayEndpoint, authKey *string) (*corev1.Secret, error) {
labels := peerRelayServiceLabels(pr.Name, idx)
return tailscaled.NewConfigSecret(tailscaled.ConfigSecretOptions{
Name: configSecretName(pr.Name, idx),
Namespace: r.tailscaleNamespace,
Labels: labels,
Config: peerRelayTailscaledConfig(pr, idx, endpoint, authKey),
})
}
func (r *Reconciler) peerRelayStatefulSet(pr *tsapi.PeerRelay, replicas int32, pc *tsapi.ProxyClass) *appsv1.StatefulSet {
labels := peerRelayLabels(pr.Name)
ss := tailscaled.NewStatefulSet(tailscaled.StatefulSetOptions{
Name: resourceName(pr.Name),
Namespace: r.tailscaleNamespace,
Labels: labels,
Image: r.proxyImage,
Replicas: replicas,
ServiceAccountName: "proxies",
ConfigSecretNameFunc: func(idx int32) string {
return configSecretName(pr.Name, idx)
},
})
return tailscaled.ApplyProxyClass(ss, pc, managedLabelKeys, nil)
}
var managedLabelKeys = []string{
kubetypes.LabelManaged,
reconciler.LabelParentType,
reconciler.LabelParentName,
}
func (r *Reconciler) getProxyClass(ctx context.Context, pr *tsapi.PeerRelay) (*tsapi.ProxyClass, error) {
if pr.Spec.ProxyClass == "" {
return nil, nil
}
var pc tsapi.ProxyClass
if err := r.Get(ctx, types.NamespacedName{Name: pr.Spec.ProxyClass}, &pc); err != nil {
return nil, fmt.Errorf("failed to get ProxyClass %q: %w", pr.Spec.ProxyClass, err)
}
return &pc, nil
}
+56
View File
@@ -8,14 +8,32 @@
package reconciler
import (
"context"
"slices"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"tailscale.com/kube/kubetypes"
)
const (
// FinalizerName is the common finalizer used across all Tailscale Kubernetes resources.
FinalizerName = "tailscale.com/finalizer"
// LabelParentType identifies which Tailscale CRD kind owns a managed resource. Every resource that a Tailscale
// CRD reconciler creates should carry this label alongside LabelParentName.
LabelParentType = "tailscale.com/parent-resource-type"
// LabelParentName identifies the name of the Tailscale CRD that owns a managed resource. Combined with
// LabelParentType, this uniquely identifies the parent within its scope.
LabelParentName = "tailscale.com/parent-resource"
// LabelParentNamespace identifies the namespace of the owning Tailscale CRD. It is only stamped when the parent
// CRD is namespaced; cluster-scoped parents omit it.
LabelParentNamespace = "tailscale.com/parent-resource-ns"
)
// SetFinalizer adds the finalizer to the resource if not already present.
@@ -37,3 +55,41 @@ func RemoveFinalizer(obj client.Object) {
finalizers := obj.GetFinalizers()
obj.SetFinalizers(append(finalizers[:idx], finalizers[idx+1:]...))
}
// Labels returns the standard ownership labels stamped on every resource a Tailscale CRD reconciler creates:
// tailscale.com/managed=true, plus LabelParentType and LabelParentName. If parentNamespace is non-empty (i.e. the
// parent CRD is namespaced) it is stamped as LabelParentNamespace; cluster-scoped parents pass "".
func Labels(parentType, parentName, parentNamespace string) map[string]string {
labels := map[string]string{
kubetypes.LabelManaged: "true",
LabelParentType: parentType,
LabelParentName: parentName,
}
if parentNamespace != "" {
labels[LabelParentNamespace] = parentNamespace
}
return labels
}
// EnqueueForChild returns a handler.MapFunc that enqueues a reconcile.Request for the parent CRD of a managed child
// resource. It filters by tailscale.com/managed=true and LabelParentType, and derives the request's NamespacedName
// from LabelParentName plus LabelParentNamespace (blank namespace for cluster-scoped parents). Use it on Watches of
// child resources so drift or cloud-controller updates propagate to the owning CRD's reconciler.
func EnqueueForChild(parentType string) handler.MapFunc {
return func(_ context.Context, o client.Object) []reconcile.Request {
labels := o.GetLabels()
if labels[kubetypes.LabelManaged] != "true" || labels[LabelParentType] != parentType {
return nil
}
name := labels[LabelParentName]
if name == "" {
return nil
}
return []reconcile.Request{{NamespacedName: types.NamespacedName{
Name: name,
Namespace: labels[LabelParentNamespace],
}}}
}
}
@@ -6,11 +6,14 @@
package reconciler_test
import (
"maps"
"slices"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile"
"tailscale.com/k8s-operator/reconciler"
)
@@ -40,3 +43,99 @@ func TestFinalizers(t *testing.T) {
t.Fatalf("object still has finalizer %q: %v", reconciler.FinalizerName, object.Finalizers)
}
}
func TestLabels(t *testing.T) {
t.Parallel()
t.Run("cluster-scoped-parent", func(t *testing.T) {
got := reconciler.Labels("peerrelay", "test", "")
want := map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource-type": "peerrelay",
"tailscale.com/parent-resource": "test",
}
if !maps.Equal(got, want) {
t.Errorf("expected %v, got %v", want, got)
}
})
t.Run("namespaced-parent", func(t *testing.T) {
got := reconciler.Labels("connector", "test", "kube-system")
want := map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource-type": "connector",
"tailscale.com/parent-resource": "test",
"tailscale.com/parent-resource-ns": "kube-system",
}
if !maps.Equal(got, want) {
t.Errorf("expected %v, got %v", want, got)
}
})
}
func TestEnqueueForChild(t *testing.T) {
t.Parallel()
enqueue := reconciler.EnqueueForChild("peerrelay")
tests := []struct {
name string
labels map[string]string
want []ctrlreconcile.Request
}{
{
name: "matching-cluster-scoped",
labels: map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource-type": "peerrelay",
"tailscale.com/parent-resource": "test",
},
want: []ctrlreconcile.Request{{NamespacedName: types.NamespacedName{Name: "test"}}},
},
{
name: "matching-namespaced",
labels: map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource-type": "peerrelay",
"tailscale.com/parent-resource": "test",
"tailscale.com/parent-resource-ns": "kube-system",
},
want: []ctrlreconcile.Request{{NamespacedName: types.NamespacedName{Name: "test", Namespace: "kube-system"}}},
},
{
name: "not-managed",
labels: map[string]string{
"tailscale.com/parent-resource-type": "peerrelay",
"tailscale.com/parent-resource": "test",
},
},
{
name: "wrong-parent-type",
labels: map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource-type": "proxygroup",
"tailscale.com/parent-resource": "test",
},
},
{
name: "missing-parent-name",
labels: map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource-type": "peerrelay",
},
},
{
name: "no-labels",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: tc.labels}}
got := enqueue(t.Context(), obj)
if !slices.Equal(got, tc.want) {
t.Errorf("expected %v, got %v", tc.want, got)
}
})
}
}
@@ -0,0 +1,56 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package tailscaled
import (
"context"
"encoding/json"
"fmt"
corev1 "k8s.io/api/core/v1"
tailscaleclient "tailscale.com/client/tailscale/v2"
"tailscale.com/ipn"
"tailscale.com/k8s-operator/tsclient"
)
// ClientProvider returns a Tailscale API client for the given tailnet name. A blank name should return the
// operator's default client.
type ClientProvider interface {
For(tailnet string) (tsclient.Client, error)
}
// NewAuthKey mints a single-use, preauthorized tailnet auth key with the given tags. The key is intended for one
// tailscaled pod to consume on first startup; callers should not persist or share it.
func NewAuthKey(ctx context.Context, client tsclient.Client, tags []string) (string, error) {
var caps tailscaleclient.KeyCapabilities
caps.Devices.Create.Reusable = false
caps.Devices.Create.Preauthorized = true
caps.Devices.Create.Tags = tags
key, err := client.Keys().CreateAuthKey(ctx, tailscaleclient.CreateKeyRequest{Capabilities: caps})
if err != nil {
return "", fmt.Errorf("failed to create auth key: %w", err)
}
return key.Key, nil
}
// AuthKeyFromConfigSecret returns the auth key embedded in the tailscaled config file stored in secret, or nil if
// none is set. secret is expected to be a Secret produced by NewConfigSecret. The Data map may contain multiple
// versioned config files (cap-<n>.hujson); the first one to parse successfully and yield a non-empty AuthKey wins.
func AuthKeyFromConfigSecret(secret *corev1.Secret) *string {
for _, body := range secret.Data {
var conf ipn.ConfigVAlpha
if err := json.Unmarshal(body, &conf); err != nil {
continue
}
if conf.AuthKey != nil && *conf.AuthKey != "" {
return conf.AuthKey
}
}
return nil
}
@@ -0,0 +1,115 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package tailscaled
import (
"slices"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
)
// ApplyProxyClass overlays the settings in pc onto ss. It's the generic slice of ProxyClass application used by
// any reconciler that produces a tailscaled StatefulSet (peer relay, connector, proxy group, etc.).
func ApplyProxyClass(ss *appsv1.StatefulSet, pc *tsapi.ProxyClass, managedLabels, managedAnnotations []string) *appsv1.StatefulSet {
if pc == nil || ss == nil || pc.Spec.StatefulSet == nil {
return ss
}
if wantsLabels := pc.Spec.StatefulSet.Labels.Parse(); len(wantsLabels) > 0 {
ss.ObjectMeta.Labels = mergeProtected(ss.ObjectMeta.Labels, wantsLabels, managedLabels)
}
if wantsAnnots := pc.Spec.StatefulSet.Annotations; len(wantsAnnots) > 0 {
ss.ObjectMeta.Annotations = mergeProtected(ss.ObjectMeta.Annotations, wantsAnnots, managedAnnotations)
}
if pc.Spec.StatefulSet.Pod == nil {
return ss
}
wantsPod := pc.Spec.StatefulSet.Pod
if wantsPodLabels := wantsPod.Labels.Parse(); len(wantsPodLabels) > 0 {
ss.Spec.Template.ObjectMeta.Labels = mergeProtected(ss.Spec.Template.ObjectMeta.Labels, wantsPodLabels, managedLabels)
}
if wantsPodAnnots := wantsPod.Annotations; len(wantsPodAnnots) > 0 {
ss.Spec.Template.ObjectMeta.Annotations = mergeProtected(ss.Spec.Template.ObjectMeta.Annotations, wantsPodAnnots, managedAnnotations)
}
ss.Spec.Template.Spec.SecurityContext = wantsPod.SecurityContext
ss.Spec.Template.Spec.ImagePullSecrets = wantsPod.ImagePullSecrets
ss.Spec.Template.Spec.NodeName = wantsPod.NodeName
ss.Spec.Template.Spec.NodeSelector = wantsPod.NodeSelector
ss.Spec.Template.Spec.Affinity = wantsPod.Affinity
ss.Spec.Template.Spec.Tolerations = wantsPod.Tolerations
ss.Spec.Template.Spec.PriorityClassName = wantsPod.PriorityClassName
ss.Spec.Template.Spec.TopologySpreadConstraints = wantsPod.TopologySpreadConstraints
if wantsPod.DNSPolicy != nil {
ss.Spec.Template.Spec.DNSPolicy = *wantsPod.DNSPolicy
}
if wantsPod.DNSConfig != nil {
ss.Spec.Template.Spec.DNSConfig = wantsPod.DNSConfig
}
if wantsPod.TailscaleContainer != nil {
for i := range ss.Spec.Template.Spec.Containers {
c := &ss.Spec.Template.Spec.Containers[i]
if c.Name != containerName {
continue
}
applyContainerOverlay(c, wantsPod.TailscaleContainer)
break
}
}
return ss
}
func mergeProtected(current, custom map[string]string, protected []string) map[string]string {
if custom == nil {
custom = make(map[string]string)
}
for k, v := range current {
if slices.Contains(protected, k) {
custom[k] = v
}
}
return custom
}
func applyContainerOverlay(c *corev1.Container, overlay *tsapi.Container) {
if overlay.SecurityContext != nil {
c.SecurityContext = overlay.SecurityContext
}
if len(overlay.Resources.Requests) > 0 {
c.Resources.Requests = overlay.Resources.Requests
}
if len(overlay.Resources.Limits) > 0 {
c.Resources.Limits = overlay.Resources.Limits
}
for _, e := range overlay.Env {
// Env vars added by ProxyClass are appended; Kubernetes uses the last entry for a duplicate name, so this
// lets the user override anything we set (e.g. TS_USERSPACE) without us having to know the full list.
c.Env = append(c.Env, corev1.EnvVar{Name: string(e.Name), Value: e.Value})
}
if overlay.Image != "" {
c.Image = overlay.Image
}
if overlay.ImagePullPolicy != "" {
c.ImagePullPolicy = overlay.ImagePullPolicy
}
}
@@ -0,0 +1,223 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
// Package tailscaled provides shared building blocks for operator reconcilers that manage StatefulSets running
// tailscaled pods (peer relays, connectors, proxy groups, etc). Callers describe the workload via StatefulSetOptions
// / ConfigSecretOptions and this package returns fully-populated *appsv1.StatefulSet and *corev1.Secret objects
// wired up the same way across the codebase: config-file-driven tailscaled started from a per-replica Secret
// mounted at /etc/tsconfig/<pod-name>.
package tailscaled
import (
"encoding/json"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"tailscale.com/ipn"
tsoperator "tailscale.com/k8s-operator"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
)
const (
// ConfigVolumeMountPath is the base directory tailscaled reads config files from. Each pod's config lives at
// <ConfigVolumeMountPath>/<POD_NAME>/cap-<version>.hujson.
ConfigVolumeMountPath = "/etc/tsconfig"
// ConfigDirEnvVar is the env var containerboot reads to find the config file directory. It is templated with
// $(POD_NAME) so each replica picks its own directory at runtime.
ConfigDirEnvVar = "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR"
// containerName is the single container inside each pod that runs tailscaled.
containerName = "tailscaled"
)
// StatefulSetOptions describes a StatefulSet of tailscaled pods. The zero value is not valid , Name, Namespace,
// Image, Labels, and ConfigSecretNameFunc must be set.
type StatefulSetOptions struct {
// Name is the StatefulSet's metadata name; pods will be named <Name>-<ordinal>.
Name string
// Namespace is the namespace the StatefulSet lives in.
Namespace string
// Labels are applied to the StatefulSet, its pod template, and used as the label selector. Callers must
// include enough labels to uniquely identify the workload , typically at least tailscale.com/parent-resource
// and tailscale.com/parent-resource-type.
Labels map[string]string
// Image is the tailscale container image used for every pod.
Image string
// Replicas is the desired number of pods.
Replicas int32
// ServiceAccountName is the ServiceAccount used by every pod. Must have get/create/patch/update permission on
// the per-pod state Secret named after each pod (containerboot's TS_KUBE_SECRET). Defaults to "default" when
// unset, which is unlikely to have the needed RBAC.
ServiceAccountName string
// ConfigSecretNameFunc returns the name of the config Secret containing tailscaled config for the given
// replica ordinal. Its output is used to build a per-replica volume and mount into the pod at
// <ConfigVolumeMountPath>/<Name>-<ordinal>.
ConfigSecretNameFunc func(idx int32) string
}
// NewStatefulSet returns a *appsv1.StatefulSet configured to run tailscaled from per-replica config Secrets.
// The caller is responsible for setting resource requests/limits, ProxyClass overrides, etc. after the fact.
func NewStatefulSet(opts StatefulSetOptions) *appsv1.StatefulSet {
volumes := make([]corev1.Volume, 0, opts.Replicas)
mounts := make([]corev1.VolumeMount, 0, opts.Replicas)
for i := int32(0); i < opts.Replicas; i++ {
volName := fmt.Sprintf("tailscaledconfig-%d", i)
volumes = append(volumes, corev1.Volume{
Name: volName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{SecretName: opts.ConfigSecretNameFunc(i)},
},
})
mounts = append(mounts, corev1.VolumeMount{
Name: volName,
ReadOnly: true,
MountPath: fmt.Sprintf("%s/%s-%d", ConfigVolumeMountPath, opts.Name, i),
})
}
return &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
APIVersion: "apps/v1",
Kind: "StatefulSet",
},
ObjectMeta: metav1.ObjectMeta{
Name: opts.Name,
Namespace: opts.Namespace,
Labels: opts.Labels,
},
Spec: appsv1.StatefulSetSpec{
Replicas: &opts.Replicas,
ServiceName: opts.Name,
Selector: &metav1.LabelSelector{MatchLabels: opts.Labels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: opts.Labels},
Spec: corev1.PodSpec{
ServiceAccountName: opts.ServiceAccountName,
Volumes: volumes,
Containers: []corev1.Container{{
Name: containerName,
Image: opts.Image,
VolumeMounts: mounts,
Env: []corev1.EnvVar{
{
Name: "POD_NAME",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"},
},
},
{
// containerboot picks up the config file matching its own capability version from
// this directory.
Name: ConfigDirEnvVar,
Value: fmt.Sprintf("%s/$(POD_NAME)", ConfigVolumeMountPath),
},
{
// tailscaled persists device/machine keys in this Secret so a pod restart doesn't
// force reauth. Naming it after the pod gives each replica its own state.
Name: "TS_KUBE_SECRET",
Value: "$(POD_NAME)",
},
},
}},
},
},
},
}
}
// ConfigSecretOptions describes a single-replica tailscaled config Secret. Name, Namespace, and Config must be
// set. If CapVersion is 0, tailcfg.CurrentCapabilityVersion is used.
type ConfigSecretOptions struct {
Name string
Namespace string
Labels map[string]string
CapVersion tailcfg.CapabilityVersion
Config ipn.ConfigVAlpha
}
// NewConfigSecret marshals opts.Config into JSON and returns a *corev1.Secret with the file keyed by
// tsoperator.TailscaledConfigFileName(opts.CapVersion). The tailscale.com/secret-type=config label is stamped on
// automatically alongside any caller-provided labels.
func NewConfigSecret(opts ConfigSecretOptions) (*corev1.Secret, error) {
cap := opts.CapVersion
if cap == 0 {
cap = tailcfg.CurrentCapabilityVersion
}
body, err := json.Marshal(opts.Config)
if err != nil {
return nil, fmt.Errorf("failed to marshal tailscaled config: %w", err)
}
labels := make(map[string]string, len(opts.Labels)+1)
for k, v := range opts.Labels {
labels[k] = v
}
labels[kubetypes.LabelSecretType] = kubetypes.LabelSecretTypeConfig
return &corev1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Name: opts.Name,
Namespace: opts.Namespace,
Labels: labels,
},
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(cap): body,
},
}, nil
}
// StateSecretOptions describes a per-pod tailscaled state Secret. Name must match the pod name (the value
// containerboot reads from TS_KUBE_SECRET) so that tailscaled can locate it at runtime.
type StateSecretOptions struct {
Name string
Namespace string
Labels map[string]string
}
// NewStateSecret returns an empty *corev1.Secret to be pre-created for tailscaled's kube state store. Pre-creating it
// (rather than letting containerboot create it on first run) lets callers stamp ownership labels so cleanup can select
// state Secrets by label rather than by pod-name convention. The tailscale.com/secret-type=state label is stamped on
// automatically alongside any caller-provided labels; tailscaled populates the Data on first run.
func NewStateSecret(opts StateSecretOptions) *corev1.Secret {
labels := make(map[string]string, len(opts.Labels)+1)
for k, v := range opts.Labels {
labels[k] = v
}
labels[kubetypes.LabelSecretType] = kubetypes.LabelSecretTypeState
return &corev1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Name: opts.Name,
Namespace: opts.Namespace,
Labels: labels,
},
}
}
// DeviceIDFromStateSecret returns the tailnet device ID that tailscaled recorded in secret, or "" if none. secret
// should be a state Secret populated by containerboot; the device ID is the value stored under kubetypes.KeyDeviceID.
func DeviceIDFromStateSecret(secret *corev1.Secret) string {
return string(secret.Data[kubetypes.KeyDeviceID])
}