From d040cd079fab507d650cbf373457e3ccda4c21d4 Mon Sep 17 00:00:00 2001 From: Sunny Date: Sun, 5 Apr 2020 16:42:28 +0530 Subject: [PATCH] Add interactive vm exec support Breaking change: Moves -t short flag from timeout to tty. This change makes the VM exec command interactive by default. This enables the ability to pipe host input to VM commands using exec. TTY is disabled by default. It can be enabled by using -t exec flag. Adds e2e test to verify the feature. --- cmd/ignite/cmd/vmcmd/exec.go | 3 +- cmd/ignite/run/exec.go | 45 +++++++++++-------------- docs/cli/ignite/ignite_exec.md | 3 +- e2e/vm_exec_test.go | 60 ++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 28 deletions(-) create mode 100644 e2e/vm_exec_test.go 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))) +}