Skip to content

Commit 2f2d9b7

Browse files
authored
ssh add pipe support (#25)
* ssh add pipe support
1 parent e4a5df9 commit 2f2d9b7

2 files changed

Lines changed: 82 additions & 26 deletions

File tree

internal/verda-cli/cmd/ssh/ssh.go

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ssh
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os"
78
"os/exec"
@@ -34,6 +35,10 @@ func NewCmdSSH(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command {
3435
SSH connection.
3536
3637
Any arguments after -- are passed directly to the ssh command.
38+
39+
Supports piping commands via stdin. When stdin is not a terminal,
40+
the input is forwarded to the remote host and pseudo-TTY allocation
41+
is disabled automatically.
3742
`),
3843
Example: cmdutil.Examples(`
3944
# SSH by hostname
@@ -47,6 +52,12 @@ func NewCmdSSH(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command {
4752
4853
# Pass extra ssh arguments
4954
verda ssh gpu-runner -- -L 8080:localhost:8080
55+
56+
# Pipe a command to run remotely
57+
echo "nvidia-smi" | verda ssh gpu-runner
58+
59+
# Pipe a script
60+
cat setup.sh | verda ssh gpu-runner
5061
`),
5162
Args: cobra.ArbitraryArgs, // extra args after "--" are passed to ssh
5263
DisableFlagParsing: false,
@@ -98,35 +109,14 @@ func runSSH(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams,
98109

99110
// Interactive picker when no target is provided.
100111
if target == "" {
101-
running := filterRunning(instances)
102-
if len(running) == 0 {
103-
_, _ = fmt.Fprintln(ioStreams.ErrOut, "No running instances found.")
104-
return nil
105-
}
106-
107-
labels := make([]string, 0, len(running)+1)
108-
for i := range running {
109-
ip := ""
110-
if running[i].IP != nil && *running[i].IP != "" {
111-
ip = *running[i].IP
112-
}
113-
labels = append(labels, fmt.Sprintf("%-20s %-18s %s %s",
114-
running[i].Hostname,
115-
running[i].InstanceType,
116-
running[i].Location,
117-
ip,
118-
))
119-
}
120-
labels = append(labels, "Cancel")
121-
122-
idx, err := f.Prompter().Select(cmd.Context(), "Select instance to SSH into", labels)
112+
picked, err := pickInstance(cmd.Context(), f, ioStreams, instances)
123113
if err != nil {
124-
return nil // User pressed Esc/Ctrl+C during prompt.
114+
return err
125115
}
126-
if idx == len(running) {
127-
return nil // Cancel selected.
116+
if picked == "" {
117+
return nil // canceled or no running instances
128118
}
129-
target = running[idx].Hostname
119+
target = picked
130120
}
131121

132122
inst := resolveInstance(instances, target)
@@ -150,6 +140,9 @@ func runSSH(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams,
150140
}
151141

152142
sshArgs := []string{"ssh"}
143+
if !isTerminal(os.Stdin) {
144+
sshArgs = append(sshArgs, "-T")
145+
}
153146
if opts.KeyFile != "" {
154147
sshArgs = append(sshArgs, "-i", opts.KeyFile)
155148
}
@@ -163,6 +156,44 @@ func runSSH(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams,
163156
return syscall.Exec(sshPath, sshArgs, os.Environ()) //nolint:gosec // Intentional: replace process with ssh using user-provided host and args.
164157
}
165158

159+
// pickInstance shows an interactive picker for running instances.
160+
// Returns the selected hostname, or "" if canceled/no running instances.
161+
func pickInstance(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, instances []verda.Instance) (string, error) {
162+
if !isTerminal(os.Stdin) {
163+
return "", errors.New("instance ID or hostname is required when piping stdin")
164+
}
165+
166+
running := filterRunning(instances)
167+
if len(running) == 0 {
168+
_, _ = fmt.Fprintln(ioStreams.ErrOut, "No running instances found.")
169+
return "", nil
170+
}
171+
172+
labels := make([]string, 0, len(running)+1)
173+
for i := range running {
174+
ip := ""
175+
if running[i].IP != nil && *running[i].IP != "" {
176+
ip = *running[i].IP
177+
}
178+
labels = append(labels, fmt.Sprintf("%-20s %-18s %s %s",
179+
running[i].Hostname,
180+
running[i].InstanceType,
181+
running[i].Location,
182+
ip,
183+
))
184+
}
185+
labels = append(labels, "Cancel")
186+
187+
idx, err := f.Prompter().Select(ctx, "Select instance to SSH into", labels)
188+
if err != nil {
189+
return "", err
190+
}
191+
if idx == len(running) {
192+
return "", nil // Cancel selected.
193+
}
194+
return running[idx].Hostname, nil
195+
}
196+
166197
// filterRunning returns only instances with status running.
167198
func filterRunning(instances []verda.Instance) []verda.Instance {
168199
var running []verda.Instance
@@ -174,6 +205,15 @@ func filterRunning(instances []verda.Instance) []verda.Instance {
174205
return running
175206
}
176207

208+
// isTerminal reports whether f is a terminal.
209+
func isTerminal(f *os.File) bool {
210+
fi, err := f.Stat()
211+
if err != nil {
212+
return false
213+
}
214+
return fi.Mode()&os.ModeCharDevice != 0
215+
}
216+
177217
// resolveInstance finds an instance by exact ID match first, then by hostname.
178218
func resolveInstance(instances []verda.Instance, target string) *verda.Instance {
179219
// Exact ID match.

internal/verda-cli/cmd/ssh/ssh_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ssh
33
import (
44
"bytes"
55
"errors"
6+
"os"
67
"testing"
78

89
"github.com/spf13/cobra"
@@ -13,6 +14,21 @@ import (
1314

1415
func strPtr(s string) *string { return &s }
1516

17+
func TestIsTerminalReturnsFalseForPipe(t *testing.T) {
18+
t.Parallel()
19+
20+
r, w, err := os.Pipe()
21+
if err != nil {
22+
t.Fatal(err)
23+
}
24+
defer func() { _ = r.Close() }()
25+
defer func() { _ = w.Close() }()
26+
27+
if isTerminal(r) {
28+
t.Fatal("expected pipe to not be a terminal")
29+
}
30+
}
31+
1632
func TestResolveInstanceByID(t *testing.T) {
1733
t.Parallel()
1834

0 commit comments

Comments
 (0)