From 052d41ce4761d280f0b811cd8c2e8b078ccdc22b Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Thu, 23 Apr 2026 14:36:01 -0700 Subject: [PATCH 1/2] fix(conditions): truncate condition messages exceeding Kubernetes 32768-byte limit If an upstream source such as ArgoCD embeds a large payload (e.g. an entire pod spec) in an error message, that text can flow through the promotion engine into a Stage condition and cause every PatchStatus call to fail, leaving the Stage permanently stuck in "pending". Truncate condition messages to the Kubernetes-enforced 32768-byte limit inside conditions.Set so no single large upstream message can block a status update. The truncation is UTF-8-safe and appends " ... (truncated)" so operators know the message was cut. Fixes #6147 Signed-off-by: Eron Wright --- pkg/conditions/conditions.go | 26 +++++++++++++ pkg/conditions/conditions_test.go | 64 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/pkg/conditions/conditions.go b/pkg/conditions/conditions.go index 967114ee27..992b294479 100644 --- a/pkg/conditions/conditions.go +++ b/pkg/conditions/conditions.go @@ -2,10 +2,15 @@ package conditions import ( "time" + "unicode/utf8" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// maxConditionMessageLength is the Kubernetes-enforced maximum byte length for +// a condition message field. +const maxConditionMessageLength = 32768 + // Getter is an interface that allows getting conditions from a (sub)resource. type Getter interface { // GetConditions returns the conditions on the resource. @@ -61,6 +66,11 @@ func Set(on Setter, conditions ...*metav1.Condition) { continue } + // Kubernetes enforces a 32768-byte limit on condition messages. Truncate + // to prevent status patch failures when upstream sources (e.g. ArgoCD) + // embed large payloads in error messages. + condition.Message = truncateMessage(condition.Message) + // Set ObservedGeneration if applicable if objGeneration != 0 && condition.ObservedGeneration == 0 { condition.ObservedGeneration = objGeneration @@ -122,3 +132,19 @@ func Delete(on Setter, conditionType string) { func Equal(a, b metav1.Condition) bool { return a.Type == b.Type && a.Status == b.Status && a.Reason == b.Reason && a.Message == b.Message } + +// truncateMessage truncates msg to maxConditionMessageLength bytes, appending a +// suffix to indicate truncation. The truncation point is adjusted to avoid +// splitting a multi-byte UTF-8 character. +func truncateMessage(msg string) string { + if len(msg) <= maxConditionMessageLength { + return msg + } + const suffix = " ... (truncated)" + end := maxConditionMessageLength - len(suffix) + // Walk back to a valid UTF-8 rune boundary. + for end > 0 && !utf8.RuneStart(msg[end]) { + end-- + } + return msg[:end] + suffix +} diff --git a/pkg/conditions/conditions_test.go b/pkg/conditions/conditions_test.go index 4c21e14f5e..33e374dfee 100644 --- a/pkg/conditions/conditions_test.go +++ b/pkg/conditions/conditions_test.go @@ -498,6 +498,23 @@ func TestSet(t *testing.T) { } } +func TestSet_TruncatesLongMessage(t *testing.T) { + t.Parallel() + + longMsg := string(make([]byte, maxConditionMessageLength+100)) + setter := &mockSetter{} + Set(setter, &metav1.Condition{ + Type: "Test", + Status: metav1.ConditionTrue, + Reason: "Reason", + Message: longMsg, + }) + + conds := setter.GetConditions() + require.Len(t, conds, 1) + require.LessOrEqual(t, len(conds[0].Message), maxConditionMessageLength) +} + func TestDelete(t *testing.T) { const ( mockType1 = "MockType1" @@ -733,6 +750,53 @@ func TestEqual(t *testing.T) { } } +func TestTruncateMessage(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input string + assert func(*testing.T, string) + }{ + { + name: "short message unchanged", + input: "short", + assert: func(t *testing.T, got string) { + require.Equal(t, "short", got) + }, + }, + { + name: "exactly at limit unchanged", + input: string(make([]byte, maxConditionMessageLength)), + assert: func(t *testing.T, got string) { + require.Equal(t, maxConditionMessageLength, len(got)) + }, + }, + { + name: "over limit gets truncated", + input: string(make([]byte, maxConditionMessageLength+1)), + assert: func(t *testing.T, got string) { + require.LessOrEqual(t, len(got), maxConditionMessageLength) + require.Contains(t, got, "(truncated)") + }, + }, + { + name: "multi-byte UTF-8 not split", + input: string(make([]byte, maxConditionMessageLength-1)) + "é", // 2-byte rune crosses boundary + assert: func(t *testing.T, got string) { + require.LessOrEqual(t, len(got), maxConditionMessageLength) + require.Contains(t, got, "(truncated)") + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.assert(t, truncateMessage(tc.input)) + }) + } +} + type deepCopyable interface { DeepCopy() Setter } From 14b02eb0aff0dcffa36301f1835eb285aac74fd4 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Mon, 27 Apr 2026 14:31:22 -0700 Subject: [PATCH 2/2] test(conditions): strengthen UTF-8 walk-back coverage in truncateMessage Position the multi-byte rune so its continuation byte falls exactly at the cut index, ensuring the walk-back loop is actually exercised. Signed-off-by: Eron Wright --- pkg/conditions/conditions_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/conditions/conditions_test.go b/pkg/conditions/conditions_test.go index 33e374dfee..e5562dac54 100644 --- a/pkg/conditions/conditions_test.go +++ b/pkg/conditions/conditions_test.go @@ -1,8 +1,10 @@ package conditions import ( + "strings" "testing" "time" + "unicode/utf8" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -781,11 +783,17 @@ func TestTruncateMessage(t *testing.T) { }, }, { - name: "multi-byte UTF-8 not split", - input: string(make([]byte, maxConditionMessageLength-1)) + "é", // 2-byte rune crosses boundary + name: "multi-byte UTF-8 not split", + // The suffix " ... (truncated)" is 16 bytes, so the cut index is + // maxConditionMessageLength-16 = 32752. Place é (UTF-8: 0xC3 0xA9) + // starting at index 32751 so its continuation byte (0xA9) lands + // exactly at the cut point, forcing the walk-back to trigger. + input: string(make([]byte, maxConditionMessageLength-len(" ... (truncated)")-1)) + + strings.Repeat("é", 20), assert: func(t *testing.T, got string) { require.LessOrEqual(t, len(got), maxConditionMessageLength) require.Contains(t, got, "(truncated)") + require.True(t, utf8.ValidString(got)) }, }, }