remove vm session feature
Cuts the daemon-managed guest-session machinery (start/list/show/
logs/stop/kill/attach/send). The feature shipped aimed at agent-
orchestration workflows (programmatic stdin piping into a long-lived
guest process) that aren't driving any concrete user today, and the
~2.3K LOC of daemon surface area — attach bridge, FIFO keepalive,
controller registry, sessionstream framing, SQLite persistence — was
locking in an API we'd have to keep through v0.1.0.
Anything session-flavoured that people actually need today can be
done with `vm ssh + tmux` or `vm run -- cmd`.
Deleted:
- internal/cli/commands_vm_session.go
- internal/daemon/{guest_sessions,session_lifecycle,session_attach,session_stream,session_controller}.go
- internal/daemon/session/ (guest-session helpers package)
- internal/sessionstream/ (framing package)
- internal/daemon/guest_sessions_test.go
- internal/store/guest_session_test.go
- GuestSession* types from internal/{api,model}
- Store UpsertGuestSession/GetGuestSession/ListGuestSessionsByVM/DeleteGuestSession + scanner helpers
- guest.session.* RPC dispatch entries
- 5 CLI session tests, 2 completion tests, 2 printer tests
Extracted:
- ShellQuote + FormatStepError lifted to internal/daemon/workspace/util.go
(only non-session consumer); workspace package now self-contained
- internal/daemon/guest_ssh.go keeps guestSSHClient + dialGuest +
waitForGuestSSH — still used by workspace prepare/export
- internal/daemon/fake_firecracker_test.go preserves the test helper
that used to live in guest_sessions_test.go
Store schema: CREATE TABLE guest_sessions and its column migrations
removed. Existing dev DBs keep an orphan table (harmless, pre-v0.1.0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c42fcbe012
commit
2b6437d1b4
34 changed files with 194 additions and 4031 deletions
|
|
@ -46,7 +46,6 @@ func TestListCommandsHaveLsAlias(t *testing.T) {
|
|||
{"vm", "list"},
|
||||
{"image", "list"},
|
||||
{"kernel", "list"},
|
||||
{"vm", "session", "list"},
|
||||
}
|
||||
for _, path := range cases {
|
||||
t.Run(path[len(path)-1], func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1918,34 +1918,9 @@ func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir s
|
|||
return nil
|
||||
}
|
||||
|
||||
func TestVMSessionSendCommandExists(t *testing.T) {
|
||||
root := NewBangerCommand()
|
||||
vm, _, err := root.Find([]string{"vm"})
|
||||
if err != nil {
|
||||
t.Fatalf("find vm: %v", err)
|
||||
}
|
||||
session, _, err := vm.Find([]string{"session"})
|
||||
if err != nil {
|
||||
t.Fatalf("find session: %v", err)
|
||||
}
|
||||
if _, _, err := session.Find([]string{"send"}); err != nil {
|
||||
t.Fatalf("find session send: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSessionSendRejectsWrongArgCount(t *testing.T) {
|
||||
cmd := NewBangerCommand()
|
||||
cmd.SetArgs([]string{"vm", "session", "send", "only-one-arg"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "usage: banger vm session send") {
|
||||
t.Fatalf("Execute() error = %v, want send usage error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stubEnsureDaemonForSend isolates XDG dirs and installs a daemon-ping
|
||||
// fake onto the caller's *deps so `ensureDaemon` short-circuits without
|
||||
// trying to spawn bangerd. `vm session send` uses this to avoid needing
|
||||
// a built binary on disk.
|
||||
// trying to spawn bangerd.
|
||||
func stubEnsureDaemonForSend(t *testing.T, d *deps) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config"))
|
||||
|
|
@ -1956,98 +1931,6 @@ func stubEnsureDaemonForSend(t *testing.T, d *deps) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVMSessionSendWithMessageFlag(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubEnsureDaemonForSend(t, d)
|
||||
|
||||
var capturedParams api.GuestSessionSendParams
|
||||
d.guestSessionSend = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
|
||||
capturedParams = params
|
||||
return api.GuestSessionSendResult{
|
||||
Session: model.GuestSession{ID: "sess-id", Name: "planner"},
|
||||
BytesWritten: len(params.Payload),
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := d.newRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner", "--message", `{"type":"abort"}`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
wantPayload := []byte(`{"type":"abort"}` + "\n")
|
||||
if string(capturedParams.Payload) != string(wantPayload) {
|
||||
t.Fatalf("payload = %q, want %q", capturedParams.Payload, wantPayload)
|
||||
}
|
||||
if capturedParams.VMIDOrName != "devbox" {
|
||||
t.Fatalf("VMIDOrName = %q, want %q", capturedParams.VMIDOrName, "devbox")
|
||||
}
|
||||
if capturedParams.SessionIDOrName != "planner" {
|
||||
t.Fatalf("SessionIDOrName = %q, want %q", capturedParams.SessionIDOrName, "planner")
|
||||
}
|
||||
if !strings.Contains(out.String(), "17") {
|
||||
t.Fatalf("output = %q, want bytes_written in output", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSessionSendMessageAlreadyHasNewline(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubEnsureDaemonForSend(t, d)
|
||||
|
||||
var capturedPayload []byte
|
||||
d.guestSessionSend = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
|
||||
capturedPayload = params.Payload
|
||||
return api.GuestSessionSendResult{
|
||||
Session: model.GuestSession{Name: "s"},
|
||||
BytesWritten: len(params.Payload),
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := d.newRootCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "s", "--message", "{\"type\":\"abort\"}\n"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
// Must not double-append newline.
|
||||
if capturedPayload[len(capturedPayload)-1] != '\n' {
|
||||
t.Fatalf("payload missing trailing newline: %q", capturedPayload)
|
||||
}
|
||||
if len(capturedPayload) > 0 && capturedPayload[len(capturedPayload)-2] == '\n' {
|
||||
t.Fatalf("payload has double trailing newline: %q", capturedPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSessionSendFromStdin(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubEnsureDaemonForSend(t, d)
|
||||
|
||||
var capturedPayload []byte
|
||||
d.guestSessionSend = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
|
||||
capturedPayload = params.Payload
|
||||
return api.GuestSessionSendResult{
|
||||
Session: model.GuestSession{Name: "planner"},
|
||||
BytesWritten: len(params.Payload),
|
||||
}, nil
|
||||
}
|
||||
|
||||
stdinPayload := `{"type":"steer","message":"Focus on src/"}` + "\n"
|
||||
cmd := d.newRootCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetIn(strings.NewReader(stdinPayload))
|
||||
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
if string(capturedPayload) != stdinPayload {
|
||||
t.Fatalf("payload = %q, want %q", capturedPayload, stdinPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMWorkspaceExportCommandExists(t *testing.T) {
|
||||
root := NewBangerCommand()
|
||||
vm, _, err := root.Find([]string{"vm"})
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ func (d *deps) newVMCommand() *cobra.Command {
|
|||
d.newVMSetCommand(),
|
||||
d.newVMSSHCommand(),
|
||||
d.newVMWorkspaceCommand(),
|
||||
d.newVMSessionCommand(),
|
||||
d.newVMLogsCommand(),
|
||||
d.newVMStatsCommand(),
|
||||
d.newVMPortsCommand(),
|
||||
|
|
|
|||
|
|
@ -1,370 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/model"
|
||||
"banger/internal/sessionstream"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func (d *deps) newVMSessionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "session",
|
||||
Short: "Manage long-lived guest commands inside a VM",
|
||||
Long: "Start, inspect, stop, and attach to daemon-managed guest commands. Pipe-mode sessions expose live stdio for interactive protocols. Attach is exclusive and currently uses a same-host local bridge.",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
d.newVMSessionStartCommand(),
|
||||
d.newVMSessionListCommand(),
|
||||
d.newVMSessionShowCommand(),
|
||||
d.newVMSessionLogsCommand(),
|
||||
d.newVMSessionStopCommand(),
|
||||
d.newVMSessionKillCommand(),
|
||||
d.newVMSessionAttachCommand(),
|
||||
d.newVMSessionSendCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (d *deps) newVMSessionStartCommand() *cobra.Command {
|
||||
var name string
|
||||
var cwd string
|
||||
var stdinMode string
|
||||
var envPairs []string
|
||||
var tagPairs []string
|
||||
var requiredCommands []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "start <id-or-name> <command> [args...]",
|
||||
Short: "Start a managed guest command",
|
||||
Long: "Start a daemon-managed guest command. The daemon verifies that the guest working directory exists and that the requested command is present in guest PATH before launch. Use --stdin-mode pipe when you need live attach.",
|
||||
Args: minArgsUsage(2, "usage: banger vm session start <id-or-name> [flags] -- <command> [args...]"),
|
||||
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm session start devbox --name planner --cwd /root/repo --stdin-mode pipe --require-command git -- pi --mode rpc --no-session
|
||||
banger vm session start devbox --name shell --stdin-mode pipe -- bash -lc 'exec bash'
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := parseKeyValuePairs(envPairs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := parseKeyValuePairs(tagPairs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := d.guestSessionStart(cmd.Context(), layout.SocketPath, api.GuestSessionStartParams{
|
||||
VMIDOrName: args[0],
|
||||
Name: name,
|
||||
Command: args[1],
|
||||
Args: append([]string(nil), args[2:]...),
|
||||
CWD: cwd,
|
||||
Env: env,
|
||||
StdinMode: stdinMode,
|
||||
Tags: tags,
|
||||
RequiredCommands: append([]string(nil), requiredCommands...),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := printGuestSessionSummary(cmd.OutOrStdout(), result.Session); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Session.Status == model.GuestSessionStatusFailed && strings.TrimSpace(result.Session.LaunchMessage) != "" {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: session failed at %s: %s\n", result.Session.LaunchStage, result.Session.LaunchMessage)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "session name")
|
||||
cmd.Flags().StringVar(&cwd, "cwd", "", "guest working directory; must already exist")
|
||||
cmd.Flags().StringVar(&stdinMode, "stdin-mode", string(model.GuestSessionStdinClosed), "stdin mode: closed or pipe (pipe enables attach)")
|
||||
cmd.Flags().StringArrayVar(&envPairs, "env", nil, "environment entry in KEY=VALUE form")
|
||||
cmd.Flags().StringArrayVar(&tagPairs, "tag", nil, "session tag in KEY=VALUE form")
|
||||
cmd.Flags().StringArrayVar(&requiredCommands, "require-command", nil, "extra guest command that must exist in PATH before launch; repeatable")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (d *deps) newVMSessionListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <id-or-name>",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List managed guest commands for a VM",
|
||||
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
|
||||
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := d.guestSessionList(cmd.Context(), layout.SocketPath, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printGuestSessionTable(cmd.OutOrStdout(), result.Sessions)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deps) newVMSessionShowCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "show <id-or-name> <session>",
|
||||
Short: "Show managed guest command details",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"),
|
||||
ValidArgsFunction: d.completeSessionNames,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := d.guestSessionGet(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(cmd.OutOrStdout(), result.Session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deps) newVMSessionLogsCommand() *cobra.Command {
|
||||
var stream string
|
||||
var tailLines int
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs <id-or-name> <session>",
|
||||
Short: "Show stdout or stderr for a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] <id-or-name> <session>"),
|
||||
ValidArgsFunction: d.completeSessionNames,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := d.guestSessionLogs(cmd.Context(), layout.SocketPath, api.GuestSessionLogsParams{VMIDOrName: args[0], SessionIDOrName: args[1], Stream: stream, TailLines: tailLines})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprint(cmd.OutOrStdout(), result.Content)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&stream, "stream", "stdout", "log stream to read")
|
||||
cmd.Flags().IntVarP(&tailLines, "lines", "n", 200, "number of lines to tail")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (d *deps) newVMSessionStopCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop <id-or-name> <session>",
|
||||
Short: "Send SIGTERM to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"),
|
||||
ValidArgsFunction: d.completeSessionNames,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := d.guestSessionStop(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deps) newVMSessionKillCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "kill <id-or-name> <session>",
|
||||
Short: "Send SIGKILL to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"),
|
||||
ValidArgsFunction: d.completeSessionNames,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := d.guestSessionKill(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deps) newVMSessionAttachCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "attach <id-or-name> <session>",
|
||||
Short: "Attach local stdio to an attachable guest session",
|
||||
Long: "Attach local stdio to a pipe-mode session through a daemon-created local Unix socket bridge. Only one active attach is allowed at a time, and the client must run on the same host as the daemon.",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session attach <id-or-name> <session>"),
|
||||
ValidArgsFunction: d.completeSessionNames,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := d.guestSessionAttachBegin(cmd.Context(), layout.SocketPath, api.GuestSessionAttachBeginParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
socketPath := strings.TrimSpace(result.SocketPath)
|
||||
if socketPath == "" && result.TransportKind == "unix_socket" {
|
||||
socketPath = strings.TrimSpace(result.TransportTarget)
|
||||
}
|
||||
return runGuestSessionAttach(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), socketPath)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deps) newVMSessionSendCommand() *cobra.Command {
|
||||
var message string
|
||||
cmd := &cobra.Command{
|
||||
Use: "send <id-or-name> <session>",
|
||||
Short: "Write bytes to a running guest session's stdin pipe",
|
||||
Long: "Write a payload to the stdin pipe of a running pipe-mode guest session without holding the exclusive attach. Use --message for an inline JSONL string, or pipe bytes via stdin when --message is omitted. A trailing newline is appended to --message values that lack one.",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session send <id-or-name> <session> [--message '<json>']"),
|
||||
ValidArgsFunction: d.completeSessionNames,
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm session send devbox planner --message '{"type":"abort"}'
|
||||
banger vm session send devbox planner --message '{"type":"steer","message":"Focus on src/"}'
|
||||
echo '{"type":"prompt","prompt":"Summarize."}' | banger vm session send devbox planner
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var payload []byte
|
||||
if message != "" {
|
||||
payload = []byte(message)
|
||||
if len(payload) > 0 && payload[len(payload)-1] != '\n' {
|
||||
payload = append(payload, '\n')
|
||||
}
|
||||
} else {
|
||||
payload, err = io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read stdin: %w", err)
|
||||
}
|
||||
}
|
||||
result, err := d.guestSessionSend(cmd.Context(), layout.SocketPath, api.GuestSessionSendParams{
|
||||
VMIDOrName: args[0],
|
||||
SessionIDOrName: args[1],
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "sent %d bytes to session %s\n", result.BytesWritten, result.Session.Name)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&message, "message", "", "JSONL message to send; a trailing newline is appended if absent")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseKeyValuePairs(values []string) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string, len(values))
|
||||
for _, value := range values {
|
||||
key, raw, ok := strings.Cut(value, "=")
|
||||
if !ok || strings.TrimSpace(key) == "" {
|
||||
return nil, fmt.Errorf("invalid key=value entry %q", value)
|
||||
}
|
||||
result[strings.TrimSpace(key)] = raw
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func runGuestSessionAttach(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, socketPath string) error {
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
writeErrCh <- streamGuestSessionAttachInput(conn, stdin)
|
||||
}()
|
||||
for {
|
||||
channel, payload, err := sessionstream.ReadFrame(conn)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
switch channel {
|
||||
case sessionstream.ChannelStdout:
|
||||
if _, err := stdout.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
case sessionstream.ChannelStderr:
|
||||
if _, err := stderr.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
case sessionstream.ChannelControl:
|
||||
message, err := sessionstream.ReadControl(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch message.Type {
|
||||
case "exit":
|
||||
if message.ExitCode != nil && *message.ExitCode != 0 {
|
||||
return fmt.Errorf("guest session exited with code %d", *message.ExitCode)
|
||||
}
|
||||
return nil
|
||||
case "error":
|
||||
if strings.TrimSpace(message.Error) == "" {
|
||||
return errors.New("guest session attach failed")
|
||||
}
|
||||
return errors.New(message.Error)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-writeErrCh:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamGuestSessionAttachInput(conn net.Conn, stdin io.Reader) error {
|
||||
if stdin == nil {
|
||||
return sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "eof"})
|
||||
}
|
||||
buffer := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := stdin.Read(buffer)
|
||||
if n > 0 {
|
||||
if writeErr := sessionstream.WriteFrame(conn, sessionstream.ChannelStdin, buffer[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "eof"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,9 +21,9 @@ import (
|
|||
// - Fail silently. Completion is advisory; any error path returns an
|
||||
// empty suggestion list rather than propagating to the user.
|
||||
|
||||
// defaultCompletionLister + defaultCompletionSessionLister back the
|
||||
// corresponding *deps fields; tests inject their own fakes via the
|
||||
// struct instead of mutating package-level vars.
|
||||
// defaultCompletionLister backs the *deps.completionLister field;
|
||||
// tests inject their own fake via the struct instead of mutating
|
||||
// package-level vars.
|
||||
func defaultCompletionLister(ctx context.Context, socketPath, method string) ([]string, error) {
|
||||
switch method {
|
||||
case "vm.list":
|
||||
|
|
@ -66,20 +66,6 @@ func defaultCompletionLister(ctx context.Context, socketPath, method string) ([]
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func defaultCompletionSessionLister(ctx context.Context, socketPath, vmIDOrName string) ([]string, error) {
|
||||
result, err := rpc.Call[api.GuestSessionListResult](ctx, socketPath, "guest.session.list", api.VMRefParams{IDOrName: vmIDOrName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(result.Sessions))
|
||||
for _, session := range result.Sessions {
|
||||
if session.Name != "" {
|
||||
names = append(names, session.Name)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// daemonSocketForCompletion returns the socket path IFF the daemon is
|
||||
// already running. Returns "", false when no daemon is up — completion
|
||||
// callers use this as the bail signal.
|
||||
|
|
@ -177,25 +163,3 @@ func (d *deps) completeKernelNames(cmd *cobra.Command, args []string, toComplete
|
|||
}
|
||||
return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// completeSessionNames handles `... <vm> <session>` commands: pos 0
|
||||
// completes VMs, pos 1 completes sessions owned by args[0], pos 2+ is
|
||||
// silent.
|
||||
func (d *deps) completeSessionNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return d.completeVMNames(cmd, args, toComplete)
|
||||
case 1:
|
||||
socket, ok := d.daemonSocketForCompletion(cmd.Context())
|
||||
if !ok {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
names, err := d.completionSessionLister(cmd.Context(), socket, args[0])
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return filterPrefix(names, nil, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,7 @@ func stubCompletionSeams(
|
|||
d *deps,
|
||||
pingErr error,
|
||||
names map[string][]string,
|
||||
listErr error,
|
||||
sessions map[string][]string,
|
||||
sessionErr error,
|
||||
) {
|
||||
listErr error) {
|
||||
t.Helper()
|
||||
|
||||
d.daemonPing = func(ctx context.Context, socketPath string) (api.PingResult, error) {
|
||||
|
|
@ -37,12 +34,6 @@ func stubCompletionSeams(
|
|||
}
|
||||
return names[method], nil
|
||||
}
|
||||
d.completionSessionLister = func(ctx context.Context, socketPath, vmIDOrName string) ([]string, error) {
|
||||
if sessionErr != nil {
|
||||
return nil, sessionErr
|
||||
}
|
||||
return sessions[vmIDOrName], nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPrefix(t *testing.T) {
|
||||
|
|
@ -82,7 +73,7 @@ func testCmdWithCtx() *cobra.Command {
|
|||
|
||||
func TestCompleteVMNamesHappyPath(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil)
|
||||
|
||||
got, directive := d.completeVMNames(testCmdWithCtx(), nil, "")
|
||||
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||
|
|
@ -95,7 +86,7 @@ func TestCompleteVMNamesHappyPath(t *testing.T) {
|
|||
|
||||
func TestCompleteVMNamesDaemonDown(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, errors.New("connection refused"), nil, nil, nil, nil)
|
||||
stubCompletionSeams(t, d, errors.New("connection refused"), nil, nil)
|
||||
|
||||
got, directive := d.completeVMNames(testCmdWithCtx(), nil, "")
|
||||
if len(got) != 0 {
|
||||
|
|
@ -108,7 +99,7 @@ func TestCompleteVMNamesDaemonDown(t *testing.T) {
|
|||
|
||||
func TestCompleteVMNamesRPCError(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, nil, nil, errors.New("rpc failed"), nil, nil)
|
||||
stubCompletionSeams(t, d, nil, nil, errors.New("rpc failed"))
|
||||
|
||||
got, _ := d.completeVMNames(testCmdWithCtx(), nil, "")
|
||||
if len(got) != 0 {
|
||||
|
|
@ -118,7 +109,7 @@ func TestCompleteVMNamesRPCError(t *testing.T) {
|
|||
|
||||
func TestCompleteVMNamesExcludesAlreadyEntered(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil)
|
||||
|
||||
got, _ := d.completeVMNames(testCmdWithCtx(), []string{"alpha"}, "")
|
||||
want := []string{"beta", "gamma"}
|
||||
|
|
@ -129,7 +120,7 @@ func TestCompleteVMNamesExcludesAlreadyEntered(t *testing.T) {
|
|||
|
||||
func TestCompleteVMNamesPrefixFilter(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "alphabet"}}, nil, nil, nil)
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "alphabet"}}, nil)
|
||||
|
||||
got, _ := d.completeVMNames(testCmdWithCtx(), nil, "alp")
|
||||
want := []string{"alpha", "alphabet"}
|
||||
|
|
@ -140,7 +131,7 @@ func TestCompleteVMNamesPrefixFilter(t *testing.T) {
|
|||
|
||||
func TestCompleteVMNameOnlyAtPos0(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha"}}, nil, nil, nil)
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha"}}, nil)
|
||||
|
||||
atPos0, _ := d.completeVMNameOnlyAtPos0(testCmdWithCtx(), nil, "")
|
||||
if len(atPos0) != 1 || atPos0[0] != "alpha" {
|
||||
|
|
@ -155,7 +146,7 @@ func TestCompleteVMNameOnlyAtPos0(t *testing.T) {
|
|||
|
||||
func TestCompleteImageNames(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"image.list": {"debian-bookworm", "alpine"}}, nil, nil, nil)
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"image.list": {"debian-bookworm", "alpine"}}, nil)
|
||||
|
||||
got, _ := d.completeImageNames(testCmdWithCtx(), nil, "")
|
||||
if !reflect.DeepEqual(got, []string{"debian-bookworm", "alpine"}) {
|
||||
|
|
@ -165,7 +156,7 @@ func TestCompleteImageNames(t *testing.T) {
|
|||
|
||||
func TestCompleteKernelNames(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"kernel.list": {"generic-6.12"}}, nil, nil, nil)
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"kernel.list": {"generic-6.12"}}, nil)
|
||||
|
||||
got, _ := d.completeKernelNames(testCmdWithCtx(), nil, "")
|
||||
if len(got) != 1 || got[0] != "generic-6.12" {
|
||||
|
|
@ -175,58 +166,10 @@ func TestCompleteKernelNames(t *testing.T) {
|
|||
|
||||
func TestCompleteImageNameOnlyAtPos0SilentAfterFirst(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"image.list": {"alpine"}}, nil, nil, nil)
|
||||
stubCompletionSeams(t, d, nil, map[string][]string{"image.list": {"alpine"}}, nil)
|
||||
|
||||
after, _ := d.completeImageNameOnlyAtPos0(testCmdWithCtx(), []string{"alpine"}, "")
|
||||
if len(after) != 0 {
|
||||
t.Errorf("expected silence at pos 1+, got %v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSessionNames(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d,
|
||||
nil,
|
||||
map[string][]string{"vm.list": {"devbox"}},
|
||||
nil,
|
||||
map[string][]string{"devbox": {"planner", "worker"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Position 0 → VMs.
|
||||
vms, _ := d.completeSessionNames(testCmdWithCtx(), nil, "")
|
||||
if len(vms) != 1 || vms[0] != "devbox" {
|
||||
t.Errorf("pos 0: got %v", vms)
|
||||
}
|
||||
|
||||
// Position 1 → sessions scoped to args[0].
|
||||
sessions, _ := d.completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "")
|
||||
if !reflect.DeepEqual(sessions, []string{"planner", "worker"}) {
|
||||
t.Errorf("pos 1: got %v", sessions)
|
||||
}
|
||||
|
||||
// Position 1 with prefix filter.
|
||||
filtered, _ := d.completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "wor")
|
||||
if len(filtered) != 1 || filtered[0] != "worker" {
|
||||
t.Errorf("pos 1 prefix: got %v", filtered)
|
||||
}
|
||||
|
||||
// Position 2+ silent.
|
||||
past, _ := d.completeSessionNames(testCmdWithCtx(), []string{"devbox", "planner"}, "")
|
||||
if len(past) != 0 {
|
||||
t.Errorf("pos 2+: got %v", past)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSessionNamesDaemonDown(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
stubCompletionSeams(t, d, errors.New("down"), nil, nil, nil, nil)
|
||||
|
||||
got, directive := d.completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no suggestions when daemon down, got %v", got)
|
||||
}
|
||||
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||
t.Errorf("directive = %d, want NoFileComp", directive)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,36 +31,27 @@ import (
|
|||
// validators) stay package-level because they hold no references to
|
||||
// external systems.
|
||||
type deps struct {
|
||||
bangerdPath func() (string, error)
|
||||
daemonExePath func(pid int) string
|
||||
doctor func(ctx context.Context) (system.Report, error)
|
||||
sshExec func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error
|
||||
hostCommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
vmHealth func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error)
|
||||
vmSSH func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error)
|
||||
vmDelete func(ctx context.Context, socketPath, idOrName string) error
|
||||
vmList func(ctx context.Context, socketPath string) (api.VMListResult, error)
|
||||
daemonPing func(ctx context.Context, socketPath string) (api.PingResult, error)
|
||||
vmCreateBegin func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error)
|
||||
vmCreateStatus func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error)
|
||||
vmCreateCancel func(ctx context.Context, socketPath, operationID string) error
|
||||
vmPorts func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error)
|
||||
vmWorkspacePrepare func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error)
|
||||
vmWorkspaceExport func(ctx context.Context, socketPath string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error)
|
||||
guestSessionStart func(ctx context.Context, socketPath string, params api.GuestSessionStartParams) (api.GuestSessionShowResult, error)
|
||||
guestSessionGet func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error)
|
||||
guestSessionList func(ctx context.Context, socketPath, idOrName string) (api.GuestSessionListResult, error)
|
||||
guestSessionStop func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error)
|
||||
guestSessionKill func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error)
|
||||
guestSessionLogs func(ctx context.Context, socketPath string, params api.GuestSessionLogsParams) (api.GuestSessionLogsResult, error)
|
||||
guestSessionAttachBegin func(ctx context.Context, socketPath string, params api.GuestSessionAttachBeginParams) (api.GuestSessionAttachBeginResult, error)
|
||||
guestSessionSend func(ctx context.Context, socketPath string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error)
|
||||
guestWaitForSSH func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error
|
||||
guestDial func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error)
|
||||
buildVMRunToolingPlan func(ctx context.Context, repoRoot string) toolingplan.Plan
|
||||
cwd func() (string, error)
|
||||
completionLister func(ctx context.Context, socketPath, method string) ([]string, error)
|
||||
completionSessionLister func(ctx context.Context, socketPath, vmIDOrName string) ([]string, error)
|
||||
bangerdPath func() (string, error)
|
||||
daemonExePath func(pid int) string
|
||||
doctor func(ctx context.Context) (system.Report, error)
|
||||
sshExec func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error
|
||||
hostCommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
vmHealth func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error)
|
||||
vmSSH func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error)
|
||||
vmDelete func(ctx context.Context, socketPath, idOrName string) error
|
||||
vmList func(ctx context.Context, socketPath string) (api.VMListResult, error)
|
||||
daemonPing func(ctx context.Context, socketPath string) (api.PingResult, error)
|
||||
vmCreateBegin func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error)
|
||||
vmCreateStatus func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error)
|
||||
vmCreateCancel func(ctx context.Context, socketPath, operationID string) error
|
||||
vmPorts func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error)
|
||||
vmWorkspacePrepare func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error)
|
||||
vmWorkspaceExport func(ctx context.Context, socketPath string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error)
|
||||
guestWaitForSSH func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error
|
||||
guestDial func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error)
|
||||
buildVMRunToolingPlan func(ctx context.Context, repoRoot string) toolingplan.Plan
|
||||
cwd func() (string, error)
|
||||
completionLister func(ctx context.Context, socketPath, method string) ([]string, error)
|
||||
}
|
||||
|
||||
func defaultDeps() *deps {
|
||||
|
|
@ -125,30 +116,6 @@ func defaultDeps() *deps {
|
|||
vmWorkspaceExport: func(ctx context.Context, socketPath string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
||||
return rpc.Call[api.WorkspaceExportResult](ctx, socketPath, "vm.workspace.export", params)
|
||||
},
|
||||
guestSessionStart: func(ctx context.Context, socketPath string, params api.GuestSessionStartParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.start", params)
|
||||
},
|
||||
guestSessionGet: func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.get", params)
|
||||
},
|
||||
guestSessionList: func(ctx context.Context, socketPath, idOrName string) (api.GuestSessionListResult, error) {
|
||||
return rpc.Call[api.GuestSessionListResult](ctx, socketPath, "guest.session.list", api.VMRefParams{IDOrName: idOrName})
|
||||
},
|
||||
guestSessionStop: func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.stop", params)
|
||||
},
|
||||
guestSessionKill: func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.kill", params)
|
||||
},
|
||||
guestSessionLogs: func(ctx context.Context, socketPath string, params api.GuestSessionLogsParams) (api.GuestSessionLogsResult, error) {
|
||||
return rpc.Call[api.GuestSessionLogsResult](ctx, socketPath, "guest.session.logs", params)
|
||||
},
|
||||
guestSessionAttachBegin: func(ctx context.Context, socketPath string, params api.GuestSessionAttachBeginParams) (api.GuestSessionAttachBeginResult, error) {
|
||||
return rpc.Call[api.GuestSessionAttachBeginResult](ctx, socketPath, "guest.session.attach.begin", params)
|
||||
},
|
||||
guestSessionSend: func(ctx context.Context, socketPath string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
|
||||
return rpc.Call[api.GuestSessionSendResult](ctx, socketPath, "guest.session.send", params)
|
||||
},
|
||||
guestWaitForSSH: func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||
knownHosts, _ := bangerKnownHostsPath()
|
||||
return guest.WaitForSSH(ctx, address, privateKeyPath, knownHosts, interval)
|
||||
|
|
@ -157,9 +124,8 @@ func defaultDeps() *deps {
|
|||
knownHosts, _ := bangerKnownHostsPath()
|
||||
return guest.Dial(ctx, address, privateKeyPath, knownHosts)
|
||||
},
|
||||
buildVMRunToolingPlan: toolingplan.Build,
|
||||
cwd: os.Getwd,
|
||||
completionLister: defaultCompletionLister,
|
||||
completionSessionLister: defaultCompletionSessionLister,
|
||||
buildVMRunToolingPlan: toolingplan.Build,
|
||||
cwd: os.Getwd,
|
||||
completionLister: defaultCompletionLister,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -52,37 +51,6 @@ func TestDashIfEmpty(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseKeyValuePairs(t *testing.T) {
|
||||
t.Run("nil when empty", func(t *testing.T) {
|
||||
got, err := parseKeyValuePairs(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("got %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parses entries", func(t *testing.T) {
|
||||
got, err := parseKeyValuePairs([]string{"a=1", " b = two", "c=x=y"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]string{"a": "1", "b": " two", "c": "x=y"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects malformed entries", func(t *testing.T) {
|
||||
for _, bad := range []string{"noequals", "=noKey", " =v"} {
|
||||
if _, err := parseKeyValuePairs([]string{bad}); err == nil {
|
||||
t.Errorf("expected error for %q", bad)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExitCodeErrorError(t *testing.T) {
|
||||
e := ExitCodeError{Code: 42}
|
||||
got := e.Error()
|
||||
|
|
@ -234,38 +202,6 @@ func TestPrintKernelCatalogTable(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPrintGuestSessionTable(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sessions := []model.GuestSession{
|
||||
{ID: "abcdef0123456789", Name: "planner", Status: "running", Command: "pi", CWD: "/root/repo", Attachable: true},
|
||||
{ID: "short", Name: "once", Status: "exited", Command: "true", CWD: "/tmp", Attachable: false},
|
||||
}
|
||||
if err := printGuestSessionTable(&buf, sessions); err != nil {
|
||||
t.Fatalf("printGuestSessionTable: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
for _, want := range []string{"ID", "NAME", "planner", "once", "yes", "no", "pi"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintGuestSessionSummary(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
session := model.GuestSession{
|
||||
ID: "id1", Name: "s", Status: "exited", Command: "true", CWD: "/root",
|
||||
}
|
||||
if err := printGuestSessionSummary(&buf, session); err != nil {
|
||||
t.Fatalf("printGuestSessionSummary: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
fields := strings.Split(strings.TrimRight(got, "\n"), "\t")
|
||||
if len(fields) != 5 {
|
||||
t.Fatalf("expected 5 tab-separated fields, got %d: %q", len(fields), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printJSON(&buf, map[string]int{"a": 1, "b": 2}); err != nil {
|
||||
|
|
@ -340,10 +276,6 @@ type failWriter struct{}
|
|||
func (failWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("boom") }
|
||||
|
||||
func TestPrintersPropagateWriteErrors(t *testing.T) {
|
||||
sessions := []model.GuestSession{{ID: "id", Name: "n"}}
|
||||
if err := printGuestSessionTable(failWriter{}, sessions); err == nil {
|
||||
t.Error("expected write error from printGuestSessionTable")
|
||||
}
|
||||
kernels := []api.KernelEntry{{Name: "k"}}
|
||||
if err := printKernelListTable(failWriter{}, kernels); err == nil {
|
||||
t.Error("expected write error from printKernelListTable")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package cli
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
|
@ -276,30 +275,6 @@ func printKernelCatalogTable(out anyWriter, entries []api.KernelCatalogEntry) er
|
|||
return w.Flush()
|
||||
}
|
||||
|
||||
// -- guest session printers -----------------------------------------
|
||||
|
||||
func printGuestSessionSummary(out anyWriter, session model.GuestSession) error {
|
||||
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\n", session.ID, session.Name, session.Status, session.Command, session.CWD)
|
||||
return err
|
||||
}
|
||||
|
||||
func printGuestSessionTable(out io.Writer, sessions []model.GuestSession) error {
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintln(tw, "ID\tNAME\tSTATUS\tATTACH\tCOMMAND\tCWD"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
attach := "no"
|
||||
if session.Attachable {
|
||||
attach = "yes"
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", shortID(session.ID), session.Name, session.Status, attach, session.Command, session.CWD); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// -- doctor printer -------------------------------------------------
|
||||
|
||||
func printDoctorReport(out anyWriter, report system.Report) error {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue