diff --git a/internal/probe/probe.go b/internal/probe/probe.go new file mode 100644 index 0000000..b8c850f --- /dev/null +++ b/internal/probe/probe.go @@ -0,0 +1,248 @@ +// Package probe discovers tmux sessions on localhost and configured remotes. +package probe + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "sync" + "time" + + "github.com/KofTwentyTwo/limen/internal/config" + "github.com/KofTwentyTwo/limen/internal/ssh" + "github.com/KofTwentyTwo/limen/internal/tmuxinfo" +) + +const ( + // LocalhostName is the synthetic host name used for the local machine. + LocalhostName = "localhost" + + defaultProbeTimeout = 3 * time.Second +) + +// ProbeResult is one host's in-memory probe outcome. +type ProbeResult struct { + HostName string + Reachable bool + Sessions []SessionInfo + Error error + Latency time.Duration +} + +// SessionInfo aliases tmux session metadata for probe consumers. +type SessionInfo = tmuxinfo.SessionInfo + +// CommandResult captures a completed tmux or ssh probe command. +type CommandResult struct { + Stdout string + Stderr string + ExitCode int + Err error +} + +// Runner executes commands for probes. +type Runner interface { + Run(ctx context.Context, name string, args ...string) CommandResult +} + +// RunnerFunc adapts a function into a Runner. +type RunnerFunc func(ctx context.Context, name string, args ...string) CommandResult + +// Run executes f. +func (f RunnerFunc) Run(ctx context.Context, name string, args ...string) CommandResult { + return f(ctx, name, args...) +} + +// Options holds probe dependencies and timing knobs. +type Options struct { + LookPath func(string) (string, error) + Runner Runner + Timeout time.Duration +} + +// Localhost probes the local tmux server. +func Localhost(ctx context.Context, opts Options) ProbeResult { + deps := opts.withDefaults() + start := time.Now() + probeCtx, cancel := context.WithTimeout(ctx, deps.Timeout) + defer cancel() + + tmuxPath, err := deps.LookPath("tmux") + if err != nil { + return ProbeResult{ + HostName: LocalhostName, + Reachable: false, + Error: fmt.Errorf("tmux not found: %w", err), + Latency: elapsed(start), + } + } + + result := deps.Runner.Run(probeCtx, tmuxPath, "ls", "-F", tmuxinfo.LocalFormat) + return interpret(LocalhostName, result, tmuxinfo.ParseLocal, start) +} + +// Remote probes a configured remote host through ssh. +func Remote(ctx context.Context, host config.Host, opts Options) ProbeResult { + deps := opts.withDefaults() + start := time.Now() + + probeCtx, cancel := context.WithTimeout(ctx, deps.Timeout) + defer cancel() + + argv := ssh.ProbeArgv(host, tmuxinfo.RemoteFormat) + result := deps.Runner.Run(probeCtx, argv[0], argv[1:]...) + return interpret(host.Name, result, tmuxinfo.ParseRemote, start) +} + +// Start begins one probe per host and emits results as each one completes. +func Start(ctx context.Context, hosts []config.Host, opts Options) <-chan ProbeResult { + results := make(chan ProbeResult) + + go func() { + defer close(results) + + var wg sync.WaitGroup + send := func(result ProbeResult) { + select { + case results <- result: + case <-ctx.Done(): + } + } + + wg.Add(1) + go func() { + defer wg.Done() + send(Localhost(ctx, opts)) + }() + + for _, host := range hosts { + host := host + wg.Add(1) + go func() { + defer wg.Done() + send(Remote(ctx, host, opts)) + }() + } + + wg.Wait() + }() + + return results +} + +func interpret(hostName string, result CommandResult, parse func(string) ([]tmuxinfo.SessionInfo, error), start time.Time) ProbeResult { + combinedOutput := result.Stdout + result.Stderr + + if errors.Is(result.Err, context.DeadlineExceeded) || errors.Is(result.Err, context.Canceled) { + return unreachable(hostName, result.Err, start) + } + + if result.ExitCode == 1 && tmuxinfo.IsNoServerRunning(combinedOutput) { + return reachable(hostName, []tmuxinfo.SessionInfo{}, start) + } + + if result.Err != nil && result.ExitCode == 0 { + return unreachable(hostName, commandError(result), start) + } + + if result.ExitCode != 0 { + return unreachable(hostName, commandError(result), start) + } + + sessions, err := parse(result.Stdout) + if err != nil { + if errors.Is(err, tmuxinfo.ErrNoServer) { + return reachable(hostName, []tmuxinfo.SessionInfo{}, start) + } + return unreachable(hostName, err, start) + } + + return reachable(hostName, sessions, start) +} + +func reachable(hostName string, sessions []tmuxinfo.SessionInfo, start time.Time) ProbeResult { + return ProbeResult{ + HostName: hostName, + Reachable: true, + Sessions: sessions, + Latency: elapsed(start), + } +} + +func unreachable(hostName string, err error, start time.Time) ProbeResult { + return ProbeResult{ + HostName: hostName, + Reachable: false, + Sessions: []tmuxinfo.SessionInfo{}, + Error: err, + Latency: elapsed(start), + } +} + +func commandError(result CommandResult) error { + message := result.Stderr + if message == "" { + message = result.Stdout + } + if message == "" && result.Err != nil { + return result.Err + } + if message == "" { + return fmt.Errorf("command exited with status %d", result.ExitCode) + } + return errors.New(message) +} + +func elapsed(start time.Time) time.Duration { + duration := time.Since(start) + if duration <= 0 { + return time.Nanosecond + } + return duration +} + +func (opts Options) withDefaults() Options { + if opts.Timeout == 0 { + opts.Timeout = defaultProbeTimeout + } + if opts.LookPath == nil { + opts.LookPath = exec.LookPath + } + if opts.Runner == nil { + opts.Runner = execRunner{} + } + return opts +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, name string, args ...string) CommandResult { + cmd := exec.CommandContext(ctx, name, args...) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result := CommandResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Err: err, + } + if err == nil { + return result + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } + if ctxErr := ctx.Err(); ctxErr != nil { + result.Err = ctxErr + } + + return result +} diff --git a/internal/probe/probe_test.go b/internal/probe/probe_test.go new file mode 100644 index 0000000..0d86c3a --- /dev/null +++ b/internal/probe/probe_test.go @@ -0,0 +1,469 @@ +package probe + +import ( + "context" + "errors" + "os" + "os/exec" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/KofTwentyTwo/limen/internal/config" + "github.com/KofTwentyTwo/limen/internal/tmuxinfo" +) + +func TestLocalhostReturnsSessionsWhenTmuxSucceeds(t *testing.T) { + runner := &recordingRunner{ + result: CommandResult{ + Stdout: "api|3|1|1779799500\n", + ExitCode: 0, + }, + } + + got := Localhost(context.Background(), Options{ + LookPath: lookPath("/opt/homebrew/bin/tmux", nil), + Runner: runner, + }) + + if got.HostName != LocalhostName { + t.Fatalf("HostName = %q, want %q", got.HostName, LocalhostName) + } + if !got.Reachable { + t.Fatalf("Reachable = false, error = %v", got.Error) + } + if len(got.Sessions) != 1 || got.Sessions[0].Name != "api" || !got.Sessions[0].Attached { + t.Fatalf("Sessions = %#v", got.Sessions) + } + wantArgs := []string{"ls", "-F", tmuxinfo.LocalFormat} + if runner.name != "/opt/homebrew/bin/tmux" || !reflect.DeepEqual(runner.args, wantArgs) { + t.Fatalf("runner saw %q %#v, want tmux %#v", runner.name, runner.args, wantArgs) + } + if got.Latency <= 0 { + t.Fatalf("Latency = %s, want positive duration", got.Latency) + } +} + +func TestLocalhostMissingTmuxIsUnreachable(t *testing.T) { + got := Localhost(context.Background(), Options{ + LookPath: lookPath("", exec.ErrNotFound), + Runner: &recordingRunner{}, + }) + + if got.Reachable { + t.Fatal("Reachable = true, want false") + } + if got.Error == nil || !strings.Contains(got.Error.Error(), "tmux not found") { + t.Fatalf("Error = %v, want tmux not found diagnostic", got.Error) + } +} + +func TestLocalhostNoServerOutputIsReachableWithZeroSessions(t *testing.T) { + got := Localhost(context.Background(), Options{ + LookPath: lookPath("/usr/bin/tmux", nil), + Runner: &recordingRunner{result: CommandResult{ + Stderr: "no server running on /tmp/tmux-501/default\n", + ExitCode: 1, + Err: errors.New("exit status 1"), + }}, + }) + + if !got.Reachable { + t.Fatalf("Reachable = false, error = %v", got.Error) + } + if len(got.Sessions) != 0 { + t.Fatalf("len(Sessions) = %d, want 0", len(got.Sessions)) + } +} + +func TestLocalhostUsesPerHostTimeout(t *testing.T) { + runner := RunnerFunc(func(ctx context.Context, name string, args ...string) CommandResult { + <-ctx.Done() + return CommandResult{Err: ctx.Err()} + }) + + got := Localhost(context.Background(), Options{ + LookPath: lookPath("/usr/bin/tmux", nil), + Runner: runner, + Timeout: time.Nanosecond, + }) + + if got.Reachable { + t.Fatal("Reachable = true, want false") + } + if !errors.Is(got.Error, context.DeadlineExceeded) { + t.Fatalf("Error = %v, want context deadline exceeded", got.Error) + } +} + +func TestLocalhostMalformedOutputIsUnreachable(t *testing.T) { + got := Localhost(context.Background(), Options{ + LookPath: lookPath("/usr/bin/tmux", nil), + Runner: &recordingRunner{result: CommandResult{ + Stdout: "api|not-a-number|1|1779799500\n", + ExitCode: 0, + }}, + }) + + if got.Reachable { + t.Fatal("Reachable = true, want false") + } + if got.Error == nil || !strings.Contains(got.Error.Error(), "invalid window count") { + t.Fatalf("Error = %v, want parse diagnostic", got.Error) + } +} + +func TestRemoteReturnsSessionsWhenSSHSucceeds(t *testing.T) { + host := config.Host{Name: "prod", Hostname: "prod.example.com", User: "deploy", Port: 2222} + runner := &recordingRunner{result: CommandResult{ + Stdout: "api|3|1\n", + ExitCode: 0, + }} + + got := Remote(context.Background(), host, Options{Runner: runner}) + + if got.HostName != "prod" { + t.Fatalf("HostName = %q, want prod", got.HostName) + } + if !got.Reachable { + t.Fatalf("Reachable = false, error = %v", got.Error) + } + if len(got.Sessions) != 1 || got.Sessions[0].Name != "api" || !got.Sessions[0].Created.IsZero() { + t.Fatalf("Sessions = %#v", got.Sessions) + } + wantArgs := []string{ + "-o", "ConnectTimeout=2", + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-p", "2222", + "deploy@prod.example.com", + "tmux", "ls", "-F", tmuxinfo.RemoteFormat, + } + if runner.name != "ssh" || !reflect.DeepEqual(runner.args, wantArgs) { + t.Fatalf("runner saw %q %#v, want ssh %#v", runner.name, runner.args, wantArgs) + } +} + +func TestRemoteNoOutputIsReachableWithZeroSessions(t *testing.T) { + host := config.Host{Name: "prod", Hostname: "prod.example.com"} + + got := Remote(context.Background(), host, Options{ + Runner: &recordingRunner{result: CommandResult{ExitCode: 0}}, + }) + + if !got.Reachable { + t.Fatalf("Reachable = false, error = %v", got.Error) + } + if len(got.Sessions) != 0 { + t.Fatalf("len(Sessions) = %d, want 0", len(got.Sessions)) + } +} + +func TestRemoteNoServerOutputIsReachableWithZeroSessions(t *testing.T) { + host := config.Host{Name: "prod", Hostname: "prod.example.com"} + + got := Remote(context.Background(), host, Options{ + Runner: &recordingRunner{result: CommandResult{ + Stderr: "no server running on /tmp/tmux-501/default\n", + ExitCode: 1, + Err: errors.New("exit status 1"), + }}, + }) + + if !got.Reachable { + t.Fatalf("Reachable = false, error = %v", got.Error) + } + if len(got.Sessions) != 0 { + t.Fatalf("len(Sessions) = %d, want 0", len(got.Sessions)) + } +} + +func TestRemoteNoServerOnStdoutIsReachableWithZeroSessions(t *testing.T) { + host := config.Host{Name: "prod", Hostname: "prod.example.com"} + + got := Remote(context.Background(), host, Options{ + Runner: &recordingRunner{result: CommandResult{ + Stdout: "no server running on /tmp/tmux-501/default\n", + ExitCode: 0, + }}, + }) + + if !got.Reachable { + t.Fatalf("Reachable = false, error = %v", got.Error) + } + if len(got.Sessions) != 0 { + t.Fatalf("len(Sessions) = %d, want 0", len(got.Sessions)) + } +} + +func TestRemoteNonZeroExitIsUnreachable(t *testing.T) { + host := config.Host{Name: "prod", Hostname: "prod.example.com"} + + got := Remote(context.Background(), host, Options{ + Runner: &recordingRunner{result: CommandResult{ + Stderr: "ssh: connect to host prod.example.com port 22: Operation timed out\n", + ExitCode: 255, + Err: errors.New("exit status 255"), + }}, + }) + + if got.Reachable { + t.Fatal("Reachable = true, want false") + } + if got.Error == nil || !strings.Contains(got.Error.Error(), "ssh: connect to host prod.example.com port 22") { + t.Fatalf("Error = %v, want ssh diagnostic", got.Error) + } + if len(got.Sessions) != 0 { + t.Fatalf("len(Sessions) = %d, want 0", len(got.Sessions)) + } +} + +func TestRemoteRunnerErrorWithoutExitCodeIsUnreachable(t *testing.T) { + host := config.Host{Name: "prod", Hostname: "prod.example.com"} + + got := Remote(context.Background(), host, Options{ + Runner: &recordingRunner{result: CommandResult{ + Err: errors.New("exec: ssh: executable file not found in PATH"), + }}, + }) + + if got.Reachable { + t.Fatal("Reachable = true, want false") + } + if got.Error == nil || !strings.Contains(got.Error.Error(), "executable file not found") { + t.Fatalf("Error = %v, want runner diagnostic", got.Error) + } +} + +func TestRemoteUsesPerHostTimeout(t *testing.T) { + host := config.Host{Name: "prod", Hostname: "prod.example.com"} + runner := RunnerFunc(func(ctx context.Context, name string, args ...string) CommandResult { + <-ctx.Done() + return CommandResult{Err: ctx.Err()} + }) + + got := Remote(context.Background(), host, Options{ + Runner: runner, + Timeout: time.Nanosecond, + }) + + if got.Reachable { + t.Fatal("Reachable = true, want false") + } + if !errors.Is(got.Error, context.DeadlineExceeded) { + t.Fatalf("Error = %v, want context deadline exceeded", got.Error) + } +} + +func TestStartEmitsLocalhostAndConfiguredHostsThenCloses(t *testing.T) { + hosts := []config.Host{ + {Name: "prod", Hostname: "prod.example.com"}, + {Name: "dev", Hostname: "dev.example.com"}, + } + runner := RunnerFunc(func(ctx context.Context, name string, args ...string) CommandResult { + return CommandResult{ExitCode: 0} + }) + + results := Start(context.Background(), hosts, Options{ + LookPath: lookPath("/usr/bin/tmux", nil), + Runner: runner, + }) + + got := map[string]ProbeResult{} + for result := range results { + got[result.HostName] = result + } + + for _, name := range []string{LocalhostName, "prod", "dev"} { + result, ok := got[name] + if !ok { + t.Fatalf("missing result for %s in %#v", name, got) + } + if !result.Reachable { + t.Fatalf("%s Reachable = false, error = %v", name, result.Error) + } + } +} + +func TestStartRunsRemoteProbesConcurrently(t *testing.T) { + hosts := []config.Host{ + {Name: "prod", Hostname: "prod.example.com"}, + {Name: "dev", Hostname: "dev.example.com"}, + } + started := make(chan struct{}, len(hosts)) + release := make(chan struct{}) + var closeRelease sync.Once + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + t.Cleanup(func() { + closeRelease.Do(func() { + close(release) + }) + }) + + runner := RunnerFunc(func(ctx context.Context, name string, args ...string) CommandResult { + if name != "ssh" { + return CommandResult{ExitCode: 0} + } + + started <- struct{}{} + select { + case <-release: + return CommandResult{ExitCode: 0} + case <-ctx.Done(): + return CommandResult{Err: ctx.Err()} + } + }) + + results := Start(ctx, hosts, Options{ + LookPath: lookPath("/usr/bin/tmux", nil), + Runner: runner, + }) + + for i := 0; i < len(hosts); i++ { + select { + case <-started: + case <-time.After(100 * time.Millisecond): + t.Fatalf("only %d remote probes started, want %d", i, len(hosts)) + } + } + + closeRelease.Do(func() { + close(release) + }) + + got := 0 + for range results { + got++ + } + if got != len(hosts)+1 { + t.Fatalf("results emitted = %d, want %d", got, len(hosts)+1) + } +} + +func TestExecRunnerCapturesSuccessfulCommand(t *testing.T) { + result := execRunner{}.Run(context.Background(), os.Args[0], helperArgs("success")...) + + if result.Err != nil { + t.Fatalf("Run returned error: %v", result.Err) + } + if result.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0", result.ExitCode) + } + if result.Stdout != "probe stdout\n" { + t.Fatalf("Stdout = %q, want helper stdout", result.Stdout) + } + if result.Stderr != "probe stderr\n" { + t.Fatalf("Stderr = %q, want helper stderr", result.Stderr) + } +} + +func TestExecRunnerCapturesExitCode(t *testing.T) { + result := execRunner{}.Run(context.Background(), os.Args[0], helperArgs("exit-7")...) + + if result.Err == nil { + t.Fatal("Run returned nil error") + } + if result.ExitCode != 7 { + t.Fatalf("ExitCode = %d, want 7", result.ExitCode) + } + if result.Stderr != "probe failed\n" { + t.Fatalf("Stderr = %q, want helper stderr", result.Stderr) + } +} + +func TestExecRunnerReportsContextTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) + defer cancel() + + result := execRunner{}.Run(ctx, os.Args[0], helperArgs("sleep")...) + + if !errors.Is(result.Err, context.DeadlineExceeded) { + t.Fatalf("Err = %v, want context deadline exceeded", result.Err) + } +} + +func TestCommandErrorUsesExitStatusWhenNoOutputExists(t *testing.T) { + err := commandError(CommandResult{ExitCode: 255}) + + if err == nil || err.Error() != "command exited with status 255" { + t.Fatalf("commandError() = %v, want exit status diagnostic", err) + } +} + +func TestElapsedNeverReturnsNonPositiveDuration(t *testing.T) { + got := elapsed(time.Now().Add(time.Second)) + + if got <= 0 { + t.Fatalf("elapsed() = %s, want positive duration", got) + } +} + +func TestOptionsWithDefaultsFillsDependencies(t *testing.T) { + got := (Options{}).withDefaults() + + if got.LookPath == nil { + t.Fatal("LookPath is nil") + } + if got.Runner == nil { + t.Fatal("Runner is nil") + } +} + +type recordingRunner struct { + name string + args []string + result CommandResult +} + +func (r *recordingRunner) Run(ctx context.Context, name string, args ...string) CommandResult { + r.name = name + r.args = append([]string(nil), args...) + if r.result.Err == nil && ctx.Err() != nil { + r.result.Err = ctx.Err() + } + return r.result +} + +func lookPath(path string, err error) func(string) (string, error) { + return func(file string) (string, error) { + if file != "tmux" { + return "", errors.New("unexpected lookpath target: " + file) + } + return path, err + } +} + +func helperArgs(action string) []string { + return []string{"-test.run=TestExecRunnerHelperProcess", "--", action} +} + +func TestExecRunnerHelperProcess(t *testing.T) { + action := "" + for i, arg := range os.Args { + if arg == "--" && i+1 < len(os.Args) { + action = os.Args[i+1] + break + } + } + if action == "" { + return + } + + switch action { + case "success": + _, _ = os.Stdout.WriteString("probe stdout\n") + _, _ = os.Stderr.WriteString("probe stderr\n") + os.Exit(0) + case "exit-7": + _, _ = os.Stderr.WriteString("probe failed\n") + os.Exit(7) + case "sleep": + time.Sleep(time.Second) + os.Exit(0) + default: + t.Fatalf("unknown helper action %q", action) + } +} diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go new file mode 100644 index 0000000..7bb4c99 --- /dev/null +++ b/internal/ssh/ssh.go @@ -0,0 +1,52 @@ +// Package ssh builds deterministic ssh command vectors. +package ssh + +import ( + "strconv" + + "github.com/KofTwentyTwo/limen/internal/config" +) + +// Target returns the host target accepted by ssh. +func Target(host config.Host) string { + if host.User == "" { + return host.Hostname + } + return host.User + "@" + host.Hostname +} + +// SessionArgv builds the design-specified ssh argv for an interactive tmux command. +func SessionArgv(host config.Host, tmuxArgs ...string) []string { + argv := []string{"ssh", "-t"} + argv = appendPort(argv, host) + argv = append(argv, Target(host), "tmux") + argv = append(argv, tmuxArgs...) + return argv +} + +// LocalTmuxArgv builds the local tmux argv for hosts that do not need ssh. +func LocalTmuxArgv(tmuxArgs ...string) []string { + argv := []string{"tmux"} + argv = append(argv, tmuxArgs...) + return argv +} + +// ProbeArgv builds the non-interactive remote probe argv. +func ProbeArgv(host config.Host, format string) []string { + argv := []string{ + "ssh", + "-o", "ConnectTimeout=2", + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + } + argv = appendPort(argv, host) + argv = append(argv, Target(host), "tmux", "ls", "-F", format) + return argv +} + +func appendPort(argv []string, host config.Host) []string { + if host.Port != 0 && host.Port != 22 { + argv = append(argv, "-p", strconv.Itoa(host.Port)) + } + return argv +} diff --git a/internal/ssh/ssh_test.go b/internal/ssh/ssh_test.go new file mode 100644 index 0000000..fad2742 --- /dev/null +++ b/internal/ssh/ssh_test.go @@ -0,0 +1,115 @@ +package ssh + +import ( + "reflect" + "testing" + + "github.com/KofTwentyTwo/limen/internal/config" +) + +func TestSessionArgvBuildsDesignExamples(t *testing.T) { + tests := []struct { + name string + host config.Host + tmux []string + want []string + }{ + { + name: "hostname only attach", + host: config.Host{Name: "prod", Hostname: "prod.example.com"}, + tmux: []string{"attach", "-t", "api"}, + want: []string{"ssh", "-t", "prod.example.com", "tmux", "attach", "-t", "api"}, + }, + { + name: "user new", + host: config.Host{Name: "dev", Hostname: "dev.lan", User: "james"}, + tmux: []string{"new"}, + want: []string{"ssh", "-t", "james@dev.lan", "tmux", "new"}, + }, + { + name: "user and non-default port named new", + host: config.Host{Name: "bldr", Hostname: "bldr.lan", User: "ci", Port: 2222}, + tmux: []string{"new", "-s", "build"}, + want: []string{"ssh", "-t", "-p", "2222", "ci@bldr.lan", "tmux", "new", "-s", "build"}, + }, + { + name: "non-default port without user", + host: config.Host{Name: "bldr", Hostname: "bldr.lan", Port: 2222}, + tmux: []string{"new"}, + want: []string{"ssh", "-t", "-p", "2222", "bldr.lan", "tmux", "new"}, + }, + { + name: "default port omitted", + host: config.Host{Name: "prod", Hostname: "prod.example.com", Port: 22}, + tmux: []string{"new"}, + want: []string{"ssh", "-t", "prod.example.com", "tmux", "new"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SessionArgv(tt.host, tt.tmux...); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("SessionArgv() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestLocalTmuxArgvBuildsLocalDesignExamples(t *testing.T) { + tests := []struct { + name string + tmux []string + want []string + }{ + { + name: "attach", + tmux: []string{"attach", "-t", "api"}, + want: []string{"tmux", "attach", "-t", "api"}, + }, + { + name: "new", + tmux: []string{"new"}, + want: []string{"tmux", "new"}, + }, + { + name: "named new", + tmux: []string{"new", "-s", "build"}, + want: []string{"tmux", "new", "-s", "build"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := LocalTmuxArgv(tt.tmux...); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("LocalTmuxArgv() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestProbeArgvBuildsRemoteProbeCommand(t *testing.T) { + host := config.Host{Name: "prod", Hostname: "prod.example.com", User: "deploy", Port: 2222} + + got := ProbeArgv(host, "#{session_name}|#{session_windows}|#{session_attached}") + want := []string{ + "ssh", + "-o", "ConnectTimeout=2", + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-p", "2222", + "deploy@prod.example.com", + "tmux", "ls", "-F", "#{session_name}|#{session_windows}|#{session_attached}", + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("ProbeArgv() = %#v, want %#v", got, want) + } +} + +func TestTargetBuildsUserAtHostnameWhenUserIsSet(t *testing.T) { + host := config.Host{Name: "dev", Hostname: "dev.lan", User: "james"} + + if got := Target(host); got != "james@dev.lan" { + t.Fatalf("Target() = %q, want james@dev.lan", got) + } +} diff --git a/internal/tmuxinfo/tmuxinfo.go b/internal/tmuxinfo/tmuxinfo.go new file mode 100644 index 0000000..5076e22 --- /dev/null +++ b/internal/tmuxinfo/tmuxinfo.go @@ -0,0 +1,128 @@ +// Package tmuxinfo parses tmux session inventory output. +package tmuxinfo + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +const ( + // LocalFormat is the tmux format used when probing localhost. + LocalFormat = "#{session_name}|#{session_windows}|#{session_attached}|#{session_created}" + + // RemoteFormat is the tmux format used when probing remote hosts. + RemoteFormat = "#{session_name}|#{session_windows}|#{session_attached}" +) + +// ErrNoServer marks tmux's normal "no server running" response. +var ErrNoServer = errors.New("tmuxinfo: no tmux server running") + +// SessionInfo is the tmux session shape used by probe results. +type SessionInfo struct { + Name string + Windows int + WindowNames []string + Attached bool + Created time.Time +} + +// ParseLocal parses localhost tmux ls output. +func ParseLocal(output string) ([]SessionInfo, error) { + return parse(output, "local", 4) +} + +// ParseRemote parses remote tmux ls output. +func ParseRemote(output string) ([]SessionInfo, error) { + return parse(output, "remote", 3) +} + +// IsNoServerRunning reports whether output is tmux's no-server diagnostic. +func IsNoServerRunning(output string) bool { + return strings.Contains(output, "no server running on /tmp/tmux-") +} + +func parse(output string, variant string, fieldCount int) ([]SessionInfo, error) { + if IsNoServerRunning(output) { + return nil, ErrNoServer + } + + trimmed := strings.TrimSpace(output) + if trimmed == "" { + return []SessionInfo{}, nil + } + + lines := strings.Split(trimmed, "\n") + sessions := make([]SessionInfo, 0, len(lines)) + for i, line := range lines { + session, err := parseLine(line, variant, fieldCount, i+1) + if err != nil { + return nil, err + } + sessions = append(sessions, session) + } + + return sessions, nil +} + +func parseLine(line string, variant string, fieldCount int, row int) (SessionInfo, error) { + fields := strings.Split(line, "|") + if len(fields) != fieldCount { + return SessionInfo{}, fmt.Errorf("tmuxinfo: malformed %s row %d: expected %d fields, got %d", variant, row, fieldCount, len(fields)) + } + + name := fields[0] + if name == "" { + return SessionInfo{}, fmt.Errorf("tmuxinfo: %s row %d has empty session name", variant, row) + } + + windows, err := parseNonNegativeInt(fields[1], variant, row, "window count") + if err != nil { + return SessionInfo{}, err + } + + attachedCount, err := parseNonNegativeInt(fields[2], variant, row, "attached count") + if err != nil { + return SessionInfo{}, err + } + + session := SessionInfo{ + Name: name, + Windows: windows, + Attached: attachedCount > 0, + } + + if fieldCount == 4 { + created, err := parseNonNegativeTimestamp(fields[3], variant, row) + if err != nil { + return SessionInfo{}, err + } + session.Created = time.Unix(created, 0).UTC() + } + + return session, nil +} + +func parseNonNegativeInt(value string, variant string, row int, label string) (int, error) { + number, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("tmuxinfo: %s row %d has invalid %s %q: %w", variant, row, label, value, err) + } + if number < 0 { + return 0, fmt.Errorf("tmuxinfo: %s row %d has invalid %s %q", variant, row, label, value) + } + return number, nil +} + +func parseNonNegativeTimestamp(value string, variant string, row int) (int64, error) { + created, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("tmuxinfo: %s row %d has invalid created timestamp %q: %w", variant, row, value, err) + } + if created < 0 { + return 0, fmt.Errorf("tmuxinfo: %s row %d has invalid created timestamp %q", variant, row, value) + } + return created, nil +} diff --git a/internal/tmuxinfo/tmuxinfo_test.go b/internal/tmuxinfo/tmuxinfo_test.go new file mode 100644 index 0000000..03223e1 --- /dev/null +++ b/internal/tmuxinfo/tmuxinfo_test.go @@ -0,0 +1,172 @@ +package tmuxinfo + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestParseLocalReadsSessionRows(t *testing.T) { + got, err := ParseLocal("api|3|1|1779799500\nops|1|0|1779799600\n") + if err != nil { + t.Fatalf("ParseLocal returned error: %v", err) + } + + if len(got) != 2 { + t.Fatalf("len(sessions) = %d, want 2", len(got)) + } + + api := got[0] + if api.Name != "api" { + t.Fatalf("api.Name = %q, want api", api.Name) + } + if api.Windows != 3 { + t.Fatalf("api.Windows = %d, want 3", api.Windows) + } + if !api.Attached { + t.Fatal("api.Attached = false, want true") + } + wantCreated := time.Unix(1779799500, 0).UTC() + if !api.Created.Equal(wantCreated) { + t.Fatalf("api.Created = %s, want %s", api.Created, wantCreated) + } + + ops := got[1] + if ops.Name != "ops" || ops.Windows != 1 || ops.Attached || !ops.Created.Equal(time.Unix(1779799600, 0).UTC()) { + t.Fatalf("ops = %#v", ops) + } +} + +func TestParseRemoteReadsSessionRowsWithoutCreatedTime(t *testing.T) { + got, err := ParseRemote("api|3|2\nops|1|0\n") + if err != nil { + t.Fatalf("ParseRemote returned error: %v", err) + } + + if len(got) != 2 { + t.Fatalf("len(sessions) = %d, want 2", len(got)) + } + + api := got[0] + if api.Name != "api" || api.Windows != 3 || !api.Attached || !api.Created.IsZero() { + t.Fatalf("api = %#v", api) + } + + ops := got[1] + if ops.Name != "ops" || ops.Windows != 1 || ops.Attached || !ops.Created.IsZero() { + t.Fatalf("ops = %#v", ops) + } +} + +func TestParseEmptyOutputReturnsZeroSessions(t *testing.T) { + for _, parse := range []struct { + name string + fn func(string) ([]SessionInfo, error) + }{ + {name: "local", fn: ParseLocal}, + {name: "remote", fn: ParseRemote}, + } { + t.Run(parse.name, func(t *testing.T) { + got, err := parse.fn("\n\t ") + if err != nil { + t.Fatalf("parse returned error: %v", err) + } + if len(got) != 0 { + t.Fatalf("len(sessions) = %d, want 0", len(got)) + } + }) + } +} + +func TestParseRejectsMalformedRowsWithUsefulErrors(t *testing.T) { + tests := []struct { + name string + parse func(string) ([]SessionInfo, error) + output string + wantErr string + }{ + { + name: "local missing created field", + parse: ParseLocal, + output: "api|3|1\n", + wantErr: "tmuxinfo: malformed local row 1: expected 4 fields, got 3", + }, + { + name: "remote extra field", + parse: ParseRemote, + output: "api|3|1|1779799500\n", + wantErr: "tmuxinfo: malformed remote row 1: expected 3 fields, got 4", + }, + { + name: "invalid window count", + parse: ParseRemote, + output: "api|many|1\n", + wantErr: `tmuxinfo: remote row 1 has invalid window count "many"`, + }, + { + name: "invalid attached count", + parse: ParseRemote, + output: "api|3|yes\n", + wantErr: `tmuxinfo: remote row 1 has invalid attached count "yes"`, + }, + { + name: "invalid created timestamp", + parse: ParseLocal, + output: "api|3|1|not-a-time\n", + wantErr: `tmuxinfo: local row 1 has invalid created timestamp "not-a-time"`, + }, + { + name: "negative created timestamp", + parse: ParseLocal, + output: "api|3|1|-1\n", + wantErr: `tmuxinfo: local row 1 has invalid created timestamp "-1"`, + }, + { + name: "empty session name", + parse: ParseRemote, + output: "|3|1\n", + wantErr: "tmuxinfo: remote row 1 has empty session name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.parse(tt.output) + if err == nil { + t.Fatal("parse returned nil error") + } + if !strings.HasPrefix(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want prefix %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestParseRejectsNegativeNumbersWithoutFormattingNoise(t *testing.T) { + _, err := ParseLocal("api|-1|0|1779799500\n") + if err == nil { + t.Fatal("ParseLocal returned nil error") + } + + want := `tmuxinfo: local row 1 has invalid window count "-1"` + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestIsNoServerRunningRecognizesTmuxMessage(t *testing.T) { + if !IsNoServerRunning("no server running on /tmp/tmux-501/default\n") { + t.Fatal("IsNoServerRunning returned false for tmux no-server message") + } + if IsNoServerRunning("ssh: connect to host prod port 22: Operation timed out") { + t.Fatal("IsNoServerRunning returned true for unrelated output") + } +} + +func TestParseNoServerOutputReturnsSentinelError(t *testing.T) { + _, err := ParseRemote("no server running on /tmp/tmux-501/default\n") + if !errors.Is(err, ErrNoServer) { + t.Fatalf("ParseRemote error = %v, want ErrNoServer", err) + } +}