diff --git a/CHANGELOG.md b/CHANGELOG.md index 38546ae66..66f560078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `tt log`: a module for viewing instances logs. Supported options: + * `--lines` number of lines to print. + * `--follow` print appended data as log files grow. + ## [2.3.1] - 2024-06-13 ### Added diff --git a/cli/cmd/log.go b/cli/cmd/log.go new file mode 100644 index 000000000..a0abc16a6 --- /dev/null +++ b/cli/cmd/log.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + + "github.com/spf13/cobra" + "github.com/tarantool/tt/cli/cmd/internal" + "github.com/tarantool/tt/cli/cmdcontext" + "github.com/tarantool/tt/cli/modules" + "github.com/tarantool/tt/cli/running" + "github.com/tarantool/tt/cli/tail" + "github.com/tarantool/tt/cli/util" +) + +var logOpts struct { + nLines int // How many lines to print. + follow bool // Follow logs output. +} + +// NewLogCmd creates log command. +func NewLogCmd() *cobra.Command { + var logCmd = &cobra.Command{ + Use: "log [ | ] [flags]", + Short: `Get logs of instance(s)`, + Run: func(cmd *cobra.Command, args []string) { + cmdCtx.CommandName = cmd.Name() + err := modules.RunCmd(&cmdCtx, cmd.CommandPath(), &modulesInfo, + internalLogModule, args) + util.HandleCmdErr(cmd, err) + }, + ValidArgsFunction: func( + cmd *cobra.Command, + args []string, + toComplete string) ([]string, cobra.ShellCompDirective) { + return internal.ValidArgsFunction( + cliOpts, &cmdCtx, cmd, toComplete, + running.ExtractAppNames, + running.ExtractInstanceNames) + }, + } + + logCmd.Flags().IntVarP(&logOpts.nLines, "lines", "n", 10, + "Count of last lines to output") + logCmd.Flags().BoolVarP(&logOpts.follow, "follow", "f", false, + "Output appended data as the log file grows") + + return logCmd +} + +func printLines(ctx context.Context, in <-chan string) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case line, ok := <-in: + if !ok { + return nil + } + fmt.Println(line) + } + } +} + +func follow(instances []running.InstanceCtx, n int) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + nextColor := tail.DefaultColorPicker() + color := nextColor() + const logLinesChannelCapacity = 64 + logLines := make(chan string, logLinesChannelCapacity) + tailRoutinesStarted := 0 + for _, inst := range instances { + if err := tail.Follow(ctx, logLines, + tail.NewLogFormatter(running.GetAppInstanceName(inst)+": ", color), + inst.Log, n); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + stop() + return fmt.Errorf("cannot read log file %q: %s", inst.Log, err) + } + tailRoutinesStarted++ + color = nextColor() + } + + if tailRoutinesStarted > 0 { + return printLines(ctx, logLines) + } + return nil +} + +func printLastN(instances []running.InstanceCtx, n int) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + nextColor := tail.DefaultColorPicker() + color := nextColor() + for _, inst := range instances { + logLines, err := tail.TailN(ctx, + tail.NewLogFormatter(running.GetAppInstanceName(inst)+": ", color), inst.Log, n) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + stop() + return fmt.Errorf("cannot read log file %q: %s", inst.Log, err) + } + if err := printLines(ctx, logLines); err != nil { + return err + } + color = nextColor() + } + return nil +} + +// internalLogModule is a default log module. +func internalLogModule(cmdCtx *cmdcontext.CmdCtx, args []string) error { + if !isConfigExist(cmdCtx) { + return errNoConfig + } + + var err error + var runningCtx running.RunningCtx + if err = running.FillCtx(cliOpts, cmdCtx, &runningCtx, args); err != nil { + return err + } + + if logOpts.follow { + return follow(runningCtx.Instances, logOpts.nLines) + } + + return printLastN(runningCtx.Instances, logOpts.nLines) +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 4a49ee720..4e9c5416f 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -187,6 +187,7 @@ After that tt will be able to manage the application using 'replicaset_example' NewEnvCmd(), NewDownloadCmd(), NewKillCmd(), + NewLogCmd(), ) if err := injectCmds(rootCmd); err != nil { panic(err.Error()) diff --git a/cli/tail/color.go b/cli/tail/color.go new file mode 100644 index 000000000..6a0ada096 --- /dev/null +++ b/cli/tail/color.go @@ -0,0 +1,24 @@ +package tail + +import "github.com/fatih/color" + +// ColorPicker returns a color. +type ColorPicker func() color.Color + +// DefaultColorPicker create a color picker to get a color from a default colors set. +func DefaultColorPicker() ColorPicker { + var colorTable = []color.Color{ + *color.New(color.FgCyan), + *color.New(color.FgGreen), + *color.New(color.FgMagenta), + *color.New(color.FgYellow), + *color.New(color.FgBlue), + } + + i := 0 + return func() color.Color { + color := colorTable[i] + i = (i + 1) % len(colorTable) + return color + } +} diff --git a/cli/tail/color_test.go b/cli/tail/color_test.go new file mode 100644 index 000000000..50817c72f --- /dev/null +++ b/cli/tail/color_test.go @@ -0,0 +1,24 @@ +package tail_test + +import ( + "testing" + + "github.com/fatih/color" + "github.com/tarantool/tt/cli/tail" +) + +func TestDefaultColorPicker(t *testing.T) { + expectedColors := []color.Color{ + *color.New(color.FgCyan), + *color.New(color.FgGreen), + *color.New(color.FgMagenta), + *color.New(color.FgYellow), + *color.New(color.FgBlue), + } + + colorPicker := tail.DefaultColorPicker() + for i := 0; i < 10; i++ { + got := colorPicker() + got.Equals(&expectedColors[i%len(expectedColors)]) + } +} diff --git a/cli/tail/tail.go b/cli/tail/tail.go new file mode 100644 index 000000000..926acba8d --- /dev/null +++ b/cli/tail/tail.go @@ -0,0 +1,172 @@ +package tail + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/fatih/color" + "github.com/nxadm/tail" +) + +const blockSize = 8192 + +// LogFormatter is a function used to format log string before output. +type LogFormatter func(str string) string + +// NewLogFormatter creates a function to make log prefix colored. +func NewLogFormatter(prefix string, color color.Color) LogFormatter { + buf := strings.Builder{} + buf.Grow(512) + return func(str string) string { + buf.Reset() + color.Fprint(&buf, prefix) + buf.WriteString(str) + return buf.String() + } +} + +// newTailReader returns a reader for last count lines. +func newTailReader(ctx context.Context, reader io.ReadSeeker, count int) (io.Reader, int64, error) { + end, err := reader.Seek(0, io.SeekEnd) + if err != nil { + return nil, 0, err + } + + if count <= 0 { + return &io.LimitedReader{R: reader, N: 0}, end, nil + } + + startPos := end + // Skip last char because it can be new-line. For example, tail reader for 'line\n' and n==1 + // should not count last \n as a line. + readOffset := end - 1 + + buf := make([]byte, blockSize) + linesFound := 0 + for readOffset != 0 && linesFound != count { + + select { + case <-ctx.Done(): + return nil, 0, ctx.Err() + default: + } + + limitedReader := io.LimitedReader{R: reader, N: int64(len(buf))} + readOffset -= limitedReader.N + if readOffset < 0 { + limitedReader.N += readOffset + readOffset = 0 + } + readOffset, err = reader.Seek(readOffset, io.SeekStart) + if err != nil { + return nil, 0, err + } + readBytes, err := limitedReader.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return nil, startPos, fmt.Errorf("failed to read: %s", err) + } + for i := readBytes - 1; i > 0; i-- { + if buf[i] == '\n' { + // In case of \n\n\n bytes, start position should not be moved one byte forward. + if startPos-(readOffset+int64(i)) == 1 { + startPos = readOffset + int64(i) + } else { + startPos = readOffset + int64(i) + 1 + } + + linesFound++ + if linesFound == count { + break + } + } + } + } + if linesFound == count { + reader.Seek(startPos, io.SeekStart) + return &io.LimitedReader{R: reader, N: end - startPos}, startPos, nil + } + reader.Seek(0, io.SeekStart) + return &io.LimitedReader{R: reader, N: end}, 0, nil +} + +// TailN calls sends last n lines of the file to the channel. +func TailN(ctx context.Context, logFormatter LogFormatter, fileName string, + n int) (<-chan string, error) { + if n < 0 { + return nil, fmt.Errorf("negative lines count is not supported") + } + + file, err := os.Open(fileName) + if err != nil { + return nil, fmt.Errorf("cannot open %q: %w", fileName, err) + } + + reader, _, err := newTailReader(ctx, file, n) + if err != nil { + file.Close() + return nil, err + } + + scanner := bufio.NewScanner(reader) + out := make(chan string, 8) + go func() { + defer close(out) + defer file.Close() + for scanner.Scan() { + select { + case <-ctx.Done(): + return + case out <- logFormatter(scanner.Text()): + } + } + }() + return out, nil +} + +// Follow sends to the channel each new line from the file as it grows. +func Follow(ctx context.Context, out chan<- string, logFormatter LogFormatter, fileName string, + n int) error { + file, err := os.Open(fileName) + if err != nil { + return fmt.Errorf("cannot open %q: %w", fileName, err) + } + defer file.Close() + + _, startPos, err := newTailReader(ctx, file, n) + if err != nil { + return err + } + + t, err := tail.TailFile(fileName, tail.Config{ + Location: &tail.SeekInfo{ + Offset: startPos, + Whence: io.SeekStart, + }, + MustExist: true, + Follow: true, + ReOpen: true, + CompleteLines: false, + Logger: tail.DiscardingLogger}) + if err != nil { + return err + } + + go func() { + for { + select { + case <-ctx.Done(): + t.Stop() + t.Wait() + return + case line := <-t.Lines: + out <- logFormatter(line.Text) + } + } + }() + return nil +} diff --git a/cli/tail/tail_test.go b/cli/tail/tail_test.go new file mode 100644 index 000000000..d19e6901a --- /dev/null +++ b/cli/tail/tail_test.go @@ -0,0 +1,340 @@ +package tail + +import ( + "bytes" + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTailReader(t *testing.T) { + type args struct { + text []byte + count int + } + + fiveLines := []byte(`one +two +three +four +five +`) + + last1000 := bytes.Repeat([]byte("one two three four five\n"), 1000) + + tests := []struct { + name string + args args + want []byte + wantErr bool + }{ + { + name: "Last 3 lines", + args: args{ + text: fiveLines, + count: 3, + }, + want: fiveLines[8:], + wantErr: false, + }, + { + name: "Last 3 lines, last empty line", + args: args{ + text: fiveLines, + count: 3, + }, + want: fiveLines[8:], + wantErr: false, + }, + { + name: "More than we have", + args: args{ + text: fiveLines, + count: 10, + }, + want: fiveLines, + wantErr: false, + }, + { + name: "Empty", + args: args{ + text: []byte{}, + count: 10, + }, + want: []byte{}, + wantErr: true, // EOF + }, + { + name: "No new-line, want 0", + args: args{ + text: []byte("line"), + count: 0, + }, + want: []byte("line"), + wantErr: true, // EOF. + }, + { + name: "No new-line, want 1", + args: args{ + text: []byte("line"), + count: 1, + }, + want: []byte("line"), + wantErr: false, // EOF. + }, + { + name: "Only new-line, want 1", + args: args{ + text: []byte("\n"), + count: 1, + }, + want: []byte("\n"), + wantErr: false, + }, + { + name: "Multiple new-lines", + args: args{ + text: []byte("\n\n\n\n"), + count: 2, + }, + want: []byte("\n\n"), + wantErr: false, + }, + { + name: "Large buffer", + args: args{ + text: bytes.Repeat(last1000, 3), + count: 1000, + }, + want: last1000, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := bytes.NewReader(tt.args.text) + tailReader, _, err := newTailReader(context.Background(), reader, tt.args.count) + require.NoError(t, err) + + buf := make([]byte, 1024*1024) + n, err := tailReader.Read(buf) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, string(tt.want), string(buf[:n])) + }) + } +} + +func linesChecker(t *testing.T, expected []string) func(str string) { + i := 0 + return func(str string) { + require.Less(t, i, len(expected)) + assert.Equal(t, expected[i], string(str)) + i++ + } +} + +func TestTailN(t *testing.T) { + type args struct { + n int + } + + tmpDir := t.TempDir() + + fiveLines := `one +two +three +four +five +` + + tests := []struct { + name string + text string + args args + check func(str string) + wantErr bool + }{ + { + name: "Last 3 lines", + text: fiveLines, + args: args{ + n: 3, + }, + check: linesChecker(t, []string{"three", "four", "five"}), + wantErr: false, + }, + { + name: "No last new-line, want 3", + text: fiveLines[:len(fiveLines)-1], + args: args{ + n: 3, + }, + check: linesChecker(t, []string{"three", "four", "five"}), + wantErr: false, + }, + { + name: "Empty, want 3", + text: "", + args: args{ + n: 3, + }, + check: linesChecker(t, []string{}), + wantErr: false, + }, + { + name: "Only new-lines, wamt 3", + text: "\n\n\n\n\n\n\n\n\n", + args: args{ + n: 3, + }, + check: linesChecker(t, []string{"", "", ""}), + wantErr: false, + }, + { + name: "Only new-lines, want 0", + text: "\n\n\n\n\n\n\n\n\n", + args: args{ + n: 0, + }, + check: linesChecker(t, []string{}), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outFile, err := os.CreateTemp(tmpDir, "*.txt") + require.NoError(t, err) + defer outFile.Close() + outFile.WriteString(tt.text) + outFile.Close() + + in, err := TailN(context.Background(), func(str string) string { + return str + }, outFile.Name(), tt.args.n) + assert.NoError(t, err) + for line := range in { + tt.check(line) + } + }) + } +} + +func TestPrintLastNLinesFileDoesNotExist(t *testing.T) { + in, err := TailN(context.Background(), func(str string) string { + return str + }, "some_file_name", 10) + assert.Error(t, err) + assert.Nil(t, in) +} + +func TestFollow(t *testing.T) { + tests := []struct { + name string + initialText string + linesToAppend []string + expectedLastLines []string + expectedAppendedLines []string + nLines int + }{ + { + name: "Follow, no last lines", + initialText: "line 1\n", + linesToAppend: []string{"line 2", "line 3"}, + expectedAppendedLines: []string{"line 2", "line 3"}, + }, + { + name: "Follow, want 1 last line", + initialText: "line 1\n", + linesToAppend: []string{"line 2", "line 3"}, + expectedLastLines: []string{"line 1"}, + expectedAppendedLines: []string{"line 2", "line 3"}, + nLines: 1, + }, + { + name: "Follow, empty file, want 1 last", + initialText: "", + linesToAppend: []string{"line 1", "line 2"}, + expectedAppendedLines: []string{"line 1", "line 2"}, + nLines: 1, + }, + { + name: "Follow, more lines, want 10", + initialText: "line 1\nline 2\nline 3\nline 4\n", + linesToAppend: []string{"line 5", "line 6", "line 7"}, + expectedLastLines: []string{"line 1", "line 2", "line 3", "line 4"}, + expectedAppendedLines: []string{"line 5", "line 6", "line 7"}, + nLines: 10, + }, + { + name: "Follow, more lines, want 2", + initialText: "line 1\nline 2\nline 3\nline 4\n", + linesToAppend: []string{"line 5", "line 6", "line 7"}, + expectedLastLines: []string{"line 3", "line 4"}, + expectedAppendedLines: []string{"line 5", "line 6", "line 7"}, + nLines: 2, + }, + } + + tmpDir := t.TempDir() + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + outFile, err := os.CreateTemp(tmpDir, "*.txt") + require.NoError(t, err) + defer outFile.Close() + + outFile.WriteString(tt.initialText) + outFile.Sync() + + ctx, stop := context.WithTimeout(context.Background(), time.Second*2) + defer stop() + in := make(chan string) + err = Follow(ctx, in, + func(str string) string { return str }, outFile.Name(), tt.nLines) + require.NoError(t, err) + + if tt.nLines > 0 && len(tt.expectedLastLines) > 0 { + i := 0 + for i != len(tt.expectedLastLines) { + select { + case <-ctx.Done(): + require.Fail(t, "timed out, no initial lines received") + return + case line := <-in: + assert.Equal(t, tt.expectedLastLines[i], line) + i++ + } + } + } + + // Need some time to start watching for changes after reading last lines. + time.Sleep(time.Millisecond * 500) + for _, line := range tt.linesToAppend { + outFile.WriteString(line + "\n") + } + assert.NoError(t, outFile.Sync()) + + i := 0 + for i != len(tt.expectedAppendedLines) { + select { + case <-ctx.Done(): + assert.Fail(t, "timed out, no lines received") + return + case line := <-in: + assert.Equal(t, tt.expectedAppendedLines[i], line) + i++ + } + } + }) + } + +} diff --git a/go.mod b/go.mod index e3f3a76f3..497e0261c 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( github.com/dustin/go-humanize v1.0.0 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.5 // indirect @@ -92,6 +93,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/nxadm/tail v1.4.11 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 62278eb5b..8fd77a46b 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -328,6 +330,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -668,6 +672,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/test/integration/log/test_log.py b/test/integration/log/test_log.py new file mode 100644 index 000000000..d0e550388 --- /dev/null +++ b/test/integration/log/test_log.py @@ -0,0 +1,280 @@ +import os +import subprocess +import time + +import pytest + +from utils import config_name + + +@pytest.fixture(scope="function") +def mock_env_dir(tmpdir): + with open(os.path.join(tmpdir, config_name), 'w') as f: + f.write('env:\n instances_enabled: ie\n') + + for app_n in range(2): + app = os.path.join(tmpdir, 'ie', f'app{app_n}') + os.makedirs(app, 0o755) + with open(os.path.join(app, 'instances.yml'), 'w') as f: + for i in range(4): + f.write(f'inst{i}:\n') + os.makedirs(os.path.join(app, 'var', 'log', f'inst{i}'), 0o755) + + with open(os.path.join(app, 'init.lua'), 'w') as f: + f.write('') + + for i in range(3): # Skip log for instance 4. + with open(os.path.join(app, 'var', 'log', f'inst{i}', 'tt.log'), 'w') as f: + f.writelines([f'line {j}\n' for j in range(20)]) + + return tmpdir + + +def test_log_output_default_run(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True + ) + + assert process.wait(10) == 0 + output = process.stdout.read() + + for inst_n in range(3): + assert '\n'.join([f'app0:inst{inst_n}: line {i}' for i in range(10, 20)]) in output + assert '\n'.join([f'app1:inst{inst_n}: line {i}' for i in range(10, 20)]) in output + + assert 'app0:inst3' not in output + assert 'app1:inst3' not in output + + +def test_log_limit_lines_count(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', '-n', '3'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True + ) + + assert process.wait(10) == 0 + output = process.stdout.read() + + for inst_n in range(3): + assert '\n'.join([f'app0:inst{inst_n}: line {i}' for i in range(17, 20)]) in output + assert '\n'.join([f'app1:inst{inst_n}: line {i}' for i in range(17, 20)]) in output + + +def test_log_more_lines(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', '-n', '300'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True + ) + + assert process.wait(10) == 0 + output = process.stdout.read() + + for inst_n in range(3): + assert '\n'.join([f'app0:inst{inst_n}: line {i}' for i in range(0, 20)]) in output + assert '\n'.join([f'app1:inst{inst_n}: line {i}' for i in range(0, 20)]) in output + + +def test_log_want_zero(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', '-n', '0'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True + ) + + assert process.wait(10) == 0 + output = process.stdout.readlines() + + assert len(output) == 0 + + +def test_log_specific_instance(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', 'app0:inst1', '-n', '3'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True + ) + + assert process.wait(10) == 0 + output = process.stdout.read() + + assert '\n'.join([f'app0:inst1: line {i}' for i in range(17, 20)]) in output + + assert 'app0:inst0' not in output and 'app0:inst2' not in output + assert 'app1' not in output + + +def test_log_specific_app(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', 'app1'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True + ) + + assert process.wait(10) == 0 + output = process.stdout.read() + + for inst_n in range(3): + assert '\n'.join([f'app1:inst{inst_n}: line {i}' for i in range(10, 20)]) in output + + assert 'app0' not in output + + +def test_log_negative_lines_num(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', '-n', '-10'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True + ) + + assert process.wait(10) != 0 + output = process.stdout.read() + + assert 'negative' in output + + +def test_log_no_app(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', 'no_app'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True + ) + + assert process.wait(10) != 0 + output = process.stdout.read() + + assert 'can\'t collect instance information for no_app' in output + + +def test_log_no_inst(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', 'app0:inst4'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True + ) + + assert process.wait(10) != 0 + output = process.stdout.read() + + assert 'app0:inst4: instance(s) not found' in output + + +def wait_for_lines_in_output(stdout, expected_lines): + output = '' + retries = 10 + found = 0 + while True: + line = stdout.readline() + if line == '': + if retries == 0: + break + time.sleep(0.2) + retries -= 1 + else: + retries = 10 + output += line + for expected in expected_lines: + if expected in line: + found += 1 + break + + if found == len(expected_lines): + break + + return output + + +def test_log_output_default_follow(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', '-f'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + ) + + output = wait_for_lines_in_output(process.stdout, + ['app0:inst0: line 19', 'app1:inst2: line 19', + 'app0:inst1: line 19', 'app1:inst1: line 19']) + + with open(os.path.join(mock_env_dir, 'ie', 'app0', 'var', 'log', 'inst0', 'tt.log'), 'w') as f: + f.writelines([f'line {i}\n' for i in range(20, 23)]) + + with open(os.path.join(mock_env_dir, 'ie', 'app1', 'var', 'log', 'inst2', 'tt.log'), 'w') as f: + f.writelines([f'line {i}\n' for i in range(20, 23)]) + + output += wait_for_lines_in_output(process.stdout, + ['app1:inst2: line 22', 'app0:inst0: line 22']) + + process.terminate() + for i in range(10, 23): + assert f'app0:inst0: line {i}' in output + assert f'app1:inst2: line {i}' in output + + for i in range(10, 20): + assert f'app0:inst1: line {i}' in output + assert f'app1:inst1: line {i}' in output + + +def test_log_output_default_follow_want_zero_last(tt_cmd, mock_env_dir): + cmd = [tt_cmd, 'log', '-f', '-n', '0'] + process = subprocess.Popen( + cmd, + cwd=mock_env_dir, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + universal_newlines=True, + bufsize=1 + ) + + time.sleep(1) + + with open(os.path.join(mock_env_dir, 'ie', 'app0', 'var', 'log', 'inst0', 'tt.log'), 'w') as f: + f.writelines([f'line {i}\n' for i in range(20, 23)]) + + with open(os.path.join(mock_env_dir, 'ie', 'app1', 'var', 'log', 'inst2', 'tt.log'), 'w') as f: + f.writelines([f'line {i}\n' for i in range(20, 23)]) + + output = wait_for_lines_in_output(process.stdout, + ['app1:inst2: line 22', 'app0:inst0: line 22']) + + process.terminate() + for i in range(20, 23): + assert f'app0:inst0: line {i}' in output + assert f'app1:inst2: line {i}' in output + + assert 'app0:inst1' not in output + assert 'app0:inst2' not in output + assert 'app1:inst0' not in output