From 50ccfe022dd7d61c07430948428006bf7c6bc59b Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Wed, 22 Jul 2026 08:39:50 +0800 Subject: [PATCH] fix: cap condition message to Kubernetes 32768-byte limit Kubernetes rejects status updates whose condition Message exceeds 32768 bytes. When a many-path ApisixRoute sync fails, the aggregated sub-errors overflow this limit and the status update is silently dropped. Add a shared, rune-safe TruncateConditionMessage helper and route every condition constructor Message through it (NewConditionTypeAccepted, NewCondition, NewPolicyCondition, NewPolicyConflictCondition). Sync from apache/apisix-ingress-controller#2816 (part of #2783) --- internal/controller/status.go | 7 ++- internal/controller/utils/utils.go | 38 +++++++++++- internal/controller/utils/utils_test.go | 80 +++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 internal/controller/utils/utils_test.go diff --git a/internal/controller/status.go b/internal/controller/status.go index 7281836bf..29cf971a3 100644 --- a/internal/controller/status.go +++ b/internal/controller/status.go @@ -24,6 +24,7 @@ import ( gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" "github.com/apache/apisix-ingress-controller/internal/controller/status" + cutils "github.com/apache/apisix-ingress-controller/internal/controller/utils" "github.com/apache/apisix-ingress-controller/internal/provider" ) @@ -47,7 +48,7 @@ func NewCondition(observedGeneration int64, status bool, message string) metav1. Type: ConditionTypeAvailable, Reason: reason, Status: condition, - Message: message, + Message: cutils.TruncateConditionMessage(message), ObservedGeneration: observedGeneration, } } @@ -79,7 +80,7 @@ func NewPolicyCondition(observedGeneration int64, status bool, message string) m Type: string(gatewayv1alpha2.PolicyConditionAccepted), Reason: reason, Status: conditionStatus, - Message: message, + Message: cutils.TruncateConditionMessage(message), ObservedGeneration: observedGeneration, LastTransitionTime: metav1.Now(), } @@ -90,7 +91,7 @@ func NewPolicyConflictCondition(observedGeneration int64, message string) metav1 Type: string(gatewayv1alpha2.PolicyConditionAccepted), Reason: string(gatewayv1alpha2.PolicyReasonConflicted), Status: metav1.ConditionFalse, - Message: message, + Message: cutils.TruncateConditionMessage(message), ObservedGeneration: observedGeneration, LastTransitionTime: metav1.Now(), } diff --git a/internal/controller/utils/utils.go b/internal/controller/utils/utils.go index a22babc15..b1ac1bf10 100644 --- a/internal/controller/utils/utils.go +++ b/internal/controller/utils/utils.go @@ -18,12 +18,48 @@ package utils import ( + "unicode/utf8" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiv2 "github.com/apache/apisix-ingress-controller/api/v2" "github.com/apache/apisix-ingress-controller/internal/utils" ) +const ( + // maxConditionMessageBytes is the hard limit Kubernetes enforces on a + // status condition Message (status.conditions[].message). Updates whose + // message exceeds this are rejected with "Too long: may not be more than + // 32768 bytes". + maxConditionMessageBytes = 32768 + + // conditionMessageTruncationMarker is appended to a message that had to be + // truncated to fit within maxConditionMessageBytes. + conditionMessageTruncationMarker = " ... (truncated)" +) + +// TruncateConditionMessage caps a condition Message so a status update is never +// rejected for exceeding Kubernetes' 32768-byte limit. Truncation is rune-safe: +// it trims back to a UTF-8 rune boundary (never splitting a multi-byte rune) and +// appends conditionMessageTruncationMarker to signal that content was dropped. +func TruncateConditionMessage(msg string) string { + if len(msg) <= maxConditionMessageBytes { + return msg + } + + budget := maxConditionMessageBytes - len(conditionMessageTruncationMarker) + truncated := msg[:budget] + // Back off any partial trailing rune left by the byte-wise cut. + for len(truncated) > 0 { + if r, size := utf8.DecodeLastRuneInString(truncated); r == utf8.RuneError && size <= 1 { + truncated = truncated[:len(truncated)-1] + continue + } + break + } + return truncated + conditionMessageTruncationMarker +} + func SetApisixCRDConditionWithGeneration(status *apiv2.ApisixStatus, generation int64, condition metav1.Condition) { condition.ObservedGeneration = generation SetApisixCRDCondition(status, condition) @@ -51,7 +87,7 @@ func NewConditionTypeAccepted(reason apiv2.ApisixRouteConditionReason, status bo ObservedGeneration: generation, LastTransitionTime: metav1.Now(), Reason: string(reason), - Message: msg, + Message: TruncateConditionMessage(msg), } return condition } diff --git a/internal/controller/utils/utils_test.go b/internal/controller/utils/utils_test.go new file mode 100644 index 000000000..02af4f7a3 --- /dev/null +++ b/internal/controller/utils/utils_test.go @@ -0,0 +1,80 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package utils + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestTruncateConditionMessage(t *testing.T) { + t.Run("message under limit is unchanged", func(t *testing.T) { + msg := strings.Repeat("a", maxConditionMessageBytes) + if got := TruncateConditionMessage(msg); got != msg { + t.Fatalf("expected message to be unchanged, got len=%d", len(got)) + } + }) + + t.Run("ascii message over limit is capped and marked", func(t *testing.T) { + msg := strings.Repeat("a", maxConditionMessageBytes+100) + got := TruncateConditionMessage(msg) + if len(got) > maxConditionMessageBytes { + t.Fatalf("truncated message len=%d exceeds limit %d", len(got), maxConditionMessageBytes) + } + if !strings.HasSuffix(got, conditionMessageTruncationMarker) { + t.Fatalf("truncated message does not end with marker: %q", got[len(got)-len(conditionMessageTruncationMarker):]) + } + if !utf8.ValidString(got) { + t.Fatalf("truncated message is not valid UTF-8") + } + }) + + // Build a message from 3-byte runes so the byte budget lands in the middle + // of a rune, proving the cut backs off to a rune boundary. + t.Run("multi-byte rune straddling the boundary is rune-safe", func(t *testing.T) { + const rune3 = "δΈ­" // 3 bytes + msg := strings.Repeat(rune3, maxConditionMessageBytes) // ~3x over the limit + got := TruncateConditionMessage(msg) + + if len(got) > maxConditionMessageBytes { + t.Fatalf("truncated message len=%d exceeds limit %d", len(got), maxConditionMessageBytes) + } + if !utf8.ValidString(got) { + t.Fatalf("truncated message is not valid UTF-8 (a rune was split)") + } + if !strings.HasSuffix(got, conditionMessageTruncationMarker) { + t.Fatalf("truncated message does not end with marker") + } + // The content before the marker must consist only of whole 3-byte runes. + content := strings.TrimSuffix(got, conditionMessageTruncationMarker) + if strings.Trim(content, rune3) != "" { + t.Fatalf("truncated content contains a partial rune") + } + }) +} + +// TestNewConditionTypeAcceptedTruncates ensures the constructor routes its +// Message through the cap so no status update can exceed the Kubernetes limit. +func TestNewConditionTypeAcceptedTruncates(t *testing.T) { + huge := strings.Repeat("x", maxConditionMessageBytes*2) + cond := NewConditionTypeAccepted("SyncFailed", false, 1, huge) + if len(cond.Message) > maxConditionMessageBytes { + t.Fatalf("condition message len=%d exceeds limit %d", len(cond.Message), maxConditionMessageBytes) + } +}