blob: fd8da3954d3de18861e7c9be7c1086d85a06a7b1 [file] [log] [blame]
// Copyright 2020 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package fuzz
import (
"bytes"
"fmt"
"io"
"time"
"github.com/golang/glog"
"golang.org/x/crypto/ssh"
)
// An InstanceCmd represents a remote command to be run on an Instance
// This interface is a superset of os.exec.Cmd
// TODO(https://fxbug.dev/42121947): use timeout for all methods
type InstanceCmd interface {
// Output runs the command and returns its combined output.
Output() ([]byte, error)
// Runs the specified command and waits for it to complete.
//
// The returned error is nil if the command runs, has no problems copying
// input and output, and exits with a zero exit status.
Run() error
// Start starts the specified command but does not wait for it to complete.
Start() error
// StdinPipe returns a pipe that will be connected to the command's input
// when the command starts. The pipe will be closed automatically after
// Wait sees the command exit. A caller need only call Close to force the
// pipe to close sooner. For example, if the command being run will not
// exit until the input is closed, the caller must close the pipe.
//
// If used, this must be called before Start() or Run().
StdinPipe() (io.WriteCloser, error)
// StdoutPipe returns a pipe that will be connected to the command's stdout
// when the command starts.
//
// Wait will close the pipe after seeing the command exit, so most callers
// need not close the pipe themselves; however, an implication is that it
// is incorrect to call Wait before all reads from the pipe have completed.
// For the same reason, it is incorrect to call Run when using StdoutPipe.
//
// If used, this must be called before Start() or Run().
StdoutPipe() (io.ReadCloser, error)
// Same as StdoutPipe but for stderr
//
// If used, this must be called before Start() or Run().
StderrPipe() (io.ReadCloser, error)
// Wait waits for the command to exit and waits for any copying
// to input or from output to complete.
//
// The command must have been started by Start.
//
// The returned error is nil if the command runs, has no problems
// copying input and output, and exits with a zero exit status
// before any timeout set with SetTimeout is triggered.
//
// Wait releases any resources associated with the InstanceCmd.
Wait() error
// The following methods are *not* included in exec.Cmd:
// SetTimeout sets a timeout that will be used for all blocking
// methods. If a method takes longer than the timeout duration to
// complete, it will return early with an error. If not set, or
// set to 0, methods will block forever.
SetTimeout(duration time.Duration)
// Kill forcefully terminates the remote command
Kill() error
}
// A SSHInstanceCmd represents a command to be run over SSH
type SSHInstanceCmd struct {
connector *SSHConnector
cmdline string
pid int
errlog bytes.Buffer
session *ssh.Session
// true if StderrPipe has been called
stderrpipe bool
timeout time.Duration
}
// Output executes the command and returns its output
func (c *SSHInstanceCmd) Output() ([]byte, error) {
if err := c.initialize(); err != nil {
return nil, err
}
var buf bytes.Buffer
c.session.Stdout = &buf
c.session.Stderr = &buf
if err := c.Run(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Run runs the command to completion
func (c *SSHInstanceCmd) Run() error {
if err := c.Start(); err != nil {
return err
}
return c.Wait()
}
// Initialize the underlying SSH Session object, if necessary.
func (c *SSHInstanceCmd) initialize() error {
if c.session != nil {
return nil
}
if c.connector.client == nil {
if err := c.connector.Connect(); err != nil {
return err
}
}
session, err := c.connector.client.NewSession()
if err != nil {
return fmt.Errorf("error starting ssh session: %s", err)
}
c.session = session
return nil
}
// Start the command, but don't wait for it to complete
func (c *SSHInstanceCmd) Start() error {
if c.pid != 0 {
return fmt.Errorf("start called on already-running command")
}
if err := c.initialize(); err != nil {
return err
}
glog.Infof("Running ssh command: %s", c.cmdline)
// Log stderr in case the command fails.
// TODO(https://fxbug.dev/42121947): limit size of errlog, we only really want the
// tail for debugging
if !c.stderrpipe {
if c.session.Stderr == nil {
c.session.Stderr = &c.errlog
} else {
c.session.Stderr = io.MultiWriter(c.session.Stderr, &c.errlog)
}
}
if err := c.session.Start(c.cmdline); err != nil {
return err
}
return nil
}
// The StdoutPipe and StderrPipe methods for ssh.Session diverge slightly from
// exec.Cmd in that they return Readers instead of ReadClosers, so closing the
// read end of the pipe cannot be used propagate errors backwards in a series
// of chained commands. We work around this here by implementing a Close method
// that calls Kill on the command.
//
// Note: We *also* can't configure Stdout/Stderr directly with our own Pipes
// because in that case EOFs are not propagated to the pipes when the remote
// command exits.
type KillCloser struct {
io.Reader
cmd *SSHInstanceCmd
}
func (kc *KillCloser) Close() error {
return kc.cmd.Kill()
}
// StdinPipe returns a pipe connected to the command's input
func (c *SSHInstanceCmd) StdinPipe() (io.WriteCloser, error) {
if err := c.initialize(); err != nil {
return nil, err
}
return c.session.StdinPipe()
}
// StdoutPipe returns a pipe connected to the command's stdout
func (c *SSHInstanceCmd) StdoutPipe() (io.ReadCloser, error) {
if err := c.initialize(); err != nil {
return nil, err
}
r, err := c.session.StdoutPipe()
if err != nil {
return nil, err
}
return &KillCloser{Reader: r, cmd: c}, nil
}
// StderrPipe returns a pipe connected to the command's stderr
func (c *SSHInstanceCmd) StderrPipe() (io.ReadCloser, error) {
if err := c.initialize(); err != nil {
return nil, err
}
r, err := c.session.StderrPipe()
if err != nil {
return nil, err
}
c.stderrpipe = true
return &KillCloser{Reader: r, cmd: c}, nil
}
// Kill sends a KILL signal to the remote process
func (c *SSHInstanceCmd) Kill() error {
return c.session.Signal(ssh.SIGKILL)
}
// SetTimeout sets the global timeout
func (c *SSHInstanceCmd) SetTimeout(duration time.Duration) {
c.timeout = duration
}
// Wait for the remote command to complete
func (c *SSHInstanceCmd) Wait() error {
defer c.session.Close()
errs := make(chan error)
go func() { errs <- c.session.Wait() }()
var timeoutCh <-chan time.Time
if c.timeout != 0 {
timeoutCh = time.After(c.timeout)
}
select {
case err := <-errs:
if err == nil {
return nil
}
if cmderr, ok := err.(*ssh.ExitError); ok {
return &InstanceCmdError{
ReturnCode: cmderr.ExitStatus(),
Command: c.cmdline,
Stderr: c.errlog.String(),
}
}
return err
case <-timeoutCh:
// TODO(https://fxbug.dev/42121947): clean up the ssh command
return fmt.Errorf("timeout waiting for command to complete")
}
}
// InstanceCmdError includes extra information when commands fail
type InstanceCmdError struct {
ReturnCode int
Command string
Stderr string
}
func (e *InstanceCmdError) Error() string {
return fmt.Sprintf("'%s' exited with error: %d (%s)", e.Command, e.ReturnCode, e.Stderr)
}