Skip to content

common: add ssh.ExecWithOutput to allow streaming output#865

Open
danishprakash wants to merge 1 commit into
podman-container-tools:mainfrom
danishprakash:ssh-execwithoutput
Open

common: add ssh.ExecWithOutput to allow streaming output#865
danishprakash wants to merge 1 commit into
podman-container-tools:mainfrom
danishprakash:ssh-execwithoutput

Conversation

@danishprakash

Copy link
Copy Markdown
Member

Follow-up to 2681204 that added ExecWithInput, allowing executing commands over SSH while passing an optional input reader. For cases where remote > local output needs to be streamed, add a similar ExecWithOutput that returns a ReadCloser.

Follow-up to 2681204 that added ExecWithInput allowing executing
commands over ssh while passing an optional input reader. For cases
where remote > local output needs to be streamed, add a similar
ExecWithOutput that returns a ReadCloser.

Signed-off-by: Danish Prakash <contact@danishpraka.sh>
@github-actions github-actions Bot added the common Related to "common" package label May 21, 2026
@mtrmac

mtrmac commented May 25, 2026

Copy link
Copy Markdown
Contributor

Thanks! (I didn’t read the code ~at all.) Is there an expected user in the Podman ecosystem?

@danishprakash

Copy link
Copy Markdown
Member Author

Ah, yes, I should've mentioned this, but this is a requirement for implementing support for podman-container-tools/podman#28321. For supporting *-dir formats between remote to local/remote ie "save image on remote host and stream it back". I'm yet to raise the PR on podman, which uses this.


output := &bytes.Buffer{}
cmd.Stdout = output
if err := cmd.Run(); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICS this could use cmd.Output() without having to implement the “collect stdout” and “include stderr on failure” parts; so nativePrepareSSHCmd can have a simpler interface.

}

func (r *nativeExecOutputReader) Close() error {
err := r.cmd.Wait()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 ReadCloser.Close, which most ReadCloser consumers don’t bother to do.

I think doing this in Read at the point r.stdout.Read hits an EOF would be more reliable, admittedly at the cost of some complexity.

If this design remained, at the very least the API would need a fairly loud documentation that the caller must check errors on .Close().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 Read() that doesn't really scream idiomatic Go to me but I could be wrong. On the other hand, expecting users to check errors on Close() is also fragile as you mentioned.

I found that using io.Pipe might be suitable. With that, we use let the process write stdout to the write end of the pipe and we start the command in a goroutine while instantly returning the read end of the pipe.

func nativeConnectionExecWithOutput(...) (io.ReadCloser, error) {
  cmd, _ := nativePrepareSSHCmd(options, input)
  stderr := &bytes.Buffer{}
  cmd.Stderr = stderr

  r, w := io.Pipe()
  cmd.Stdout = w

  go func() {
      err := cmd.Run()
      if err != nil {
          err = fmt.Errorf("%v: %w", stderr.String(), err)
      }
      w.CloseWithError(err)
  }()

  return r, nil
}

CloseWithError ensures that upon an error, the next read on the pipe, the caller would receive the error so effectively moving error propogation from Close to Read.

Comment on lines +220 to +235
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 ssh.Dial here in 3 different patterns — one of the differences looks justifiable, but it’s unclear.

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.

Comment on lines +210 to +211
// safe to close the connection now that the session is done
// and the exit status has been received.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 golangExecOutputReader.sess field could be documented to exist within client to make the relationship / lifetime explicit there.


func (r *golangExecOutputReader) Close() error {
r.sess.Close()
err := <-r.waitCh

Copy link
Copy Markdown
Contributor

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 Read when it hits EOF.

}

func (r *nativeExecOutputReader) Close() error {
err := r.cmd.Wait()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the caller fails due to an unrelated error and calls Close early, couldn’t this deadlock? We never consume the rest of r.stdout, the child process could block on writing to its stdout, Wait will never return.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, one option is for us to kill the process on Close, or to provide the command under a cancellable context and the caller then calls cancel()s on an early close.


func (r *golangExecOutputReader) Close() error {
r.sess.Close()
err := <-r.waitCh

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See elsewhere, this could hang on an early Close().

@mtrmac

mtrmac commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

#913 is adding a context.Context parameter to a ssh function. @Honny1 is it desirable/valuable to add it to other code paths, like this one, as well?

@Honny1

Honny1 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Yes, I think it's valuable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

common Related to "common" package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants