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) +}