feat(probe): add tmux and ssh discovery #6

Merged
KofTwentyTwo merged 1 commits from feature/GH-2-probe-ssh into develop 2026-05-26 20:23:00 +00:00
6 changed files with 1184 additions and 0 deletions

No files matched your search

+248
View File
@@ -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
}
+469
View File
@@ -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)
}
}
+52
View File
@@ -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
}
+115
View File
@@ -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)
}
}
+128
View File
@@ -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
}
+172
View File
@@ -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)
}
}