mirror of
https://github.com/coder/coder.git
synced 2025-03-16 23:40:29 +00:00
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:
@ -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()
|
||||
}()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
140
cli/cliui/resources.go
Normal 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
|
||||
}
|
92
cli/cliui/resources_test.go
Normal file
92
cli/cliui/resources_test.go
Normal 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")
|
||||
})
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)))
|
||||
}
|
||||
|
@ -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: ` ▄█▀ ▀█▄
|
||||
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
|
||||
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
|
||||
|
16
cli/ssh.go
16
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 <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
|
||||
|
@ -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
|
||||
|
48
cli/start.go
48
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)),
|
||||
},
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -15,7 +15,7 @@ import (
|
||||
type WorkspaceAgentStatus string
|
||||
|
||||
const (
|
||||
WorkspaceAgentWaiting WorkspaceAgentStatus = "waiting"
|
||||
WorkspaceAgentConnecting WorkspaceAgentStatus = "connecting"
|
||||
WorkspaceAgentConnected WorkspaceAgentStatus = "connected"
|
||||
WorkspaceAgentDisconnected WorkspaceAgentStatus = "disconnected"
|
||||
)
|
||||
|
@ -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
6
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
|
||||
|
16
go.sum
16
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=
|
||||
|
@ -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(),
|
||||
}},
|
||||
})
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user