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 }