fix: separate stdout and stderr in SSH CLI executor to prevent output contamination (closes #270)

This commit is contained in:
Pasha Sviderski
2026-03-14 14:56:04 +10:00
parent 1525d182b5
commit 9e4c759ffc
+32 -23
View File
@@ -1,12 +1,15 @@
package sshexec package sshexec
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type SSHCLIRemote struct { type SSHCLIRemote struct {
@@ -25,15 +28,18 @@ func NewSSHCLIRemote(user, host string, port int, keyPath string) *SSHCLIRemote
} }
} }
// TODO: Refactor and reuse this with buildDialArgs and buildSSHArgs from // newSSHCommand creates an exec.Cmd for ssh that sends SIGINT on context cancellation, giving the
// SSHCLI Connector. // remote process a chance to exit gracefully before being killed.
func (r *SSHCLIRemote) buildSSHArgs() []string { func (r *SSHCLIRemote) newSSHCommand(ctx context.Context, cmd string) *exec.Cmd {
args := []string{"-o", "ConnectTimeout=5"} args := []string{
"-o", "ConnectTimeout=5",
// Disable pseudo-terminal allocation to prevent SSH from executing as a login shell.
"-T",
}
if r.port != 0 { if r.port != 0 {
args = append(args, "-p", strconv.Itoa(r.port)) args = append(args, "-p", strconv.Itoa(r.port))
} }
if r.keyPath != "" { if r.keyPath != "" {
args = append(args, "-i", r.keyPath) args = append(args, "-i", r.keyPath)
} }
@@ -42,36 +48,39 @@ func (r *SSHCLIRemote) buildSSHArgs() []string {
if r.user != "" { if r.user != "" {
dst = fmt.Sprintf("%s@%s", r.user, dst) dst = fmt.Sprintf("%s@%s", r.user, dst)
} }
args = append(args, dst) args = append(args, dst, cmd)
return args execCmd := exec.CommandContext(ctx, "ssh", args...)
execCmd.Cancel = func() error {
return execCmd.Process.Signal(os.Interrupt)
}
execCmd.WaitDelay = 5 * time.Second
return execCmd
} }
func (r *SSHCLIRemote) Run(ctx context.Context, cmd string) (string, error) { func (r *SSHCLIRemote) Run(ctx context.Context, cmd string) (string, error) {
args := r.buildSSHArgs() var stdout, stderr bytes.Buffer
args = append(args, cmd) err := r.Stream(ctx, cmd, &stdout, &stderr)
out := strings.TrimSpace(stdout.String())
execCmd := exec.CommandContext(ctx, "ssh", args...)
output, err := execCmd.CombinedOutput()
if err != nil { if err != nil {
return strings.TrimSpace(string(output)), return out, fmt.Errorf("%w: %s", err, stderr.String())
fmt.Errorf("run command on remote host: %w: %s", err, string(output))
} }
return strings.TrimSpace(string(output)), nil return out, nil
} }
func (r *SSHCLIRemote) Stream(ctx context.Context, cmd string, stdout, stderr io.Writer) error { func (r *SSHCLIRemote) Stream(ctx context.Context, cmd string, stdout, stderr io.Writer) error {
args := r.buildSSHArgs() sshCmd := r.newSSHCommand(ctx, cmd)
args = append(args, cmd) sshCmd.Stdout = stdout
sshCmd.Stderr = stderr
execCmd := exec.CommandContext(ctx, "ssh", args...) if err := sshCmd.Run(); err != nil {
execCmd.Stdout = stdout return fmt.Errorf("run command on remote host: %w", err)
execCmd.Stderr = stderr }
return nil
return execCmd.Run()
} }
// no-op as there is no persistent connection. // Close is no-op as there is no persistent connection.
func (r *SSHCLIRemote) Close() error { func (r *SSHCLIRemote) Close() error {
return nil return nil
} }