Skip to content

Commit 596bebd

Browse files
weicaoapecloud-bot
authored andcommitted
fix: truncate over-sized status condition message/reason (#10322)
(cherry picked from commit dc9c432)
1 parent 8d79b36 commit 596bebd

4 files changed

Lines changed: 243 additions & 6 deletions

File tree

controllers/apps/cluster/cluster_status_conditions.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ func newFailedProvisioningStartedCondition(err error) metav1.Condition {
7777
return metav1.Condition{
7878
Type: appsv1.ConditionTypeProvisioningStarted,
7979
Status: metav1.ConditionFalse,
80-
Message: err.Error(),
81-
Reason: getConditionReasonWithError(ReasonPreCheckFailed, err),
80+
Message: intctrlutil.TruncateConditionMessage(err.Error()),
81+
Reason: intctrlutil.TruncateConditionReason(getConditionReasonWithError(ReasonPreCheckFailed, err)),
8282
}
8383
}
8484

@@ -105,8 +105,8 @@ func newFailedApplyResourcesCondition(err error) metav1.Condition {
105105
return metav1.Condition{
106106
Type: appsv1.ConditionTypeApplyResources,
107107
Status: metav1.ConditionFalse,
108-
Message: err.Error(),
109-
Reason: getConditionReasonWithError(ReasonApplyResourcesFailed, err),
108+
Message: intctrlutil.TruncateConditionMessage(err.Error()),
109+
Reason: intctrlutil.TruncateConditionReason(getConditionReasonWithError(ReasonApplyResourcesFailed, err)),
110110
}
111111
}
112112

controllers/apps/component/utils.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ func newFailedProvisioningStartedCondition(err error) metav1.Condition {
7575
return metav1.Condition{
7676
Type: appsv1.ConditionTypeProvisioningStarted,
7777
Status: metav1.ConditionFalse,
78-
Message: err.Error(),
79-
Reason: getConditionReasonWithError(reasonPreCheckFailed, err),
78+
Message: intctrlutil.TruncateConditionMessage(err.Error()),
79+
Reason: intctrlutil.TruncateConditionReason(getConditionReasonWithError(reasonPreCheckFailed, err)),
8080
}
8181
}
8282

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
Copyright (C) 2022-2026 ApeCloud Co., Ltd
3+
4+
This file is part of KubeBlocks project
5+
6+
This program is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU Affero General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
This program is distributed in the hope that it will be useful
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU Affero General Public License for more details.
15+
16+
You should have received a copy of the GNU Affero General Public License
17+
along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
package controllerutil
21+
22+
import (
23+
"unicode/utf8"
24+
)
25+
26+
// metav1.Condition.message has a CRD maxLength of 32768 bytes. Leave a margin
27+
// so the truncation marker fits and writes still succeed when the unbounded
28+
// underlying error (for example, a kbagent action stderr dump that already
29+
// exceeds 32768 bytes by itself) would otherwise cause the API server to
30+
// reject the status patch with "Too long: may not be more than 32768 bytes".
31+
const maxConditionMessageBytes = 32000
32+
33+
// metav1.Condition.reason has a CRD maxLength of 1024 bytes.
34+
const maxConditionReasonBytes = 1024
35+
36+
// conditionMsgTruncationMarker is appended to a truncated message so readers
37+
// can tell the message was cut off rather than naturally short. The full
38+
// underlying error remains available in the controller log; this marker only
39+
// covers the on-cluster Condition surface.
40+
const conditionMsgTruncationMarker = "\n[...truncated; see controller log for full error]"
41+
42+
// TruncateConditionMessage returns msg unchanged when it fits in the Condition
43+
// message budget, otherwise it returns a UTF-8 safe prefix followed by the
44+
// truncation marker. The result is guaranteed to be valid UTF-8 and no longer
45+
// than maxConditionMessageBytes bytes.
46+
func TruncateConditionMessage(msg string) string {
47+
if len(msg) <= maxConditionMessageBytes {
48+
return msg
49+
}
50+
limit := maxConditionMessageBytes - len(conditionMsgTruncationMarker)
51+
truncated := msg[:limit]
52+
// A naive byte-level cut can land in the middle of a multi-byte rune.
53+
// Walk back one byte at a time until the remaining prefix parses as
54+
// valid UTF-8 — this also handles the case where the final byte is a
55+
// rune start but the rune itself is incomplete.
56+
for !utf8.ValidString(truncated) && len(truncated) > 0 {
57+
truncated = truncated[:len(truncated)-1]
58+
}
59+
return truncated + conditionMsgTruncationMarker
60+
}
61+
62+
// TruncateConditionReason returns reason unchanged when it fits in the
63+
// Condition reason budget, otherwise a UTF-8 safe prefix cut to fit. No marker
64+
// is appended — reason is a short identifier consumed by automation, not a
65+
// human-readable message.
66+
func TruncateConditionReason(reason string) string {
67+
if len(reason) <= maxConditionReasonBytes {
68+
return reason
69+
}
70+
truncated := reason[:maxConditionReasonBytes]
71+
for !utf8.ValidString(truncated) && len(truncated) > 0 {
72+
truncated = truncated[:len(truncated)-1]
73+
}
74+
return truncated
75+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
Copyright (C) 2022-2026 ApeCloud Co., Ltd
3+
4+
This file is part of KubeBlocks project
5+
6+
This program is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU Affero General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
This program is distributed in the hope that it will be useful
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU Affero General Public License for more details.
15+
16+
You should have received a copy of the GNU Affero General Public License
17+
along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
package controllerutil
21+
22+
import (
23+
"strings"
24+
"testing"
25+
"unicode/utf8"
26+
)
27+
28+
// threeByteRune is the codepoint U+4E2D encoded as the bytes 0xE4 0xB8 0xAD.
29+
// It is used to exercise the UTF-8 boundary walk-back without putting
30+
// non-ASCII bytes into the source file (the Go compiler converts the
31+
// universal-character-name escape into the same 3-byte UTF-8 sequence).
32+
const threeByteRune = "\u4e2d"
33+
34+
func TestTruncateConditionMessage_EmptyAndSmall(t *testing.T) {
35+
for name, in := range map[string]string{
36+
"empty": "",
37+
"short": "boom",
38+
"at_limit": strings.Repeat("a", maxConditionMessageBytes),
39+
"just_below": strings.Repeat("a", maxConditionMessageBytes-1),
40+
} {
41+
t.Run(name, func(t *testing.T) {
42+
got := TruncateConditionMessage(in)
43+
if got != in {
44+
t.Fatalf("expected unchanged message, got len=%d (in len=%d)", len(got), len(in))
45+
}
46+
})
47+
}
48+
}
49+
50+
func TestTruncateConditionMessage_LargeASCII(t *testing.T) {
51+
in := strings.Repeat("x", maxConditionMessageBytes*2)
52+
got := TruncateConditionMessage(in)
53+
if len(got) > maxConditionMessageBytes {
54+
t.Fatalf("expected len <= %d, got %d", maxConditionMessageBytes, len(got))
55+
}
56+
if !strings.HasSuffix(got, conditionMsgTruncationMarker) {
57+
t.Fatalf("expected trailing truncation marker, got %q", got[len(got)-100:])
58+
}
59+
if !utf8.ValidString(got) {
60+
t.Fatalf("expected valid UTF-8 result")
61+
}
62+
}
63+
64+
func TestTruncateConditionMessage_UTF8RuneBoundaryAfterFirstByte(t *testing.T) {
65+
// threeByteRune is 3 bytes. Build a payload whose naive byte-cut at
66+
// maxConditionMessageBytes - len(marker) lands one byte past the
67+
// rune start, leaving the first byte alone at the tail (rune start
68+
// but incomplete). The implementation must walk back to a valid
69+
// UTF-8 boundary.
70+
limit := maxConditionMessageBytes - len(conditionMsgTruncationMarker)
71+
// Prefix of (limit-1) ASCII bytes, then threeByteRune. The byte at
72+
// index (limit-1) is the first byte of the multi-byte rune.
73+
in := strings.Repeat("a", limit-1) + threeByteRune + strings.Repeat("a", maxConditionMessageBytes)
74+
75+
got := TruncateConditionMessage(in)
76+
if !utf8.ValidString(got) {
77+
t.Fatalf("result is not valid UTF-8: %q", got[len(got)-10:])
78+
}
79+
if len(got) > maxConditionMessageBytes {
80+
t.Fatalf("expected len <= %d, got %d", maxConditionMessageBytes, len(got))
81+
}
82+
if !strings.HasSuffix(got, conditionMsgTruncationMarker) {
83+
t.Fatalf("expected marker suffix")
84+
}
85+
}
86+
87+
func TestTruncateConditionMessage_UTF8RuneBoundaryAfterSecondByte(t *testing.T) {
88+
// Variant: naive cut lands after the second byte of threeByteRune
89+
// (the second byte is a continuation byte, not a rune start, but
90+
// walking back to the prior byte still leaves an incomplete rune -
91+
// the loop must continue until the result parses as valid UTF-8).
92+
limit := maxConditionMessageBytes - len(conditionMsgTruncationMarker)
93+
in := strings.Repeat("a", limit-2) + threeByteRune + strings.Repeat("a", maxConditionMessageBytes)
94+
95+
got := TruncateConditionMessage(in)
96+
if !utf8.ValidString(got) {
97+
t.Fatalf("result is not valid UTF-8")
98+
}
99+
if len(got) > maxConditionMessageBytes {
100+
t.Fatalf("expected len <= %d, got %d", maxConditionMessageBytes, len(got))
101+
}
102+
if !strings.HasSuffix(got, conditionMsgTruncationMarker) {
103+
t.Fatalf("expected marker suffix")
104+
}
105+
}
106+
107+
func TestTruncateConditionMessage_RealWorldErrorShape(t *testing.T) {
108+
// Approximates the C03 scenario: a single kbagent action error that
109+
// already exceeds 32 KiB on its own. Verify it fits the post-fix
110+
// budget and stays valid UTF-8.
111+
payload := "action: udf-shardingShardRemove, executed on pod: rds-x, error: exit code: 1, stderr: "
112+
payload += strings.Repeat("rds-cluster-shard-pod-fqdn-line ", 2000) // ~64 KiB
113+
got := TruncateConditionMessage(payload)
114+
if len(got) > maxConditionMessageBytes {
115+
t.Fatalf("expected len <= %d, got %d", maxConditionMessageBytes, len(got))
116+
}
117+
if !utf8.ValidString(got) {
118+
t.Fatalf("expected valid UTF-8 result")
119+
}
120+
if !strings.HasSuffix(got, conditionMsgTruncationMarker) {
121+
t.Fatalf("expected truncation marker")
122+
}
123+
}
124+
125+
func TestTruncateConditionReason_EmptyAndSmall(t *testing.T) {
126+
for name, in := range map[string]string{
127+
"empty": "",
128+
"short": "ApplyResourcesFailed",
129+
"at_limit": strings.Repeat("a", maxConditionReasonBytes),
130+
} {
131+
t.Run(name, func(t *testing.T) {
132+
got := TruncateConditionReason(in)
133+
if got != in {
134+
t.Fatalf("expected unchanged reason, got len=%d", len(got))
135+
}
136+
})
137+
}
138+
}
139+
140+
func TestTruncateConditionReason_LargeASCII(t *testing.T) {
141+
in := strings.Repeat("R", maxConditionReasonBytes*3)
142+
got := TruncateConditionReason(in)
143+
if len(got) > maxConditionReasonBytes {
144+
t.Fatalf("expected len <= %d, got %d", maxConditionReasonBytes, len(got))
145+
}
146+
if !utf8.ValidString(got) {
147+
t.Fatalf("expected valid UTF-8 result")
148+
}
149+
}
150+
151+
func TestTruncateConditionReason_UTF8RuneBoundary(t *testing.T) {
152+
// Naive cut lands inside a 3-byte rune. The walk-back must stop on
153+
// a valid UTF-8 prefix.
154+
in := strings.Repeat("R", maxConditionReasonBytes-1) + threeByteRune + strings.Repeat("R", maxConditionReasonBytes)
155+
got := TruncateConditionReason(in)
156+
if !utf8.ValidString(got) {
157+
t.Fatalf("result not valid UTF-8")
158+
}
159+
if len(got) > maxConditionReasonBytes {
160+
t.Fatalf("expected len <= %d, got %d", maxConditionReasonBytes, len(got))
161+
}
162+
}

0 commit comments

Comments
 (0)