cmd/k8s-operator: truncate long label values in metrics resources (#18895)

* cmd/k8s-operator: truncate long label values in metrics resources

Kubernetes label values have a 63-character limit, but resource names
can be up to 253 characters. When a Service or Ingress with a long
name is exposed via Tailscale, the operator fails to reconcile because
it uses the parent resource name directly as label values on metrics
Services.

Truncate label values that may exceed the limit by keeping the first
54 characters and appending a SHA256-based hash suffix to preserve
uniqueness.

Fixes #18894

Signed-off-by: Daniel Pañeda <daniel.paneda@clickhouse.com>
Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

* cmd/k8s-operator: move TruncateLabelValue to shared k8s-operator package

Move the label truncation helper to k8s-operator/utils.go so it can be
reused by other components that need to produce valid Kubernetes labels.

Signed-off-by: Daniel Pañeda <daniel.paneda@clickhouse.com>
Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

* cmd/k8s-operator: truncate long domain label values in cert resources

Applies TruncateLabelValue to certResourceLabels in order to prevent API
server validation failures. This covers both the HA Ingress and kube-apiserver
proxy reconcilers, as both flow through certResourceLabels.

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

* cmd/k8s-operator: remove empty metrics_resources_test.go, use hyphens in test names to satisfy go vet

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

---------

Signed-off-by: Daniel Pañeda <daniel.paneda@clickhouse.com>
Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
Co-authored-by: chaosinthecrd <tom@tmlabs.co.uk>
This commit is contained in:
Daniel Pañeda
2026-04-28 22:11:59 +09:00
committed by GitHub
parent 384b7fb561
commit 7735b15de3
4 changed files with 101 additions and 6 deletions
+1 -1
View File
@@ -1081,7 +1081,7 @@ func certResourceLabels(pgName, domain string) map[string]string {
return map[string]string{
kubetypes.LabelManaged: "true",
labelProxyGroup: pgName,
labelDomain: domain,
labelDomain: tsoperator.TruncateLabelValue(domain),
}
}
+6 -5
View File
@@ -19,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"
)
@@ -227,13 +228,13 @@ func metricsResourceLabels(opts *metricsOpts) map[string]string {
kubetypes.LabelManaged: "true",
labelMetricsTarget: opts.proxyStsName,
labelPromProxyType: opts.proxyType,
labelPromProxyParentName: 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] = opts.proxyLabels[LabelParentNamespace]
lbls[labelPromProxyParentNamespace] = kube.TruncateLabelValue(opts.proxyLabels[LabelParentNamespace])
}
lbls[labelPromJob] = promJobName(opts)
lbls[labelPromJob] = kube.TruncateLabelValue(promJobName(opts))
return lbls
}
@@ -250,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: proxyLabels[LabelParentName],
labelPromProxyParentName: kube.TruncateLabelValue(proxyLabels[LabelParentName]),
}
// Include namespace label for proxies created for a namespaced type.
if isNamespacedProxyType(proxyType) {
sel[labelPromProxyParentNamespace] = proxyLabels[LabelParentNamespace]
sel[labelPromProxyParentNamespace] = kube.TruncateLabelValue(proxyLabels[LabelParentNamespace])
}
return sel
}
+16
View File
@@ -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
}
+78
View File
@@ -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)
}
}