Skip to content

Commit 62c4c28

Browse files
authored
Merge pull request #1840 from himsngh/himsngh/1839
[1839] Fix: Truncate status.deploy.stdout as it can exceed etcd's limit on large clusters, causing reconciliation delay
2 parents be1faef + 392d9b5 commit 62c4c28

7 files changed

Lines changed: 438 additions & 2 deletions

File tree

pkg/app/app.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ type Hooks struct {
4040
type Opts struct {
4141
DefaultSyncPeriod time.Duration
4242
MinimumSyncPeriod time.Duration
43+
// MaxStatusOutputBytes caps each stdout/stderr field written into App status.
44+
// Zero means use defaultMaxStatusOutputBytes (1 MiB).
45+
MaxStatusOutputBytes int
4346
}
4447

4548
type App struct {

pkg/app/app_reconcile.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func (a *App) reconcileFetchTemplateDeploy() exec.CmdRunResult {
127127
var fetchResult exec.CmdRunResult
128128
assetsPath, fetchResult = a.fetch(assetsPath)
129129

130+
fetchResult = fetchResult.WithTruncatedStrings(a.maxOutputBytes())
130131
a.app.Status.Fetch = &v1alpha1.AppStatusFetch{
131132
Stderr: fetchResult.Stderr,
132133
Stdout: fetchResult.Stdout,
@@ -178,7 +179,7 @@ func (a *App) reconcileFetchTemplateDeploy() exec.CmdRunResult {
178179
}
179180

180181
func (a *App) updateLastDeploy(result exec.CmdRunResult) exec.CmdRunResult {
181-
result = result.WithFriendlyYAMLStrings()
182+
result = result.WithFriendlyYAMLStrings().WithTruncatedStrings(a.maxOutputBytes())
182183

183184
a.app.Status.Deploy = &v1alpha1.AppStatusDeploy{
184185
Stdout: result.Stdout,
@@ -242,7 +243,7 @@ func (a *App) resetLastDeployStartedAt() {
242243
}
243244

244245
func (a *App) reconcileInspect() error {
245-
inspectResult := a.inspect().WithFriendlyYAMLStrings()
246+
inspectResult := a.inspect().WithFriendlyYAMLStrings().WithTruncatedStrings(a.maxOutputBytes())
246247

247248
if !inspectResult.IsEmpty() {
248249
a.app.Status.Inspect = &v1alpha1.AppStatusInspect{
@@ -339,6 +340,7 @@ func (a *App) removeAllConditions() {
339340
}
340341

341342
func (a *App) setUsefulErrorMessage(result exec.CmdRunResult) {
343+
result = result.WithTruncatedStrings(a.maxOutputBytes())
342344
switch {
343345
case result.Stderr != "":
344346
a.app.Status.UsefulErrorMessage = result.Stderr

pkg/app/app_status_truncate.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright 2026 The Carvel Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package app
5+
6+
// defaultMaxStatusOutputBytes is the default cap for each stdout/stderr string
7+
// written into an App status sub-field (deploy, fetch, inspect).
8+
// etcd rejects objects larger than its 1.5 MiB limit (often 2 MiB at the
9+
// Kubernetes gRPC client level); on clusters with large number of nodes (ex 250+),
10+
// a single kapp deploy can produce several MiB of output, causing the UpdateStatus call to
11+
// fail and delay reconciliation until a subsequent no-op reconcile fits.
12+
//
13+
// Keeping the tail is intentional: the most actionable content (resource
14+
// summary, error lines) always appears at the end of kapp output.
15+
const defaultMaxStatusOutputBytes = 1 * 1024 * 1024 // 1 MiB
16+
17+
// maxOutputBytes returns the configured output cap for this App, falling back
18+
// to defaultMaxStatusOutputBytes when Opts.MaxStatusOutputBytes is zero.
19+
func (a *App) maxOutputBytes() int {
20+
if a.opts.MaxStatusOutputBytes > 0 {
21+
return a.opts.MaxStatusOutputBytes
22+
}
23+
return defaultMaxStatusOutputBytes
24+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright 2026 The Carvel Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package app
5+
6+
import (
7+
"strings"
8+
"testing"
9+
10+
"carvel.dev/kapp-controller/pkg/apis/kappctrl/v1alpha1"
11+
"carvel.dev/kapp-controller/pkg/deploy"
12+
"carvel.dev/kapp-controller/pkg/exec"
13+
"carvel.dev/kapp-controller/pkg/fetch"
14+
"carvel.dev/kapp-controller/pkg/metrics"
15+
"carvel.dev/kapp-controller/pkg/template"
16+
"github.com/stretchr/testify/assert"
17+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
18+
logf "sigs.k8s.io/controller-runtime/pkg/log"
19+
)
20+
21+
// newAppForTest returns a minimal *App wired with no-op hooks.
22+
func newAppForTest(t *testing.T, appModel v1alpha1.App, opts Opts) *App {
23+
t.Helper()
24+
return NewApp(appModel, Hooks{
25+
BlockDeletion: func() error { return nil },
26+
UnblockDeletion: func() error { return nil },
27+
UpdateStatus: func(string) error { return nil },
28+
}, fetch.Factory{}, template.Factory{}, deploy.Factory{},
29+
logf.Log.WithName("test"),
30+
opts,
31+
metrics.NewMetrics(),
32+
FakeComponentInfo{},
33+
)
34+
}
35+
36+
func Test_updateLastDeploy_TruncatesLargeStdout(t *testing.T) {
37+
const limit = 50
38+
a := newAppForTest(t, v1alpha1.App{
39+
ObjectMeta: metav1.ObjectMeta{Name: "test-app", Namespace: "default"},
40+
}, Opts{MaxStatusOutputBytes: limit})
41+
a.app.Status.Deploy = &v1alpha1.AppStatusDeploy{}
42+
43+
largeStdout := strings.Repeat("resource change line\n", 1000)
44+
a.updateLastDeploy(exec.CmdRunResult{Stdout: largeStdout, Finished: true, ExitCode: 0})
45+
46+
got := a.app.Status.Deploy.Stdout
47+
assert.True(t, strings.HasPrefix(got, exec.TruncationMarker),
48+
"deploy stdout should start with truncation marker when over limit")
49+
assert.LessOrEqual(t, len(got), len(exec.TruncationMarker)+limit,
50+
"deploy stdout should not exceed marker + limit bytes")
51+
}
52+
53+
func Test_updateLastDeploy_TruncatesLargeStderr(t *testing.T) {
54+
const limit = 50
55+
a := newAppForTest(t, v1alpha1.App{
56+
ObjectMeta: metav1.ObjectMeta{Name: "test-app", Namespace: "default"},
57+
}, Opts{MaxStatusOutputBytes: limit})
58+
a.app.Status.Deploy = &v1alpha1.AppStatusDeploy{}
59+
60+
largeStderr := strings.Repeat("error line\n", 1000)
61+
a.updateLastDeploy(exec.CmdRunResult{Stderr: largeStderr, Finished: true, ExitCode: 1})
62+
63+
got := a.app.Status.Deploy.Stderr
64+
assert.True(t, strings.HasPrefix(got, exec.TruncationMarker))
65+
assert.LessOrEqual(t, len(got), len(exec.TruncationMarker)+limit)
66+
}
67+
68+
func Test_updateLastDeploy_NoTruncation_WhenUnderLimit(t *testing.T) {
69+
a := newAppForTest(t, v1alpha1.App{
70+
ObjectMeta: metav1.ObjectMeta{Name: "test-app", Namespace: "default"},
71+
}, Opts{MaxStatusOutputBytes: 10000})
72+
a.app.Status.Deploy = &v1alpha1.AppStatusDeploy{}
73+
74+
smallStdout := "Changes\n\nOp: 1 add\n"
75+
a.updateLastDeploy(exec.CmdRunResult{Stdout: smallStdout, Finished: true, ExitCode: 0})
76+
77+
assert.NotEmpty(t, a.app.Status.Deploy.Stdout)
78+
assert.False(t, strings.HasPrefix(a.app.Status.Deploy.Stdout, exec.TruncationMarker),
79+
"output under the limit must not be prefixed with the truncation marker")
80+
}
81+
82+
func Test_updateLastDeploy_TailIsPreserved(t *testing.T) {
83+
const limit = 40
84+
a := newAppForTest(t, v1alpha1.App{
85+
ObjectMeta: metav1.ObjectMeta{Name: "test-app", Namespace: "default"},
86+
}, Opts{MaxStatusOutputBytes: limit})
87+
a.app.Status.Deploy = &v1alpha1.AppStatusDeploy{}
88+
89+
// WithFriendlyYAMLStrings (called inside updateLastDeploy) strips trailing
90+
// whitespace, so do not include a trailing newline in the expected assertion.
91+
tail := "IMPORTANT: deploy succeeded"
92+
a.updateLastDeploy(exec.CmdRunResult{
93+
Stdout: strings.Repeat("x", 500) + tail + "\n",
94+
Finished: true,
95+
ExitCode: 0,
96+
})
97+
98+
assert.Contains(t, a.app.Status.Deploy.Stdout, tail,
99+
"the tail (most recent output) must survive truncation")
100+
}
101+
102+
func Test_setUsefulErrorMessage_TruncatesLargeStderr(t *testing.T) {
103+
const limit = 50
104+
a := newAppForTest(t, v1alpha1.App{
105+
ObjectMeta: metav1.ObjectMeta{Name: "test-app", Namespace: "default"},
106+
}, Opts{MaxStatusOutputBytes: limit})
107+
108+
largeStderr := strings.Repeat("kapp: Error: resource conflict\n", 1000)
109+
a.setUsefulErrorMessage(exec.CmdRunResult{Stderr: largeStderr, ExitCode: 1})
110+
111+
got := a.app.Status.UsefulErrorMessage
112+
assert.True(t, strings.HasPrefix(got, exec.TruncationMarker))
113+
assert.LessOrEqual(t, len(got), len(exec.TruncationMarker)+limit)
114+
}
115+
116+
func Test_setUsefulErrorMessage_NoTruncation_WhenUnderLimit(t *testing.T) {
117+
a := newAppForTest(t, v1alpha1.App{
118+
ObjectMeta: metav1.ObjectMeta{Name: "test-app", Namespace: "default"},
119+
}, Opts{MaxStatusOutputBytes: 10000})
120+
121+
stderr := "kapp: Error: resource configmap/foo not found"
122+
a.setUsefulErrorMessage(exec.CmdRunResult{Stderr: stderr, ExitCode: 1})
123+
124+
assert.Equal(t, stderr, a.app.Status.UsefulErrorMessage)
125+
}

pkg/exec/cmd_run_result.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ import (
77
"fmt"
88
"regexp"
99
"strings"
10+
"unicode/utf8"
1011
)
1112

1213
var (
1314
trailingSpace = regexp.MustCompile("\\s+\n")
1415
)
1516

17+
// TruncationMarker is prepended to any output field that was clipped.
18+
// Operators can detect truncation by checking for this prefix.
19+
const TruncationMarker = "[output truncated]\n"
20+
1621
type CmdRunResult struct {
1722
Stdout string
1823
Stderr string
@@ -65,3 +70,31 @@ func (r CmdRunResult) WithFriendlyYAMLStrings() CmdRunResult {
6570
func (r CmdRunResult) IsEmpty() bool {
6671
return r == (CmdRunResult{})
6772
}
73+
74+
// WithTruncatedStrings returns a copy of r with Stdout and Stderr each capped
75+
// to maxBytes by keeping the tail (the most actionable kapp output always
76+
// appears last). When a field is under the limit it is left unchanged.
77+
func (r CmdRunResult) WithTruncatedStrings(maxBytes int) CmdRunResult {
78+
return CmdRunResult{
79+
Stdout: truncateOutput(r.Stdout, maxBytes),
80+
Stderr: truncateOutput(r.Stderr, maxBytes),
81+
ExitCode: r.ExitCode,
82+
Error: r.Error,
83+
Finished: r.Finished,
84+
}
85+
}
86+
87+
// truncateOutput returns s unchanged when len(s) <= maxBytes. Otherwise it
88+
// returns TruncationMarker followed by the last maxBytes bytes of s, adjusted
89+
// forward to a valid UTF-8 rune boundary.
90+
func truncateOutput(s string, maxBytes int) string {
91+
if len(s) <= maxBytes {
92+
return s
93+
}
94+
start := len(s) - maxBytes
95+
// Advance past UTF-8 continuation bytes to avoid splitting a multi-byte rune.
96+
for start < len(s) && !utf8.RuneStart(s[start]) {
97+
start++
98+
}
99+
return TruncationMarker + s[start:]
100+
}

pkg/exec/cmd_run_result_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright 2026 The Carvel Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package exec
5+
6+
import (
7+
"strings"
8+
"testing"
9+
"unicode/utf8"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func Test_truncateOutput(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
input string
19+
maxBytes int
20+
check func(t *testing.T, result string)
21+
}{
22+
{
23+
name: "under limit is unchanged",
24+
input: "small output",
25+
maxBytes: 1024,
26+
check: func(t *testing.T, result string) {
27+
assert.Equal(t, "small output", result)
28+
},
29+
},
30+
{
31+
name: "exactly at limit is unchanged",
32+
input: strings.Repeat("x", 100),
33+
maxBytes: 100,
34+
check: func(t *testing.T, result string) {
35+
assert.Equal(t, strings.Repeat("x", 100), result)
36+
},
37+
},
38+
{
39+
name: "empty string is unchanged",
40+
input: "",
41+
maxBytes: 100,
42+
check: func(t *testing.T, result string) {
43+
assert.Equal(t, "", result)
44+
},
45+
},
46+
{
47+
name: "over limit has marker prefix and tail suffix",
48+
input: strings.Repeat("a", 200) + strings.Repeat("z", 50),
49+
maxBytes: 50,
50+
check: func(t *testing.T, result string) {
51+
assert.True(t, strings.HasPrefix(result, TruncationMarker))
52+
assert.True(t, strings.HasSuffix(result, strings.Repeat("z", 50)))
53+
// Marker does not count toward maxBytes.
54+
assert.Equal(t, len(TruncationMarker)+50, len(result))
55+
},
56+
},
57+
{
58+
name: "tail content preserved for realistic kapp output",
59+
// Many per-resource lines followed by an actionable summary. maxBytes
60+
// is set to hold only the last ~100 bytes so truncation is guaranteed,
61+
// and the summary at the tail must survive.
62+
input: strings.Repeat("op add configmap/cm-001 (v1) namespace: default\n", 100) +
63+
"Op: 100 add, 0 delete\nWait to: 100 reconcile\n",
64+
maxBytes: 100,
65+
check: func(t *testing.T, result string) {
66+
assert.True(t, strings.HasPrefix(result, TruncationMarker),
67+
"large output must start with the truncation marker")
68+
assert.Contains(t, result, "100 add",
69+
"the actionable summary at the tail must be preserved after truncation")
70+
},
71+
},
72+
{
73+
name: "UTF-8 boundary: continuation byte is skipped",
74+
// s = 98 'a's + "éé" (102 bytes)
75+
// maxBytes = 3 → start = 99 → s[99] = 0xA9 (continuation of first 'é')
76+
// → advance to 100 → s[100] = 0xC3 (leading byte of second 'é') → stop
77+
// kept = s[100:] = "é"
78+
input: strings.Repeat("a", 98) + "éé",
79+
maxBytes: 3,
80+
check: func(t *testing.T, result string) {
81+
require.Equal(t, 102, len(strings.Repeat("a", 98)+"éé"))
82+
assert.True(t, utf8.ValidString(result), "result must be valid UTF-8")
83+
assert.Equal(t, TruncationMarker+"é", result)
84+
},
85+
},
86+
{
87+
name: "pure multibyte runes produce valid UTF-8",
88+
input: strings.Repeat("あ", 100), // 300 bytes; each rune is 3 bytes
89+
maxBytes: 100,
90+
check: func(t *testing.T, result string) {
91+
// 100 / 3 = 33 complete runes (99 bytes); byte 100 is a continuation
92+
// byte and is skipped.
93+
assert.True(t, utf8.ValidString(result))
94+
assert.Equal(t, TruncationMarker+strings.Repeat("あ", 33), result)
95+
},
96+
},
97+
}
98+
99+
for _, tc := range tests {
100+
t.Run(tc.name, func(t *testing.T) {
101+
tc.check(t, truncateOutput(tc.input, tc.maxBytes))
102+
})
103+
}
104+
}
105+
106+
func Test_WithTruncatedStrings(t *testing.T) {
107+
const limit = 20
108+
r := CmdRunResult{
109+
Stdout: strings.Repeat("o", 100),
110+
Stderr: strings.Repeat("e", 100),
111+
ExitCode: 1,
112+
Finished: true,
113+
}
114+
got := r.WithTruncatedStrings(limit)
115+
116+
assert.True(t, strings.HasPrefix(got.Stdout, TruncationMarker))
117+
assert.True(t, strings.HasPrefix(got.Stderr, TruncationMarker))
118+
assert.Equal(t, r.ExitCode, got.ExitCode, "ExitCode must be preserved")
119+
assert.Equal(t, r.Finished, got.Finished, "Finished must be preserved")
120+
assert.Equal(t, r.Error, got.Error, "Error must be preserved")
121+
}

0 commit comments

Comments
 (0)