github.com/iqoqo/nomad@v0.11.3-0.20200911112621-d7021c74d101/e2e/framework/provisioning/ssh_runner.go (about) 1 package provisioning 2 3 import ( 4 "context" 5 "fmt" 6 "log" 7 "os" 8 "os/exec" 9 "path/filepath" 10 "strings" 11 "testing" 12 "time" 13 ) 14 15 // SSHRunner is a ProvisioningRunner that deploys via ssh. 16 // Terraform does all of this more elegantly and portably in its 17 // ssh communicator, but by shelling out we avoid pulling in TF's as 18 // a Nomad dependency, and avoid some long-standing issues with 19 // connections to Windows servers. The tradeoff is losing portability 20 // but in practice we're always going to run this from a Unixish 21 // machine. 22 type SSHRunner struct { 23 Key string // `json:"key"` 24 User string // `json:"user"` 25 Host string // `json:"host"` 26 Port int // `json:"port"` 27 28 // none of these are available at time of construction, but 29 // should be populated in Open(). 30 t *testing.T 31 controlSockPath string 32 ctx context.Context 33 cancelFunc context.CancelFunc 34 copyMethod func(*SSHRunner, string, string) error 35 muxWait chan struct{} 36 } 37 38 // Open establishes the ssh connection. We keep this connection open 39 // so that we can multiplex subsequent ssh connections. 40 func (runner *SSHRunner) Open(t *testing.T) error { 41 runner.t = t 42 runner.Logf("opening connection to %s", runner.Host) 43 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 44 runner.ctx = ctx 45 runner.cancelFunc = cancel 46 runner.muxWait = make(chan struct{}) 47 48 home, _ := os.UserHomeDir() 49 runner.controlSockPath = filepath.Join( 50 home, ".ssh", 51 fmt.Sprintf("ssh-control-%s-%d.sock", runner.Host, os.Getpid())) 52 53 cmd := exec.CommandContext(ctx, 54 "ssh", 55 "-M", "-S", runner.controlSockPath, 56 "-o", "StrictHostKeyChecking=no", // we're those terrible cloud devs 57 "-o", "UserKnownHostsFile=/dev/null", 58 "-o", "LogLevel=ERROR", 59 "-o", "ConnectTimeout=60", // give the target a while to come up 60 "-i", runner.Key, 61 "-p", fmt.Sprintf("%v", runner.Port), 62 fmt.Sprintf("%s@%s", runner.User, runner.Host), 63 ) 64 65 go func() { 66 // will block until command completes, we cancel, or timeout. 67 // there's no point in returning the error here as we only 68 // hit it when we're done and Windows unfortunately tends to 69 // return 1 even when the script is complete. 70 cmd.Run() 71 runner.muxWait <- struct{}{} 72 }() 73 return nil 74 } 75 76 func (runner *SSHRunner) Run(script string) error { 77 commands := strings.Split(strings.TrimSpace(script), "\n") 78 for _, command := range commands { 79 err := runner.run(strings.TrimSpace(command)) 80 if err != nil { 81 runner.cancelFunc() 82 return err 83 } 84 } 85 return nil 86 } 87 88 func (runner *SSHRunner) run(command string) error { 89 if runner.controlSockPath == "" { 90 return fmt.Errorf("Run failed: you need to call Open() first") 91 } 92 runner.Logf("running '%s'", command) 93 cmd := exec.CommandContext(runner.ctx, 94 "ssh", 95 "-S", runner.controlSockPath, 96 "-o", "StrictHostKeyChecking=no", 97 "-o", "UserKnownHostsFile=/dev/null", 98 "-o", "LogLevel=ERROR", 99 "-i", runner.Key, 100 "-p", fmt.Sprintf("%v", runner.Port), 101 fmt.Sprintf("%s@%s", runner.User, runner.Host), 102 command) 103 104 stdoutStderr, err := cmd.CombinedOutput() 105 if err != nil && err != context.Canceled { 106 runner.LogErrOutput(string(stdoutStderr)) 107 return err 108 } 109 runner.LogOutput(string(stdoutStderr)) 110 return nil 111 } 112 113 // Copy uploads the local path to the remote path. We call into 114 // different copy methods for Linux vs Windows because their path 115 // semantics are slightly different and the typical ssh users have 116 // different permissions. 117 func (runner *SSHRunner) Copy(local, remote string) error { 118 return runner.copyMethod(runner, local, remote) 119 } 120 121 // TODO: would be nice to set file owner/mode here 122 func copyLinux(runner *SSHRunner, local, remote string) error { 123 t := runner.t 124 runner.Logf("copying '%s' to '%s'", local, remote) 125 remoteDir, remoteFileName := filepath.Split(remote) 126 127 // we stage to /tmp so that we can handle root-owned files 128 tempPath := fmt.Sprintf("/tmp/%s", remoteFileName) 129 130 cmd := exec.CommandContext(runner.ctx, 131 "scp", "-r", 132 "-o", fmt.Sprintf("ControlPath=%s", runner.controlSockPath), 133 "-o", "StrictHostKeyChecking=no", 134 "-o", "UserKnownHostsFile=/dev/null", 135 "-o", "LogLevel=ERROR", 136 "-i", runner.Key, 137 "-P", fmt.Sprintf("%v", runner.Port), 138 local, 139 fmt.Sprintf("%s@%s:%s", runner.User, runner.Host, tempPath)) 140 141 stdoutStderr, err := cmd.CombinedOutput() 142 if err != nil && err != context.Canceled { 143 runner.LogErrOutput(string(stdoutStderr)) 144 runner.cancelFunc() 145 return err 146 } 147 148 fi, err := os.Stat(local) 149 if err != nil { 150 t.Fatalf("could not read '%s'", local) 151 } 152 if fi.IsDir() { 153 // this is a little inefficient but it lets us merge the contents of 154 // a bundled directory with existing directories 155 err = runner.Run( 156 fmt.Sprintf("sudo mkdir -p %s; sudo cp -R %s %s; sudo rm -r %s", 157 remote, tempPath, remoteDir, tempPath)) 158 } else { 159 err = runner.run(fmt.Sprintf("sudo mv %s %s", tempPath, remoteDir)) 160 } 161 return err 162 } 163 164 // staging to Windows tempdirs is a little messier, but "fortunately" 165 // nobody seems to complain about connecting via ssh as Administrator on 166 // Windows so we can just bypass the problem. 167 func copyWindows(runner *SSHRunner, local, remote string) error { 168 runner.Logf("copying '%s' to '%s'", local, remote) 169 remoteDir, _ := filepath.Split(remote) 170 fi, err := os.Stat(local) 171 if err != nil { 172 runner.t.Fatalf("could not read '%s'", local) 173 } 174 remotePath := remote 175 if fi.IsDir() { 176 remotePath = remoteDir 177 } 178 cmd := exec.CommandContext(runner.ctx, 179 "scp", "-r", 180 "-o", fmt.Sprintf("ControlPath=%s", runner.controlSockPath), 181 "-o", "StrictHostKeyChecking=no", 182 "-o", "UserKnownHostsFile=/dev/null", 183 "-o", "LogLevel=ERROR", 184 "-i", runner.Key, 185 "-P", fmt.Sprintf("%v", runner.Port), 186 local, 187 fmt.Sprintf("%s@%s:'%s'", runner.User, runner.Host, remotePath)) 188 189 stdoutStderr, err := cmd.CombinedOutput() 190 if err != nil && err != context.Canceled { 191 runner.LogErrOutput(string(stdoutStderr)) 192 runner.cancelFunc() 193 return err 194 } 195 return err 196 } 197 198 func (runner *SSHRunner) Close() { 199 runner.Log("closing connection") 200 runner.cancelFunc() 201 <-runner.muxWait 202 } 203 204 // 'go test -v' only emits logs after the entire test run is complete, 205 // but that makes it much harder to debug hanging deployments. These 206 // methods wrap the test logger or just emit directly w/ fmt.Print if 207 // the '-v' flag was set. 208 209 func (runner *SSHRunner) Log(args ...interface{}) { 210 if runner.t == nil { 211 log.Fatal("no t.Testing configured for SSHRunner") 212 } 213 if testing.Verbose() { 214 fmt.Printf("[" + runner.Host + "] ") 215 fmt.Println(args...) 216 } else { 217 runner.t.Log(args...) 218 } 219 } 220 221 func (runner *SSHRunner) Logf(format string, args ...interface{}) { 222 if runner.t == nil { 223 log.Fatal("no t.Testing configured for SSHRunner") 224 } 225 if testing.Verbose() { 226 fmt.Printf("["+runner.Host+"] "+format+"\n", args...) 227 } else { 228 runner.t.Logf("["+runner.Host+"] "+format, args...) 229 } 230 } 231 232 func (runner *SSHRunner) LogOutput(output string) { 233 if testing.Verbose() { 234 fmt.Println("\033[32m" + output + "\033[0m") 235 } else { 236 runner.t.Log(output) 237 } 238 } 239 240 func (runner *SSHRunner) LogErrOutput(output string) { 241 if testing.Verbose() { 242 fmt.Println("\033[31m" + output + "\033[0m") 243 } else { 244 runner.t.Log(output) 245 } 246 }