Files
dozzle/internal/cloud/tools_stream_test.go
T

469 lines
16 KiB
Go

package cloud
import (
"context"
"regexp"
"sync"
"testing"
"time"
"github.com/amir20/dozzle/internal/container"
container_support "github.com/amir20/dozzle/internal/support/container"
pb "github.com/amir20/dozzle/proto/cloud"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestParseStreamArgs_Valid(t *testing.T) {
args, re, err := parseStreamArgs(`{"container_id":"abc","host_id":"host1","level":"error","regex":"foo.*bar"}`)
assert.NoError(t, err)
assert.Equal(t, "abc", args.ContainerID)
assert.Equal(t, "host1", args.Host)
assert.Equal(t, "error", args.Level)
assert.NotNil(t, re)
}
func TestParseStreamArgs_HostOptional(t *testing.T) {
// host_id is optional now — only container_id is required.
args, _, err := parseStreamArgs(`{"container_id":"abc"}`)
assert.NoError(t, err)
assert.Equal(t, "abc", args.ContainerID)
assert.Empty(t, args.Host)
}
func TestParseStreamArgs_MissingContainer(t *testing.T) {
_, _, err := parseStreamArgs(`{"host_id":"host1"}`)
assert.Error(t, err)
assert.Contains(t, err.Error(), "container_id is required")
}
func TestParseStreamArgs_InvalidJSON(t *testing.T) {
_, _, err := parseStreamArgs(`{invalid`)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse arguments")
}
func TestParseStreamArgs_InvalidRegex(t *testing.T) {
_, _, err := parseStreamArgs(`{"container_id":"abc","host_id":"h","regex":"[invalid"}`)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid regex pattern")
}
func TestParseStreamArgs_NoRegex(t *testing.T) {
args, re, err := parseStreamArgs(`{"container_id":"abc","host_id":"h"}`)
assert.NoError(t, err)
assert.Equal(t, "abc", args.ContainerID)
assert.Nil(t, re)
}
func TestMatchesFilters_NoFilters(t *testing.T) {
event := &container.LogEvent{RawMessage: "hello world", Level: "info"}
args := &fetchLogsArgs{}
msg, ok := matchesFilters(event, args, nil)
assert.True(t, ok)
assert.Equal(t, "hello world", msg)
}
func TestMatchesFilters_LevelMismatch(t *testing.T) {
event := &container.LogEvent{RawMessage: "hello", Level: "info"}
args := &fetchLogsArgs{Level: "error"}
_, ok := matchesFilters(event, args, nil)
assert.False(t, ok)
}
func TestMatchesFilters_LevelMatch(t *testing.T) {
event := &container.LogEvent{RawMessage: "hello", Level: "ERROR"}
args := &fetchLogsArgs{Level: "error"}
msg, ok := matchesFilters(event, args, nil)
assert.True(t, ok)
assert.Equal(t, "hello", msg)
}
func TestMatchesFilters_QueryMatch(t *testing.T) {
event := &container.LogEvent{RawMessage: "Hello World", Level: "info"}
args := &fetchLogsArgs{Query: "hello"}
msg, ok := matchesFilters(event, args, nil)
assert.True(t, ok)
assert.Equal(t, "Hello World", msg)
}
func TestMatchesFilters_QueryMismatch(t *testing.T) {
event := &container.LogEvent{RawMessage: "Hello World", Level: "info"}
args := &fetchLogsArgs{Query: "missing"}
_, ok := matchesFilters(event, args, nil)
assert.False(t, ok)
}
func TestMatchesFilters_RegexMatch(t *testing.T) {
event := &container.LogEvent{RawMessage: "error: connection refused", Level: "error"}
args := &fetchLogsArgs{}
re := regexp.MustCompile(`error:.*refused`)
msg, ok := matchesFilters(event, args, re)
assert.True(t, ok)
assert.Equal(t, "error: connection refused", msg)
}
func TestMatchesFilters_RegexMismatch(t *testing.T) {
event := &container.LogEvent{RawMessage: "info: all good", Level: "info"}
args := &fetchLogsArgs{}
re := regexp.MustCompile(`error:.*refused`)
_, ok := matchesFilters(event, args, re)
assert.False(t, ok)
}
func TestMatchesFilters_FallbackToMessage(t *testing.T) {
event := &container.LogEvent{RawMessage: "", Message: "fallback msg", Level: "info"}
args := &fetchLogsArgs{}
msg, ok := matchesFilters(event, args, nil)
assert.True(t, ok)
assert.Equal(t, "fallback msg", msg)
}
func TestMatchesFilters_InverseRegex(t *testing.T) {
re := regexp.MustCompile(`error`)
tests := []struct {
name string
message string
level string
args fetchLogsArgs
re *regexp.Regexp
wantOk bool
}{
{
name: "normal mode: regex matches",
message: "error: connection refused",
level: "info",
args: fetchLogsArgs{},
re: re,
wantOk: true,
},
{
name: "normal mode: regex no match",
message: "info: all good",
level: "info",
args: fetchLogsArgs{},
re: re,
wantOk: false,
},
{
name: "inverse mode: regex matches is excluded",
message: "error: connection refused",
level: "info",
args: fetchLogsArgs{Inverse: true},
re: re,
wantOk: false,
},
{
name: "inverse mode: regex no match is included",
message: "info: all good",
level: "info",
args: fetchLogsArgs{Inverse: true},
re: re,
wantOk: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := &container.LogEvent{RawMessage: tt.message, Level: tt.level}
_, ok := matchesFilters(event, &tt.args, tt.re)
assert.Equal(t, tt.wantOk, ok)
})
}
}
func TestMatchesFilters_InverseQuery(t *testing.T) {
tests := []struct {
name string
message string
args fetchLogsArgs
wantOk bool
}{
{
name: "normal query match",
message: "hello world",
args: fetchLogsArgs{Query: "hello"},
wantOk: true,
},
{
name: "normal query no match",
message: "foo bar",
args: fetchLogsArgs{Query: "hello"},
wantOk: false,
},
{
name: "inverse query: match is excluded",
message: "hello world",
args: fetchLogsArgs{Query: "hello", Inverse: true},
wantOk: false,
},
{
name: "inverse query: no match is included",
message: "foo bar",
args: fetchLogsArgs{Query: "hello", Inverse: true},
wantOk: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := &container.LogEvent{RawMessage: tt.message, Level: "info"}
_, ok := matchesFilters(event, &tt.args, nil)
assert.Equal(t, tt.wantOk, ok)
})
}
}
// StreamMockClientService extends MockClientService to allow controllable StreamLogs behavior
type StreamMockClientService struct {
MockClientService
streamFunc func(ctx context.Context, c container.Container, from time.Time, stdTypes container.StdType, events chan<- *container.LogEvent) error
}
func (m *StreamMockClientService) StreamLogs(ctx context.Context, c container.Container, from time.Time, stdTypes container.StdType, events chan<- *container.LogEvent) error {
if m.streamFunc != nil {
return m.streamFunc(ctx, c, from, stdTypes, events)
}
return nil
}
func TestExecuteStreamLogs_BasicFlow(t *testing.T) {
mockClient := &StreamMockClientService{}
mockClient.streamFunc = func(ctx context.Context, _ container.Container, _ time.Time, _ container.StdType, events chan<- *container.LogEvent) error {
events <- &container.LogEvent{RawMessage: "line 1", Level: "info", Stream: "stdout", Timestamp: 1000}
events <- &container.LogEvent{RawMessage: "line 2", Level: "info", Stream: "stdout", Timestamp: 2000}
return nil
}
cs := container_support.NewContainerService(mockClient, container.Container{ID: "abc123", Name: "test-container", Host: "host1"})
mockHost := &MockHostService{}
withResolver(mockHost, container.Container{ID: "abc123", Name: "test-container", Host: "host1"})
mockHost.On("FindContainer", "host1", "abc123", container.ContainerLabels(nil)).Return(cs, nil)
var mu sync.Mutex
var responses []*pb.ToolResponse
send := func(resp *pb.ToolResponse) error {
mu.Lock()
defer mu.Unlock()
responses = append(responses, resp)
return nil
}
argsJSON := `{"container_id":"abc123","host_id":"host1"}`
err := executeStreamLogs(context.Background(), "req1", argsJSON, ToolDeps{HostService: mockHost}, send)
assert.NoError(t, err)
mu.Lock()
defer mu.Unlock()
// Should have at least one response with end_stream=true
assert.NotEmpty(t, responses)
lastResp := responses[len(responses)-1]
callTool := lastResp.GetCallTool()
assert.NotNil(t, callTool)
assert.True(t, callTool.EndStream)
}
func TestExecuteStreamLogs_WithLevelFilter(t *testing.T) {
mockClient := &StreamMockClientService{}
mockClient.streamFunc = func(ctx context.Context, _ container.Container, _ time.Time, _ container.StdType, events chan<- *container.LogEvent) error {
events <- &container.LogEvent{RawMessage: "info msg", Level: "info", Stream: "stdout", Timestamp: 1000}
events <- &container.LogEvent{RawMessage: "error msg", Level: "error", Stream: "stderr", Timestamp: 2000}
events <- &container.LogEvent{RawMessage: "another info", Level: "info", Stream: "stdout", Timestamp: 3000}
return nil
}
cs := container_support.NewContainerService(mockClient, container.Container{ID: "abc123", Name: "test-container", Host: "host1"})
mockHost := &MockHostService{}
withResolver(mockHost, container.Container{ID: "abc123", Name: "test-container", Host: "host1"})
mockHost.On("FindContainer", "host1", "abc123", container.ContainerLabels(nil)).Return(cs, nil)
var mu sync.Mutex
var responses []*pb.ToolResponse
send := func(resp *pb.ToolResponse) error {
mu.Lock()
defer mu.Unlock()
responses = append(responses, resp)
return nil
}
argsJSON := `{"container_id":"abc123","host_id":"host1","level":"error"}`
err := executeStreamLogs(context.Background(), "req1", argsJSON, ToolDeps{HostService: mockHost}, send)
assert.NoError(t, err)
mu.Lock()
defer mu.Unlock()
// Collect all entries across all responses
var allEntries []*pb.LogEntry
for _, resp := range responses {
ct := resp.GetCallTool()
if ct != nil && ct.GetFetchLogs() != nil {
allEntries = append(allEntries, ct.GetFetchLogs().Entries...)
}
}
assert.Len(t, allEntries, 1)
assert.Equal(t, "error msg", allEntries[0].Message)
}
func TestExecuteStreamLogs_CancelContext(t *testing.T) {
mockClient := &StreamMockClientService{}
mockClient.streamFunc = func(ctx context.Context, _ container.Container, _ time.Time, _ container.StdType, events chan<- *container.LogEvent) error {
// Block until context is cancelled
<-ctx.Done()
return ctx.Err()
}
cs := container_support.NewContainerService(mockClient, container.Container{ID: "abc123", Name: "test-container", Host: "host1"})
mockHost := &MockHostService{}
withResolver(mockHost, container.Container{ID: "abc123", Name: "test-container", Host: "host1"})
mockHost.On("FindContainer", "host1", "abc123", container.ContainerLabels(nil)).Return(cs, nil)
var mu sync.Mutex
var responses []*pb.ToolResponse
send := func(resp *pb.ToolResponse) error {
mu.Lock()
defer mu.Unlock()
responses = append(responses, resp)
return nil
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- executeStreamLogs(ctx, "req1", `{"container_id":"abc123","host_id":"host1"}`, ToolDeps{HostService: mockHost}, send)
}()
// Give it a moment to start, then cancel
time.Sleep(50 * time.Millisecond)
cancel()
err := <-done
assert.ErrorIs(t, err, context.Canceled)
mu.Lock()
defer mu.Unlock()
// Should have sent end_stream
if len(responses) > 0 {
lastResp := responses[len(responses)-1]
callTool := lastResp.GetCallTool()
assert.True(t, callTool.EndStream)
}
}
func TestExecuteStreamLogs_InvalidArgs(t *testing.T) {
mockHost := &MockHostService{}
send := func(resp *pb.ToolResponse) error { return nil }
err := executeStreamLogs(context.Background(), "req1", `{invalid`, ToolDeps{HostService: mockHost}, send)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse arguments")
}
func TestExecuteStreamLogs_ContainerNotFound(t *testing.T) {
// host1 exists but the requested container is absent from the listing. With
// an explicit host the resolver falls through to FindContainer's direct
// lookup (legacy path), which reports the not-found error.
mockHost := &MockHostService{}
mockHost.On("ListAllContainers", container.ContainerLabels(nil)).Return([]container.Container{}, nil)
mockHost.On("Hosts").Return([]container.Host{{ID: "host1", Name: "host1"}})
mockHost.On("FindContainer", "host1", "missing", container.ContainerLabels(nil)).Return(nil, assert.AnError)
send := func(resp *pb.ToolResponse) error { return nil }
err := executeStreamLogs(context.Background(), "req1", `{"container_id":"missing","host_id":"host1"}`, ToolDeps{HostService: mockHost}, send)
assert.Error(t, err)
assert.Contains(t, err.Error(), "container not found")
}
func TestExecuteStreamLogs_BatchingAt50(t *testing.T) {
mockClient := &StreamMockClientService{}
mockClient.streamFunc = func(ctx context.Context, _ container.Container, _ time.Time, _ container.StdType, events chan<- *container.LogEvent) error {
for i := range 60 {
events <- &container.LogEvent{RawMessage: "msg", Level: "info", Stream: "stdout", Timestamp: int64(i)}
}
return nil
}
cs := container_support.NewContainerService(mockClient, container.Container{ID: "abc123", Name: "test-container", Host: "host1"})
mockHost := &MockHostService{}
withResolver(mockHost, container.Container{ID: "abc123", Name: "test-container", Host: "host1"})
mockHost.On("FindContainer", mock.Anything, mock.Anything, mock.Anything).Return(cs, nil)
var mu sync.Mutex
var responses []*pb.ToolResponse
send := func(resp *pb.ToolResponse) error {
mu.Lock()
defer mu.Unlock()
responses = append(responses, resp)
return nil
}
err := executeStreamLogs(context.Background(), "req1", `{"container_id":"abc123","host_id":"host1"}`, ToolDeps{HostService: mockHost}, send)
assert.NoError(t, err)
mu.Lock()
defer mu.Unlock()
// First batch should have 50 entries (batch at batchSize=50)
assert.GreaterOrEqual(t, len(responses), 2, "should have at least 2 responses (batch + end_stream)")
firstBatch := responses[0].GetCallTool().GetFetchLogs()
assert.Len(t, firstBatch.Entries, 50)
assert.False(t, responses[0].GetCallTool().EndStream)
}
func TestExecuteStreamLogs_StreamsFromContainerStart(t *testing.T) {
// Docker caps the follow stream at the last 100 lines and k8s at 500, so
// `from` is what decides how many of them survive the since-filter. Passing
// a recent timestamp discarded the backlog and left the cloud dashboard on
// an empty pane until the container next logged. Anchor at StartedAt so the
// backend's tail cap delivers the tail it already fetched.
startedAt := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
var gotFrom time.Time
mockClient := &StreamMockClientService{}
mockClient.streamFunc = func(_ context.Context, _ container.Container, from time.Time, _ container.StdType, events chan<- *container.LogEvent) error {
gotFrom = from
events <- &container.LogEvent{RawMessage: "backlog", Level: "info", Stream: "stdout", Timestamp: 1000}
return nil
}
c := container.Container{ID: "abc123", Name: "test-container", Host: "host1", StartedAt: startedAt}
mockHost := &MockHostService{}
withResolver(mockHost, c)
mockHost.On("FindContainer", "host1", "abc123", container.ContainerLabels(nil)).
Return(container_support.NewContainerService(mockClient, c), nil)
err := executeStreamLogs(context.Background(), "req1", `{"container_id":"abc123","host_id":"host1"}`,
ToolDeps{HostService: mockHost}, func(*pb.ToolResponse) error { return nil })
assert.NoError(t, err)
assert.Equal(t, startedAt, gotFrom)
}
func TestExecuteStreamLogs_FallsBackWhenStartedAtUnknown(t *testing.T) {
// A container with no StartedAt (agent gaps, odd backends) must still get a
// usable window rather than the zero time, which would ask the daemon for
// every line it has ever held.
var gotFrom time.Time
mockClient := &StreamMockClientService{}
mockClient.streamFunc = func(_ context.Context, _ container.Container, from time.Time, _ container.StdType, _ chan<- *container.LogEvent) error {
gotFrom = from
return nil
}
c := container.Container{ID: "abc123", Name: "test-container", Host: "host1"}
mockHost := &MockHostService{}
withResolver(mockHost, c)
mockHost.On("FindContainer", "host1", "abc123", container.ContainerLabels(nil)).
Return(container_support.NewContainerService(mockClient, c), nil)
err := executeStreamLogs(context.Background(), "req1", `{"container_id":"abc123","host_id":"host1"}`,
ToolDeps{HostService: mockHost}, func(*pb.ToolResponse) error { return nil })
assert.NoError(t, err)
assert.False(t, gotFrom.IsZero())
assert.WithinDuration(t, time.Now(), gotFrom, time.Minute)
}