feat: Improve resource preview and first-time experience (#946)

* Improve CLI documentation

* feat: Allow workspace resources to attach multiple agents

This enables a "kubernetes_pod" to attach multiple agents that
could be for multiple services. Each agent is required to have
a unique name, so SSH syntax is:

`coder ssh <workspace>.<agent>`

A resource can have zero agents too, they aren't required.

* Add tree view

* Improve table UI

* feat: Allow workspace resources to attach multiple agents

This enables a "kubernetes_pod" to attach multiple agents that
could be for multiple services. Each agent is required to have
a unique name, so SSH syntax is:

`coder ssh <workspace>.<agent>`

A resource can have zero agents too, they aren't required.

* Rename `tunnel` to `skip-tunnel`

This command was `true` by default, which causes
a confusing user experience.

* Add disclaimer about editing templates

* Add help to template create

* Improve workspace create flow

* Add end-to-end test for config-ssh

* Improve testing of config-ssh

* Fix workspace list

* Fix config ssh tests

* Update cli/configssh.go

Co-authored-by: Cian Johnston <public@cianjohnston.ie>

* Fix requested changes

* Remove socat requirement

* Fix resources not reading in TTY

Co-authored-by: Cian Johnston <public@cianjohnston.ie>
This commit is contained in:
Kyle Carberry 2022-04-11 18:54:30 -05:00 committed by GitHub
parent 19b4323512
commit fb9dc4f346
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 979 additions and 317 deletions

View File

@ -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()
}()

View File

@ -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
}

View File

@ -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,

View File

@ -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
}

140
cli/cliui/resources.go Normal file
View File

@ -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
}

View File

@ -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")
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
require.NoError(t, err)
}()
<-doneChan
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)
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)))
}

View File

@ -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: `

View File

@ -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 <workspace> [agent]",
Use: "ssh <workspace>",
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

View File

@ -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

View File

@ -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)),
},

View File

@ -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)

View File

@ -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 <directory> [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",
})
}

View File

@ -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]

View File

@ -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
},
}

View File

@ -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
},
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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 <name>",
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
}
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
WorkspaceName: workspaceName,
})
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
_, _ = 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
}

View File

@ -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]

View File

@ -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)
},
}
}

View File

@ -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()))
tableWriter := table.NewWriter()
tableWriter.SetStyle(table.StyleLight)
tableWriter.Style().Options.SeparateColumns = false
tableWriter.AppendHeader(table.Row{"Workspace", "Template", "Status", "Last Built", "Outdated"})
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"))
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
},
}
}

View File

@ -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,
})
},
}
}

View File

@ -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())

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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,16 +181,16 @@ func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) {
_ = updateConnectionTimes()
}()
err = updateConnectionTimes()
if err != nil {
_ = conn.Close(websocket.StatusAbnormalClosure, err.Error())
return
}
err = ensureLatestBuild()
if err != nil {
_ = conn.Close(websocket.StatusGoingAway, "")
return
}
err = updateConnectionTimes()
if err != nil {
_ = conn.Close(websocket.StatusAbnormalClosure, err.Error())
return
}
api.Logger.Info(r.Context(), "accepting agent", slog.F("resource", resource), slog.F("agent", agent))
@ -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.

View File

@ -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)

View File

@ -15,7 +15,7 @@ import (
type WorkspaceAgentStatus string
const (
WorkspaceAgentWaiting WorkspaceAgentStatus = "waiting"
WorkspaceAgentConnecting WorkspaceAgentStatus = "connecting"
WorkspaceAgentConnected WorkspaceAgentStatus = "connected"
WorkspaceAgentDisconnected WorkspaceAgentStatus = "disconnected"
)

View File

@ -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
)

6
go.mod
View File

@ -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

16
go.sum
View File

@ -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=

View File

@ -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(),
}},
})

View File

@ -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 {

View File

@ -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)

View File

@ -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,