Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,29 +108,57 @@ 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))
}

if err != nil {
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 {
Expand All @@ -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)
Expand All @@ -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 != "" {
Expand All @@ -165,5 +193,8 @@ func nonInteractiveLogs(logLoader *views.LogLoader, format *command.Output, cmd
}
}
}
if err := w.close(); err != nil {
return err
}
return ctx.Err()
}
105 changes: 105 additions & 0 deletions cmd/logs_output_test.go
Original file line number Diff line number Diff line change
@@ -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)
}