-
Notifications
You must be signed in to change notification settings - Fork 140
common: add ssh.ExecWithOutput to allow streaming output #865
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -192,6 +192,87 @@ func golangConnectionScp(options ConnectionScpOptions) (*ConnectionScpReport, er | |
| return &ConnectionScpReport{Response: remote.Name()}, nil | ||
| } | ||
|
|
||
| type golangExecOutputReader struct { | ||
| stdout io.Reader | ||
| sess *ssh.Session | ||
| client *ssh.Client | ||
| stderr *bytes.Buffer | ||
| waitCh <-chan error | ||
| } | ||
|
|
||
| func (r *golangExecOutputReader) Read(p []byte) (int, error) { | ||
| return r.stdout.Read(p) | ||
| } | ||
|
|
||
| func (r *golangExecOutputReader) Close() error { | ||
| r.sess.Close() | ||
| err := <-r.waitCh | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See elsewhere, this could hang on an early |
||
| // safe to close the connection now that the session is done | ||
| // and the exit status has been received. | ||
|
Comment on lines
+210
to
+211
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’m not sure what this is saying, is it somehow unusual / unexpected? Perhaps, instead, the |
||
| r.client.Close() | ||
| if err != nil { | ||
| return fmt.Errorf("%v: %w", r.stderr.String(), err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func golangConnectionExecWithOutput(options ConnectionExecOptions, input io.Reader) (io.ReadCloser, error) { | ||
| if !strings.HasPrefix(options.Host, "ssh://") { | ||
| options.Host = "ssh://" + options.Host | ||
| } | ||
| _, uri, err := Validate(options.User, options.Host, options.Port, options.Identity) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| cfg, err := ValidateAndConfigure(uri, options.Identity, false) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| dialAdd, err := ssh.Dial("tcp", uri.Host, cfg) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to connect: %w", err) | ||
| } | ||
|
Comment on lines
+220
to
+235
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think sharing / not duplicating at least this part would be valuable. We already have 5 callers of I’m not at all asking for cleaning up the existing differences — but consolidating the existing code where it is common, and calling a shared helper, instead of adding another copy that can diverge over time would be valuable. |
||
|
|
||
| sess, err := dialAdd.NewSession() | ||
| if err != nil { | ||
| dialAdd.Close() | ||
| return nil, err | ||
| } | ||
|
|
||
| stderr := &bytes.Buffer{} | ||
| sess.Stderr = stderr | ||
| if input != nil { | ||
| sess.Stdin = input | ||
| } | ||
|
|
||
| stdout, err := sess.StdoutPipe() | ||
| if err != nil { | ||
| sess.Close() | ||
| dialAdd.Close() | ||
| return nil, err | ||
| } | ||
|
|
||
| if err := sess.Start(strings.Join(options.Args, " ")); err != nil { | ||
| sess.Close() | ||
| dialAdd.Close() | ||
| return nil, err | ||
| } | ||
|
|
||
| waitCh := make(chan error, 1) | ||
| go func() { | ||
| waitCh <- sess.Wait() | ||
| }() | ||
|
|
||
| return &golangExecOutputReader{ | ||
| stdout: stdout, | ||
| sess: sess, | ||
| client: dialAdd, | ||
| stderr: stderr, | ||
| waitCh: waitCh, | ||
| }, nil | ||
| } | ||
|
|
||
| // ExecRemoteCommand takes a ssh client connection and a command to run and executes the | ||
| // command on the specified client. The function returns the Stdout from the client or the Stderr. | ||
| func ExecRemoteCommand(dial *ssh.Client, run string) ([]byte, error) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -101,27 +101,25 @@ func nativeConnectionCreate(options ConnectionCreateOptions) error { | |
| }) | ||
| } | ||
|
|
||
| func nativeConnectionExec(options ConnectionExecOptions, input io.Reader) (*ConnectionExecReport, error) { | ||
| func nativePrepareSSHCmd(options ConnectionExecOptions, input io.Reader) (*exec.Cmd, *bytes.Buffer, error) { | ||
| dst, uri, err := Validate(options.User, options.Host, options.Port, options.Identity) | ||
| if err != nil { | ||
| return nil, err | ||
| return nil, nil, err | ||
| } | ||
|
|
||
| ssh, err := exec.LookPath("ssh") | ||
| if err != nil { | ||
| return nil, err | ||
| return nil, nil, err | ||
| } | ||
|
|
||
| output := &bytes.Buffer{} | ||
| errors := &bytes.Buffer{} | ||
| if host, _, ok := strings.Cut(uri.Host, "/run"); ok { | ||
| uri.Host = host | ||
| } | ||
|
|
||
| options.Args = append([]string{uri.User.String() + "@" + uri.Hostname()}, options.Args...) | ||
| conf, err := config.Default() | ||
| if err != nil { | ||
| return nil, err | ||
| return nil, nil, err | ||
| } | ||
|
|
||
| args := []string{} | ||
|
|
@@ -132,19 +130,67 @@ func nativeConnectionExec(options ConnectionExecOptions, input io.Reader) (*Conn | |
| args = append(args, "-F", conf.Engine.SSHConfig) | ||
| } | ||
| args = append(args, options.Args...) | ||
| info := exec.Command(ssh, args...) | ||
| info.Stdout = output | ||
| info.Stderr = errors | ||
|
|
||
| stderr := &bytes.Buffer{} | ||
| cmd := exec.Command(ssh, args...) | ||
| cmd.Stderr = stderr | ||
| if input != nil { | ||
| info.Stdin = input | ||
| cmd.Stdin = input | ||
| } | ||
| err = info.Run() | ||
|
|
||
| return cmd, stderr, nil | ||
| } | ||
|
|
||
| func nativeConnectionExec(options ConnectionExecOptions, input io.Reader) (*ConnectionExecReport, error) { | ||
| cmd, stderr, err := nativePrepareSSHCmd(options, input) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| output := &bytes.Buffer{} | ||
| cmd.Stdout = output | ||
| if err := cmd.Run(); err != nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AFAICS this could use |
||
| return nil, fmt.Errorf("%v: %w", stderr.String(), err) | ||
| } | ||
| return &ConnectionExecReport{Response: output.String()}, nil | ||
| } | ||
|
|
||
| type nativeExecOutputReader struct { | ||
| stdout io.ReadCloser | ||
| cmd *exec.Cmd | ||
| stderr *bytes.Buffer | ||
| } | ||
|
|
||
| func (r *nativeExecOutputReader) Read(p []byte) (int, error) { | ||
| return r.stdout.Read(p) | ||
| } | ||
|
|
||
| func (r *nativeExecOutputReader) Close() error { | ||
| err := r.cmd.Wait() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can work but it requires the caller to check errors on a I think doing this in If this design remained, at the very least the API would need a fairly loud documentation that the caller must check errors on
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right; I didn't think of usage semantics. I'm a bit unsure of waiting inside I found that using
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Intuitively, I think that if we decide what should happen on
If I’m reading the code correctly, closing the reader end should end up closing a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the caller fails due to an unrelated error and calls
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, one option is for us to kill the process on |
||
| if err != nil { | ||
| return fmt.Errorf("%v: %w", r.stderr.String(), err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func nativeConnectionExecWithOutput(options ConnectionExecOptions, input io.Reader) (io.ReadCloser, error) { | ||
| cmd, stderr, err := nativePrepareSSHCmd(options, input) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| stdout, err := cmd.StdoutPipe() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| if err := cmd.Start(); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &nativeExecOutputReader{stdout: stdout, cmd: cmd, stderr: stderr}, nil | ||
| } | ||
|
|
||
| func nativeConnectionScp(options ConnectionScpOptions) (*ConnectionScpReport, error) { | ||
| host, remotePath, localPath, swap, err := ParseScpArgs(options) | ||
| if err != nil { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also probably safer to do this in
Readwhen it hitsEOF.