feat(exec): add command handoff planning #7
No files matched your search
@@ -0,0 +1,155 @@
|
||||
// Package exec plans and performs the final process handoff.
|
||||
package exec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/KofTwentyTwo/limen/internal/config"
|
||||
"github.com/KofTwentyTwo/limen/internal/probe"
|
||||
"github.com/KofTwentyTwo/limen/internal/ssh"
|
||||
"github.com/KofTwentyTwo/limen/internal/state"
|
||||
)
|
||||
|
||||
const maxSessionNameLength = 64
|
||||
|
||||
// ErrEmptyArgv reports an invalid handoff plan.
|
||||
var ErrEmptyArgv = errors.New("empty argv")
|
||||
|
||||
// Plan is the final command vector and the host it targets.
|
||||
type Plan struct {
|
||||
HostName string
|
||||
Argv []string
|
||||
}
|
||||
|
||||
// SyscallExec is the injectable boundary around syscall.Exec.
|
||||
type SyscallExec func(path string, argv []string, env []string) error
|
||||
|
||||
// Options supplies dependencies for Handoff.
|
||||
type Options struct {
|
||||
StatePath string
|
||||
Now func() time.Time
|
||||
Environ func() []string
|
||||
Stderr io.Writer
|
||||
LoadState func(string) state.State
|
||||
SaveState func(string, state.State) error
|
||||
Exec SyscallExec
|
||||
}
|
||||
|
||||
// LocalAttach plans `tmux attach -t <session>` for localhost.
|
||||
func LocalAttach(session string) Plan {
|
||||
return Plan{
|
||||
HostName: probe.LocalhostName,
|
||||
Argv: ssh.LocalTmuxArgv("attach", "-t", session),
|
||||
}
|
||||
}
|
||||
|
||||
// LocalNew plans `tmux new`, optionally with `-s <name>`.
|
||||
func LocalNew(name string) (Plan, error) {
|
||||
args, err := newSessionArgs(name)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
return Plan{
|
||||
HostName: probe.LocalhostName,
|
||||
Argv: ssh.LocalTmuxArgv(args...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RemoteAttach plans `ssh -t <host> tmux attach -t <session>`.
|
||||
func RemoteAttach(host config.Host, session string) Plan {
|
||||
return Plan{
|
||||
HostName: host.Name,
|
||||
Argv: ssh.SessionArgv(host, "attach", "-t", session),
|
||||
}
|
||||
}
|
||||
|
||||
// RemoteNew plans `ssh -t <host> tmux new`, optionally with `-s <name>`.
|
||||
func RemoteNew(host config.Host, name string) (Plan, error) {
|
||||
args, err := newSessionArgs(name)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
return Plan{
|
||||
HostName: host.Name,
|
||||
Argv: ssh.SessionArgv(host, args...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NormalizeSessionName trims and validates a name for `tmux new -s`.
|
||||
// Empty input is valid and means tmux should create an unnamed session.
|
||||
func NormalizeSessionName(name string) (string, bool, error) {
|
||||
normalized := strings.TrimSpace(name)
|
||||
if normalized == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if strings.ContainsAny(normalized, ":.") {
|
||||
return "", false, errors.New("name cannot contain : or .")
|
||||
}
|
||||
if len([]rune(normalized)) > maxSessionNameLength {
|
||||
return "", false, fmt.Errorf("name cannot exceed %d characters", maxSessionNameLength)
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
// Handoff records the destination host, then replaces the current process.
|
||||
// State is advisory: a write failure must not strand the user before exec.
|
||||
func Handoff(plan Plan, opts Options) int {
|
||||
deps := opts.withDefaults()
|
||||
if len(plan.Argv) == 0 {
|
||||
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", ErrEmptyArgv)
|
||||
return 127
|
||||
}
|
||||
|
||||
st := deps.LoadState(deps.StatePath)
|
||||
if st.LastAttached == nil {
|
||||
st.LastAttached = map[string]time.Time{}
|
||||
}
|
||||
st.LastAttached[plan.HostName] = deps.Now().UTC()
|
||||
_ = deps.SaveState(deps.StatePath, st)
|
||||
|
||||
if err := deps.Exec(plan.Argv[0], plan.Argv, deps.Environ()); err != nil {
|
||||
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err)
|
||||
return 127
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func newSessionArgs(name string) ([]string, error) {
|
||||
normalized, named, err := NormalizeSessionName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !named {
|
||||
return []string{"new"}, nil
|
||||
}
|
||||
return []string{"new", "-s", normalized}, nil
|
||||
}
|
||||
|
||||
func (opts Options) withDefaults() Options {
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
if opts.Environ == nil {
|
||||
opts.Environ = os.Environ
|
||||
}
|
||||
if opts.Stderr == nil {
|
||||
opts.Stderr = io.Discard
|
||||
}
|
||||
if opts.LoadState == nil {
|
||||
opts.LoadState = state.Load
|
||||
}
|
||||
if opts.SaveState == nil {
|
||||
opts.SaveState = state.Save
|
||||
}
|
||||
if opts.Exec == nil {
|
||||
opts.Exec = syscall.Exec
|
||||
}
|
||||
return opts
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/KofTwentyTwo/limen/internal/config"
|
||||
"github.com/KofTwentyTwo/limen/internal/probe"
|
||||
"github.com/KofTwentyTwo/limen/internal/state"
|
||||
)
|
||||
|
||||
func TestLocalAttachPlansTmuxAttach(t *testing.T) {
|
||||
got := LocalAttach("api")
|
||||
want := Plan{
|
||||
HostName: probe.LocalhostName,
|
||||
Argv: []string{"tmux", "attach", "-t", "api"},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("LocalAttach() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalNewPlansUnnamedAndNamedSessions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want Plan
|
||||
}{
|
||||
{
|
||||
name: "unnamed",
|
||||
input: " ",
|
||||
want: Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new"}},
|
||||
},
|
||||
{
|
||||
name: "named",
|
||||
input: " build ",
|
||||
want: Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new", "-s", "build"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := LocalNew(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalNew returned error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("LocalNew() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteAttachPlansSSHAttach(t *testing.T) {
|
||||
host := config.Host{Name: "prod", Hostname: "prod.example.com", User: "deploy", Port: 2222}
|
||||
|
||||
got := RemoteAttach(host, "api")
|
||||
want := Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{
|
||||
"ssh", "-t", "-p", "2222", "deploy@prod.example.com",
|
||||
"tmux", "attach", "-t", "api",
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RemoteAttach() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteNewPlansSSHNew(t *testing.T) {
|
||||
host := config.Host{Name: "dev", Hostname: "dev.lan", User: "james"}
|
||||
|
||||
got, err := RemoteNew(host, " work ")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteNew returned error: %v", err)
|
||||
}
|
||||
want := Plan{
|
||||
HostName: "dev",
|
||||
Argv: []string{"ssh", "-t", "james@dev.lan", "tmux", "new", "-s", "work"},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RemoteNew() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionPlannersReturnValidationErrors(t *testing.T) {
|
||||
if _, err := LocalNew("bad:name"); err == nil || err.Error() != "name cannot contain : or ." {
|
||||
t.Fatalf("LocalNew error = %v, want invalid name", err)
|
||||
}
|
||||
|
||||
host := config.Host{Name: "dev", Hostname: "dev.lan"}
|
||||
if _, err := RemoteNew(host, "bad.name"); err == nil || err.Error() != "name cannot contain : or ." {
|
||||
t.Fatalf("RemoteNew error = %v, want invalid name", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSessionName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantName string
|
||||
wantNamed bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty creates unnamed", input: " \t ", wantNamed: false},
|
||||
{name: "trims whitespace", input: " Build ", wantName: "Build", wantNamed: true},
|
||||
{name: "rejects colon", input: "bad:name", wantErr: "name cannot contain : or ."},
|
||||
{name: "rejects dot", input: "bad.name", wantErr: "name cannot contain : or ."},
|
||||
{name: "rejects over cap", input: repeatRune('a', 65), wantErr: "name cannot exceed 64 characters"},
|
||||
{name: "accepts cap", input: repeatRune('a', 64), wantName: repeatRune('a', 64), wantNamed: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotName, gotNamed, err := NormalizeSessionName(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || err.Error() != tt.wantErr {
|
||||
t.Fatalf("NormalizeSessionName error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeSessionName returned error: %v", err)
|
||||
}
|
||||
if gotName != tt.wantName || gotNamed != tt.wantNamed {
|
||||
t.Fatalf("NormalizeSessionName() = (%q, %v), want (%q, %v)", gotName, gotNamed, tt.wantName, tt.wantNamed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffUpdatesStateBeforeExec(t *testing.T) {
|
||||
now := time.Date(2026, 5, 26, 13, 45, 0, 0, time.FixedZone("test", -5*60*60))
|
||||
var calls []string
|
||||
var saved state.State
|
||||
var execPath string
|
||||
var execArgv []string
|
||||
var execEnv []string
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh", "-t", "prod", "tmux", "new"}}, Options{
|
||||
StatePath: "/tmp/state.json",
|
||||
Now: func() time.Time { return now },
|
||||
Environ: func() []string { return []string{"TERM=xterm-256color"} },
|
||||
Stderr: &stderr,
|
||||
LoadState: func(path string) state.State {
|
||||
calls = append(calls, "load:"+path)
|
||||
return state.State{LastAttached: map[string]time.Time{"localhost": now.Add(-time.Hour)}}
|
||||
},
|
||||
SaveState: func(path string, st state.State) error {
|
||||
calls = append(calls, "save:"+path)
|
||||
saved = st
|
||||
return nil
|
||||
},
|
||||
Exec: func(path string, argv []string, env []string) error {
|
||||
calls = append(calls, "exec")
|
||||
execPath = path
|
||||
execArgv = append([]string(nil), argv...)
|
||||
execEnv = append([]string(nil), env...)
|
||||
return errors.New("exec unavailable")
|
||||
},
|
||||
})
|
||||
|
||||
if code != 127 {
|
||||
t.Fatalf("Handoff exit code = %d, want 127", code)
|
||||
}
|
||||
wantCalls := []string{"load:/tmp/state.json", "save:/tmp/state.json", "exec"}
|
||||
if !reflect.DeepEqual(calls, wantCalls) {
|
||||
t.Fatalf("calls = %#v, want %#v", calls, wantCalls)
|
||||
}
|
||||
if got := saved.LastAttached["prod"]; !got.Equal(now.UTC()) {
|
||||
t.Fatalf("saved prod timestamp = %s, want %s", got, now.UTC())
|
||||
}
|
||||
if _, ok := saved.LastAttached["localhost"]; !ok {
|
||||
t.Fatal("existing localhost timestamp was not preserved")
|
||||
}
|
||||
if execPath != "ssh" || !reflect.DeepEqual(execArgv, []string{"ssh", "-t", "prod", "tmux", "new"}) {
|
||||
t.Fatalf("exec saw %q %#v", execPath, execArgv)
|
||||
}
|
||||
if !reflect.DeepEqual(execEnv, []string{"TERM=xterm-256color"}) {
|
||||
t.Fatalf("exec env = %#v", execEnv)
|
||||
}
|
||||
if got := stderr.String(); got != "limen: exec failed: exec unavailable\n" {
|
||||
t.Fatalf("stderr = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffTreatsStateSaveFailureAsAdvisory(t *testing.T) {
|
||||
var calledExec bool
|
||||
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh"}}, Options{
|
||||
LoadState: func(path string) state.State {
|
||||
return state.State{}
|
||||
},
|
||||
SaveState: func(path string, st state.State) error {
|
||||
return errors.New("disk full")
|
||||
},
|
||||
Exec: func(path string, argv []string, env []string) error {
|
||||
calledExec = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("Handoff exit code = %d, want 0", code)
|
||||
}
|
||||
if !calledExec {
|
||||
t.Fatal("exec was not called after advisory state save failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffReportsEmptyArgvAsExecFailure(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := Handoff(Plan{HostName: "localhost"}, Options{Stderr: &stderr})
|
||||
|
||||
if code != 127 {
|
||||
t.Fatalf("Handoff exit code = %d, want 127", code)
|
||||
}
|
||||
if got := stderr.String(); got != "limen: exec failed: empty argv\n" {
|
||||
t.Fatalf("stderr = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func repeatRune(r rune, count int) string {
|
||||
var builder strings.Builder
|
||||
for i := 0; i < count; i++ {
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
Reference in new issue
Block a user