feat(probe): add tmux and ssh discovery #6
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user