mirror of https://github.com/tailscale/tailscale/
cmd/k8s-operator, k8s-operator: support Static Endpoints on ProxyGroups (#16115)
updates: #14674 Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>pull/16328/head
parent
53f67c4396
commit
f81baa2d56
@ -0,0 +1,203 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
k8soperator "tailscale.com/k8s-operator"
|
||||||
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/kube/kubetypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tailscaledPortMax = 65535
|
||||||
|
tailscaledPortMin = 1024
|
||||||
|
testSvcName = "test-node-port-range"
|
||||||
|
|
||||||
|
invalidSvcNodePort = 777777
|
||||||
|
)
|
||||||
|
|
||||||
|
// getServicesNodePortRange is a hacky function that attempts to determine Service NodePort range by
|
||||||
|
// creating a deliberately invalid Service with a NodePort that is too large and parsing the returned
|
||||||
|
// validation error. Returns nil if unable to determine port range.
|
||||||
|
// https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport
|
||||||
|
func getServicesNodePortRange(ctx context.Context, c client.Client, tsNamespace string, logger *zap.SugaredLogger) *tsapi.PortRange {
|
||||||
|
svc := &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: testSvcName,
|
||||||
|
Namespace: tsNamespace,
|
||||||
|
Labels: map[string]string{
|
||||||
|
kubetypes.LabelManaged: "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Type: corev1.ServiceTypeNodePort,
|
||||||
|
Ports: []corev1.ServicePort{
|
||||||
|
{
|
||||||
|
Name: testSvcName,
|
||||||
|
Port: 8080,
|
||||||
|
TargetPort: intstr.FromInt32(8080),
|
||||||
|
Protocol: corev1.ProtocolUDP,
|
||||||
|
NodePort: invalidSvcNodePort,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE(ChaosInTheCRD): ideally this would be a server side dry-run but could not get it working
|
||||||
|
err := c.Create(ctx, svc)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if validPorts := getServicesNodePortRangeFromErr(err.Error()); validPorts != "" {
|
||||||
|
pr, err := parseServicesNodePortRange(validPorts)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("failed to parse NodePort range set for Kubernetes Cluster: %w", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return pr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getServicesNodePortRangeFromErr(err string) string {
|
||||||
|
reg := regexp.MustCompile(`\d{1,5}-\d{1,5}`)
|
||||||
|
matches := reg.FindAllString(err, -1)
|
||||||
|
if len(matches) != 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseServicesNodePortRange converts the `ValidPorts` string field in the Kubernetes PortAllocator error and converts it to
|
||||||
|
// PortRange
|
||||||
|
func parseServicesNodePortRange(p string) (*tsapi.PortRange, error) {
|
||||||
|
parts := strings.Split(p, "-")
|
||||||
|
s, err := strconv.ParseUint(parts[0], 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse string as uint16: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var e uint64
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
e = uint64(s)
|
||||||
|
case 2:
|
||||||
|
e, err = strconv.ParseUint(parts[1], 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse string as uint16: %w", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("failed to parse port range %q", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
portRange := &tsapi.PortRange{Port: uint16(s), EndPort: uint16(e)}
|
||||||
|
if !portRange.IsValid() {
|
||||||
|
return nil, fmt.Errorf("port range %q is not valid", portRange.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return portRange, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateNodePortRanges checks that the port range specified is valid. It also ensures that the specified ranges
|
||||||
|
// lie within the NodePort Service port range specified for the Kubernetes API Server.
|
||||||
|
func validateNodePortRanges(ctx context.Context, c client.Client, kubeRange *tsapi.PortRange, pc *tsapi.ProxyClass) error {
|
||||||
|
if pc.Spec.StaticEndpoints == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
portRanges := pc.Spec.StaticEndpoints.NodePort.Ports
|
||||||
|
|
||||||
|
if kubeRange != nil {
|
||||||
|
for _, pr := range portRanges {
|
||||||
|
if !kubeRange.Contains(pr.Port) || (pr.EndPort != 0 && !kubeRange.Contains(pr.EndPort)) {
|
||||||
|
return fmt.Errorf("range %q is not within Cluster configured range %q", pr.String(), kubeRange.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range portRanges {
|
||||||
|
if !r.IsValid() {
|
||||||
|
return fmt.Errorf("port range %q is invalid", r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(ChaosInTheCRD): if a ProxyClass that made another invalid (due to port range clash) is deleted,
|
||||||
|
// the invalid ProxyClass doesn't get reconciled on, and therefore will not go valid. We should fix this.
|
||||||
|
proxyClassRanges, err := getPortsForProxyClasses(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get port ranges for ProxyClasses: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range portRanges {
|
||||||
|
for pcName, pcr := range proxyClassRanges {
|
||||||
|
if pcName == pc.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pcr.ClashesWith(r) {
|
||||||
|
return fmt.Errorf("port ranges for ProxyClass %q clash with existing ProxyClass %q", pc.Name, pcName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(portRanges) == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(portRanges, func(i, j int) bool {
|
||||||
|
return portRanges[i].Port < portRanges[j].Port
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 1; i < len(portRanges); i++ {
|
||||||
|
prev := portRanges[i-1]
|
||||||
|
curr := portRanges[i]
|
||||||
|
if curr.Port <= prev.Port || curr.Port <= prev.EndPort {
|
||||||
|
return fmt.Errorf("overlapping ranges: %q and %q", prev.String(), curr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPortsForProxyClasses gets the port ranges for all the other existing ProxyClasses
|
||||||
|
func getPortsForProxyClasses(ctx context.Context, c client.Client) (map[string]tsapi.PortRanges, error) {
|
||||||
|
pcs := new(tsapi.ProxyClassList)
|
||||||
|
|
||||||
|
err := c.List(ctx, pcs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list ProxyClasses: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
portRanges := make(map[string]tsapi.PortRanges)
|
||||||
|
for _, i := range pcs.Items {
|
||||||
|
if !k8soperator.ProxyClassIsReady(&i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if se := i.Spec.StaticEndpoints; se != nil && se.NodePort != nil {
|
||||||
|
portRanges[i.Name] = se.NodePort.Ports
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return portRanges, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRandomPort() uint16 {
|
||||||
|
return uint16(rand.IntN(tailscaledPortMax-tailscaledPortMin+1) + tailscaledPortMin)
|
||||||
|
}
|
||||||
@ -0,0 +1,277 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/tstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetServicesNodePortRangeFromErr(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
errStr string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid_error_string",
|
||||||
|
errStr: "NodePort 777777 is not in the allowed range 30000-32767",
|
||||||
|
want: "30000-32767",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error_string_with_different_message",
|
||||||
|
errStr: "some other error without a port range",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error_string_with_multiple_port_ranges",
|
||||||
|
errStr: "range 1000-2000 and another range 3000-4000",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_error_string",
|
||||||
|
errStr: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error_string_with_range_at_start",
|
||||||
|
errStr: "30000-32767 is the range",
|
||||||
|
want: "30000-32767",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := getServicesNodePortRangeFromErr(tt.errStr); got != tt.want {
|
||||||
|
t.Errorf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseServicesNodePortRange(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
p string
|
||||||
|
want *tsapi.PortRange
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid_range",
|
||||||
|
p: "30000-32767",
|
||||||
|
want: &tsapi.PortRange{Port: 30000, EndPort: 32767},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single_port_range",
|
||||||
|
p: "30000",
|
||||||
|
want: &tsapi.PortRange{Port: 30000, EndPort: 30000},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid_format_non_numeric_end",
|
||||||
|
p: "30000-abc",
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid_format_non_numeric_start",
|
||||||
|
p: "abc-32767",
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_string",
|
||||||
|
p: "",
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too_many_parts",
|
||||||
|
p: "1-2-3",
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port_too_large_start",
|
||||||
|
p: "65536-65537",
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port_too_large_end",
|
||||||
|
p: "30000-65536",
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inverted_range",
|
||||||
|
p: "32767-30000",
|
||||||
|
want: nil,
|
||||||
|
wantErr: true, // IsValid() will fail
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
portRange, err := parseServicesNodePortRange(tt.p)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if portRange == nil {
|
||||||
|
t.Fatalf("got nil port range, expected %v", tt.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if portRange.Port != tt.want.Port || portRange.EndPort != tt.want.EndPort {
|
||||||
|
t.Errorf("got = %v, want %v", portRange, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNodePortRanges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
portRanges []tsapi.PortRange
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid_ranges_with_unknown_kube_range",
|
||||||
|
portRanges: []tsapi.PortRange{
|
||||||
|
{Port: 30003, EndPort: 30005},
|
||||||
|
{Port: 30006, EndPort: 30007},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overlapping_ranges",
|
||||||
|
portRanges: []tsapi.PortRange{
|
||||||
|
{Port: 30000, EndPort: 30010},
|
||||||
|
{Port: 30005, EndPort: 30015},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adjacent_ranges_no_overlap",
|
||||||
|
portRanges: []tsapi.PortRange{
|
||||||
|
{Port: 30010, EndPort: 30020},
|
||||||
|
{Port: 30021, EndPort: 30022},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identical_ranges_are_overlapping",
|
||||||
|
portRanges: []tsapi.PortRange{
|
||||||
|
{Port: 30005, EndPort: 30010},
|
||||||
|
{Port: 30005, EndPort: 30010},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "range_clashes_with_existing_proxyclass",
|
||||||
|
portRanges: []tsapi.PortRange{
|
||||||
|
{Port: 31005, EndPort: 32070},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// as part of this test, we want to create an adjacent ProxyClass in order to ensure that if it clashes with the one created in this test
|
||||||
|
// that we get an error
|
||||||
|
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||||
|
opc := &tsapi.ProxyClass{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "other-pc",
|
||||||
|
},
|
||||||
|
Spec: tsapi.ProxyClassSpec{
|
||||||
|
StatefulSet: &tsapi.StatefulSet{
|
||||||
|
Annotations: defaultProxyClassAnnotations,
|
||||||
|
},
|
||||||
|
StaticEndpoints: &tsapi.StaticEndpointsConfig{
|
||||||
|
NodePort: &tsapi.NodePortConfig{
|
||||||
|
Ports: []tsapi.PortRange{
|
||||||
|
{Port: 31000}, {Port: 32000},
|
||||||
|
},
|
||||||
|
Selector: map[string]string{
|
||||||
|
"foo/bar": "baz",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: tsapi.ProxyClassStatus{
|
||||||
|
Conditions: []metav1.Condition{{
|
||||||
|
Type: string(tsapi.ProxyClassReady),
|
||||||
|
Status: metav1.ConditionTrue,
|
||||||
|
Reason: reasonProxyClassValid,
|
||||||
|
Message: reasonProxyClassValid,
|
||||||
|
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fc := fake.NewClientBuilder().
|
||||||
|
WithObjects(opc).
|
||||||
|
WithStatusSubresource(opc).
|
||||||
|
WithScheme(tsapi.GlobalScheme).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
pc := &tsapi.ProxyClass{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "pc",
|
||||||
|
},
|
||||||
|
Spec: tsapi.ProxyClassSpec{
|
||||||
|
StatefulSet: &tsapi.StatefulSet{
|
||||||
|
Annotations: defaultProxyClassAnnotations,
|
||||||
|
},
|
||||||
|
StaticEndpoints: &tsapi.StaticEndpointsConfig{
|
||||||
|
NodePort: &tsapi.NodePortConfig{
|
||||||
|
Ports: tt.portRanges,
|
||||||
|
Selector: map[string]string{
|
||||||
|
"foo/bar": "baz",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: tsapi.ProxyClassStatus{
|
||||||
|
Conditions: []metav1.Condition{{
|
||||||
|
Type: string(tsapi.ProxyClassReady),
|
||||||
|
Status: metav1.ConditionTrue,
|
||||||
|
Reason: reasonProxyClassValid,
|
||||||
|
Message: reasonProxyClassValid,
|
||||||
|
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := validateNodePortRanges(context.Background(), fc, &tsapi.PortRange{Port: 30000, EndPort: 32767}, pc)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRandomPort(t *testing.T) {
|
||||||
|
for range 100 {
|
||||||
|
port := getRandomPort()
|
||||||
|
if port < tailscaledPortMin || port > tailscaledPortMax {
|
||||||
|
t.Errorf("generated port %d which is out of range [%d, %d]", port, tailscaledPortMin, tailscaledPortMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue