diff --git a/docs/coder.md b/docs/coder.md index 2ac30553..87c28c19 100644 --- a/docs/coder.md +++ b/docs/coder.md @@ -17,7 +17,7 @@ coder provides a CLI for working with an existing Coder Enterprise installation * [coder images](coder_images.md) - Manage Coder images * [coder login](coder_login.md) - Authenticate this client for future operations * [coder logout](coder_logout.md) - Remove local authentication credentials if any exist -* [coder sh](coder_sh.md) - Open a shell and execute commands in a Coder environment +* [coder ssh](coder_ssh.md) - Enter a shell of execute a command over SSH into a Coder environment * [coder sync](coder_sync.md) - Establish a one way directory sync to a Coder environment * [coder tokens](coder_tokens.md) - manage Coder API tokens for the active user * [coder urls](coder_urls.md) - Interact with environment DevURLs diff --git a/docs/coder_sh.md b/docs/coder_sh.md deleted file mode 100644 index bd5bc2cf..00000000 --- a/docs/coder_sh.md +++ /dev/null @@ -1,37 +0,0 @@ -## coder sh - -Open a shell and execute commands in a Coder environment - -### Synopsis - -Execute a remote command on the environment -If no command is specified, the default shell is opened. -If the command is run in an interactive shell, a user prompt will occur if the environment needs to be rebuilt. - -``` -coder sh [environment_name] [] [flags] -``` - -### Examples - -``` -coder sh backend-env -coder sh front-end-dev cat ~/config.json -``` - -### Options - -``` - -h, --help help for sh -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder Enterprise installation - diff --git a/docs/coder_ssh.md b/docs/coder_ssh.md new file mode 100644 index 00000000..3ff31eb8 --- /dev/null +++ b/docs/coder_ssh.md @@ -0,0 +1,31 @@ +## coder ssh + +Enter a shell of execute a command over SSH into a Coder environment + +``` +coder ssh [environment_name] [] +``` + +### Examples + +``` +coder ssh my-dev +coder ssh my-dev pwd +``` + +### Options + +``` + -h, --help help for ssh +``` + +### Options inherited from parent commands + +``` + -v, --verbose show verbose output +``` + +### SEE ALSO + +* [coder](coder.md) - coder provides a CLI for working with an existing Coder Enterprise installation + diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 8a7a7d21..e9af2625 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -24,7 +24,7 @@ func Make() *cobra.Command { app.AddCommand( loginCmd(), logoutCmd(), - shCmd(), + sshCmd(), usersCmd(), tagsCmd(), configSSHCmd(), diff --git a/internal/cmd/configssh.go b/internal/cmd/configssh.go index b527d6df..c0b39ba9 100644 --- a/internal/cmd/configssh.go +++ b/internal/cmd/configssh.go @@ -18,7 +18,6 @@ import ( "cdr.dev/coder-cli/coder-sdk" "cdr.dev/coder-cli/internal/coderutil" - "cdr.dev/coder-cli/internal/config" ) const sshStartToken = "# ------------START-CODER-ENTERPRISE-----------" @@ -214,19 +213,6 @@ func makeSSHConfig(host, userName, envName, privateKeyFilepath string) string { `, envName, host, userName, envName, privateKeyFilepath) } -//nolint:deadcode,unused -func configuredHostname() (string, error) { - u, err := config.URL.Read() - if err != nil { - return "", err - } - url, err := url.Parse(u) - if err != nil { - return "", err - } - return url.Hostname(), nil -} - func writeStr(filename, data string) error { return ioutil.WriteFile(filename, []byte(data), 0777) } diff --git a/internal/cmd/shell.go b/internal/cmd/shell.go deleted file mode 100644 index d33dbbbf..00000000 --- a/internal/cmd/shell.go +++ /dev/null @@ -1,434 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/manifoldco/promptui" - "github.com/spf13/cobra" - "golang.org/x/crypto/ssh/terminal" - "golang.org/x/time/rate" - "golang.org/x/xerrors" - "nhooyr.io/websocket" - - "cdr.dev/wsep" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/activity" - "cdr.dev/coder-cli/internal/coderutil" - "cdr.dev/coder-cli/internal/x/xterminal" - "cdr.dev/coder-cli/pkg/clog" -) - -var ( - showInteractiveOutput = terminal.IsTerminal(int(os.Stdout.Fd())) - outputFd = os.Stdout.Fd() - inputFd = os.Stdin.Fd() -) - -func getEnvsForCompletion(user string) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - ctx := cmd.Context() - client, err := newClient(ctx) - if err != nil { - return nil, cobra.ShellCompDirectiveDefault - } - envs, err := getEnvs(ctx, client, user) - if err != nil { - return nil, cobra.ShellCompDirectiveDefault - } - - envNames := make([]string, 0, len(envs)) - for _, e := range envs { - envNames = append(envNames, e.Name) - } - return envNames, cobra.ShellCompDirectiveDefault - } -} - -// special handling for the common case of "coder sh" input without a positional argument. -func shValidArgs(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { - client, err := newClient(ctx) - if err != nil { - return clog.Error("missing [environment_name] argument") - } - _, haystack, err := searchForEnv(ctx, client, "", coder.Me) - if err != nil { - return clog.Error("missing [environment_name] argument", - fmt.Sprintf("specify one of %q", haystack), - clog.BlankLine, - clog.Tipf("run \"coder envs ls\" to view your environments"), - ) - } - return clog.Error("missing [environment_name] argument") - } - return nil -} - -func shCmd() *cobra.Command { - return &cobra.Command{ - Use: "sh [environment_name] []", - Short: "Open a shell and execute commands in a Coder environment", - Long: `Execute a remote command on the environment -If no command is specified, the default shell is opened. -If the command is run in an interactive shell, a user prompt will occur if the environment needs to be rebuilt.`, - Args: shValidArgs, - DisableFlagParsing: true, - ValidArgsFunction: getEnvsForCompletion(coder.Me), - RunE: shell, - Example: `coder sh backend-env -coder sh front-end-dev cat ~/config.json`, - } -} - -// shellEscape escapes an argument so that we can pass it 'sh -c' -// and have it do the right thing. -// -// Use this to ensure that the result of a command running in -// the development environment behaves the same as the command -// running via "coder sh". -// -// For example: -// -// $ coder sh env -// $ go run ~/test.go 1 2 "3 4" '"abc def" \\abc' 5 6 "7 8 9" -// -// should produce the same output as: -// -// $ coder sh go run ~/test.go 1 2 "3 4" '"abc def" \\abc' 5 6 "7 8 9" -func shellEscape(arg string) string { - r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, `'`, `\'`, ` `, `\ `) - return r.Replace(arg) -} - -func shell(cmd *cobra.Command, cmdArgs []string) error { - ctx := cmd.Context() - - var command string - var args []string - if len(cmdArgs) > 1 { - var escapedArgs strings.Builder - - for i, arg := range cmdArgs[1:] { - escapedArgs.WriteString(shellEscape(arg)) - - // Add spaces between arguments, except the last argument - if i < len(cmdArgs)-2 { - escapedArgs.WriteByte(' ') - } - } - - command = "/bin/sh" - args = []string{"-c"} - args = append(args, escapedArgs.String()) - } else { - // Bring user into shell if no command is specified. - shell := "$(getent passwd $(id -u) | cut -d: -f 7)" - - // force bash for the '-l' flag to the exec built-in - command = "/bin/bash" - args = []string{"-c"} - args = append(args, fmt.Sprintf("exec -l %q", shell)) - } - - envName := cmdArgs[0] - - // Before the command is run, ensure the workspace is on and ready to accept - // an ssh connection. - client, err := newClient(ctx) - if err != nil { - return err - } - - env, err := findEnv(ctx, client, envName, coder.Me) - if err != nil { - return err - } - - // TODO: Verify this is the correct behavior - if showInteractiveOutput { // checkAndRebuildEnvironment requires an interactive shell - // Checks & Rebuilds the environment if needed. - if err := checkAndRebuildEnvironment(ctx, client, env); err != nil { - return err - } - } - - if err := runCommand(cmd, client, env, command, args); err != nil { - if exitErr, ok := err.(wsep.ExitError); ok { - os.Exit(exitErr.Code) - } - return xerrors.Errorf("run command: %w", err) - } - return nil -} - -// rebuildPrompt returns function that prompts the user if they wish to -// rebuild the selected environment if a rebuild is needed. The returned prompt function will -// return an error if the user selects "no". -// This functions returns `nil` if there is no reason to prompt the user to rebuild -// the environment. -func rebuildPrompt(env *coder.Environment) (prompt func() error) { - // Option 1: If the environment is off, the rebuild is needed - if env.LatestStat.ContainerStatus == coder.EnvironmentOff { - confirm := promptui.Prompt{ - Label: fmt.Sprintf("Environment %q is \"OFF\". Rebuild it now? (this can take several minutes", env.Name), - IsConfirm: true, - } - return func() (err error) { - _, err = confirm.Run() - return - } - } - - // Option 2: If there are required rebuild messages, the rebuild is needed - var lines []string - for _, r := range env.RebuildMessages { - if r.Required { - lines = append(lines, clog.Causef(r.Text)) - } - } - - if len(lines) > 0 { - confirm := promptui.Prompt{ - Label: fmt.Sprintf("Environment %q requires a rebuild to work correctly. Do you wish to rebuild it now? (this will take a moment)", env.Name), - IsConfirm: true, - } - // This function also prints the reasons in a log statement. - // The confirm prompt does not handle new lines well in the label. - return func() (err error) { - clog.LogWarn("rebuild required", lines...) - _, err = confirm.Run() - return - } - } - - // Environment looks good, no need to prompt the user. - return nil -} - -// checkAndRebuildEnvironment will: -// 1. Check if an environment needs to be rebuilt to be used -// 2. Prompt the user if they want to rebuild the environment (returns an error if they do not) -// 3. Rebuilds the environment and waits for it to be 'ON' -// Conditions for rebuilding are: -// - Environment is offline -// - Environment has rebuild messages requiring a rebuild -func checkAndRebuildEnvironment(ctx context.Context, client coder.Client, env *coder.Environment) error { - var err error - rebuildPrompt := rebuildPrompt(env) // Fetch the prompt for rebuilding envs w/ reason - - switch { - // If this conditonal is true, a rebuild is **required** to make the sh command work. - case rebuildPrompt != nil: - // TODO: (@emyrk) I'd like to add a --force and --verbose flags to this command, - // but currently DisableFlagParsing is set to true. - // To enable force/verbose, we'd have to parse the flags ourselves, - // or make the user `coder sh -- [args]` - // - if err := rebuildPrompt(); err != nil { - // User selected not to rebuild :( - return clog.Fatal( - "environment is not ready for use", - "environment requires a rebuild", - fmt.Sprintf("its current status is %q", env.LatestStat.ContainerStatus), - clog.BlankLine, - clog.Tipf("run \"coder envs rebuild %s --follow\" to start the environment", env.Name), - ) - } - - // Start the rebuild - if err := client.RebuildEnvironment(ctx, env.ID); err != nil { - return err - } - - fallthrough // Fallthrough to watching the logs - case env.LatestStat.ContainerStatus == coder.EnvironmentCreating: - // Environment is in the process of being created, just trail the logs - // and wait until it is done - clog.LogInfo(fmt.Sprintf("Rebuilding %q", env.Name)) - - // Watch the rebuild. - if err := trailBuildLogs(ctx, client, env.ID); err != nil { - return err - } - - // newline after trailBuildLogs to place user on a fresh line for their shell - fmt.Println() - - // At this point the buildlog is complete, and the status of the env should be 'ON' - env, err = client.EnvironmentByID(ctx, env.ID) - if err != nil { - // If this api call failed, it will likely fail again, no point to retry and make the user wait - return err - } - - if env.LatestStat.ContainerStatus != coder.EnvironmentOn { - // This means we had a timeout - return clog.Fatal("the environment rebuild ran into an issue", - fmt.Sprintf("environment %q rebuild has failed and will not come online", env.Name), - fmt.Sprintf("its current status is %q", env.LatestStat.ContainerStatus), - clog.BlankLine, - // TODO: (@emyrk) can they check these logs from the cli? Isn't this the logs that - // I just showed them? I'm trying to decide what exactly to tell a user. - clog.Tipf("take a look at the build logs to determine what went wrong"), - ) - } - - case env.LatestStat.ContainerStatus == coder.EnvironmentFailed: - // A failed container might just keep re-failing. I think it should be investigated by the user - return clog.Fatal("the environment has failed to come online", - fmt.Sprintf("environment %q is not running", env.Name), - fmt.Sprintf("its current status is %q", env.LatestStat.ContainerStatus), - - clog.BlankLine, - clog.Tipf("take a look at the build logs to determine what went wrong"), - clog.Tipf("run \"coder envs rebuild %s --follow\" to attempt to rebuild the environment", env.Name), - ) - } - return nil -} - -// sendResizeEvents starts watching for the client's terminal resize signals -// and sends the event to the server so the remote tty can match the client. -func sendResizeEvents(ctx context.Context, termFD uintptr, process wsep.Process) { - events := xterminal.ResizeEvents(ctx, termFD) - - // Limit the frequency of resizes to prevent a stuttering effect. - resizeLimiter := rate.NewLimiter(rate.Every(100*time.Millisecond), 1) - for { - select { - case newsize := <-events: - if err := process.Resize(ctx, newsize.Height, newsize.Width); err != nil { - return - } - _ = resizeLimiter.Wait(ctx) - case <-ctx.Done(): - return - } - } -} - -func runCommand(cmd *cobra.Command, client coder.Client, env *coder.Environment, command string, args []string) error { - if showInteractiveOutput { - // If the client has a tty, take over it by setting the raw mode. - // This allows for all input to be directly forwarded to the remote process, - // otherwise, the local terminal would buffer input, interpret special keys, etc. - stdinState, err := xterminal.MakeRaw(inputFd) - if err != nil { - return err - } - defer func() { - // Best effort. If this fails it will result in a broken terminal, - // but there is nothing we can do about it. - _ = xterminal.Restore(inputFd, stdinState) - }() - } - - ctx, cancel := context.WithCancel(cmd.Context()) - defer cancel() - - conn, err := coderutil.DialEnvWsep(ctx, client, env) - if err != nil { - return xerrors.Errorf("dial executor: %w", err) - } - go heartbeat(ctx, conn, 15*time.Second) - - var cmdEnv []string - if showInteractiveOutput { - term := os.Getenv("TERM") - if term == "" { - term = "xterm" - } - cmdEnv = append(cmdEnv, "TERM="+term) - } - - execer := wsep.RemoteExecer(conn) - process, err := execer.Start(ctx, wsep.Command{ - Command: command, - Args: args, - TTY: showInteractiveOutput, - Stdin: true, - Env: cmdEnv, - }) - if err != nil { - var closeErr websocket.CloseError - if xerrors.As(err, &closeErr) { - return networkErr(env) - } - return xerrors.Errorf("start remote command: %w", err) - } - - // Now that the remote process successfully started, if we have a tty, start the resize event watcher. - if showInteractiveOutput { - go sendResizeEvents(ctx, outputFd, process) - } - - go func() { - stdin := process.Stdin() - defer func() { _ = stdin.Close() }() // Best effort. - - ap := activity.NewPusher(client, env.ID, sshActivityName) - wr := ap.Writer(stdin) - if _, err := io.Copy(wr, cmd.InOrStdin()); err != nil { - cancel() - } - }() - go func() { - if _, err := io.Copy(cmd.OutOrStdout(), process.Stdout()); err != nil { - cancel() - } - }() - go func() { - if _, err := io.Copy(cmd.ErrOrStderr(), process.Stderr()); err != nil { - cancel() - } - }() - - if err := process.Wait(); err != nil { - var closeErr websocket.CloseError - if xerrors.Is(err, ctx.Err()) || xerrors.As(err, &closeErr) { - return networkErr(env) - } - return err - } - return nil -} - -func networkErr(env *coder.Environment) error { - if env.LatestStat.ContainerStatus != coder.EnvironmentOn { - return clog.Fatal( - "environment is not running", - fmt.Sprintf("environment %q is not running", env.Name), - fmt.Sprintf("its current status is %q", env.LatestStat.ContainerStatus), - clog.BlankLine, - clog.Tipf("run \"coder envs rebuild %s --follow\" to start the environment", env.Name), - ) - } - return xerrors.Errorf("network error, is %q online?", env.Name) -} - -func heartbeat(ctx context.Context, conn *websocket.Conn, interval time.Duration) { - ticker := time.NewTicker(interval) - for { - select { - case <-ctx.Done(): - ticker.Stop() - return - case <-ticker.C: - if err := conn.Ping(ctx); err != nil { - // don't try to do multi-line here because the raw mode makes things weird - clog.Log(clog.Fatal("failed to ping websocket, exiting: " + err.Error())) - ticker.Stop() - os.Exit(1) - } - } - } -} - -const sshActivityName = "ssh" diff --git a/internal/cmd/shell_test.go b/internal/cmd/shell_test.go deleted file mode 100644 index 9c5875b5..00000000 --- a/internal/cmd/shell_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import "testing" - -func TestShellEscape(t *testing.T) { - t.Parallel() - - tests := []struct { - Name string - Input string - Escaped string - }{ - { - Name: "single space", - Input: "hello world", - Escaped: `hello\ world`, - }, - { - Name: "multiple spaces", - Input: "test message hello world", - Escaped: `test\ message\ hello\ \ world`, - }, - { - Name: "mixed quotes", - Input: `"''"`, - Escaped: `\"\'\'\"`, - }, - { - Name: "mixed escaped quotes", - Input: `"'\"\"'"`, - Escaped: `\"\'\\\"\\\"\'\"`, - }, - } - - for _, test := range tests { - if e, a := test.Escaped, shellEscape(test.Input); e != a { - t.Fatalf("test %q failed; expected: %q, got %q (input: %q)", - test.Name, test.Escaped, a, test.Input) - } - } -} diff --git a/internal/cmd/ssh.go b/internal/cmd/ssh.go new file mode 100644 index 00000000..18131717 --- /dev/null +++ b/internal/cmd/ssh.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "os/user" + "path/filepath" + + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh/terminal" + "golang.org/x/xerrors" + + "cdr.dev/coder-cli/coder-sdk" + "cdr.dev/coder-cli/pkg/clog" +) + +var ( + showInteractiveOutput = terminal.IsTerminal(int(os.Stdout.Fd())) +) + +func sshCmd() *cobra.Command { + cmd := cobra.Command{ + Use: "ssh [environment_name] []", + Short: "Enter a shell of execute a command over SSH into a Coder environment", + Args: shValidArgs, + Example: `coder ssh my-dev +coder ssh my-dev pwd`, + Aliases: []string{"sh"}, + DisableFlagParsing: true, + DisableFlagsInUseLine: true, + RunE: shell, + } + return &cmd +} + +func shell(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient(ctx) + if err != nil { + return err + } + me, err := client.Me(ctx) + if err != nil { + return err + } + env, err := findEnv(ctx, client, args[0], coder.Me) + if err != nil { + return err + } + if env.LatestStat.ContainerStatus != coder.EnvironmentOn { + return clog.Error("environment not available", + fmt.Sprintf("current status: \"%s\"", env.LatestStat.ContainerStatus), + clog.BlankLine, + clog.Tipf("use \"coder envs rebuild %s\" to rebuild this environment", env.Name), + ) + } + wp, err := client.WorkspaceProviderByID(ctx, env.ResourcePoolID) + if err != nil { + return err + } + u, err := url.Parse(wp.EnvproxyAccessURL) + if err != nil { + return err + } + + usr, err := user.Current() + if err != nil { + return xerrors.Errorf("get user home directory: %w", err) + } + privateKeyFilepath := filepath.Join(usr.HomeDir, ".ssh", "coder_enterprise") + + err = writeSSHKey(ctx, client, privateKeyFilepath) + if err != nil { + return err + } + ssh := exec.CommandContext(ctx, + "ssh", "-i"+privateKeyFilepath, + fmt.Sprintf("%s-%s@%s", me.Username, env.Name, u.Hostname()), + ) + if len(args) > 1 { + ssh.Args = append(ssh.Args, args[1:]...) + } + ssh.Stderr = os.Stderr + ssh.Stdout = os.Stdout + ssh.Stdin = os.Stdin + err = ssh.Run() + var exitErr *exec.ExitError + if xerrors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + return xerrors.New("unreachable") + } + return err +} + +// special handling for the common case of "coder sh" input without a positional argument. +func shValidArgs(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + err := cobra.MinimumNArgs(1)(cmd, args) + if err != nil { + client, err := newClient(ctx) + if err != nil { + return clog.Error("missing [environment_name] argument") + } + _, haystack, err := searchForEnv(ctx, client, "", coder.Me) + if err != nil { + return clog.Error("missing [environment_name] argument", + fmt.Sprintf("specify one of %q", haystack), + clog.BlankLine, + clog.Tipf("run \"coder envs ls\" to view your environments"), + ) + } + return clog.Error("missing [environment_name] argument") + } + return nil +} diff --git a/internal/cmd/urls.go b/internal/cmd/urls.go index 587f843d..94f90357 100644 --- a/internal/cmd/urls.go +++ b/internal/cmd/urls.go @@ -24,11 +24,10 @@ func urlCmd() *cobra.Command { Short: "Interact with environment DevURLs", } lsCmd := &cobra.Command{ - Use: "ls [environment_name]", - Short: "List all DevURLs for an environment", - Args: xcobra.ExactArgs(1), - ValidArgsFunction: getEnvsForCompletion(coder.Me), - RunE: listDevURLsCmd(&outputFmt), + Use: "ls [environment_name]", + Short: "List all DevURLs for an environment", + Args: xcobra.ExactArgs(1), + RunE: listDevURLsCmd(&outputFmt), } lsCmd.Flags().StringVarP(&outputFmt, "output", "o", humanOutput, "human|json")