diff --git a/cmd/ignite/cmd/vmcmd/exec.go b/cmd/ignite/cmd/vmcmd/exec.go index 6f27fd1f2..994c7be3c 100644 --- a/cmd/ignite/cmd/vmcmd/exec.go +++ b/cmd/ignite/cmd/vmcmd/exec.go @@ -42,5 +42,6 @@ func NewCmdExec(out io.Writer, err io.Writer, in io.Reader) *cobra.Command { func addExecFlags(fs *pflag.FlagSet, ef *run.ExecFlags) { fs.StringVarP(&ef.IdentityFile, "identity", "i", "", "Override the vm's default identity file") - fs.Uint32VarP(&ef.Timeout, "timeout", "t", 10, "Timeout waiting for connection in seconds") + fs.Uint32Var(&ef.Timeout, "timeout", 10, "Timeout waiting for connection in seconds") + fs.BoolVarP(&ef.Tty, "tty", "t", false, "Allocate a pseudo-TTY") } diff --git a/cmd/ignite/run/exec.go b/cmd/ignite/run/exec.go index 282bb3f18..55c22b3ae 100644 --- a/cmd/ignite/run/exec.go +++ b/cmd/ignite/run/exec.go @@ -2,7 +2,6 @@ package run import ( "fmt" - "io" "io/ioutil" "net" "os" @@ -16,9 +15,11 @@ import ( shellescape "gopkg.in/alessio/shellescape.v1" ) +// ExecFlags contains the flags supported by the exec command. type ExecFlags struct { Timeout uint32 IdentityFile string + Tty bool } type execOptions struct { @@ -27,6 +28,7 @@ type execOptions struct { command []string } +// NewExecOptions constructs and returns an execOptions. func (ef *ExecFlags) NewExecOptions(vmMatch string, command ...string) (eo *execOptions, err error) { eo = &execOptions{ ExecFlags: ef, @@ -37,6 +39,7 @@ func (ef *ExecFlags) NewExecOptions(vmMatch string, command ...string) (eo *exec return } +// Exec executes command in a VM based on the provided execOptions. func Exec(eo *execOptions) error { // Check if the VM is running if !eo.vm.Running() { @@ -72,7 +75,7 @@ func Exec(eo *execOptions) error { // Run the command, DO NOT wrap this error as the caller can check for the command exit // code in the ssh.ExitError type - return runSSHCommand(client, eo.command) + return runSSHCommand(client, eo.Tty, eo.command) } func newSignerForKey(keyPath string) (ssh.Signer, error) { @@ -96,7 +99,7 @@ func newSSHConfig(publicKey ssh.Signer, timeout uint32) *ssh.ClientConfig { } } -func runSSHCommand(client *ssh.Client, command []string) error { +func runSSHCommand(client *ssh.Client, tty bool, command []string) error { // create a session for the command session, err := client.NewSession() if err != nil { @@ -104,32 +107,22 @@ func runSSHCommand(client *ssh.Client, command []string) error { } defer session.Close() - // get a pty - // TODO: should these be based on the host terminal? - // TODO: should we request something other than xterm? - // TODO: we should probably configure the terminal modes - modes := ssh.TerminalModes{} - if err := session.RequestPty("xterm", 80, 40, modes); err != nil { - return fmt.Errorf("request for pseudo terminal failed: %v", err) + if tty { + // get a pty + // TODO: should these be based on the host terminal? + // TODO: should we request something other than xterm? + // TODO: we should probably configure the terminal modes + modes := ssh.TerminalModes{} + if err := session.RequestPty("xterm", 80, 40, modes); err != nil { + return fmt.Errorf("request for pseudo terminal failed: %v", err) + } } - // connect input / output + // Connect input / output // TODO: these should come from the cobra command instead of hardcoding os.Stderr etc. - stderr, err := session.StderrPipe() - if err != nil { - return fmt.Errorf("failed to connect stderr: %v", err) - } - go io.Copy(os.Stderr, stderr) - stdout, err := session.StdoutPipe() - if err != nil { - return fmt.Errorf("failed to connect stdout: %v", err) - } - go io.Copy(os.Stdout, stdout) - stdin, err := session.StdinPipe() - if err != nil { - return fmt.Errorf("failed to connect stdin: %v", err) - } - go io.Copy(stdin, os.Stdin) + session.Stderr = os.Stderr + session.Stdout = os.Stdout + session.Stdin = os.Stdin /* Do not wrap this error so the caller can check for the exit code diff --git a/docs/cli/ignite/ignite_exec.md b/docs/cli/ignite/ignite_exec.md index d6ce8b795..0fd78ba1b 100644 --- a/docs/cli/ignite/ignite_exec.md +++ b/docs/cli/ignite/ignite_exec.md @@ -20,7 +20,8 @@ ignite exec [flags] ``` -h, --help help for exec -i, --identity string Override the vm's default identity file - -t, --timeout uint32 Timeout waiting for connection in seconds (default 10) + --timeout uint32 Timeout waiting for connection in seconds (default 10) + -t, --tty Allocate a pseudo-TTY ``` ### Options inherited from parent commands diff --git a/e2e/vm_exec_test.go b/e2e/vm_exec_test.go new file mode 100644 index 000000000..ecec28aaa --- /dev/null +++ b/e2e/vm_exec_test.go @@ -0,0 +1,60 @@ +package e2e + +import ( + "fmt" + "os/exec" + "strings" + "testing" + + "gotest.tools/assert" +) + +func TestVMExecInteractive(t *testing.T) { + assert.Assert(t, e2eHome != "", "IGNITE_E2E_HOME should be set") + + vmName := "e2e_test_ignite_exec_interactive" + + runCmd := exec.Command( + igniteBin, + "run", "--name="+vmName, + "--ssh", + "weaveworks/ignite-ubuntu", + ) + runOut, runErr := runCmd.CombinedOutput() + + defer func() { + rmvCmd := exec.Command( + igniteBin, + "rm", "-f", vmName, + ) + rmvOut, rmvErr := rmvCmd.CombinedOutput() + assert.Check(t, rmvErr, fmt.Sprintf("vm removal: \n%q\n%s", rmvCmd.Args, rmvOut)) + }() + + assert.Check(t, runErr, fmt.Sprintf("vm run: \n%q\n%s", runCmd.Args, runOut)) + + // Pass input data from host and write to a file inside the VM. + remoteFileName := "afile.txt" + inputContent := "foooo..." + input := strings.NewReader(inputContent) + + execCmd := exec.Command( + igniteBin, + "exec", vmName, + "tee", remoteFileName, + ) + execCmd.Stdin = input + + execOut, execErr := execCmd.CombinedOutput() + assert.Check(t, execErr, fmt.Sprintf("exec: \n%q\n%s", execCmd.Args, execOut)) + + // Check the file content inside the VM. + catCmd := exec.Command( + igniteBin, + "exec", vmName, + "cat", remoteFileName, + ) + catOut, catErr := catCmd.CombinedOutput() + assert.Check(t, catErr, fmt.Sprintf("cat: \n%q\n%s", catCmd.Args, catOut)) + assert.Equal(t, string(catOut), inputContent, fmt.Sprintf("unexpected file content on host:\n\t(WNT): %q\n\t(GOT): %q", inputContent, string(catOut))) +}