diff --git a/cmd/ignite/cmd/exec.go b/cmd/ignite/cmd/exec.go new file mode 100644 index 000000000..d86a6c8fc --- /dev/null +++ b/cmd/ignite/cmd/exec.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "io" + + "github.com/spf13/cobra" + "github.com/weaveworks/ignite/cmd/ignite/cmd/vmcmd" +) + +// NewCmdExec is an alias for vmcmd.NewCmdExec +func NewCmdExec(out io.Writer, err io.Writer, in io.Reader) *cobra.Command { + return vmcmd.NewCmdExec(out, err, in) +} diff --git a/cmd/ignite/cmd/root.go b/cmd/ignite/cmd/root.go index 26bf221a8..0f3111e6d 100644 --- a/cmd/ignite/cmd/root.go +++ b/cmd/ignite/cmd/root.go @@ -93,6 +93,7 @@ func NewIgniteCommand(in io.Reader, out, err io.Writer) *cobra.Command { root.AddCommand(NewCmdRmk(os.Stdout)) root.AddCommand(NewCmdRun(os.Stdout)) root.AddCommand(NewCmdSSH(os.Stdout)) + root.AddCommand(NewCmdExec(os.Stdout, os.Stderr, os.Stdin)) root.AddCommand(NewCmdStart(os.Stdout)) root.AddCommand(NewCmdStop(os.Stdout)) root.AddCommand(NewCmdVersion(os.Stdout)) diff --git a/cmd/ignite/cmd/vmcmd/exec.go b/cmd/ignite/cmd/vmcmd/exec.go new file mode 100644 index 000000000..5207f8970 --- /dev/null +++ b/cmd/ignite/cmd/vmcmd/exec.go @@ -0,0 +1,46 @@ +package vmcmd + +import ( + "io" + + "github.com/lithammer/dedent" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/weaveworks/ignite/cmd/ignite/run" + "github.com/weaveworks/ignite/pkg/errutils" +) + +// NewCmdExec exec's into a running vm +func NewCmdExec(out io.Writer, err io.Writer, in io.Reader) *cobra.Command { + ef := &run.ExecFlags{} + + cmd := &cobra.Command{ + Use: "exec ", + Short: "exec command in a running vm", + Long: dedent.Dedent(` + exec command in a running VM using ssh and the private key created for it during generation. + If no private key was created or wanting to use a different identity file, + use the identity file flag (-i, --identity) to override the used identity file. + The given VM is matched by prefix based on its ID and name. + `), + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + errutils.Check(func() error { + eo, err := ef.NewExecOptions(args[0], args[1:]...) + if err != nil { + return err + } + + return run.Exec(eo) + }()) + }, + } + + addExecFlags(cmd.Flags(), ef) + return cmd +} + +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") +} diff --git a/cmd/ignite/run/exec.go b/cmd/ignite/run/exec.go new file mode 100644 index 000000000..d4f615e0b --- /dev/null +++ b/cmd/ignite/run/exec.go @@ -0,0 +1,154 @@ +package run + +import ( + "fmt" + "io" + "io/ioutil" + "net" + "os" + "path" + "time" + + api "github.com/weaveworks/ignite/pkg/apis/ignite" + "github.com/weaveworks/ignite/pkg/constants" + "github.com/weaveworks/ignite/pkg/util" + + "golang.org/x/crypto/ssh" + shellescape "gopkg.in/alessio/shellescape.v1" +) + +type ExecFlags struct { + Timeout uint32 + IdentityFile string +} + +type execOptions struct { + *ExecFlags + vm *api.VM + command []string +} + +func (ef *ExecFlags) NewExecOptions(vmMatch string, command ...string) (eo *execOptions, err error) { + eo = &execOptions{ + ExecFlags: ef, + command: command, + } + eo.vm, err = getVMForMatch(vmMatch) + return +} + +func Exec(eo *execOptions) error { + // Check if the VM is running + if !eo.vm.Running() { + return fmt.Errorf("VM %q is not running", eo.vm.GetUID()) + } + + // Get the IP address + ipAddrs := eo.vm.Status.IPAddresses + if len(ipAddrs) == 0 { + return fmt.Errorf("VM %q has no usable IP addresses", eo.vm.GetUID()) + } + + // If an external identity file is specified, use it instead of the internal one + privKeyFile := eo.IdentityFile + if len(privKeyFile) == 0 { + privKeyFile = path.Join(eo.vm.ObjectPath(), fmt.Sprintf(constants.VM_SSH_KEY_TEMPLATE, eo.vm.GetUID())) + if !util.FileExists(privKeyFile) { + return fmt.Errorf("no private key found for VM %q", eo.vm.GetUID()) + } + } + signer, err := newSignerForKey(privKeyFile) + if err != nil { + return fmt.Errorf("unable to create signer for private key: %v", err) + } + + // Create an SSH client, and connect, we will use this to exec + config := newSSHConfig(signer, eo.Timeout) + client, err := ssh.Dial("tcp", net.JoinHostPort(ipAddrs[0].String(), "22"), config) + if err != nil { + return fmt.Errorf("failed to dial: %v", err) + } + + // 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) +} + +func newSignerForKey(keyPath string) (ssh.Signer, error) { + key, err := ioutil.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("unable to read private key: %v", err) + } + + // Create the Signer for this private key. + return ssh.ParsePrivateKey(key) +} + +func newSSHConfig(publicKey ssh.Signer, timeout uint32) *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(publicKey), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use ssh.FixedPublicKey instead + Timeout: time.Second * time.Duration(timeout), + } +} + +func runSSHCommand(client *ssh.Client, command []string) error { + // create a session for the command + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("failed to create session: %v", err) + } + 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 psuedo terminal failed: %v", err) + } + + // 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 stderr: %v", err) + } + go io.Copy(os.Stdout, stdout) + stdin, err := session.StdinPipe() + if err != nil { + return fmt.Errorf("failed to connect stderr: %v", err) + } + go io.Copy(stdin, os.Stdin) + + /* + Do not wrap this error so the caller can check for the exit code + If the remote server does not send an exit status, an error of type *ExitMissingError is returned. + If the command completes unsuccessfully or is interrupted by a signal, the error is of type *ExitError. + Other error types may be returned for I/O problems. + */ + return session.Run(joinShellCommand(command)) +} + +// joinShellCommand joins command parts into a single string safe for passing to sh -c (or SSH) +func joinShellCommand(command []string) string { + joined := command[0] + if len(command) == 1 { + return joined + } + for _, arg := range command[1:] { + // NOTE: we need to escape nested single quotes + // https://stackoverflow.com/a/1315213 + joined += " " + shellescape.Quote(arg) + } + return joined +} diff --git a/cmd/ignite/run/ssh.go b/cmd/ignite/run/ssh.go index df1768998..cf530351f 100644 --- a/cmd/ignite/run/ssh.go +++ b/cmd/ignite/run/ssh.go @@ -8,8 +8,6 @@ import ( api "github.com/weaveworks/ignite/pkg/apis/ignite" "github.com/weaveworks/ignite/pkg/constants" "github.com/weaveworks/ignite/pkg/util" - - _ "golang.org/x/crypto/ssh" ) type SSHFlags struct { diff --git a/docs/cli/ignite.md b/docs/cli/ignite.md index 278282155..620c19621 100644 --- a/docs/cli/ignite.md +++ b/docs/cli/ignite.md @@ -43,6 +43,7 @@ Example usage: * [ignite attach](ignite_attach.md) - Attach to a running VM * [ignite completion](ignite_completion.md) - Output bash completion for ignite to stdout * [ignite create](ignite_create.md) - Create a new VM without starting it +* [ignite exec](ignite_exec.md) - exec command in a running vm * [ignite gitops](ignite_gitops.md) - Run the GitOps feature of Ignite * [ignite image](ignite_image.md) - Manage base images for VMs * [ignite inspect](ignite_inspect.md) - Inspect an Ignite Object diff --git a/docs/cli/ignite_exec.md b/docs/cli/ignite_exec.md new file mode 100644 index 000000000..192f0b2e7 --- /dev/null +++ b/docs/cli/ignite_exec.md @@ -0,0 +1,36 @@ +## ignite exec + +exec command in a running vm + +### Synopsis + + +exec command in a running VM using ssh and the private key created for it during generation. +If no private key was created or wanting to use a different identity file, +use the identity file flag (-i, --identity) to override the used identity file. +The given VM is matched by prefix based on its ID and name. + + +``` +ignite exec [flags] +``` + +### Options + +``` + -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) +``` + +### Options inherited from parent commands + +``` + --log-level loglevel Specify the loglevel for the program (default info) + -q, --quiet The quiet mode allows for machine-parsable output, by printing only IDs +``` + +### SEE ALSO + +* [ignite](ignite.md) - ignite: easily run Firecracker VMs + diff --git a/pkg/errutils/errors.go b/pkg/errutils/errors.go index 0dde191d0..0b3831c2c 100644 --- a/pkg/errutils/errors.go +++ b/pkg/errutils/errors.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "strings" + + "golang.org/x/crypto/ssh" ) const ( @@ -34,6 +36,9 @@ func Check(err error) { switch err.(type) { case nil: return + case *ssh.ExitError: + exitError := err.(*ssh.ExitError) + fatal(err.Error(), exitError.ExitStatus()) default: fatal(err.Error(), DefaultErrorExitCode) }