diff --git a/cmd/k8s-operator/metrics_resources.go b/cmd/k8s-operator/metrics_resources.go index f9b13e770..4384f4cba 100644 --- a/cmd/k8s-operator/metrics_resources.go +++ b/cmd/k8s-operator/metrics_resources.go @@ -7,8 +7,6 @@ package main import ( "context" - "crypto/sha256" - "encoding/hex" "fmt" "maps" "reflect" @@ -21,6 +19,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + kube "tailscale.com/k8s-operator" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/kube/kubetypes" ) @@ -229,13 +228,13 @@ func metricsResourceLabels(opts *metricsOpts) map[string]string { kubetypes.LabelManaged: "true", labelMetricsTarget: opts.proxyStsName, labelPromProxyType: opts.proxyType, - labelPromProxyParentName: truncateLabelValue(opts.proxyLabels[LabelParentName]), + labelPromProxyParentName: kube.TruncateLabelValue(opts.proxyLabels[LabelParentName]), } // Include namespace label for proxies created for a namespaced type. if isNamespacedProxyType(opts.proxyType) { - lbls[labelPromProxyParentNamespace] = truncateLabelValue(opts.proxyLabels[LabelParentNamespace]) + lbls[labelPromProxyParentNamespace] = kube.TruncateLabelValue(opts.proxyLabels[LabelParentNamespace]) } - lbls[labelPromJob] = truncateLabelValue(promJobName(opts)) + lbls[labelPromJob] = kube.TruncateLabelValue(promJobName(opts)) return lbls } @@ -252,11 +251,11 @@ func promJobName(opts *metricsOpts) string { func metricsSvcSelector(proxyLabels map[string]string, proxyType string) map[string]string { sel := map[string]string{ labelPromProxyType: proxyType, - labelPromProxyParentName: truncateLabelValue(proxyLabels[LabelParentName]), + labelPromProxyParentName: kube.TruncateLabelValue(proxyLabels[LabelParentName]), } // Include namespace label for proxies created for a namespaced type. if isNamespacedProxyType(proxyType) { - sel[labelPromProxyParentNamespace] = truncateLabelValue(proxyLabels[LabelParentNamespace]) + sel[labelPromProxyParentNamespace] = kube.TruncateLabelValue(proxyLabels[LabelParentNamespace]) } return sel } @@ -287,20 +286,6 @@ func isNamespacedProxyType(typ string) bool { return typ == proxyTypeIngressResource || typ == proxyTypeIngressService } -// truncateLabelValue truncates a Kubernetes label value to fit within the -// 63-character limit. If the value exceeds the limit, it is truncated and a -// short hash suffix is appended to preserve uniqueness. -func truncateLabelValue(val string) string { - const maxLen = 63 - if len(val) <= maxLen { - return val - } - hash := sha256.Sum256([]byte(val)) - suffix := hex.EncodeToString(hash[:4]) // 8 hex chars - truncated := val[:maxLen-len(suffix)-1] - return truncated + "-" + suffix -} - func mergeMapKeys(a, b map[string]string) map[string]string { m := make(map[string]string, len(a)+len(b)) maps.Copy(m, b) diff --git a/cmd/k8s-operator/metrics_resources_test.go b/cmd/k8s-operator/metrics_resources_test.go index 69fa5eae0..f3fb3d176 100644 --- a/cmd/k8s-operator/metrics_resources_test.go +++ b/cmd/k8s-operator/metrics_resources_test.go @@ -4,75 +4,3 @@ //go:build !plan9 package main - -import ( - "strings" - "testing" -) - -func TestTruncateLabelValue(t *testing.T) { - tests := []struct { - name string - input string - want string // empty means expect input unchanged - }{ - { - name: "short value unchanged", - input: "my-service", - }, - { - name: "exactly 63 chars unchanged", - input: strings.Repeat("a", 63), - }, - { - name: "64 chars gets truncated", - input: strings.Repeat("a", 64), - }, - { - name: "very long value gets truncated", - input: "tailscale-nginx-clickhouse-o11y-server-https-with-extra-long-suffix-that-exceeds-limit", - }, - { - name: "253 chars (max k8s resource name)", - input: strings.Repeat("x", 253), - }, - { - name: "empty string unchanged", - input: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := truncateLabelValue(tt.input) - if len(got) > 63 { - t.Errorf("truncateLabelValue(%q) = %q (len %d), exceeds 63 chars", tt.input, got, len(got)) - } - if len(tt.input) <= 63 && got != tt.input { - t.Errorf("truncateLabelValue(%q) = %q, want unchanged input", tt.input, got) - } - if len(tt.input) > 63 && got == tt.input { - t.Errorf("truncateLabelValue(%q) was not truncated", tt.input) - } - }) - } -} - -func TestTruncateLabelValueDeterministic(t *testing.T) { - input := strings.Repeat("a", 100) - first := truncateLabelValue(input) - for i := 0; i < 10; i++ { - got := truncateLabelValue(input) - if got != first { - t.Fatalf("non-deterministic: got %q, want %q", got, first) - } - } -} - -func TestTruncateLabelValueUniqueness(t *testing.T) { - // Two inputs sharing a long prefix but differing at the end should produce different outputs. - a := strings.Repeat("a", 100) + "-one" - b := strings.Repeat("a", 100) + "-two" - if truncateLabelValue(a) == truncateLabelValue(b) { - t.Errorf("collision: %q and %q produce the same truncated label", a, b) - } -} diff --git a/k8s-operator/utils.go b/k8s-operator/utils.go index 043a9d7b5..d83d98e0c 100644 --- a/k8s-operator/utils.go +++ b/k8s-operator/utils.go @@ -7,6 +7,8 @@ package kube import ( + "crypto/sha256" + "encoding/hex" "fmt" "tailscale.com/tailcfg" @@ -50,3 +52,17 @@ func CapVerFromFileName(name string) (tailcfg.CapabilityVersion, error) { _, err := fmt.Sscanf(name, "cap-%d.hujson", &cap) return cap, err } + +// TruncateLabelValue truncates a Kubernetes label value to fit within the +// 63-character limit. If the value exceeds the limit, it is truncated and a +// short hash suffix is appended to preserve uniqueness. +func TruncateLabelValue(val string) string { + const maxLen = 63 + if len(val) <= maxLen { + return val + } + hash := sha256.Sum256([]byte(val)) + suffix := hex.EncodeToString(hash[:4]) // 8 hex chars + truncated := val[:maxLen-len(suffix)-1] + return truncated + "-" + suffix +} diff --git a/k8s-operator/utils_test.go b/k8s-operator/utils_test.go new file mode 100644 index 000000000..3c4624c95 --- /dev/null +++ b/k8s-operator/utils_test.go @@ -0,0 +1,78 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !plan9 + +package kube + +import ( + "strings" + "testing" +) + +func TestTruncateLabelValue(t *testing.T) { + tests := []struct { + name string + input string + want string // empty means expect input unchanged + }{ + { + name: "short value unchanged", + input: "my-service", + }, + { + name: "exactly 63 chars unchanged", + input: strings.Repeat("a", 63), + }, + { + name: "64 chars gets truncated", + input: strings.Repeat("a", 64), + }, + { + name: "very long value gets truncated", + input: "tailscale-nginx-clickhouse-o11y-server-https-with-extra-long-suffix-that-exceeds-limit", + }, + { + name: "253 chars (max k8s resource name)", + input: strings.Repeat("x", 253), + }, + { + name: "empty string unchanged", + input: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TruncateLabelValue(tt.input) + if len(got) > 63 { + t.Errorf("TruncateLabelValue(%q) = %q (len %d), exceeds 63 chars", tt.input, got, len(got)) + } + if len(tt.input) <= 63 && got != tt.input { + t.Errorf("TruncateLabelValue(%q) = %q, want unchanged input", tt.input, got) + } + if len(tt.input) > 63 && got == tt.input { + t.Errorf("TruncateLabelValue(%q) was not truncated", tt.input) + } + }) + } +} + +func TestTruncateLabelValueDeterministic(t *testing.T) { + input := strings.Repeat("a", 100) + first := TruncateLabelValue(input) + for i := 0; i < 10; i++ { + got := TruncateLabelValue(input) + if got != first { + t.Fatalf("non-deterministic: got %q, want %q", got, first) + } + } +} + +func TestTruncateLabelValueUniqueness(t *testing.T) { + // Two inputs sharing a long prefix but differing at the end should produce different outputs. + a := strings.Repeat("a", 100) + "-one" + b := strings.Repeat("a", 100) + "-two" + if TruncateLabelValue(a) == TruncateLabelValue(b) { + t.Errorf("collision: %q and %q produce the same truncated label", a, b) + } +}