github.com/mutagen-io/mutagen@v0.18.0-rc1/pkg/agent/dial.go (about)

     1  package agent
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"strings"
     9  	"time"
    10  	"unicode/utf8"
    11  
    12  	transportpkg "github.com/mutagen-io/mutagen/pkg/agent/transport"
    13  	"github.com/mutagen-io/mutagen/pkg/filesystem"
    14  	"github.com/mutagen-io/mutagen/pkg/logging"
    15  	"github.com/mutagen-io/mutagen/pkg/mutagen"
    16  	"github.com/mutagen-io/mutagen/pkg/platform/terminal"
    17  	"github.com/mutagen-io/mutagen/pkg/prompting"
    18  	streampkg "github.com/mutagen-io/mutagen/pkg/stream"
    19  )
    20  
    21  const (
    22  	// agentTerminationDelay is the maximum amount of time that Mutagen will
    23  	// wait for an agent process to terminate on its own in the event of a
    24  	// handshake error before forcing termination.
    25  	agentTerminationDelay = 5 * time.Second
    26  	// agentErrorInMemoryCutoff is the maximum number of bytes that Mutagen will
    27  	// capture in memory from the standard error output of an agent process.
    28  	agentErrorInMemoryCutoff = 32 * 1024
    29  )
    30  
    31  // connect connects to an agent-based endpoint using the specified transport,
    32  // connection mode, and prompter. It accepts a hint as to whether or not the
    33  // remote environment is cmd.exe-based and returns hints as to whether or not
    34  // installation should be attempted and whether or not the remote environment is
    35  // cmd.exe-based.
    36  func connect(logger *logging.Logger, transport Transport, mode, prompter string, cmdExe bool) (io.ReadWriteCloser, bool, bool, error) {
    37  	// Compute the agent invocation command, relative to the user's home
    38  	// directory on the remote. Unless we have reason to assume that this is a
    39  	// cmd.exe environment, we construct a path using forward slashes. This will
    40  	// work for all POSIX systems and POSIX-like environments on Windows. If we
    41  	// know we're hitting a cmd.exe environment, then we use backslashes,
    42  	// otherwise the invocation won't work. Watching for cmd.exe to fail on
    43  	// commands with forward slashes is actually the way that we detect cmd.exe
    44  	// environments.
    45  	//
    46  	// HACK: We're assuming that none of these path components have spaces in
    47  	// them, but since we control all of them, this is probably okay.
    48  	//
    49  	// HACK: When invoking on Windows systems (whether inside a POSIX
    50  	// environment or cmd.exe), we can leave the "exe" suffix off the target
    51  	// name. Fortunately this allows us to also avoid having to try the
    52  	// combination of forward slashes + ".exe" for Windows POSIX environments.
    53  	pathSeparator := "/"
    54  	if cmdExe {
    55  		pathSeparator = "\\"
    56  	}
    57  	dataDirectoryName := filesystem.MutagenDataDirectoryName
    58  	if mutagen.DevelopmentModeEnabled {
    59  		dataDirectoryName = filesystem.MutagenDataDirectoryDevelopmentName
    60  	}
    61  	agentInvocationPath := strings.Join([]string{
    62  		dataDirectoryName,
    63  		filesystem.MutagenAgentsDirectoryName,
    64  		mutagen.Version,
    65  		BaseName,
    66  	}, pathSeparator)
    67  
    68  	// Compute the command to invoke.
    69  	command := fmt.Sprintf("%s %s --%s=%s", agentInvocationPath, mode, FlagLogLevel, logger.Level())
    70  
    71  	// Set up (but do not start) an agent process.
    72  	message := "Connecting to agent (POSIX)..."
    73  	if cmdExe {
    74  		message = "Connecting to agent (Windows)..."
    75  	}
    76  	if err := prompting.Message(prompter, message); err != nil {
    77  		return nil, false, false, fmt.Errorf("unable to message prompter: %w", err)
    78  	}
    79  	agentProcess, err := transport.Command(command)
    80  	if err != nil {
    81  		return nil, false, false, fmt.Errorf("unable to create agent command: %w", err)
    82  	}
    83  
    84  	// Create a buffer that we can use to capture the process' standard error
    85  	// output in order to give better feedback when there's an error.
    86  	errorBuffer := bytes.NewBuffer(nil)
    87  
    88  	// Create a cutoff for the error buffer that avoids using large amounts of
    89  	// memory (while still being sufficiently large to capture any reasonable
    90  	// human-readable error message).
    91  	errorCutoff := streampkg.NewCutoffWriter(errorBuffer, agentErrorInMemoryCutoff)
    92  
    93  	// Create a valve that we can use to stop recording the error output once
    94  	// this function returns (at which point the error will already have been
    95  	// captured or not have occurred).
    96  	errorValve := streampkg.NewValveWriter(errorCutoff)
    97  	defer errorValve.Shut()
    98  
    99  	// Create a splitter that will forward standard error output to both the
   100  	// error buffer and the logger. The error log level we apply here only
   101  	// applies to non-log messages printed to standard error - all log messages
   102  	// routed through standard error have their levels forwarded.
   103  	errorTee := io.MultiWriter(errorValve, logger.Writer(logging.LevelError))
   104  
   105  	// Create a transport stream to communicate with the process and forward
   106  	// standard error output. Set a non-zero termination delay for the stream so
   107  	// that (in the event of a handshake failure) the process will be allowed to
   108  	// exit with its natural exit code (instead of an exit code due to forced
   109  	// termination) and will be able to yield some error output for diagnosing
   110  	// the issue.
   111  	stream, err := transportpkg.NewStream(agentProcess, errorTee)
   112  	if err != nil {
   113  		return nil, false, false, fmt.Errorf("unable to create agent process stream: %w", err)
   114  	}
   115  	stream.SetTerminationDelay(agentTerminationDelay)
   116  
   117  	// Start the process.
   118  	if err = agentProcess.Start(); err != nil {
   119  		return nil, false, false, fmt.Errorf("unable to start agent process: %w", err)
   120  	}
   121  
   122  	// Perform a handshake with the remote to ensure that we're talking with a
   123  	// Mutagen agent.
   124  	if err := ClientHandshake(stream); err != nil {
   125  		// Close the stream to ensure that the underlying process and any
   126  		// I/O-forwarding Goroutines have terminated. The error returned from
   127  		// Close will be non-nil if the process exits with a non-0 exit code, so
   128  		// we don't want to check it, but transport.Stream guarantees that if
   129  		// Close returns, then the underlying process has fully terminated,
   130  		// which is all we care about.
   131  		stream.Close()
   132  
   133  		// Extract any error output, ensure that it's UTF-8, strip out any
   134  		// whitespace (primarily trailing newlines), and neutralize any control
   135  		// characters.
   136  		errorOutput := errorBuffer.String()
   137  		if !utf8.ValidString(errorOutput) {
   138  			return nil, false, false, errors.New("remote did not return UTF-8 output")
   139  		}
   140  		errorOutput = terminal.NeutralizeControlCharacters(strings.TrimSpace(errorOutput))
   141  
   142  		// Wrap up the handshake error with additional context.
   143  		if errorOutput != "" {
   144  			err = fmt.Errorf("unable to handshake with agent process: %w (error output: %s)", err, errorOutput)
   145  		} else {
   146  			err = fmt.Errorf("unable to handshake with agent process: %w", err)
   147  		}
   148  
   149  		// See if we can classify the exact nature of the handshake failure. In
   150  		// particular, we want to identify whether or not we should try to
   151  		// (re-)install the agent binary and whether or not we're talking to a
   152  		// Windows cmd.exe environment. We have to delegate this responsibility
   153  		// to the transport, because each transport has different error
   154  		// classification mechanisms. We don't bother returning classification
   155  		// failure errors because they don't contain any useful information; the
   156  		// user is far better off trying to interpret the original error and
   157  		// error output from the handshake failure.
   158  		tryInstall, cmdExe, classifyErr := transport.ClassifyError(agentProcess.ProcessState, errorOutput)
   159  		if classifyErr != nil {
   160  			return nil, false, false, err
   161  		}
   162  		return nil, tryInstall, cmdExe, err
   163  	}
   164  
   165  	// Now that we've successfully connected, disable the termination delay on
   166  	// the process stream.
   167  	stream.SetTerminationDelay(time.Duration(0))
   168  
   169  	// Perform a version handshake.
   170  	if err := mutagen.ClientVersionHandshake(stream); err != nil {
   171  		stream.Close()
   172  		return nil, false, false, fmt.Errorf("version handshake error: %w", err)
   173  	}
   174  
   175  	// Done.
   176  	return stream, false, false, nil
   177  }
   178  
   179  // Dial connects to an agent-based endpoint using the specified transport,
   180  // connection mode, and prompter.
   181  func Dial(logger *logging.Logger, transport Transport, mode, prompter string) (io.ReadWriteCloser, error) {
   182  	// Validate that the mode is sane.
   183  	if !(mode == CommandSynchronizer || mode == CommandForwarder) {
   184  		return nil, errors.New("invalid agent dial mode")
   185  	}
   186  
   187  	// Attempt a connection. If this fails but we detect a Windows cmd.exe
   188  	// environment in the process, then re-attempt a connection under the
   189  	// cmd.exe assumption.
   190  	stream, tryInstall, cmdExe, err := connect(logger, transport, mode, prompter, false)
   191  	if err == nil {
   192  		return stream, nil
   193  	} else if cmdExe {
   194  		stream, tryInstall, cmdExe, err = connect(logger, transport, mode, prompter, true)
   195  		if err == nil {
   196  			return stream, nil
   197  		}
   198  	}
   199  
   200  	// If connection attempts have failed, then check whether or not an install
   201  	// is recommended. If not, then bail.
   202  	if !tryInstall {
   203  		return nil, err
   204  	}
   205  
   206  	// Attempt to install.
   207  	if err := install(logger, transport, prompter); err != nil {
   208  		return nil, fmt.Errorf("unable to install agent: %w", err)
   209  	}
   210  
   211  	// Re-attempt connectivity.
   212  	stream, _, _, err = connect(logger, transport, mode, prompter, cmdExe)
   213  	if err != nil {
   214  		return nil, err
   215  	}
   216  	return stream, nil
   217  }