diff --git a/agent/agent.go b/agent/agent.go index cc4131297c390..141075c8ce5ea 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -101,6 +101,11 @@ func (a *agent) run(ctx context.Context) { func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { go func() { + select { + case <-a.closed: + _ = conn.Close() + case <-conn.Closed(): + } <-conn.Closed() a.connCloseWait.Done() }() diff --git a/agent/agent_test.go b/agent/agent_test.go index 9461da962041a..0ceea3830c0fc 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2,7 +2,12 @@ package agent_test import ( "context" + "fmt" + "io" + "net" + "os/exec" "runtime" + "strconv" "strings" "testing" @@ -29,7 +34,8 @@ func TestAgent(t *testing.T) { t.Parallel() t.Run("SessionExec", func(t *testing.T) { t.Parallel() - session := setupSSH(t) + session := setupSSHSession(t) + command := "echo test" if runtime.GOOS == "windows" { command = "cmd.exe /c echo test" @@ -41,7 +47,7 @@ func TestAgent(t *testing.T) { t.Run("GitSSH", func(t *testing.T) { t.Parallel() - session := setupSSH(t) + session := setupSSHSession(t) command := "sh -c 'echo $GIT_SSH_COMMAND'" if runtime.GOOS == "windows" { command = "cmd.exe /c echo %GIT_SSH_COMMAND%" @@ -53,7 +59,7 @@ func TestAgent(t *testing.T) { t.Run("SessionTTY", func(t *testing.T) { t.Parallel() - session := setupSSH(t) + session := setupSSHSession(t) prompt := "$" command := "bash" if runtime.GOOS == "windows" { @@ -76,9 +82,77 @@ func TestAgent(t *testing.T) { err = session.Wait() require.NoError(t, err) }) + + t.Run("LocalForwarding", func(t *testing.T) { + t.Parallel() + random, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + _ = random.Close() + tcpAddr, valid := random.Addr().(*net.TCPAddr) + require.True(t, valid) + randomPort := tcpAddr.Port + + local, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + tcpAddr, valid = local.Addr().(*net.TCPAddr) + require.True(t, valid) + localPort := tcpAddr.Port + done := make(chan struct{}) + go func() { + conn, err := local.Accept() + require.NoError(t, err) + _ = conn.Close() + close(done) + }() + + err = setupSSHCommand(t, []string{"-L", fmt.Sprintf("%d:127.0.0.1:%d", randomPort, localPort)}, []string{"echo", "test"}).Start() + require.NoError(t, err) + + conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(localPort)) + require.NoError(t, err) + conn.Close() + <-done + }) } -func setupSSH(t *testing.T) *ssh.Session { +func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { + agentConn := setupAgent(t) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + ssh, err := agentConn.SSH() + require.NoError(t, err) + go io.Copy(conn, ssh) + go io.Copy(ssh, conn) + } + }() + t.Cleanup(func() { + _ = listener.Close() + }) + tcpAddr, valid := listener.Addr().(*net.TCPAddr) + require.True(t, valid) + args := append(beforeArgs, + "-o", "HostName "+tcpAddr.IP.String(), + "-o", "Port "+strconv.Itoa(tcpAddr.Port), + "-o", "StrictHostKeyChecking=no", "host") + args = append(args, afterArgs...) + return exec.Command("ssh", args...) +} + +func setupSSHSession(t *testing.T) *ssh.Session { + sshClient, err := setupAgent(t).SSHClient() + require.NoError(t, err) + session, err := sshClient.NewSession() + require.NoError(t, err) + return session +} + +func setupAgent(t *testing.T) *agent.Conn { client, server := provisionersdk.TransportPipe() closer := agent.New(func(ctx context.Context, opts *peer.ConnOptions) (*peerbroker.Listener, error) { return peerbroker.Listen(server, nil, opts) @@ -100,14 +174,9 @@ func setupSSH(t *testing.T) *ssh.Session { t.Cleanup(func() { _ = conn.Close() }) - agentClient := &agent.Conn{ + + return &agent.Conn{ Negotiator: api, Conn: conn, } - sshClient, err := agentClient.SSHClient() - require.NoError(t, err) - session, err := sshClient.NewSession() - require.NoError(t, err) - - return session } diff --git a/cli/cliui/cliui.go b/cli/cliui/cliui.go index e69280cdf4119..f4093c7b54335 100644 --- a/cli/cliui/cliui.go +++ b/cli/cliui/cliui.go @@ -26,6 +26,7 @@ var Styles = struct { Checkmark, Code, Crossmark, + Error, Field, Keyword, Paragraph, @@ -41,6 +42,7 @@ var Styles = struct { Checkmark: defaultStyles.Checkmark, Code: defaultStyles.Code, Crossmark: defaultStyles.Error.Copy().SetString("✘"), + Error: defaultStyles.Error, Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}), Keyword: defaultStyles.Keyword, Paragraph: defaultStyles.Paragraph, diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 9d78e25626304..6a741e526def3 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -5,7 +5,6 @@ import ( "bytes" "encoding/json" "fmt" - "io" "os" "os/signal" "runtime" @@ -45,11 +44,11 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { var line string var err error - inFile, valid := cmd.InOrStdin().(*os.File) - if opts.Secret && valid && isatty.IsTerminal(inFile.Fd()) { + inFile, isInputFile := cmd.InOrStdin().(*os.File) + if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { line, err = speakeasy.Ask("") } else { - if !opts.IsConfirm && runtime.GOOS == "darwin" && valid { + if !opts.IsConfirm && runtime.GOOS == "darwin" && isInputFile { var restore func() restore, err = removeLineLengthLimit(int(inFile.Fd())) if err != nil { @@ -66,22 +65,7 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { // This enables multiline JSON to be pasted into an input, and have // it parse properly. if err == nil && (strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[")) { - pipeReader, pipeWriter := io.Pipe() - defer pipeWriter.Close() - defer pipeReader.Close() - go func() { - _, _ = pipeWriter.Write([]byte(line)) - _, _ = reader.WriteTo(pipeWriter) - }() - var rawMessage json.RawMessage - err := json.NewDecoder(pipeReader).Decode(&rawMessage) - if err == nil { - var buf bytes.Buffer - err = json.Compact(&buf, rawMessage) - if err == nil { - line = buf.String() - } - } + line, err = promptJSON(reader, line) } } if err != nil { @@ -118,3 +102,39 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { return "", Canceled } } + +func promptJSON(reader *bufio.Reader, line string) (string, error) { + var data bytes.Buffer + for { + _, _ = data.WriteString(line) + var rawMessage json.RawMessage + err := json.Unmarshal(data.Bytes(), &rawMessage) + if err != nil { + if err.Error() != "unexpected end of JSON input" { + // If a real syntax error occurs in JSON, + // we want to return that partial line to the user. + err = nil + line = data.String() + break + } + + // Read line-by-line. We can't use a JSON decoder + // here because it doesn't work by newline, so + // reads will block. + line, err = reader.ReadString('\n') + if err != nil { + break + } + continue + } + // Compacting the JSON makes it easier for parsing and testing. + rawJSON := data.Bytes() + data.Reset() + err = json.Compact(&data, rawJSON) + if err != nil { + return line, xerrors.Errorf("compact json: %w", err) + } + return data.String(), nil + } + return line, nil +} diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go new file mode 100644 index 0000000000000..324d0f452ac67 --- /dev/null +++ b/cli/cliui/resources.go @@ -0,0 +1,140 @@ +package cliui + +import ( + "fmt" + "io" + "sort" + "strconv" + + "github.com/jedib0t/go-pretty/v6/table" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" +) + +type WorkspaceResourcesOptions struct { + WorkspaceName string + HideAgentState bool + HideAccess bool + Title string +} + +// WorkspaceResources displays the connection status and tree-view of provided resources. +// ┌────────────────────────────────────────────────────────────────────────────┐ +// │ RESOURCE STATUS ACCESS │ +// ├────────────────────────────────────────────────────────────────────────────┤ +// │ google_compute_disk.root persistent │ +// ├────────────────────────────────────────────────────────────────────────────┤ +// │ google_compute_instance.dev ephemeral │ +// │ └─ dev (linux, amd64) ⦾ connecting [10s] coder ssh dev.dev │ +// ├────────────────────────────────────────────────────────────────────────────┤ +// │ kubernetes_pod.dev ephemeral │ +// │ ├─ go (linux, amd64) ⦿ connected coder ssh dev.go │ +// │ └─ postgres (linux, amd64) ⦾ disconnected [4s] coder ssh dev.postgres │ +// └────────────────────────────────────────────────────────────────────────────┘ +func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource, options WorkspaceResourcesOptions) error { + // Sort resources by type for consistent output. + sort.Slice(resources, func(i, j int) bool { + return resources[i].Type < resources[j].Type + }) + + // Address on stop indexes whether a resource still exists when in the stopped transition. + addressOnStop := map[string]codersdk.WorkspaceResource{} + for _, resource := range resources { + if resource.Transition != database.WorkspaceTransitionStop { + continue + } + addressOnStop[resource.Address] = resource + } + // Displayed stores whether a resource has already been shown. + // Resources can be stored with numerous states, which we + // process prior to display. + displayed := map[string]struct{}{} + + tableWriter := table.NewWriter() + if options.Title != "" { + tableWriter.SetTitle(options.Title) + } + tableWriter.SetStyle(table.StyleLight) + tableWriter.Style().Options.SeparateColumns = false + row := table.Row{"Resource", "Status"} + if !options.HideAccess { + row = append(row, "Access") + } + tableWriter.AppendHeader(row) + + totalAgents := 0 + for _, resource := range resources { + totalAgents += len(resource.Agents) + } + + for _, resource := range resources { + if resource.Type == "random_string" { + // Hide resources that aren't substantial to a user! + // This is an unfortunate case, and we should allow + // callers to hide resources eventually. + continue + } + if _, shown := displayed[resource.Address]; shown { + // The same resource can have multiple transitions. + continue + } + displayed[resource.Address] = struct{}{} + + // Sort agents by name for consistent output. + sort.Slice(resource.Agents, func(i, j int) bool { + return resource.Agents[i].Name < resource.Agents[j].Name + }) + _, existsOnStop := addressOnStop[resource.Address] + resourceState := "ephemeral" + if existsOnStop { + resourceState = "persistent" + } + // Display a line for the resource. + tableWriter.AppendRow(table.Row{ + Styles.Bold.Render(resource.Type + "." + resource.Name), + Styles.Placeholder.Render(resourceState), + "", + }) + // Display all agents associated with the resource. + for index, agent := range resource.Agents { + sshCommand := "coder ssh " + options.WorkspaceName + if totalAgents > 1 { + sshCommand += "." + agent.Name + } + sshCommand = Styles.Code.Render(sshCommand) + var agentStatus string + if !options.HideAgentState { + switch agent.Status { + case codersdk.WorkspaceAgentConnecting: + since := database.Now().Sub(agent.CreatedAt) + agentStatus = Styles.Warn.Render("⦾ connecting") + " " + + Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") + case codersdk.WorkspaceAgentDisconnected: + since := database.Now().Sub(*agent.DisconnectedAt) + agentStatus = Styles.Error.Render("⦾ disconnected") + " " + + Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") + case codersdk.WorkspaceAgentConnected: + agentStatus = Styles.Keyword.Render("⦿ connected") + } + } + + pipe := "├" + if index == len(resource.Agents)-1 { + pipe = "└" + } + row := table.Row{ + // These tree from a resource! + fmt.Sprintf("%s─ %s (%s, %s)", pipe, agent.Name, agent.OperatingSystem, agent.Architecture), + agentStatus, + } + if !options.HideAccess { + row = append(row, sshCommand) + } + tableWriter.AppendRow(row) + } + tableWriter.AppendSeparator() + } + _, err := fmt.Fprintln(writer, tableWriter.Render()) + return err +} diff --git a/cli/cliui/resources_test.go b/cli/cliui/resources_test.go new file mode 100644 index 0000000000000..2711a695c8934 --- /dev/null +++ b/cli/cliui/resources_test.go @@ -0,0 +1,92 @@ +package cliui_test + +import ( + "testing" + "time" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/pty/ptytest" + "github.com/stretchr/testify/require" +) + +func TestWorkspaceResources(t *testing.T) { + t.Parallel() + t.Run("SingleAgentSSH", func(t *testing.T) { + t.Parallel() + ptty := ptytest.New(t) + go func() { + err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ + Type: "google_compute_instance", + Name: "dev", + Transition: database.WorkspaceTransitionStart, + Agents: []codersdk.WorkspaceAgent{{ + Name: "dev", + Status: codersdk.WorkspaceAgentConnected, + Architecture: "amd64", + OperatingSystem: "linux", + }}, + }}, cliui.WorkspaceResourcesOptions{ + WorkspaceName: "example", + }) + require.NoError(t, err) + }() + ptty.ExpectMatch("coder ssh example") + }) + + t.Run("MultipleStates", func(t *testing.T) { + t.Parallel() + ptty := ptytest.New(t) + disconnected := database.Now().Add(-4 * time.Second) + go func() { + err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ + Address: "disk", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_disk", + Name: "root", + }, { + Address: "disk", + Transition: database.WorkspaceTransitionStop, + Type: "google_compute_disk", + Name: "root", + }, { + Address: "another", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_instance", + Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + CreatedAt: database.Now().Add(-10 * time.Second), + Status: codersdk.WorkspaceAgentConnecting, + Name: "dev", + OperatingSystem: "linux", + Architecture: "amd64", + }}, + }, { + Transition: database.WorkspaceTransitionStart, + Type: "kubernetes_pod", + Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + Status: codersdk.WorkspaceAgentConnected, + Name: "go", + Architecture: "amd64", + OperatingSystem: "linux", + }, { + DisconnectedAt: &disconnected, + Status: codersdk.WorkspaceAgentDisconnected, + Name: "postgres", + Architecture: "amd64", + OperatingSystem: "linux", + }}, + }}, cliui.WorkspaceResourcesOptions{ + WorkspaceName: "dev", + HideAgentState: false, + HideAccess: false, + }) + require.NoError(t, err) + }() + ptty.ExpectMatch("google_compute_disk.root") + ptty.ExpectMatch("google_compute_instance.dev") + ptty.ExpectMatch("coder ssh dev.postgres") + }) +} diff --git a/cli/cliui/select.go b/cli/cliui/select.go index fa101414aca40..5b9f535049aa2 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -1,11 +1,13 @@ package cliui import ( + "errors" "flag" "io" "os" "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/spf13/cobra" ) @@ -48,7 +50,6 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) { if flag.Lookup("test.v") != nil { return opts.Options[0], nil } - opts.HideSearch = false var value string err := survey.AskOne(&survey.Select{ Options: opts.Options, @@ -63,6 +64,9 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) { }, fileReadWriter{ Writer: cmd.OutOrStdout(), }, cmd.OutOrStdout())) + if errors.Is(err, terminal.InterruptErr) { + return value, Canceled + } return value, err } diff --git a/cli/configssh.go b/cli/configssh.go index 6bcd117372f09..46d50283eb83f 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -31,7 +31,9 @@ const sshEndToken = "# ------------END-CODER------------" func configSSH() *cobra.Command { var ( - sshConfigFile string + sshConfigFile string + sshOptions []string + skipProxyCommand bool ) cmd := &cobra.Command{ Use: "config-ssh", @@ -60,11 +62,13 @@ func configSSH() *cobra.Command { if len(workspaces) == 0 { return xerrors.New("You don't have any workspaces!") } - binPath, err := currentBinPath(cmd) + + binaryFile, err := currentBinPath(cmd) if err != nil { return err } + root := createConfig(cmd) sshConfigContent += "\n" + sshStartToken + "\n" + sshStartMessage + "\n\n" sshConfigContentMutex := sync.Mutex{} var errGroup errgroup.Group @@ -85,13 +89,21 @@ func configSSH() *cobra.Command { if len(resource.Agents) > 1 { hostname += "." + agent.Name } - sshConfigContent += strings.Join([]string{ + configOptions := []string{ "Host coder." + hostname, - "\tHostName coder." + hostname, - fmt.Sprintf("\tProxyCommand %q ssh --stdio %s", binPath, hostname), + } + for _, option := range sshOptions { + configOptions = append(configOptions, "\t"+option) + } + configOptions = append(configOptions, + "\tHostName coder."+hostname, "\tConnectTimeout=0", "\tStrictHostKeyChecking=no", - }, "\n") + "\n" + ) + if !skipProxyCommand { + configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname)) + } + sshConfigContent += strings.Join(configOptions, "\n") + "\n" sshConfigContentMutex.Unlock() } } @@ -118,6 +130,9 @@ func configSSH() *cobra.Command { }, } cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", "~/.ssh/config", "Specifies the path to an SSH config.") + cmd.Flags().StringArrayVarP(&sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.") + cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.") + _ = cmd.Flags().MarkHidden("skip-proxy-command") return cmd } diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 662275f2979da..56f8b4f83ec9a 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -1,43 +1,136 @@ package cli_test import ( + "context" + "io" + "net" "os" + "os/exec" + "strconv" + "strings" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/agent" "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/codersdk" + "github.com/coder/coder/peer" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/pty/ptytest" ) func TestConfigSSH(t *testing.T) { t.Parallel() - t.Run("Create", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - coderdtest.NewProvisionerDaemon(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - tempFile, err := os.CreateTemp(t.TempDir(), "") - require.NoError(t, err) - _ = tempFile.Close() - cmd, root := clitest.New(t, "config-ssh", "--ssh-config-file", tempFile.Name()) - clitest.SetupConfig(t, client, root) - doneChan := make(chan struct{}) - pty := ptytest.New(t) - cmd.SetIn(pty.Input()) - cmd.SetOut(pty.Output()) - go func() { - defer close(doneChan) - err := cmd.Execute() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + }}, + }}, + }, + }, + }}, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + tempFile, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + _ = tempFile.Close() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil, nil) + require.NoError(t, err) + defer agentConn.Close() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + _ = listener.Close() + }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + ssh, err := agentConn.SSH() require.NoError(t, err) - }() - <-doneChan + go io.Copy(conn, ssh) + go io.Copy(ssh, conn) + } + }() + t.Cleanup(func() { + _ = listener.Close() }) + + tcpAddr, valid := listener.Addr().(*net.TCPAddr) + require.True(t, valid) + cmd, root := clitest.New(t, "config-ssh", + "--ssh-option", "HostName "+tcpAddr.IP.String(), + "--ssh-option", "Port "+strconv.Itoa(tcpAddr.Port), + "--ssh-config-file", tempFile.Name(), + "--skip-proxy-command") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + <-doneChan + + t.Log(tempFile.Name()) + // #nosec + sshCmd := exec.Command("ssh", "-F", tempFile.Name(), "coder."+workspace.Name, "echo", "test") + sshCmd.Stderr = os.Stderr + data, err := sshCmd.Output() + require.NoError(t, err) + require.Equal(t, "test", strings.TrimSpace(string(data))) } diff --git a/cli/root.go b/cli/root.go index e7b28fe3ed3f5..08384b211af1d 100644 --- a/cli/root.go +++ b/cli/root.go @@ -29,9 +29,10 @@ const ( func Root() *cobra.Command { cmd := &cobra.Command{ - Use: "coder", - Version: buildinfo.Version(), - SilenceUsage: true, + Use: "coder", + Version: buildinfo.Version(), + SilenceErrors: true, + SilenceUsage: true, Long: ` ▄█▀ ▀█▄ ▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█ ▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀ diff --git a/cli/ssh.go b/cli/ssh.go index 6cdda4423880e..9f4c0183b756c 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -5,6 +5,7 @@ import ( "io" "net" "os" + "strings" "time" "github.com/google/uuid" @@ -26,14 +27,16 @@ func ssh() *cobra.Command { stdio bool ) cmd := &cobra.Command{ - Use: "ssh [agent]", + Use: "ssh ", + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { return err } - workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) + workspaceParts := strings.Split(args[0], ".") + workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, workspaceParts[0]) if err != nil { return err } @@ -66,16 +69,16 @@ func ssh() *cobra.Command { return xerrors.New("workspace has no agents") } var agent codersdk.WorkspaceAgent - if len(args) >= 2 { + if len(workspaceParts) >= 2 { for _, otherAgent := range agents { - if otherAgent.Name != args[1] { + if otherAgent.Name != workspaceParts[1] { continue } agent = otherAgent break } if agent.ID == uuid.Nil { - return xerrors.Errorf("agent not found by name %q", args[1]) + return xerrors.Errorf("agent not found by name %q", workspaceParts[1]) } } if agent.ID == uuid.Nil { @@ -125,7 +128,8 @@ func ssh() *cobra.Command { return err } - if isatty.IsTerminal(os.Stdout.Fd()) { + stdoutFile, valid := cmd.OutOrStdout().(*os.File) + if valid && isatty.IsTerminal(stdoutFile.Fd()) { state, err := term.MakeRaw(int(os.Stdin.Fd())) if err != nil { return err diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 208d8379d0b24..d3bd0503607ec 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -54,31 +54,28 @@ func TestSSH(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) - go func() { - // Run this async so the SSH command has to wait for - // the build and agent to connect! - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = agentToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - }) - t.Cleanup(func() { - _ = agentCloser.Close() - }) - }() - cmd, root := clitest.New(t, "ssh", workspace.Name) clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t) cmd.SetIn(pty.Input()) + cmd.SetErr(pty.Output()) cmd.SetOut(pty.Output()) go func() { defer close(doneChan) err := cmd.Execute() require.NoError(t, err) }() + pty.ExpectMatch("Waiting") + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = agentToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) // Shells on Mac, Windows, and Linux all exit shells with the "exit" command. pty.WriteLine("exit") <-doneChan diff --git a/cli/start.go b/cli/start.go index 5b438db4a334f..c7b98db850929 100644 --- a/cli/start.go +++ b/cli/start.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "database/sql" "encoding/pem" + "errors" "fmt" "net" "net/http" @@ -55,7 +56,7 @@ func start() *cobra.Command { tlsEnable bool tlsKeyFile string tlsMinVersion string - useTunnel bool + skipTunnel bool traceDatadog bool secureAuthCookie bool sshKeygenAlgorithmRaw string @@ -100,24 +101,35 @@ func start() *cobra.Command { } if accessURL == "" { accessURL = localURL.String() + } else { + // If an access URL is specified, always skip tunneling. + skipTunnel = true } var tunnelErr <-chan error // If we're attempting to tunnel in dev-mode, the access URL // needs to be changed to use the tunnel. - if dev && useTunnel { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Coder requires a network endpoint that can be accessed by provisioned workspaces. In dev mode, a free tunnel can be created for you. This will expose your Coder deployment to the internet.")+"\n") + if dev && !skipTunnel { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Wrap.Render( + "Coder requires a URL accessible by workspaces you provision. "+ + "A free tunnel can be created for simple setup. This will "+ + "expose your Coder deployment to a publicly accessible URL. "+ + cliui.Styles.Field.Render("--access-url")+" can be specified instead.\n", + )) _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: "Would you like Coder to start a tunnel for simple setup?", + Text: "Would you like to start a tunnel for simple setup?", IsConfirm: true, }) + if errors.Is(err, cliui.Canceled) { + return err + } if err == nil { accessURL, tunnelErr, err = tunnel.New(cmd.Context(), localURL.String()) if err != nil { return xerrors.Errorf("create tunnel: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL)+"\n") } + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) } validator, err := idtoken.NewValidator(cmd.Context(), option.WithoutAuthentication()) if err != nil { @@ -145,6 +157,10 @@ func start() *cobra.Command { SSHKeygenAlgorithm: sshKeygenAlgorithm, } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "access-url: %s\n", accessURL) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "provisioner-daemons: %d\n", provisionerDaemonCount) + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) + if !dev { sqlDB, err := sql.Open("postgres", postgresURL) if err != nil { @@ -213,26 +229,24 @@ func start() *cobra.Command { return xerrors.Errorf("create first user: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+ - cliui.Styles.Field.Render("dev")+` mode. All data is in-memory! Do not use in production. Press `+cliui.Styles.Field.Render("ctrl+c")+` to clean up provisioned infrastructure.`))+ - ` -`+ - cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Run `+cliui.Styles.Code.Render("coder templates init")+" in a new terminal to get started.\n"))+` -`) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Wrap.Render(`Started in dev mode. All data is in-memory! `+cliui.Styles.Bold.Render("Do not use in production")+`. Press `+ + cliui.Styles.Field.Render("ctrl+c")+` to clean up provisioned infrastructure.`)+"\n\n") + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Wrap.Render(`Run `+cliui.Styles.Code.Render("coder templates init")+ + " in a new terminal to start creating workspaces.")+"\n") } else { // This is helpful for tests, but can be silently ignored. // Coder may be ran as users that don't have permission to write in the homedir, // such as via the systemd service. _ = config.URL().Write(client.URL.String()) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+ + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+ cliui.Styles.Field.Render("production")+` mode. All data is stored in the PostgreSQL provided! Press `+cliui.Styles.Field.Render("ctrl+c")+` to gracefully shutdown.`))+"\n") hasFirstUser, err := client.HasFirstUser(cmd.Context()) if !hasFirstUser && err == nil { // This could fail for a variety of TLS-related reasons. // This is a helpful starter message, and not critical for user interaction. - _, _ = fmt.Fprint(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.FocusedPrompt.String()+`Run `+cliui.Styles.Code.Render("coder login "+client.URL.String())+" in a new terminal to get started.\n"))) + _, _ = fmt.Fprint(cmd.ErrOrStderr(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.FocusedPrompt.String()+`Run `+cliui.Styles.Code.Render("coder login "+client.URL.String())+" in a new terminal to get started.\n"))) } } @@ -342,8 +356,8 @@ func start() *cobra.Command { "Specifies the path to the private key for the certificate. It requires a PEM-encoded file") cliflag.StringVarP(root.Flags(), &tlsMinVersion, "tls-min-version", "", "CODER_TLS_MIN_VERSION", "tls12", `Specifies the minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`) - cliflag.BoolVarP(root.Flags(), &useTunnel, "tunnel", "", "CODER_DEV_TUNNEL", true, "Serve dev mode through a Cloudflare Tunnel for easy setup") - _ = root.Flags().MarkHidden("tunnel") + cliflag.BoolVarP(root.Flags(), &skipTunnel, "skip-tunnel", "", "CODER_DEV_SKIP_TUNNEL", false, "Skip serving dev mode through an exposed tunnel for simple setup.") + _ = root.Flags().MarkHidden("skip-tunnel") cliflag.BoolVarP(root.Flags(), &traceDatadog, "trace-datadog", "", "CODER_TRACE_DATADOG", false, "Send tracing data to a datadog agent") cliflag.BoolVarP(root.Flags(), &secureAuthCookie, "secure-auth-cookie", "", "CODER_SECURE_AUTH_COOKIE", false, "Specifies if the 'Secure' property is set on browser session cookies") cliflag.StringVarP(root.Flags(), &sshKeygenAlgorithmRaw, "ssh-keygen-algorithm", "", "CODER_SSH_KEYGEN_ALGORITHM", "ed25519", "Specifies the algorithm to use for generating ssh keys. "+ @@ -409,8 +423,8 @@ func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger s return provisionerd.New(client.ListenProvisionerDaemon, &provisionerd.Options{ Logger: logger, - PollInterval: 50 * time.Millisecond, - UpdateInterval: 50 * time.Millisecond, + PollInterval: 500 * time.Millisecond, + UpdateInterval: 500 * time.Millisecond, Provisioners: provisionerd.Provisioners{ string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)), }, diff --git a/cli/start_test.go b/cli/start_test.go index 78ac0c86b5315..bbe49ad463e1a 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -72,7 +72,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0") + root, cfg := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0") go func() { err := root.ExecuteContext(ctx) require.ErrorIs(t, err, context.Canceled) @@ -97,7 +97,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", + root, _ := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--tls-enable", "--tls-min-version", "tls9") err := root.ExecuteContext(ctx) require.Error(t, err) @@ -106,7 +106,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", + root, _ := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--tls-enable", "--tls-client-auth", "something") err := root.ExecuteContext(ctx) require.Error(t, err) @@ -115,7 +115,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", + root, _ := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--tls-enable") err := root.ExecuteContext(ctx) require.Error(t, err) @@ -126,7 +126,7 @@ func TestStart(t *testing.T) { defer cancelFunc() certPath, keyPath := generateTLSCertificate(t) - root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", + root, cfg := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--tls-enable", "--tls-cert-file", certPath, "--tls-key-file", keyPath) go func() { err := root.ExecuteContext(ctx) @@ -162,7 +162,7 @@ func TestStart(t *testing.T) { } ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", "--provisioner-daemons", "0") + root, cfg := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--provisioner-daemons", "0") done := make(chan struct{}) go func() { defer close(done) @@ -204,7 +204,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", "--trace-datadog=true") + root, _ := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--trace-datadog=true") done := make(chan struct{}) go func() { defer close(done) diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 5d9ece4c3e698..d2f1219094438 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -1,15 +1,14 @@ package cli import ( - "errors" "fmt" "os" "path/filepath" "sort" + "strings" "time" "github.com/briandowns/spinner" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -27,7 +26,7 @@ func templateCreate() *cobra.Command { provisioner string ) cmd := &cobra.Command{ - Use: "create [name]", + Use: "create [name]", Short: "Create a template from the current directory", RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) @@ -50,12 +49,32 @@ func templateCreate() *cobra.Command { return xerrors.Errorf("A template already exists named %q!", templateName) } + // Confirm upload of the users current directory. + // Truncate if in the home directory, because a shorter path looks nicer. + displayDirectory := directory + userHomeDir, err := os.UserHomeDir() + if err != nil { + return xerrors.Errorf("get home dir: %w", err) + } + if strings.HasPrefix(displayDirectory, userHomeDir) { + displayDirectory = strings.TrimPrefix(displayDirectory, userHomeDir) + displayDirectory = "~" + displayDirectory + } + _, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: fmt.Sprintf("Create and upload %q?", displayDirectory), + IsConfirm: true, + Default: "yes", + }) + if err != nil { + return err + } + spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond) spin.Writer = cmd.OutOrStdout() spin.Suffix = cliui.Styles.Keyword.Render(" Uploading current directory...") spin.Start() defer spin.Stop() - archive, err := provisionersdk.Tar(directory) + archive, err := provisionersdk.Tar(directory, provisionersdk.TemplateArchiveLimit) if err != nil { return err } @@ -66,9 +85,6 @@ func templateCreate() *cobra.Command { } spin.Stop() - spin = spinner.New(spinner.CharSets[5], 100*time.Millisecond) - spin.Writer = cmd.OutOrStdout() - spin.Suffix = cliui.Styles.Keyword.Render("Something") job, parameters, err := createValidTemplateVersion(cmd, client, organization, database.ProvisionerType(provisioner), resp.Hash) if err != nil { return err @@ -76,14 +92,10 @@ func templateCreate() *cobra.Command { if !yes { _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: "Create template?", + Text: "Confirm create?", IsConfirm: true, - Default: "yes", }) if err != nil { - if errors.Is(err, promptui.ErrAbort) { - return nil - } return err } } @@ -97,7 +109,13 @@ func templateCreate() *cobra.Command { return err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "The %s template has been created!\n", templateName) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n"+cliui.Styles.Wrap.Render( + "The "+cliui.Styles.Keyword.Render(templateName)+" template has been created! "+ + "Developers can provision a workspace with this template using:")+"\n") + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder workspace create "+templateName)) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + return nil }, } @@ -192,11 +210,13 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org return nil, nil, xerrors.New(version.Job.Error) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Checkmark.String()+" Successfully imported template source!\n") - resources, err := client.TemplateVersionResources(cmd.Context(), version.ID) if err != nil { return nil, nil, err } - return &version, parameters, displayTemplateVersionInfo(cmd, resources) + return &version, parameters, cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ + HideAgentState: true, + HideAccess: true, + Title: "Template Preview", + }) } diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index 7fce2d80d5c01..ce4ce48624eca 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -35,7 +35,8 @@ func TestTemplateCreate(t *testing.T) { require.NoError(t, err) }() matches := []string{ - "Create template?", "yes", + "Create and upload", "yes", + "Confirm create?", "yes", } for i := 0; i < len(matches); i += 2 { match := matches[i] diff --git a/cli/templateinit.go b/cli/templateinit.go index 603bcd87ac26d..84afa5874fc46 100644 --- a/cli/templateinit.go +++ b/cli/templateinit.go @@ -28,7 +28,9 @@ func templateInit() *cobra.Command { exampleByName[example.Name] = example } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Templates contain Infrastructure as Code that works with Coder to provision development workspaces. Get started by selecting an example:\n")) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render( + "A template defines infrastructure as code to be provisioned "+ + "for individual developer workspaces. Select an example to get started:\n")) option, err := cliui.Select(cmd, cliui.SelectOptions{ Options: exampleNames, }) @@ -56,7 +58,7 @@ func templateInit() *cobra.Command { } else { relPath = "./" + relPath } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%sExtracting %s to %s...\n", cliui.Styles.Prompt, cliui.Styles.Field.Render(selectedTemplate.ID), cliui.Styles.Keyword.Render(relPath)) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Extracting %s to %s...\n", cliui.Styles.Field.Render(selectedTemplate.ID), relPath) err = os.MkdirAll(directory, 0700) if err != nil { return err @@ -65,8 +67,9 @@ func templateInit() *cobra.Command { if err != nil { return err } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Inside that directory, get started by running:") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Code.Render("coder templates create"))+"\n") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Create your template by running:") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Code.Render("cd "+relPath+" && coder templates create"))+"\n") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Examples provide a starting point and are expected to be edited! 🎨")) return nil }, } diff --git a/cli/templatelist.go b/cli/templatelist.go index 410e489f15c3d..980215bcb4a5d 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -2,11 +2,12 @@ package cli import ( "fmt" - "text/tabwriter" - "time" "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" + + "github.com/coder/coder/cli/cliui" ) func templateList() *cobra.Command { @@ -18,7 +19,6 @@ func templateList() *cobra.Command { if err != nil { return err } - start := time.Now() organization, err := currentOrganization(cmd, client) if err != nil { return err @@ -34,30 +34,25 @@ func templateList() *cobra.Command { return nil } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Templates found in %s %s\n\n", - caret, - color.HiWhiteString(organization.Name), - color.HiBlackString("[%dms]", - time.Since(start).Milliseconds())) + tableWriter := table.NewWriter() + tableWriter.SetStyle(table.StyleLight) + tableWriter.Style().Options.SeparateColumns = false + tableWriter.AppendHeader(table.Row{"Name", "Source", "Last Updated", "Used By"}) - writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0) - _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", - color.HiBlackString("Template"), - color.HiBlackString("Source"), - color.HiBlackString("Last Updated"), - color.HiBlackString("Used By")) for _, template := range templates { suffix := "" if template.WorkspaceOwnerCount != 1 { suffix = "s" } - _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", - color.New(color.FgHiCyan).Sprint(template.Name), - color.WhiteString("Archive"), - color.WhiteString(template.UpdatedAt.Format("January 2, 2006")), - color.New(color.FgHiWhite).Sprintf("%d developer%s", template.WorkspaceOwnerCount, suffix)) + tableWriter.AppendRow(table.Row{ + cliui.Styles.Bold.Render(template.Name), + "Archive", + template.UpdatedAt.Format("January 2, 2006"), + cliui.Styles.Fuschia.Render(fmt.Sprintf("%d developer%s", template.WorkspaceOwnerCount, suffix)), + }) } - return writer.Flush() + _, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render()) + return err }, } } diff --git a/cli/templates.go b/cli/templates.go index e1f65ca4b1adc..9ec6dbef10db0 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -1,15 +1,8 @@ package cli import ( - "fmt" - "sort" - "github.com/fatih/color" "github.com/spf13/cobra" - - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" ) func templates() *cobra.Command { @@ -41,43 +34,3 @@ func templates() *cobra.Command { return cmd } - -func displayTemplateVersionInfo(cmd *cobra.Command, resources []codersdk.WorkspaceResource) error { - sort.Slice(resources, func(i, j int) bool { - return fmt.Sprintf("%s.%s", resources[i].Type, resources[i].Name) < fmt.Sprintf("%s.%s", resources[j].Type, resources[j].Name) - }) - - addressOnStop := map[string]codersdk.WorkspaceResource{} - for _, resource := range resources { - if resource.Transition != database.WorkspaceTransitionStop { - continue - } - addressOnStop[resource.Address] = resource - } - - displayed := map[string]struct{}{} - for _, resource := range resources { - if resource.Type == "random_string" { - // Hide resources that aren't substantial to a user! - continue - } - _, alreadyShown := displayed[resource.Address] - if alreadyShown { - continue - } - displayed[resource.Address] = struct{}{} - - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Bold.Render("resource."+resource.Type+"."+resource.Name)) - _, existsOnStop := addressOnStop[resource.Address] - if existsOnStop { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Warn.Render("~ persistent")) - } else { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Keyword.Render("+ start")+cliui.Styles.Placeholder.Render(" (deletes on stop)")) - } - if len(resource.Agents) > 0 { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Fuschia.Render("▲ allows ssh")) - } - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - } - return nil -} diff --git a/cli/templateupdate.go b/cli/templateupdate.go index cd88561bbd5bb..333003ecf3ed7 100644 --- a/cli/templateupdate.go +++ b/cli/templateupdate.go @@ -43,7 +43,7 @@ func templateUpdate() *cobra.Command { return err } } - content, err := provisionersdk.Tar(directory) + content, err := provisionersdk.Tar(directory, provisionersdk.TemplateArchiveLimit) if err != nil { return err } diff --git a/cli/workspaceautostart_test.go b/cli/workspaceautostart_test.go index c7de625f91068..760def044d166 100644 --- a/cli/workspaceautostart_test.go +++ b/cli/workspaceautostart_test.go @@ -5,10 +5,11 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/codersdk" - "github.com/stretchr/testify/require" ) func TestWorkspaceAutostart(t *testing.T) { diff --git a/cli/workspaceautostop_test.go b/cli/workspaceautostop_test.go index 780ea40cf37d4..eb8fa583d2d61 100644 --- a/cli/workspaceautostop_test.go +++ b/cli/workspaceautostop_test.go @@ -5,10 +5,11 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/codersdk" - "github.com/stretchr/testify/require" ) func TestWorkspaceAutostop(t *testing.T) { diff --git a/cli/workspacecreate.go b/cli/workspacecreate.go index 7dbfdf29d793d..f7225da59d7b8 100644 --- a/cli/workspacecreate.go +++ b/cli/workspacecreate.go @@ -1,16 +1,14 @@ package cli import ( - "errors" "fmt" "sort" "time" - "github.com/fatih/color" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/xerrors" + "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" @@ -18,11 +16,10 @@ import ( func workspaceCreate() *cobra.Command { var ( - templateName string + workspaceName string ) cmd := &cobra.Command{ - Use: "create ", - Args: cobra.ExactArgs(1), + Use: "create [template]", Short: "Create a workspace from a template", RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) @@ -34,9 +31,14 @@ func workspaceCreate() *cobra.Command { return err } + templateName := "" + if len(args) >= 1 { + templateName = args[0] + } + var template codersdk.Template if templateName == "" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a template:")) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a template below to preview the provisioned infrastructure:")) templateNames := []string{} templateByName := map[string]codersdk.Template{} @@ -45,8 +47,16 @@ func workspaceCreate() *cobra.Command { return err } for _, template := range templates { - templateNames = append(templateNames, template.Name) - templateByName[template.Name] = template + templateName := template.Name + if template.WorkspaceOwnerCount > 0 { + developerText := "developer" + if template.WorkspaceOwnerCount != 1 { + developerText = "developers" + } + templateName += cliui.Styles.Placeholder.Render(fmt.Sprintf(" (used by %d %s)", template.WorkspaceOwnerCount, developerText)) + } + templateNames = append(templateNames, templateName) + templateByName[templateName] = template } sort.Slice(templateNames, func(i, j int) bool { return templateByName[templateNames[i]].WorkspaceOwnerCount > templateByName[templateNames[j]].WorkspaceOwnerCount @@ -70,10 +80,22 @@ func workspaceCreate() *cobra.Command { } } - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Creating with the "+cliui.Styles.Field.Render(template.Name)+" template...") + if workspaceName == "" { + workspaceName, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Specify a name for your workspace:", + Validate: func(workspaceName string) error { + _, err = client.WorkspaceByName(cmd.Context(), codersdk.Me, workspaceName) + if err == nil { + return xerrors.Errorf("A workspace already exists named %q!", workspaceName) + } + return nil + }, + }) + if err != nil { + return err + } + } - workspaceName := args[0] _, err = client.WorkspaceByName(cmd.Context(), codersdk.Me, workspaceName) if err == nil { return xerrors.Errorf("A workspace already exists named %q!", workspaceName) @@ -95,10 +117,9 @@ func workspaceCreate() *cobra.Command { continue } if !printed { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters! These can be changed after create, but may have unintended side effects (like data loss).")+"\r\n") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n") printed = true } - value, err := cliui.ParameterSchema(cmd, parameterSchema) if err != nil { return err @@ -110,29 +131,27 @@ func workspaceCreate() *cobra.Command { DestinationScheme: parameterSchema.DefaultDestinationScheme, }) } - if printed { - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.FocusedPrompt.String()+"Previewing resources...") - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - } + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + resources, err := client.TemplateVersionResources(cmd.Context(), templateVersion.ID) if err != nil { return err } - err = displayTemplateVersionInfo(cmd, resources) + err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ + WorkspaceName: workspaceName, + // Since agent's haven't connected yet, hiding this makes more sense. + HideAgentState: true, + Title: "Workspace Preview", + }) if err != nil { return err } _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: fmt.Sprintf("Create workspace %s?", color.HiCyanString(workspaceName)), - Default: "yes", + Text: "Confirm create?", IsConfirm: true, }) if err != nil { - if errors.Is(err, promptui.ErrAbort) { - return nil - } return err } @@ -145,29 +164,26 @@ func workspaceCreate() *cobra.Command { if err != nil { return err } - err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{ - Fetch: func() (codersdk.ProvisionerJob, error) { - build, err := client.WorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID) - return build.Job, err - }, - Cancel: func() error { - return client.CancelWorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID) - }, - Logs: func() (<-chan codersdk.ProvisionerJobLog, error) { - return client.WorkspaceBuildLogsAfter(cmd.Context(), workspace.LatestBuild.ID, before) - }, - }) + err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID, before) + if err != nil { + return err + } + resources, err = client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) if err != nil { return err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been created!\n\n", cliui.Styles.Keyword.Render(workspace.Name)) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder ssh "+workspace.Name)) - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - return err + err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ + WorkspaceName: workspaceName, + }) + if err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "The %s workspace has been created!\n", cliui.Styles.Keyword.Render(workspace.Name)) + return nil }, } - cmd.Flags().StringVarP(&templateName, "template", "p", "", "Specify a template name.") + cliflag.StringVarP(cmd.Flags(), &workspaceName, "name", "n", "CODER_WORKSPACE_NAME", "", "Specify a workspace name.") return cmd } diff --git a/cli/workspacecreate_test.go b/cli/workspacecreate_test.go index 575263d0533b1..26b751328dc34 100644 --- a/cli/workspacecreate_test.go +++ b/cli/workspacecreate_test.go @@ -7,6 +7,8 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/pty/ptytest" ) @@ -20,7 +22,7 @@ func TestWorkspaceCreate(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - cmd, root := clitest.New(t, "workspaces", "create", "my-workspace", "--template", template.Name) + cmd, root := clitest.New(t, "workspaces", "create", template.Name, "--name", "my-workspace") clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t) @@ -32,7 +34,121 @@ func TestWorkspaceCreate(t *testing.T) { require.NoError(t, err) }() matches := []string{ - "Create workspace", "yes", + "Confirm create", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + }) + t.Run("CreateFromList", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + cmd, root := clitest.New(t, "workspaces", "create", "--name", "my-workspace") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + matches := []string{ + "Confirm create", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + }) + t.Run("FromNothing", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + cmd, root := clitest.New(t, "workspaces", "create", "--name", "") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + matches := []string{ + "Specify a name", "my-workspace", + "Confirm create?", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + }) + t.Run("WithParameter", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: []*proto.Parse_Response{{ + Type: &proto.Parse_Response_Complete{ + Complete: &proto.Parse_Complete{ + ParameterSchemas: []*proto.ParameterSchema{{ + AllowOverrideSource: true, + Name: "region", + Description: "description", + DefaultSource: &proto.ParameterSource{ + Scheme: proto.ParameterSource_DATA, + Value: "something", + }, + DefaultDestination: &proto.ParameterDestination{ + Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE, + }, + }}, + }, + }, + }}, + Provision: echo.ProvisionComplete, + ProvisionDryRun: echo.ProvisionComplete, + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + cmd, root := clitest.New(t, "workspaces", "create", "--name", "") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + matches := []string{ + "Specify a name", "my-workspace", + "Enter a value", "bananas", + "Confirm create?", "yes", } for i := 0; i < len(matches); i += 2 { match := matches[i] diff --git a/cli/workspacedelete.go b/cli/workspacedelete.go index e77e66bd9dea7..17edffa9b7239 100644 --- a/cli/workspacedelete.go +++ b/cli/workspacedelete.go @@ -32,19 +32,7 @@ func workspaceDelete() *cobra.Command { if err != nil { return err } - err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{ - Fetch: func() (codersdk.ProvisionerJob, error) { - build, err := client.WorkspaceBuild(cmd.Context(), build.ID) - return build.Job, err - }, - Cancel: func() error { - return client.CancelWorkspaceBuild(cmd.Context(), build.ID) - }, - Logs: func() (<-chan codersdk.ProvisionerJobLog, error) { - return client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before) - }, - }) - return err + return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before) }, } } diff --git a/cli/workspacelist.go b/cli/workspacelist.go index 9b3e97b26f3de..91416c502b0e7 100644 --- a/cli/workspacelist.go +++ b/cli/workspacelist.go @@ -2,13 +2,12 @@ package cli import ( "fmt" - "text/tabwriter" - "time" - "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" ) @@ -21,7 +20,6 @@ func workspaceList() *cobra.Command { if err != nil { return err } - start := time.Now() workspaces, err := client.WorkspacesByUser(cmd.Context(), codersdk.Me) if err != nil { return err @@ -34,27 +32,47 @@ func workspaceList() *cobra.Command { return nil } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Workspaces found %s\n\n", - caret, - color.HiBlackString("[%dms]", - time.Since(start).Milliseconds())) - - writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0) - _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", - color.HiBlackString("Workspace"), - color.HiBlackString("Template"), - color.HiBlackString("Status"), - color.HiBlackString("Last Built"), - color.HiBlackString("Outdated")) + tableWriter := table.NewWriter() + tableWriter.SetStyle(table.StyleLight) + tableWriter.Style().Options.SeparateColumns = false + tableWriter.AppendHeader(table.Row{"Workspace", "Template", "Status", "Last Built", "Outdated"}) + for _, workspace := range workspaces { - _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%+v\n", - color.New(color.FgHiCyan).Sprint(workspace.Name), - color.WhiteString(workspace.TemplateName), - color.WhiteString(string(workspace.LatestBuild.Transition)), - color.WhiteString(workspace.LatestBuild.Job.CompletedAt.Format("January 2, 2006")), - workspace.Outdated) + status := "" + inProgress := false + if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobRunning || + workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobCanceling { + inProgress = true + } + + switch workspace.LatestBuild.Transition { + case database.WorkspaceTransitionStart: + status = "start" + if inProgress { + status = "starting" + } + case database.WorkspaceTransitionStop: + status = "stop" + if inProgress { + status = "stopping" + } + case database.WorkspaceTransitionDelete: + status = "delete" + if inProgress { + status = "deleting" + } + } + + tableWriter.AppendRow(table.Row{ + cliui.Styles.Bold.Render(workspace.Name), + workspace.TemplateName, + status, + workspace.LatestBuild.Job.CreatedAt.Format("January 2, 2006"), + workspace.Outdated, + }) } - return writer.Flush() + _, err = fmt.Fprintf(cmd.OutOrStdout(), tableWriter.Render()) + return err }, } } diff --git a/cli/workspaceshow.go b/cli/workspaceshow.go index 36a7b5040432d..a968916a3efe6 100644 --- a/cli/workspaceshow.go +++ b/cli/workspaceshow.go @@ -2,13 +2,32 @@ package cli import ( "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" ) func workspaceShow() *cobra.Command { return &cobra.Command{ - Use: "show", + Use: "show", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return nil + client, err := createClient(cmd) + if err != nil { + return err + } + workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) + if err != nil { + return xerrors.Errorf("get workspace: %w", err) + } + resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) + if err != nil { + return xerrors.Errorf("get workspace resources: %w", err) + } + return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ + WorkspaceName: workspace.Name, + }) }, } } diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 391e76a35c002..18612b63ba11a 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -183,6 +183,56 @@ func main() { }, }) + root.AddCommand(&cobra.Command{ + Use: "resources", + RunE: func(cmd *cobra.Command, args []string) error { + disconnected := database.Now().Add(-4 * time.Second) + return cliui.WorkspaceResources(cmd.OutOrStdout(), []codersdk.WorkspaceResource{{ + Address: "disk", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_disk", + Name: "root", + }, { + Address: "disk", + Transition: database.WorkspaceTransitionStop, + Type: "google_compute_disk", + Name: "root", + }, { + Address: "another", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_instance", + Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + CreatedAt: database.Now().Add(-10 * time.Second), + Status: codersdk.WorkspaceAgentConnecting, + Name: "dev", + OperatingSystem: "linux", + Architecture: "amd64", + }}, + }, { + Transition: database.WorkspaceTransitionStart, + Type: "kubernetes_pod", + Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + Status: codersdk.WorkspaceAgentConnected, + Name: "go", + Architecture: "amd64", + OperatingSystem: "linux", + }, { + DisconnectedAt: &disconnected, + Status: codersdk.WorkspaceAgentDisconnected, + Name: "postgres", + Architecture: "amd64", + OperatingSystem: "linux", + }}, + }}, cliui.WorkspaceResourcesOptions{ + WorkspaceName: "dev", + HideAgentState: false, + HideAccess: false, + }) + }, + }) + err := root.Execute() if err != nil { _, _ = fmt.Println(err.Error()) diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 3daffadbb921d..df91af57afcc3 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -1,14 +1,21 @@ package main import ( + "errors" + "fmt" "os" "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliui" ) func main() { err := cli.Root().Execute() if err != nil { + if errors.Is(err, cliui.Canceled) { + os.Exit(1) + } + _, _ = fmt.Fprintln(os.Stderr, cliui.Styles.Error.Render(err.Error())) os.Exit(1) } } diff --git a/cmd/templater/main.go b/cmd/templater/main.go index effd36fd49a1a..4600aa420ff5a 100644 --- a/cmd/templater/main.go +++ b/cmd/templater/main.go @@ -120,7 +120,7 @@ func parse(cmd *cobra.Command, parameters []codersdk.CreateParameterRequest) err if err != nil { return err } - content, err := provisionersdk.Tar(dir) + content, err := provisionersdk.Tar(dir, provisionersdk.TemplateArchiveLimit) if err != nil { return err } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 04667ef3f76c8..7f30e992d23d6 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -108,7 +108,7 @@ func New(t *testing.T, options *Options) *codersdk.Client { // We set the handler after server creation for the access URL. srv.Config.Handler, closeWait = coderd.New(&coderd.Options{ - AgentConnectionUpdateFrequency: 25 * time.Millisecond, + AgentConnectionUpdateFrequency: 150 * time.Millisecond, AccessURL: serverURL, Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), Database: db, @@ -264,7 +264,7 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID require.NoError(t, err) for _, resource := range resources { for _, agent := range resource.Agents { - if agent.FirstConnectedAt == nil { + if agent.Status != codersdk.WorkspaceAgentConnected { return false } } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 808ebfac0a924..ab47000cc912d 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -43,6 +43,20 @@ func (api *api) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) { defer api.websocketWaitGroup.Done() agent := httpmw.WorkspaceAgentParam(r) + apiAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert workspace agent: %s", err), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{ + Message: fmt.Sprintf("Agent isn't connected! Status: %s", apiAgent.Status), + }) + return + } + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ CompressionMode: websocket.CompressionDisabled, }) @@ -167,14 +181,14 @@ func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) { _ = updateConnectionTimes() }() - err = updateConnectionTimes() + err = ensureLatestBuild() if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + _ = conn.Close(websocket.StatusGoingAway, "") return } - err = ensureLatestBuild() + err = updateConnectionTimes() if err != nil { - _ = conn.Close(websocket.StatusGoingAway, "") + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) return } @@ -239,7 +253,7 @@ func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency case !dbAgent.FirstConnectedAt.Valid: // If the agent never connected, it's waiting for the compute // to start up. - agent.Status = codersdk.WorkspaceAgentWaiting + agent.Status = codersdk.WorkspaceAgentConnecting case dbAgent.DisconnectedAt.Time.After(dbAgent.LastConnectedAt.Time): // If we've disconnected after our last connection, we know the // agent is no longer connected. diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index a66f8d3ff2ac2..de59ee9e99b16 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -70,6 +70,9 @@ func (c *Client) ListenProvisionerDaemon(ctx context.Context) (proto.DRPCProvisi } return nil, readBodyAsError(res) } + // Allow _somewhat_ large payloads. + conn.SetReadLimit((1 << 20) * 2) + config := yamux.DefaultConfig() config.LogOutput = io.Discard session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) diff --git a/codersdk/workspaceresources.go b/codersdk/workspaceresources.go index e5b818ccb4241..9f9169f58480c 100644 --- a/codersdk/workspaceresources.go +++ b/codersdk/workspaceresources.go @@ -15,7 +15,7 @@ import ( type WorkspaceAgentStatus string const ( - WorkspaceAgentWaiting WorkspaceAgentStatus = "waiting" + WorkspaceAgentConnecting WorkspaceAgentStatus = "connecting" WorkspaceAgentConnected WorkspaceAgentStatus = "connected" WorkspaceAgentDisconnected WorkspaceAgentStatus = "disconnected" ) diff --git a/develop.sh b/develop.sh index 5b6e587f1b84f..20c3ad08a2e62 100755 --- a/develop.sh +++ b/develop.sh @@ -14,5 +14,5 @@ cd "${PROJECT_ROOT}" ( trap 'kill 0' SIGINT CODERV2_HOST=http://127.0.0.1:3000 INSPECT_XSTATE=true yarn --cwd=./site dev & - go run cmd/coder/main.go start --dev --tunnel=false + go run cmd/coder/main.go start --dev --skip-tunnel ) diff --git a/go.mod b/go.mod index 591b836971ab1..bf86f67a78f8c 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,6 @@ require ( github.com/justinas/nosurf v1.1.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/lib/pq v1.10.5 - github.com/manifoldco/promptui v0.9.0 github.com/mattn/go-isatty v0.0.14 github.com/mitchellh/mapstructure v1.4.3 github.com/moby/moby v20.10.14+incompatible @@ -96,6 +95,8 @@ require ( require github.com/go-chi/httprate v0.5.3 +require github.com/jedib0t/go-pretty/v6 v6.3.0 + require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/BurntSushi/toml v1.0.0 // indirect @@ -117,7 +118,6 @@ require ( github.com/charmbracelet/bubbles v0.10.3 // indirect github.com/charmbracelet/bubbletea v0.20.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect - github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/clbanning/mxj/v2 v2.5.5 // indirect github.com/cloudflare/brotli-go v0.0.0-20191101163834-d34379f7ff93 // indirect github.com/cloudflare/golibs v0.0.0-20210909181612-21743d7dd02a // indirect @@ -169,13 +169,11 @@ require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.15.0 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/lucas-clemente/quic-go v0.25.1-0.20220307142123-ad1cb27c1b64 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/marten-seemann/qtls-go1-16 v0.1.4 // indirect github.com/marten-seemann/qtls-go1-17 v0.1.0 // indirect diff --git a/go.sum b/go.sum index 27966cc9a85ce..861b43638d60f 100644 --- a/go.sum +++ b/go.sum @@ -288,9 +288,7 @@ github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d8 github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= -github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg= github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc= @@ -1034,6 +1032,8 @@ github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv github.com/jackc/puddle v1.2.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jedib0t/go-pretty/v6 v6.3.0 h1:QQ5yZPDUMEjbZRXDJtZlvwfDQqCYFaxV3yEzTkogUgk= +github.com/jedib0t/go-pretty/v6 v6.3.0/go.mod h1:FMkOpgGD3EZ91cW8g/96RfxoV7bdeJyzXPYgz1L1ln0= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= @@ -1066,9 +1066,6 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= -github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o= -github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= github.com/julienschmidt/httprouter v1.1.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= @@ -1125,9 +1122,6 @@ github.com/kylecarbs/cloudflared v0.0.0-20220323202451-083379ce31c3 h1:JopBWZaVm github.com/kylecarbs/cloudflared v0.0.0-20220323202451-083379ce31c3/go.mod h1:4chGYq3uDzeHSpht2LFNZc/8ulHhMW9MvHPvzT5aZx8= github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b h1:1Y1X6aR78kMEQE1iCjQodB3lA7VO4jB88Wf8ZrzXSsA= github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -github.com/kylecarbs/promptui v0.8.1-0.20201231190244-d8f2159af2b2 h1:MUREBTh4kybLY1KyuBfSx+QPfTB8XiUHs6ZxUhOPTnU= -github.com/kylecarbs/promptui v0.8.1-0.20201231190244-d8f2159af2b2/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= -github.com/kylecarbs/readline v0.0.0-20220211054233-0d62993714c8 h1:Y7O3Z3YeNRtw14QrtHpevU4dSjCkov0J40MtQ7Nc0n8= github.com/kylecarbs/readline v0.0.0-20220211054233-0d62993714c8/go.mod h1:n/KX1BZoN1m9EwoXkn/xAV4fd3k8c++gGBsgLONaPOY= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e h1:OP0ZMFeZkUnOzTFRfpuK3m7Kp4fNvC6qN+exwj7aI4M= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= @@ -1163,8 +1157,6 @@ github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1 github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= -github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -1196,7 +1188,6 @@ github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -1445,6 +1436,7 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= +github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -1988,6 +1980,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cO golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1996,7 +1989,6 @@ golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index e27024da2a399..f36893122457d 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -207,7 +207,6 @@ func (p *Server) acquireJob(ctx context.Context) { return } if job.JobId == "" { - // p.opts.Logger.Debug(context.Background(), "no jobs available") return } ctx, p.jobCancel = context.WithCancel(ctx) @@ -456,7 +455,7 @@ func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sd Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, Level: sdkproto.LogLevel_INFO, - Stage: "Detecting resources when started", + Stage: "Detecting persistent resources", CreatedAt: time.Now().UTC().UnixMilli(), }}, }) @@ -477,7 +476,7 @@ func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sd Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, Level: sdkproto.LogLevel_INFO, - Stage: "Detecting resources when stopped", + Stage: "Detecting ephemeral resources", CreatedAt: time.Now().UTC().UnixMilli(), }}, }) diff --git a/provisionersdk/archive.go b/provisionersdk/archive.go index 614a9666878b6..ff43880cec396 100644 --- a/provisionersdk/archive.go +++ b/provisionersdk/archive.go @@ -11,10 +11,16 @@ import ( "golang.org/x/xerrors" ) +const ( + // TemplateArchiveLimit represents the maximum size of a template in bytes. + TemplateArchiveLimit = 1 << 20 +) + // Tar archives a directory. -func Tar(directory string) ([]byte, error) { +func Tar(directory string, limit int64) ([]byte, error) { var buffer bytes.Buffer tarWriter := tar.NewWriter(&buffer) + totalSize := int64(0) err := filepath.Walk(directory, func(file string, fileInfo os.FileInfo, err error) error { if err != nil { return err @@ -46,9 +52,15 @@ func Tar(directory string) ([]byte, error) { if err != nil { return err } - if _, err := io.Copy(tarWriter, data); err != nil { + defer data.Close() + wrote, err := io.Copy(tarWriter, data) + if err != nil { return err } + totalSize += wrote + if limit != 0 && totalSize >= limit { + return xerrors.Errorf("Archive too big. Must be <= %d bytes", limit) + } return data.Close() }) if err != nil { diff --git a/provisionersdk/archive_test.go b/provisionersdk/archive_test.go index 102b2513e57f4..7abe9ce47bda8 100644 --- a/provisionersdk/archive_test.go +++ b/provisionersdk/archive_test.go @@ -16,7 +16,7 @@ func TestTar(t *testing.T) { file, err := os.CreateTemp(dir, "") require.NoError(t, err) _ = file.Close() - _, err = provisionersdk.Tar(dir) + _, err = provisionersdk.Tar(dir, 1024) require.NoError(t, err) } @@ -26,7 +26,7 @@ func TestUntar(t *testing.T) { file, err := os.CreateTemp(dir, "") require.NoError(t, err) _ = file.Close() - archive, err := provisionersdk.Tar(dir) + archive, err := provisionersdk.Tar(dir, 1024) require.NoError(t, err) dir = t.TempDir() err = provisionersdk.Untar(dir, archive) diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index 5b719475a8cd3..dd5af571d5e9d 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -17,7 +17,7 @@ const config: PlaywrightTestConfig = { // https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests webServer: { // Run the coder daemon directly. - command: `go run -tags embed ${path.join(__dirname, "../../cmd/coder/main.go")} start --dev --tunnel=false`, + command: `go run -tags embed ${path.join(__dirname, "../../cmd/coder/main.go")} start --dev --skip-tunnel`, port: 3000, timeout: 120 * 10000, reuseExistingServer: false,