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
+2
View File
@@ -71,6 +71,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
&TailnetList{},
&ProxyGroupPolicy{},
&ProxyGroupPolicyList{},
&PeerRelay{},
&PeerRelayList{},
)
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
@@ -0,0 +1,168 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// Code comments on these types should be treated as user facing documentation-
// they will appear on the PeerRelay CRD i.e. if someone runs kubectl explain peerrelay.
var PeerRelayKind = "PeerRelay"
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster,shortName=pr
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=`.status.conditions[?(@.type == "PeerRelayReady")].reason`,description="Status of the deployed PeerRelay resources."
// +kubebuilder:printcolumn:name="Endpoints",type="string",JSONPath=`.status.endpoints[*].address`,description="Public addresses the peer relay replicas are reachable on."
type PeerRelay struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
// Spec describes the desired state of the PeerRelay.
// More info:
// https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
Spec PeerRelaySpec `json:"spec"`
// Status describes the status of the PeerRelay. This is set
// and managed by the Tailscale operator.
// +optional
Status PeerRelayStatus `json:"status"`
}
// +kubebuilder:object:root=true
type PeerRelayList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata"`
Items []PeerRelay `json:"items"`
}
// +kubebuilder:validation:XValidation:rule="!has(self.aws) || !has(self.aws.elasticIPs) || self.aws.elasticIPs.size() >= self.replicas",message="spec.aws.elasticIPs must contain at least one entry per replica"
type PeerRelaySpec struct {
// Tags that the Tailscale node will be tagged with.
// Defaults to [tag:k8s].
// To autoapprove the device defined by a PeerRelay,
// you can configure Tailscale ACLs to give these tags the necessary
// permissions.
// See https://tailscale.com/kb/1337/acl-syntax#autoapprovers.
// If you specify custom tags here, you must also make the operator an owner of these tags.
// See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
// Tags cannot be changed once a PeerRelay node has been created.
// Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
// +optional
Tags Tags `json:"tags,omitempty"`
// HostnamePrefix specifies the hostname prefix for each
// replica. Each device will have the integer number
// from its StatefulSet pod appended to this prefix to form the full hostname.
// HostnamePrefix can contain lower case letters, numbers and dashes, it
// must not start with a dash and must be between 1 and 62 characters long.
// +optional
HostnamePrefix HostnamePrefix `json:"hostnamePrefix,omitzero"`
// ProxyClass is the name of the ProxyClass custom resource that
// contains configuration options that should be applied to the
// resources created for this PeerRelay. If unset, the operator will
// create resources with the default configuration.
// +optional
ProxyClass string `json:"proxyClass,omitempty"`
// Replicas specifies how many devices to create. Set this to enable
// high availability for peer relays.
// https://tailscale.com/kb/1115/high-availability. Defaults to 1.
// +optional
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=1
Replicas *int32 `json:"replicas,omitzero"`
// Tailnet specifies the tailnet this PeerRelay should join. If blank, the default tailnet is used. When set, this
// name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set.
// +optional
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="PeerRelay tailnet is immutable"
Tailnet string `json:"tailnet,omitempty"`
// Service contains configuration values to modify the LoadBalancer service used to expose the peer relay.
// +optional
Service *PeerRelayService `json:"service,omitzero"`
// AWS contains configuration for pinning each replica to a specific AWS Elastic IP and subnet. Only meaningful
// when running on EKS with the AWS Load Balancer Controller. When set, the per-replica values override any
// aws-load-balancer-eip-allocations or aws-load-balancer-subnets values supplied via spec.service.annotations.
// +optional
AWS *PeerRelayAWS `json:"aws,omitzero"`
}
type PeerRelayService struct {
// Annotations to apply to the LoadBalancer service. Any annotations that conflict with those used by known
// cloud providers to ensure IP addresses rather than DNS names are ignored.
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
}
// PeerRelayAWS contains AWS-specific configuration for a PeerRelay.
type PeerRelayAWS struct {
// ElasticIPs pins each replica to a specific AWS EIP allocation and subnet. Only meaningful when Network Load
// Balancers are provisioned by the AWS Load Balancer Controller. ElasticIPs supplies one allocation-subnet pair
// per replica: replica N uses ElasticIPs[N]. The list must be at least as long as spec.replicas so every replica
// has a distinct EIP; extra entries are permitted so that scale-up doesn't immediately trip validation.
//
// When set, the reconciler stamps
// service.beta.kubernetes.io/aws-load-balancer-eip-allocations and
// service.beta.kubernetes.io/aws-load-balancer-subnets on each per-replica Service, overriding any values in
// spec.service.annotations.
// +listType=atomic
// +kubebuilder:validation:MinItems=1
ElasticIPs []PeerRelayAWSElasticIP `json:"elasticIPs"`
}
// PeerRelayAWSElasticIP pairs an EIP allocation with the subnet in the same AZ.
type PeerRelayAWSElasticIP struct {
// AllocationID is the AWS EIP allocation ID (e.g. eipalloc-0123abcd) whose public IP this replica is reachable
// on. Stamped as service.beta.kubernetes.io/aws-load-balancer-eip-allocations on the replica's Service.
// +kubebuilder:validation:Pattern=`^eipalloc-[0-9a-f]+$`
AllocationID string `json:"allocationID"`
// SubnetID is the AWS subnet in the same availability zone as AllocationID (e.g. subnet-0123abcd). Stamped as
// service.beta.kubernetes.io/aws-load-balancer-subnets on the replica's Service so the NLB is provisioned in
// the same AZ as the EIP.
// +kubebuilder:validation:Pattern=`^subnet-[0-9a-f]+$`
SubnetID string `json:"subnetID"`
}
type PeerRelayStatus struct {
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions"`
// Endpoints lists the public address:port pairs each peer relay replica is reachable on. There is one entry
// per replica whose LoadBalancer Service has been assigned a public address; entries appear as the underlying
// cloud provisions each Service.
// +listType=map
// +listMapKey=replica
// +optional
Endpoints []PeerRelayEndpoint `json:"endpoints,omitempty"`
}
type PeerRelayEndpoint struct {
// Replica is the zero-based index of the peer relay replica this endpoint targets.
Replica int32 `json:"replica"`
// Address is the public IP or hostname the cloud has allocated for this replica's LoadBalancer Service.
// Peers reach this relay by connecting to Address:Port over UDP.
Address string `json:"address"`
// Port is the UDP port the peer relay listens on.
Port int32 `json:"port"`
}
// PeerRelayReady is set to True if the PeerRelay is available for use by operator workloads.
const PeerRelayReady ConditionType = `PeerRelayReady`
@@ -550,6 +550,199 @@ func (in *NodePortConfig) DeepCopy() *NodePortConfig {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PeerRelay) DeepCopyInto(out *PeerRelay) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PeerRelay.
func (in *PeerRelay) DeepCopy() *PeerRelay {
if in == nil {
return nil
}
out := new(PeerRelay)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PeerRelay) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PeerRelayAWS) DeepCopyInto(out *PeerRelayAWS) {
*out = *in
if in.ElasticIPs != nil {
in, out := &in.ElasticIPs, &out.ElasticIPs
*out = make([]PeerRelayAWSElasticIP, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PeerRelayAWS.
func (in *PeerRelayAWS) DeepCopy() *PeerRelayAWS {
if in == nil {
return nil
}
out := new(PeerRelayAWS)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PeerRelayAWSElasticIP) DeepCopyInto(out *PeerRelayAWSElasticIP) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PeerRelayAWSElasticIP.
func (in *PeerRelayAWSElasticIP) DeepCopy() *PeerRelayAWSElasticIP {
if in == nil {
return nil
}
out := new(PeerRelayAWSElasticIP)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PeerRelayEndpoint) DeepCopyInto(out *PeerRelayEndpoint) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PeerRelayEndpoint.
func (in *PeerRelayEndpoint) DeepCopy() *PeerRelayEndpoint {
if in == nil {
return nil
}
out := new(PeerRelayEndpoint)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PeerRelayList) DeepCopyInto(out *PeerRelayList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]PeerRelay, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PeerRelayList.
func (in *PeerRelayList) DeepCopy() *PeerRelayList {
if in == nil {
return nil
}
out := new(PeerRelayList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *PeerRelayList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PeerRelayService) DeepCopyInto(out *PeerRelayService) {
*out = *in
if in.Annotations != nil {
in, out := &in.Annotations, &out.Annotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PeerRelayService.
func (in *PeerRelayService) DeepCopy() *PeerRelayService {
if in == nil {
return nil
}
out := new(PeerRelayService)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PeerRelaySpec) DeepCopyInto(out *PeerRelaySpec) {
*out = *in
if in.Tags != nil {
in, out := &in.Tags, &out.Tags
*out = make(Tags, len(*in))
copy(*out, *in)
}
if in.Replicas != nil {
in, out := &in.Replicas, &out.Replicas
*out = new(int32)
**out = **in
}
if in.Service != nil {
in, out := &in.Service, &out.Service
*out = new(PeerRelayService)
(*in).DeepCopyInto(*out)
}
if in.AWS != nil {
in, out := &in.AWS, &out.AWS
*out = new(PeerRelayAWS)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PeerRelaySpec.
func (in *PeerRelaySpec) DeepCopy() *PeerRelaySpec {
if in == nil {
return nil
}
out := new(PeerRelaySpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PeerRelayStatus) DeepCopyInto(out *PeerRelayStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Endpoints != nil {
in, out := &in.Endpoints, &out.Endpoints
*out = make([]PeerRelayEndpoint, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PeerRelayStatus.
func (in *PeerRelayStatus) DeepCopy() *PeerRelayStatus {
if in == nil {
return nil
}
out := new(PeerRelayStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Pod) DeepCopyInto(out *Pod) {
*out = *in