Skip to content

Commit 8de3cc0

Browse files
Return log messages in a single line for successful render with stderr (#1039)
* Return log messages in single line in generictaskhandler Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> * Create a helper function to manage string manipulation Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> * Add unit test for the helper function Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> * Address copilot comments Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> * Add unit tests for evaluator function and use flatline for all responses Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> * Remove accidentally created wrapper-server binary Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> * Add argument to wrapper server to switch between singleline/multiline log Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> * compute flattenlog only when its enabled Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> * Update unit test Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> --------- Signed-off-by: Kushal Harish Naidu <kushal.harish.naidu@ericsson.com> Co-authored-by: Liam Fallon <35595825+liamfallon@users.noreply.github.com>
1 parent 7c07de3 commit 8de3cc0

4 files changed

Lines changed: 253 additions & 6 deletions

File tree

func/wrapper-server/main.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"os"
2626
"os/exec"
2727
"strconv"
28+
"strings"
2829
"time"
2930

3031
"github.com/kptdev/krm-functions-sdk/go/fn"
@@ -60,6 +61,7 @@ func main() {
6061
cmd.Flags().IntVar(&op.port, "port", 9446, "The server port")
6162
cmd.Flags().IntVar(&op.maxGrpcMessageSize, "max-request-body-size", 6*1024*1024, "Maximum size of grpc messages in bytes.")
6263
cmd.Flags().IntVar(&op.logLevel, "verbosity", 2, "Verbosity of logs.")
64+
cmd.Flags().BoolVar(&op.flattenLog, "flatten-log", false, "Flatten multi-line stderr into a single line in response Log, gRPC error messages, and warning logs.")
6365

6466
flagSet := flag.NewFlagSet("log-level", flag.ContinueOnError)
6567
klog.InitFlags(flagSet)
@@ -76,6 +78,7 @@ type options struct {
7678
maxGrpcMessageSize int
7779
entrypoint []string
7880
logLevel int
81+
flattenLog bool
7982
}
8083

8184
func (o *options) run() error {
@@ -100,6 +103,7 @@ func (o *options) run() error {
100103

101104
evaluator := &singleFunctionEvaluator{
102105
entrypoint: o.entrypoint,
106+
flattenLog: o.flattenLog,
103107
}
104108

105109
klog.Infof("Listening on %s", address)
@@ -130,6 +134,7 @@ type singleFunctionEvaluator struct {
130134
pb.UnimplementedFunctionEvaluatorServer
131135

132136
entrypoint []string
137+
flattenLog bool
133138
}
134139

135140
func (e *singleFunctionEvaluator) EvaluateFunction(ctx context.Context, req *pb.EvaluateFunctionRequest) (*pb.EvaluateFunctionResponse, error) {
@@ -145,6 +150,7 @@ func (e *singleFunctionEvaluator) EvaluateFunction(ctx context.Context, req *pb.
145150
var exitErr *exec.ExitError
146151
outbytes := stdout.Bytes()
147152
stderrStr := stderr.String()
153+
stderrLog := e.stderrOutput(stderrStr)
148154

149155
if err != nil {
150156
klog.V(4).Infof("Input Resource List: %s\nOutput Resource List: %s", req.ResourceList, outbytes)
@@ -153,12 +159,12 @@ func (e *singleFunctionEvaluator) EvaluateFunction(ctx context.Context, req *pb.
153159
rl, pe := fn.ParseResourceList(outbytes)
154160
if pe != nil {
155161
// If we can't parse the output resource list, we only surface the content in stderr.
156-
return nil, status.Errorf(codes.Internal, "failed to parse the output of function %q with stderr '%v': %+v", req.Image, stderrStr, pe)
162+
return nil, status.Errorf(codes.Internal, "failed to parse the output of function %q with stderr '%v': %+v", req.Image, stderrLog, pe)
157163
}
158164

159-
return nil, status.Errorf(codes.Internal, "failed to evaluate function %q with structured results: %v and stderr: %v", req.Image, rl.Results.Error(), stderrStr)
165+
return nil, status.Errorf(codes.Internal, "failed to evaluate function %q with structured results: %v and stderr: %v", req.Image, rl.Results.Error(), stderrLog)
160166
} else {
161-
return nil, status.Errorf(codes.Internal, "Failed to execute function %q: %s (%s)", req.Image, err, stderrStr)
167+
return nil, status.Errorf(codes.Internal, "Failed to execute function %q: %s (%s)", req.Image, err, stderrLog)
162168
}
163169
}
164170

@@ -168,15 +174,37 @@ func (e *singleFunctionEvaluator) EvaluateFunction(ctx context.Context, req *pb.
168174
if pErr != nil {
169175
klog.V(4).Infof("Input Resource List: %s\nOutput Resource List: %s", req.ResourceList, outbytes)
170176
// If we can't parse the output resource list, we only surface the content in stderr.
171-
return nil, status.Errorf(codes.Internal, "failed to parse the output of function %q with stderr '%v': %+v", req.Image, stderrStr, pErr)
177+
return nil, status.Errorf(codes.Internal, "failed to parse the output of function %q with stderr '%v': %+v", req.Image, stderrLog, pErr)
172178
}
173179
if rl.Results.ExitCode() != 0 {
174180
jsonBytes, _ := json.Marshal(rl.Results)
175-
klog.Warningf("failed to evaluate function %q with structured results: %s and stderr: %v", req.Image, jsonBytes, stderrStr)
181+
klog.Warningf("failed to evaluate function %q with structured results: %s and stderr: %v", req.Image, jsonBytes, stderrLog)
176182
}
177183

178184
return &pb.EvaluateFunctionResponse{
179185
ResourceList: outbytes,
180-
Log: []byte(stderrStr),
186+
Log: []byte(stderrLog),
181187
}, nil
182188
}
189+
190+
// flattenStderr normalizes multi-line stderr output into a single line for safe
191+
// embedding in log messages and gRPC error descriptions. It handles Windows (\r\n),
192+
// Unix (\n), and standalone carriage returns (\r, used by progress-style output).
193+
// Only leading/trailing newline characters are trimmed; other whitespace (spaces,
194+
// tabs) is preserved.
195+
func flattenStderr(s string) string {
196+
s = strings.ReplaceAll(s, "\r\n", "\n")
197+
s = strings.ReplaceAll(s, "\r", "\n")
198+
s = strings.Trim(s, "\n")
199+
return strings.ReplaceAll(s, "\n", " | ")
200+
}
201+
202+
// stderrOutput returns the stderr content in the appropriate form.
203+
// When flattenLog is enabled, it normalizes multi-line stderr into a single line;
204+
// otherwise it returns the raw stderr as-is, avoiding unnecessary allocations.
205+
func (e *singleFunctionEvaluator) stderrOutput(raw string) string {
206+
if e.flattenLog {
207+
return flattenStderr(raw)
208+
}
209+
return raw
210+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/bin/bash
2+
3+
# Copyright 2025 The kpt Authors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
# This script writes multi-line stderr and exits with a non-zero code.
18+
# Used to test that gRPC error messages contain flattened stderr.
19+
20+
input=$(cat)
21+
printf "%s" "$input"
22+
23+
echo "Error: validation failed" >&2
24+
echo "Field 'name' is required" >&2
25+
echo "Field 'namespace' is required" >&2
26+
exit 1
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/bin/bash
2+
3+
# Copyright 2025 The kpt Authors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
# This script passes stdin through to stdout unchanged and writes multi-line
18+
# output to stderr. Used to test that EvaluateFunctionResponse.Log preserves
19+
# the raw multi-line stderr content.
20+
21+
input=$(cat)
22+
printf "%s" "$input"
23+
24+
echo "Starting mutation" >&2
25+
echo "Replacing value" >&2
26+
echo "Completed" >&2

func/wrapper-server/wrapper_server_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ import (
1818
"bytes"
1919
"context"
2020
"flag"
21+
"strings"
2122
"testing"
2223

2324
pb "github.com/kptdev/porch/func/evaluator"
2425
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
2527
"k8s.io/klog/v2"
2628
"sigs.k8s.io/kustomize/kyaml/kio"
2729
)
@@ -190,3 +192,168 @@ func createMockResourceList(pkg string) []byte {
190192

191193
return b.Bytes()
192194
}
195+
196+
func TestFlattenStderr(t *testing.T) {
197+
tests := []struct {
198+
name string
199+
input string
200+
expected string
201+
}{
202+
{
203+
name: "single line no newline",
204+
input: "hello world",
205+
expected: "hello world",
206+
},
207+
{
208+
name: "multiple lines",
209+
input: "Starting mutation\nReplacing value\nCompleted",
210+
expected: "Starting mutation | Replacing value | Completed",
211+
},
212+
{
213+
name: "trailing newline",
214+
input: "Starting mutation\nReplacing value\n",
215+
expected: "Starting mutation | Replacing value",
216+
},
217+
{
218+
name: "leading and trailing newlines",
219+
input: "\nStarting mutation\nReplacing value\n",
220+
expected: "Starting mutation | Replacing value",
221+
},
222+
{
223+
name: "preserves leading spaces",
224+
input: " indented line\nnext line",
225+
expected: " indented line | next line",
226+
},
227+
{
228+
name: "preserves trailing spaces",
229+
input: "line with trailing space \nanother line",
230+
expected: "line with trailing space | another line",
231+
},
232+
{
233+
name: "windows line endings",
234+
input: "Starting mutation\r\nReplacing value\r\nCompleted\r\n",
235+
expected: "Starting mutation | Replacing value | Completed",
236+
},
237+
{
238+
name: "mixed line endings",
239+
input: "line1\r\nline2\nline3\r\n",
240+
expected: "line1 | line2 | line3",
241+
},
242+
{
243+
name: "standalone carriage return",
244+
input: "line1\rline2",
245+
expected: "line1 | line2",
246+
},
247+
{
248+
name: "trailing carriage return",
249+
input: "progress output\r",
250+
expected: "progress output",
251+
},
252+
{
253+
name: "empty string",
254+
input: "",
255+
expected: "",
256+
},
257+
{
258+
name: "single newline",
259+
input: "\n",
260+
expected: "",
261+
},
262+
}
263+
264+
for _, tt := range tests {
265+
t.Run(tt.name, func(t *testing.T) {
266+
result := flattenStderr(tt.input)
267+
assert.Equal(t, tt.expected, result)
268+
})
269+
}
270+
}
271+
272+
func TestEvaluateFunction_StderrLogIsFlattened(t *testing.T) {
273+
// Integration test: verifies that when --flatten-log is enabled,
274+
// EvaluateFunctionResponse.Log contains flattened single-line stderr.
275+
evaluator := singleFunctionEvaluator{
276+
entrypoint: []string{"./testdata/stderr_multiline_test.sh"},
277+
flattenLog: true,
278+
}
279+
req := &pb.EvaluateFunctionRequest{
280+
ResourceList: createMockResourceList("./testdata/deployment.yaml"),
281+
Image: "test-stderr",
282+
}
283+
284+
resp, err := evaluator.EvaluateFunction(context.Background(), req)
285+
require.NoError(t, err)
286+
require.NotNil(t, resp)
287+
288+
logStr := string(resp.Log)
289+
assert.Equal(t, "Starting mutation | Replacing value | Completed", logStr)
290+
assert.NotContains(t, logStr, "\n", "resp.Log should be flattened, not contain raw newlines")
291+
}
292+
293+
func TestEvaluateFunction_StderrLogPreservesRawByDefault(t *testing.T) {
294+
// Integration test: verifies that without --flatten-log,
295+
// EvaluateFunctionResponse.Log preserves the raw multi-line stderr.
296+
evaluator := singleFunctionEvaluator{
297+
entrypoint: []string{"./testdata/stderr_multiline_test.sh"},
298+
flattenLog: false,
299+
}
300+
req := &pb.EvaluateFunctionRequest{
301+
ResourceList: createMockResourceList("./testdata/deployment.yaml"),
302+
Image: "test-stderr",
303+
}
304+
305+
resp, err := evaluator.EvaluateFunction(context.Background(), req)
306+
require.NoError(t, err)
307+
require.NotNil(t, resp)
308+
309+
logStr := string(resp.Log)
310+
assert.Contains(t, logStr, "Starting mutation\n")
311+
assert.Contains(t, logStr, "Replacing value\n")
312+
assert.Contains(t, logStr, "Completed\n")
313+
assert.NotContains(t, logStr, " | ", "resp.Log should preserve raw newlines when flatten-log is disabled")
314+
}
315+
316+
func TestEvaluateFunction_StderrErrorContainsFlattenedMessage(t *testing.T) {
317+
// Integration test: verifies that on failure with --flatten-log enabled,
318+
// the gRPC error message contains flattened (single-line) stderr.
319+
evaluator := singleFunctionEvaluator{
320+
entrypoint: []string{"./testdata/stderr_multiline_fail_test.sh"},
321+
flattenLog: true,
322+
}
323+
req := &pb.EvaluateFunctionRequest{
324+
ResourceList: createMockResourceList("./testdata/deployment.yaml"),
325+
Image: "test-stderr-fail",
326+
}
327+
328+
resp, err := evaluator.EvaluateFunction(context.Background(), req)
329+
require.Error(t, err)
330+
require.Nil(t, resp)
331+
332+
errMsg := err.Error()
333+
// The error string should contain pipe-separated (flattened) stderr lines.
334+
assert.True(t, strings.Contains(errMsg, " | "),
335+
"error message should contain flattened stderr with ' | ' separator, got: %s", errMsg)
336+
assert.NotContains(t, errMsg, "\n",
337+
"error message should not contain raw newlines")
338+
}
339+
340+
func TestEvaluateFunction_StderrErrorPreservesRawByDefault(t *testing.T) {
341+
// Integration test: verifies that on failure without --flatten-log,
342+
// the gRPC error message contains raw multi-line stderr.
343+
evaluator := singleFunctionEvaluator{
344+
entrypoint: []string{"./testdata/stderr_multiline_fail_test.sh"},
345+
flattenLog: false,
346+
}
347+
req := &pb.EvaluateFunctionRequest{
348+
ResourceList: createMockResourceList("./testdata/deployment.yaml"),
349+
Image: "test-stderr-fail",
350+
}
351+
352+
resp, err := evaluator.EvaluateFunction(context.Background(), req)
353+
require.Error(t, err)
354+
require.Nil(t, resp)
355+
356+
errMsg := err.Error()
357+
assert.Contains(t, errMsg, "\n", "error message should include raw newlines when flatten-log is disabled")
358+
assert.NotContains(t, errMsg, " | ", "error message should preserve raw newlines when flatten-log is disabled")
359+
}

0 commit comments

Comments
 (0)