From 5860484d1cd3e0554cd1faff4dff1a77aeb3e136 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Sun, 31 May 2026 12:02:28 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20--target=20flag=20to=20lo?= =?UTF-8?q?gs=20for=20metrics=20and=20dockerd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the log path via config.MustResolve("logging", target+"_file") so the YAML logging group is the registry — no internal target map. --target is long-form only (no short alias); invalid targets are rejected with the same slices.Contains + PrintErrorWithOptions pattern as --level. Bare `ws-cli logs` keeps reading the main log. --- cmd/logs/logs.go | 15 ++++- cmd/logs/logs_test.go | 97 +++++++++++++++++++++++++++++++++ internals/logger/logger.go | 8 ++- internals/logger/logger_test.go | 74 ++++++++++++++++++++++++- 4 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 cmd/logs/logs_test.go diff --git a/cmd/logs/logs.go b/cmd/logs/logs.go index bb67ec6..f0067d2 100644 --- a/cmd/logs/logs.go +++ b/cmd/logs/logs.go @@ -12,6 +12,8 @@ import ( var validLogLevels = []string{"debug", "info", "warn", "error"} +var validLogTargets = []string{"main", "metrics", "docker"} + var LogsCmd = &cobra.Command{ Use: "logs", Short: "Retrieve workspace logs", @@ -23,6 +25,7 @@ func execute(cmd *cobra.Command, args []string) error { follow, _ := cmd.Flags().GetBool("follow") tail, _ := cmd.Flags().GetInt("tail") level, _ := cmd.Flags().GetString("level") + target, _ := cmd.Flags().GetString("target") if level != "" && !slices.Contains(validLogLevels, level) { styles.PrintErrorWithOptions(cmd.ErrOrStderr(), "Invalid log level. Must be one of:", [][]string{ @@ -34,7 +37,16 @@ func execute(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid log level") } - reader, err := logger.NewReader(tail, level) + if !slices.Contains(validLogTargets, target) { + styles.PrintErrorWithOptions(cmd.ErrOrStderr(), "Invalid log target. Must be one of:", [][]string{ + {"main", "Combined workspace log"}, + {"metrics", "Metrics exporter log"}, + {"docker", "In-container Docker daemon log"}, + }) + return fmt.Errorf("invalid log target") + } + + reader, err := logger.NewReader(tail, level, target) if err != nil { styles.PrintError(cmd.ErrOrStderr(), fmt.Sprintf("Failed to initialize log reader: %s", err)) return err @@ -58,4 +70,5 @@ func init() { LogsCmd.Flags().BoolP("follow", "f", false, "Follow log output in real-time") LogsCmd.Flags().IntP("tail", "t", 0, "Number of lines to show from the end (0 for all)") LogsCmd.Flags().StringP("level", "l", "", "Filter by log level (debug|info|warn|error)") + LogsCmd.Flags().String("target", "main", "Log target to read (main|metrics|docker)") } diff --git a/cmd/logs/logs_test.go b/cmd/logs/logs_test.go new file mode 100644 index 0000000..5945b2e --- /dev/null +++ b/cmd/logs/logs_test.go @@ -0,0 +1,97 @@ +package logs + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "gotest.tools/v3/assert" +) + +func executeLogs(args ...string) (string, error) { + buffer := new(bytes.Buffer) + + LogsCmd.Flags().Set("target", "main") + LogsCmd.Flags().Set("tail", "0") + LogsCmd.Flags().Set("level", "") + LogsCmd.Flags().Set("follow", "false") + + cmd := &cobra.Command{} + cmd.AddCommand(LogsCmd) + cmd.SetOut(buffer) + cmd.SetErr(buffer) + cmd.SetArgs(append([]string{"logs"}, args...)) + + err := cmd.Execute() + + return buffer.String(), err +} + +func _seedLogs(t *testing.T) { + t.Helper() + + tempDir := t.TempDir() + + files := map[string]string{ + "workspace.log": "main marker", + "metrics.log": "metrics marker", + "dockerd.log": "docker marker", + } + + for name, content := range files { + assert.NilError(t, os.WriteFile(filepath.Join(tempDir, name), []byte(content+"\n"), 0644)) + } + + t.Setenv("WS_LOGGING_DIR", tempDir) + t.Setenv("WS_LOGGING_MAIN_FILE", "workspace.log") + t.Setenv("WS_LOGGING_METRICS_FILE", "metrics.log") + t.Setenv("WS_LOGGING_DOCKER_FILE", "dockerd.log") +} + +func TestLogsDefaultTargetIsMain(t *testing.T) { + assert.Equal(t, "main", LogsCmd.Flags().Lookup("target").DefValue) +} + +func TestLogsNoShortTargetAlias(t *testing.T) { + assert.Assert(t, LogsCmd.Flags().ShorthandLookup("T") == nil) +} + +func TestLogsValidTargets(t *testing.T) { + tests := []struct { + target string + marker string + }{ + {"main", "main marker"}, + {"metrics", "metrics marker"}, + {"docker", "docker marker"}, + } + + for _, tt := range tests { + t.Run(tt.target, func(t *testing.T) { + _seedLogs(t) + + out, err := executeLogs("--target=" + tt.target) + assert.NilError(t, err) + assert.Assert(t, strings.Contains(out, tt.marker)) + }) + } +} + +func TestLogsInvalidTargetRejected(t *testing.T) { + out, err := executeLogs("--target=garbage") + + assert.ErrorContains(t, err, "invalid log target") + assert.Assert(t, strings.Contains(out, "Invalid log target")) +} + +func TestLogsTargetComposesWithTailAndLevel(t *testing.T) { + _seedLogs(t) + + out, err := executeLogs("--target=metrics", "--tail=5", "--level=info") + + assert.NilError(t, err) + assert.Assert(t, strings.Contains(out, "metrics marker")) +} diff --git a/internals/logger/logger.go b/internals/logger/logger.go index 83d0ff2..55c13bd 100644 --- a/internals/logger/logger.go +++ b/internals/logger/logger.go @@ -71,10 +71,14 @@ func Log(writer io.Writer, level, message string, indent int, withStamp bool) { fmt.Fprintln(writer, strings.Join(parts, " ")) } -func NewReader(tailLines int, levelFilter string) (*Reader, error) { +func NewReader(tailLines int, levelFilter, target string) (*Reader, error) { + if target == "" { + target = "main" + } + logPath := filepath.Join( config.MustResolve("logging", "dir"), - config.MustResolve("logging", "main_file"), + config.MustResolve("logging", target+"_file"), ) if _, err := os.Stat(logPath); os.IsNotExist(err) { diff --git a/internals/logger/logger_test.go b/internals/logger/logger_test.go index 3cb985e..9e51529 100644 --- a/internals/logger/logger_test.go +++ b/internals/logger/logger_test.go @@ -98,7 +98,7 @@ Plain text error message` for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reader, err := NewReader(100, tt.levelFilter) + reader, err := NewReader(100, tt.levelFilter, "main") assert.NilError(t, err) var buf bytes.Buffer @@ -110,3 +110,75 @@ Plain text error message` }) } } + +func TestReaderTargets(t *testing.T) { + tempDir := t.TempDir() + + files := map[string]string{ + "workspace.log": "main marker line", + "metrics.log": "metrics marker line", + "dockerd.log": "docker marker line", + } + + for name, content := range files { + assert.NilError(t, os.WriteFile(filepath.Join(tempDir, name), []byte(content+"\n"), 0644)) + } + + t.Setenv("WS_LOGGING_DIR", tempDir) + t.Setenv("WS_LOGGING_MAIN_FILE", "workspace.log") + t.Setenv("WS_LOGGING_METRICS_FILE", "metrics.log") + t.Setenv("WS_LOGGING_DOCKER_FILE", "dockerd.log") + + tests := []struct { + name string + target string + expected string + }{ + {"main", "main", "main marker line"}, + {"metrics", "metrics", "metrics marker line"}, + {"docker", "docker", "docker marker line"}, + {"empty defaults to main", "", "main marker line"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader, err := NewReader(0, "", tt.target) + assert.NilError(t, err) + + var buf bytes.Buffer + err = reader.ReadLogs(&buf) + assert.NilError(t, err) + + assert.Equal(t, tt.expected, strings.TrimSpace(buf.String())) + }) + } +} + +func TestReaderTargetMissingFile(t *testing.T) { + tempDir := t.TempDir() + + t.Setenv("WS_LOGGING_DIR", tempDir) + t.Setenv("WS_LOGGING_METRICS_FILE", "metrics.log") + + _, err := NewReader(0, "", "metrics") + + assert.ErrorContains(t, err, "log file not found") +} + +func TestReaderEmptyFile(t *testing.T) { + tempDir := t.TempDir() + + assert.NilError(t, os.WriteFile(filepath.Join(tempDir, "metrics.log"), []byte{}, 0644)) + + t.Setenv("WS_LOGGING_DIR", tempDir) + t.Setenv("WS_LOGGING_METRICS_FILE", "metrics.log") + + reader, err := NewReader(0, "", "metrics") + assert.NilError(t, err) + + var buf bytes.Buffer + err = reader.ReadLogs(&buf) + assert.NilError(t, err) + + assert.Equal(t, "", buf.String()) +} From 47181b70318794d4f4954ccd5e247333f69d1ec3 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Sun, 31 May 2026 12:07:42 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=96=20Bump=20version=20to=200.0.57?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/info/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/info/version.go b/cmd/info/version.go index 89d09e3..aaf44b6 100644 --- a/cmd/info/version.go +++ b/cmd/info/version.go @@ -1,3 +1,3 @@ package info -var Version = "0.0.56" +var Version = "0.0.57"