Skip to content

Commit d403ba0

Browse files
committed
play: fix cat/play working with directories
@TarantoolBot Title: improve `tt cat` `tt play` working with directories This commit improves the functionality of the `cat` and `play` commands in the Tarantool CLI. It allows these commands to work recursively with directories containing multiple files, enhancing their usability and flexibility. **Usage Example**: ```bash tt cat -r /path/to/directory1 /path/to/directory2 tt play --recursive app:instance001 /path/to/directory1 ``` Closes #TNTP-2367
1 parent cb861df commit d403ba0

File tree

8 files changed

+496
-108
lines changed

8 files changed

+496
-108
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
99

1010
### Added
1111

12+
- In commands `tt cat|play <DIR>` added options `-r`/`--recursive` to
13+
allow find WAL files inside nested subdirectories.
14+
1215
### Changed
1316

1417
### Fixed
1518

19+
- `tt cat|play <DIR>` with directories handles only `.snap` or `.xlog` files.
20+
1621
## [2.9.1] - 2025-04-15
1722

1823
The release includes minor fixes identified by Svacer and CVE linters.

cli/checkpoint/checkpoint.go

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type Opts struct {
2020
Format string
2121
Replica []int
2222
ShowSystem bool
23+
Recursive bool
2324
}
2425

2526
// Cat print the contents of .snap/.xlog files.

cli/cmd/cat.go

+7-3
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,20 @@ var catFlags = checkpoint.Opts{
2626
Format: "yaml",
2727
Replica: nil,
2828
ShowSystem: false,
29+
Recursive: false,
2930
}
3031

3132
// NewCatCmd creates a new cat command.
3233
func NewCatCmd() *cobra.Command {
3334
var catCmd = &cobra.Command{
34-
Use: "cat <FILE>...",
35+
Use: "cat <FILE|DIR>...",
3536
Short: "Print into stdout the contents of .snap/.xlog FILE(s)",
3637
Run: RunModuleFunc(internalCatModule),
3738
Example: "tt cat /path/to/file.snap /path/to/file.xlog /path/to/dir/ " +
3839
"--timestamp 2024-11-13T14:02:36.818700000+00:00\n" +
3940
" tt cat /path/to/file.snap /path/to/file.xlog /path/to/dir/ " +
40-
"--timestamp=1731592956.818",
41+
"--timestamp=1731592956.818\n" +
42+
" tt cat --recursive /path/to/dir1 /path/to/dir2",
4143
Args: func(cmd *cobra.Command, args []string) error {
4244
if len(args) == 0 {
4345
return errors.New("it is required to specify at least one .xlog/.snap file " +
@@ -61,13 +63,15 @@ func NewCatCmd() *cobra.Command {
6163
"Filter the output by replica id. May be passed more than once")
6264
catCmd.Flags().BoolVar(&catFlags.ShowSystem, "show-system", catFlags.ShowSystem,
6365
"Show the contents of system spaces")
66+
catCmd.Flags().BoolVarP(&catFlags.Recursive, "recursive", "r", catFlags.Recursive,
67+
"Process WAL files in directories recursively")
6468

6569
return catCmd
6670
}
6771

6872
// internalCatModule is a default cat module.
6973
func internalCatModule(cmdCtx *cmdcontext.CmdCtx, args []string) error {
70-
walFiles, err := util.CollectWALFiles(args)
74+
walFiles, err := util.CollectWalFiles(args, catFlags.Recursive)
7175
if err != nil {
7276
return util.InternalError(
7377
"Internal error: could not collect WAL files: %s",

cli/cmd/play.go

+7-3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ var playFlags = checkpoint.Opts{
2727
Space: nil,
2828
Replica: nil,
2929
ShowSystem: false,
30+
Recursive: false,
3031
}
3132

3233
var (
@@ -48,13 +49,14 @@ var (
4849
// NewPlayCmd creates a new play command.
4950
func NewPlayCmd() *cobra.Command {
5051
var playCmd = &cobra.Command{
51-
Use: "play (<URI> | <APP_NAME> | <APP_NAME:INSTANCE_NAME>) <FILE>...",
52+
Use: "play (<URI> | <APP_NAME> | <APP_NAME:INSTANCE_NAME>) <FILE|DIR>...",
5253
Short: "Play the contents of .snap/.xlog FILE(s) to another Tarantool instance",
5354
Run: RunModuleFunc(internalPlayModule),
5455
Example: "tt play localhost:3013 /path/to/file.snap /path/to/file.xlog " +
5556
"/path/to/dir/ --timestamp 2024-11-13T14:02:36.818700000+00:00\n" +
5657
" tt play app:instance001 /path/to/file.snap /path/to/file.xlog " +
57-
"/path/to/dir/ --timestamp=1731592956.818",
58+
"/path/to/dir/ --timestamp=1731592956.818\n" +
59+
" tt play --recursive app:instance001 /path/to/dir1 /path/to/dir2",
5860
Args: playValidateArgs,
5961
}
6062

@@ -80,6 +82,8 @@ func NewPlayCmd() *cobra.Command {
8082
"Filter the output by replica id. May be passed more than once")
8183
playCmd.Flags().BoolVar(&playFlags.ShowSystem, "show-system", playFlags.ShowSystem,
8284
"Show the contents of system spaces")
85+
playCmd.Flags().BoolVarP(&playFlags.Recursive, "recursive", "r", playFlags.Recursive,
86+
"Process WAL files in directories recursively")
8387

8488
return playCmd
8589
}
@@ -134,7 +138,7 @@ func internalPlayModule(cmdCtx *cmdcontext.CmdCtx, args []string) error {
134138
version.GetVersion, args[0], err)
135139
}
136140

137-
walFiles, err := util.CollectWALFiles(args[1:])
141+
walFiles, err := util.CollectWalFiles(args[1:], playFlags.Recursive)
138142
if err != nil {
139143
return util.InternalError(
140144
"Internal error: could not collect WAL files: %s",

cli/util/util.go

-44
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
"path/filepath"
1919
"regexp"
2020
"runtime/debug"
21-
"slices"
2221
"strconv"
2322
"strings"
2423
"text/template"
@@ -552,7 +551,6 @@ func RunCommandAndGetOutput(program string, args ...string) (string, error) {
552551

553552
// ExtractTar extracts tar archive.
554553
func ExtractTar(tarName string) error {
555-
556554
path, err := filepath.Abs(tarName)
557555
if err != nil {
558556
return err
@@ -665,7 +663,6 @@ func ExecuteCommandStdin(program string, isVerbose bool, logFile *os.File, workD
665663
cmd.Stdout = io.Discard
666664
cmd.Stderr = io.Discard
667665
}
668-
669666
}
670667
if workDir == "" {
671668
workDir, _ = os.Getwd()
@@ -998,44 +995,3 @@ func StringToTimestamp(input string) (string, error) {
998995

999996
return ts, nil
1000997
}
1001-
1002-
// CollectWALFiles globs files from args.
1003-
func CollectWALFiles(paths []string) ([]string, error) {
1004-
collectedFiles := make([]string, 0)
1005-
1006-
sortSnapFilesFirst := func(left, right string) int {
1007-
reSnap := regexp.MustCompile(`\.snap`)
1008-
reXlog := regexp.MustCompile(`\.xlog`)
1009-
if reSnap.Match([]byte(left)) && reXlog.Match([]byte(right)) {
1010-
return -1
1011-
}
1012-
if reXlog.Match([]byte(left)) && reSnap.Match([]byte(right)) {
1013-
return 1
1014-
}
1015-
return 0
1016-
}
1017-
1018-
for _, path := range paths {
1019-
entry, err := os.Stat(path)
1020-
if err != nil {
1021-
return nil, err
1022-
}
1023-
1024-
if entry.IsDir() {
1025-
foundEntries, err := os.ReadDir(path)
1026-
if err != nil {
1027-
return nil, err
1028-
}
1029-
for _, entry := range foundEntries {
1030-
collectedFiles = append(collectedFiles, filepath.Join(path, entry.Name()))
1031-
}
1032-
slices.SortFunc(collectedFiles, sortSnapFilesFirst)
1033-
1034-
continue
1035-
}
1036-
1037-
collectedFiles = append(collectedFiles, path)
1038-
}
1039-
1040-
return collectedFiles, nil
1041-
}

cli/util/util_test.go

-58
Original file line numberDiff line numberDiff line change
@@ -798,64 +798,6 @@ func TestStringToTimestamp(t *testing.T) {
798798
}
799799
}
800800

801-
func TestCollectWALFiles(t *testing.T) {
802-
srcDir := t.TempDir()
803-
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "1.xlog"), []byte{}, 0644))
804-
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "2.xlog"), []byte{}, 0644))
805-
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "1.snap"), []byte{}, 0644))
806-
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "2.snap"), []byte{}, 0644))
807-
snap1 := fmt.Sprintf("%s/%s", srcDir, "1.snap")
808-
snap2 := fmt.Sprintf("%s/%s", srcDir, "2.snap")
809-
xlog1 := fmt.Sprintf("%s/%s", srcDir, "1.xlog")
810-
xlog2 := fmt.Sprintf("%s/%s", srcDir, "2.xlog")
811-
812-
tests := []struct {
813-
name string
814-
input []string
815-
output []string
816-
expectedErrMsg string
817-
}{
818-
{
819-
name: "no_file",
820-
input: []string{},
821-
output: []string{},
822-
},
823-
{
824-
name: "incorrect_file",
825-
input: []string{"file"},
826-
expectedErrMsg: "stat file: no such file or directory",
827-
},
828-
{
829-
name: "one_file",
830-
input: []string{xlog1},
831-
output: []string{xlog1},
832-
},
833-
{
834-
name: "directory",
835-
input: []string{srcDir},
836-
output: []string{snap1, snap2, xlog1, xlog2},
837-
},
838-
{
839-
name: "mix",
840-
input: []string{srcDir, "util_test.go"},
841-
output: []string{snap1, snap2, xlog1, xlog2, "util_test.go"},
842-
},
843-
}
844-
845-
for _, test := range tests {
846-
t.Run(test.name, func(t *testing.T) {
847-
result, err := CollectWALFiles(test.input)
848-
849-
if test.expectedErrMsg != "" {
850-
assert.EqualError(t, err, test.expectedErrMsg)
851-
} else {
852-
assert.NoError(t, err)
853-
assert.Equal(t, test.output, result)
854-
}
855-
})
856-
}
857-
}
858-
859801
func TestIsURL(t *testing.T) {
860802
tests := []struct {
861803
name string

cli/util/wal.go

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package util
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io/fs"
7+
"os"
8+
"path/filepath"
9+
"slices"
10+
"strings"
11+
12+
"github.com/apex/log"
13+
)
14+
15+
const (
16+
snapSuffix = ".snap"
17+
xlogSuffix = ".xlog"
18+
)
19+
20+
// hasExt checks if the file name ends with the given suffix and is longer than the suffix itself.
21+
func hasExt(f string, s string) bool {
22+
return strings.HasSuffix(f, s) && len(f) > len(s)
23+
}
24+
25+
// isWAL checks if a file name has a .snap or .xlog extension.
26+
func isWal(f string) bool {
27+
return hasExt(f, snapSuffix) || hasExt(f, xlogSuffix)
28+
}
29+
30+
// sortWalFiles sorts a slice of file paths, ensuring .snap files come before .xlog files.
31+
// Files of the same type are sorted lexicographically.
32+
// The sorting happens in place.
33+
func sortWalFiles(files []string) {
34+
slices.SortFunc(files, func(left, right string) int {
35+
if hasExt(left, snapSuffix) && hasExt(right, xlogSuffix) {
36+
return -1
37+
}
38+
if hasExt(left, xlogSuffix) && hasExt(right, snapSuffix) {
39+
return 1
40+
}
41+
lDir, fName := filepath.Split(left)
42+
rDir, rName := filepath.Split(right)
43+
if lDir != rDir {
44+
return strings.Compare(lDir, rDir)
45+
}
46+
return strings.Compare(fName, rName)
47+
})
48+
}
49+
50+
// collectWALsFromSinglePath is an internal helper to find WAL files starting from a single path.
51+
// It handles both files and directories, respecting the isRecursive flag for directories.
52+
// It returns absolute paths of found WAL files.
53+
func collectWALsFromSinglePath(path string, isRecursive bool) ([]string, error) {
54+
collected := []string{}
55+
56+
info, err := os.Stat(path)
57+
if err != nil {
58+
return nil, fmt.Errorf("failed to stat path %q: %w", path, err)
59+
}
60+
path, err = filepath.Abs(path)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to get absolute path for %q: %w", path, err)
63+
}
64+
65+
if !info.IsDir() {
66+
if isWal(info.Name()) {
67+
collected = append(collected, path)
68+
}
69+
return collected, nil
70+
}
71+
72+
// Handle the case where path is a directory.
73+
if isRecursive {
74+
err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
75+
if err != nil {
76+
log.Warnf("Skipping %q due to error during walk: %s", p, err)
77+
if d != nil && d.IsDir() && errors.Is(err, fs.ErrPermission) {
78+
// Skip directory if permission denied, but continue walking other parts.
79+
return fs.SkipDir
80+
}
81+
return nil
82+
}
83+
84+
if !d.IsDir() && isWal(d.Name()) {
85+
collected = append(collected, p)
86+
}
87+
return nil
88+
})
89+
if err != nil {
90+
log.Warnf("Error encountered during recursive walk of %q: %s", path, err)
91+
}
92+
93+
} else {
94+
dirEntries, readErr := os.ReadDir(path)
95+
if readErr != nil {
96+
log.Warnf("Failed to read directory %q: %s", path, readErr)
97+
return collected, nil
98+
}
99+
for _, entry := range dirEntries {
100+
if !entry.IsDir() && isWal(entry.Name()) {
101+
collected = append(collected, filepath.Join(path, entry.Name()))
102+
}
103+
}
104+
}
105+
106+
return collected, nil
107+
}
108+
109+
// CollectWalFiles collects WAL (Write-Ahead Log) files based on the given
110+
// set of paths. It identifies files with ".snap" or ".xlog" extensions as WAL files.
111+
// For directory paths, it traverses them based on the isRecursive flag.
112+
// It skips directories or files it cannot access due to permissions, logging warnings.
113+
//
114+
// The function ensures that ".snap" files are sorted before ".xlog" files in the result,
115+
// and that the returned list contains unique absolute paths.
116+
//
117+
// Returns: A sorted, unique slice of strings containing the absolute paths of collected WAL files.
118+
func CollectWalFiles(paths []string, isRecursive bool) ([]string, error) {
119+
allCollectedFiles := make([]string, 0)
120+
121+
for _, p := range paths {
122+
filesFromPath, err := collectWALsFromSinglePath(p, isRecursive)
123+
if err != nil {
124+
if errors.Is(err, os.ErrNotExist) {
125+
return nil, fmt.Errorf("required %q not found: %w", p, err)
126+
}
127+
log.Warnf("Error processing path %q: %v. Skipping this path.", p, err)
128+
continue
129+
}
130+
if len(filesFromPath) == 0 {
131+
log.Warnf("No WAL files found at %q", p)
132+
} else {
133+
allCollectedFiles = append(allCollectedFiles, filesFromPath...)
134+
}
135+
}
136+
137+
// Sort the aggregated list.
138+
sortWalFiles(allCollectedFiles)
139+
140+
// For the case a file is included both as an individual file and as part of a directory.
141+
return slices.Compact(allCollectedFiles), nil
142+
}

0 commit comments

Comments
 (0)