From b687618783d659bed9b5199e714ef9794e96e56e Mon Sep 17 00:00:00 2001 From: Tian Pan Date: Fri, 9 Oct 2026 23:17:42 -0700 Subject: [PATCH] Emit a valid YAML stream from `logs -o yaml` Each log entry was marshalled with its own yaml.Marshal call and written back to back, so two or more entries produced one mapping with duplicate keys. Strict parsers reject that output, and permissive ones silently keep only the last entry. nonInteractiveLogs now writes every entry - listed history and tailed events alike - through one logWriter whose lazily created yaml.Encoder emits a `---`-separated multi-document stream. Zero entries still produce no output, each Encode flushes its document so tailing stays streaming, and JSON and text output are unchanged. --- cmd/logs.go | 49 +++++++++++++++---- cmd/logs_output_test.go | 105 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 cmd/logs_output_test.go diff --git a/cmd/logs.go b/cmd/logs.go index 029dac9..8b31ec8 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -108,14 +108,33 @@ In interactive mode you can update the filters and view logs in real time, or se return logCmd } -func writeLog(format command.Output, out io.Writer, log *lclient.Log) error { +// logWriter writes log entries in a non-interactive output format. A single +// logWriter must be used for every entry of one command invocation so that YAML +// output forms a valid multi-document stream (entries separated by "---") +// instead of concatenated mappings with duplicate keys. +type logWriter struct { + format command.Output + out io.Writer + yamlEncoder *yaml.Encoder +} + +func newLogWriter(format command.Output, out io.Writer) *logWriter { + return &logWriter{format: format, out: out} +} + +func (w *logWriter) write(log *lclient.Log) error { var str []byte var err error - if format == command.JSON { + if w.format == command.JSON { str, err = json.MarshalIndent(log, "", " ") - } else if format == command.YAML { - str, err = yaml.Marshal(log) - } else if format == command.TEXT { + } else if w.format == command.YAML { + // The encoder is created lazily so that zero entries produce an empty + // stream. Each Encode call flushes its document, preserving streaming. + if w.yamlEncoder == nil { + w.yamlEncoder = yaml.NewEncoder(w.out) + } + return w.yamlEncoder.Encode(log) + } else if w.format == command.TEXT { str = []byte(fmt.Sprintf("%s %s\n", log.Timestamp.Format(time.DateTime), log.Message)) } @@ -123,14 +142,23 @@ func writeLog(format command.Output, out io.Writer, log *lclient.Log) error { return err } - _, err = out.Write(str) + _, err = w.out.Write(str) return err } +func (w *logWriter) close() error { + if w.yamlEncoder == nil { + return nil + } + return w.yamlEncoder.Close() +} + func nonInteractiveLogs(logLoader *views.LogLoader, format *command.Output, cmd *cobra.Command, input views.LogInput) error { ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() + w := newLogWriter(*format, cmd.OutOrStdout()) + if !input.Tail { result, err := logLoader.ListLogs(ctx, input) if err != nil { @@ -140,11 +168,11 @@ func nonInteractiveLogs(logLoader *views.LogLoader, format *command.Output, cmd return nil } for _, log := range result.Logs { - if err := writeLog(*format, cmd.OutOrStdout(), &log); err != nil { + if err := w.write(&log); err != nil { return err } } - return nil + return w.close() } events, err := logLoader.TailLogs(ctx, input) @@ -156,7 +184,7 @@ func nonInteractiveLogs(logLoader *views.LogLoader, format *command.Output, cmd return event.Err } if event.Log != nil { - if err := writeLog(*format, cmd.OutOrStdout(), event.Log); err != nil { + if err := w.write(event.Log); err != nil { return err } } else if event.Status != "" { @@ -165,5 +193,8 @@ func nonInteractiveLogs(logLoader *views.LogLoader, format *command.Output, cmd } } } + if err := w.close(); err != nil { + return err + } return ctx.Err() } diff --git a/cmd/logs_output_test.go b/cmd/logs_output_test.go new file mode 100644 index 0000000..e0916d2 --- /dev/null +++ b/cmd/logs_output_test.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "encoding/json" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/render-oss/cli/internal/fakes/renderapi" + lclient "github.com/render-oss/cli/pkg/client/logs" +) + +// decodeYAMLStream strictly decodes every document in out until EOF. +func decodeYAMLStream(t *testing.T, out string) []lclient.Log { + t.Helper() + dec := yaml.NewDecoder(strings.NewReader(out)) + dec.KnownFields(true) + var decoded []lclient.Log + for { + var log lclient.Log + err := dec.Decode(&log) + if errors.Is(err, io.EOF) { + return decoded + } + require.NoError(t, err, "output:\n%s", out) + decoded = append(decoded, log) + } +} + +func orderedLogs() []lclient.Log { + base := time.Date(2026, 10, 8, 5, 0, 0, 0, time.UTC) + return []lclient.Log{ + renderapi.NewLog(lclient.Log{Message: "first marker", Timestamp: base}), + renderapi.NewLog(lclient.Log{Message: "second marker", Timestamp: base.Add(time.Second)}), + } +} + +// Two listed entries must be two YAML documents, not one mapping with +// duplicate keys that strict parsers reject and permissive parsers collapse. +func TestLogsListYAMLIsAMultiDocumentStream(t *testing.T) { + server := renderapi.NewServer(t) + logs := orderedLogs() + server.Logs.Instances = logs + + result, err := executeLogsCommand(t, server, "--output", "yaml") + require.NoError(t, err) + + decoded := decodeYAMLStream(t, result.Stdout) + require.Len(t, decoded, 2) + for i := range logs { + require.Equal(t, logs[i].Id, decoded[i].Id) + require.Equal(t, logs[i].Message, decoded[i].Message) + require.True(t, logs[i].Timestamp.Equal(decoded[i].Timestamp)) + } +} + +func TestLogsListYAMLWithNoEntriesIsEmpty(t *testing.T) { + server := renderapi.NewServer(t) + + result, err := executeLogsCommand(t, server, "--output", "yaml") + require.NoError(t, err) + require.Empty(t, result.Stdout) +} + +func TestLogsTailYAMLIsAMultiDocumentStream(t *testing.T) { + server := renderapi.NewServer(t) + logs := orderedLogs() + // The malformed trailing message ends the tail so the command returns. + server.Logs.QueueStreams(renderapi.LogStreamConfig{Logs: logs, RawMessage: "invalid JSON"}) + + result, _ := executeLogsCommand(t, server, "--tail", "--output", "yaml") + + decoded := decodeYAMLStream(t, result.Stdout) + require.Len(t, decoded, 2) + require.Equal(t, "first marker", decoded[0].Message) + require.Equal(t, "second marker", decoded[1].Message) +} + +// JSON and text output are unchanged: JSON stays one indented object per entry. +func TestLogsListJSONAndTextOutputUnchanged(t *testing.T) { + server := renderapi.NewServer(t) + logs := orderedLogs() + server.Logs.Instances = logs + + result, err := executeLogsCommand(t, server, "--output", "json") + require.NoError(t, err) + var want strings.Builder + for i := range logs { + b, err := json.MarshalIndent(&logs[i], "", " ") + require.NoError(t, err) + want.Write(b) + } + require.Equal(t, want.String(), result.Stdout) + + result, err = executeLogsCommand(t, server, "--output", "text") + require.NoError(t, err) + require.Equal(t, + "2026-10-08 05:00:00 first marker\n2026-10-08 05:00:01 second marker\n", + result.Stdout) +}