Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit fb9dc4f

Browse files
kylecarbsjohnstcn
andauthored
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 <[email protected]> * Fix requested changes * Remove socat requirement * Fix resources not reading in TTY Co-authored-by: Cian Johnston <[email protected]>
1 parent 19b4323 commit fb9dc4f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+978
-316
lines changed

agent/agent.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ func (a *agent) run(ctx context.Context) {
101101

102102
func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
103103
go func() {
104+
select {
105+
case <-a.closed:
106+
_ = conn.Close()
107+
case <-conn.Closed():
108+
}
104109
<-conn.Closed()
105110
a.connCloseWait.Done()
106111
}()

agent/agent_test.go

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ package agent_test
22

33
import (
44
"context"
5+
"fmt"
6+
"io"
7+
"net"
8+
"os/exec"
59
"runtime"
10+
"strconv"
611
"strings"
712
"testing"
813

@@ -29,7 +34,8 @@ func TestAgent(t *testing.T) {
2934
t.Parallel()
3035
t.Run("SessionExec", func(t *testing.T) {
3136
t.Parallel()
32-
session := setupSSH(t)
37+
session := setupSSHSession(t)
38+
3339
command := "echo test"
3440
if runtime.GOOS == "windows" {
3541
command = "cmd.exe /c echo test"
@@ -41,7 +47,7 @@ func TestAgent(t *testing.T) {
4147

4248
t.Run("GitSSH", func(t *testing.T) {
4349
t.Parallel()
44-
session := setupSSH(t)
50+
session := setupSSHSession(t)
4551
command := "sh -c 'echo $GIT_SSH_COMMAND'"
4652
if runtime.GOOS == "windows" {
4753
command = "cmd.exe /c echo %GIT_SSH_COMMAND%"
@@ -53,7 +59,7 @@ func TestAgent(t *testing.T) {
5359

5460
t.Run("SessionTTY", func(t *testing.T) {
5561
t.Parallel()
56-
session := setupSSH(t)
62+
session := setupSSHSession(t)
5763
prompt := "$"
5864
command := "bash"
5965
if runtime.GOOS == "windows" {
@@ -76,9 +82,77 @@ func TestAgent(t *testing.T) {
7682
err = session.Wait()
7783
require.NoError(t, err)
7884
})
85+
86+
t.Run("LocalForwarding", func(t *testing.T) {
87+
t.Parallel()
88+
random, err := net.Listen("tcp", "127.0.0.1:0")
89+
require.NoError(t, err)
90+
_ = random.Close()
91+
tcpAddr, valid := random.Addr().(*net.TCPAddr)
92+
require.True(t, valid)
93+
randomPort := tcpAddr.Port
94+
95+
local, err := net.Listen("tcp", "127.0.0.1:0")
96+
require.NoError(t, err)
97+
tcpAddr, valid = local.Addr().(*net.TCPAddr)
98+
require.True(t, valid)
99+
localPort := tcpAddr.Port
100+
done := make(chan struct{})
101+
go func() {
102+
conn, err := local.Accept()
103+
require.NoError(t, err)
104+
_ = conn.Close()
105+
close(done)
106+
}()
107+
108+
err = setupSSHCommand(t, []string{"-L", fmt.Sprintf("%d:127.0.0.1:%d", randomPort, localPort)}, []string{"echo", "test"}).Start()
109+
require.NoError(t, err)
110+
111+
conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(localPort))
112+
require.NoError(t, err)
113+
conn.Close()
114+
<-done
115+
})
79116
}
80117

81-
func setupSSH(t *testing.T) *ssh.Session {
118+
func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd {
119+
agentConn := setupAgent(t)
120+
listener, err := net.Listen("tcp", "127.0.0.1:0")
121+
require.NoError(t, err)
122+
go func() {
123+
for {
124+
conn, err := listener.Accept()
125+
if err != nil {
126+
return
127+
}
128+
ssh, err := agentConn.SSH()
129+
require.NoError(t, err)
130+
go io.Copy(conn, ssh)
131+
go io.Copy(ssh, conn)
132+
}
133+
}()
134+
t.Cleanup(func() {
135+
_ = listener.Close()
136+
})
137+
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
138+
require.True(t, valid)
139+
args := append(beforeArgs,
140+
"-o", "HostName "+tcpAddr.IP.String(),
141+
"-o", "Port "+strconv.Itoa(tcpAddr.Port),
142+
"-o", "StrictHostKeyChecking=no", "host")
143+
args = append(args, afterArgs...)
144+
return exec.Command("ssh", args...)
145+
}
146+
147+
func setupSSHSession(t *testing.T) *ssh.Session {
148+
sshClient, err := setupAgent(t).SSHClient()
149+
require.NoError(t, err)
150+
session, err := sshClient.NewSession()
151+
require.NoError(t, err)
152+
return session
153+
}
154+
155+
func setupAgent(t *testing.T) *agent.Conn {
82156
client, server := provisionersdk.TransportPipe()
83157
closer := agent.New(func(ctx context.Context, opts *peer.ConnOptions) (*peerbroker.Listener, error) {
84158
return peerbroker.Listen(server, nil, opts)
@@ -100,14 +174,9 @@ func setupSSH(t *testing.T) *ssh.Session {
100174
t.Cleanup(func() {
101175
_ = conn.Close()
102176
})
103-
agentClient := &agent.Conn{
177+
178+
return &agent.Conn{
104179
Negotiator: api,
105180
Conn: conn,
106181
}
107-
sshClient, err := agentClient.SSHClient()
108-
require.NoError(t, err)
109-
session, err := sshClient.NewSession()
110-
require.NoError(t, err)
111-
112-
return session
113182
}

cli/cliui/cliui.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ var Styles = struct {
2626
Checkmark,
2727
Code,
2828
Crossmark,
29+
Error,
2930
Field,
3031
Keyword,
3132
Paragraph,
@@ -41,6 +42,7 @@ var Styles = struct {
4142
Checkmark: defaultStyles.Checkmark,
4243
Code: defaultStyles.Code,
4344
Crossmark: defaultStyles.Error.Copy().SetString("✘"),
45+
Error: defaultStyles.Error,
4446
Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}),
4547
Keyword: defaultStyles.Keyword,
4648
Paragraph: defaultStyles.Paragraph,

cli/cliui/prompt.go

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"bytes"
66
"encoding/json"
77
"fmt"
8-
"io"
98
"os"
109
"os/signal"
1110
"runtime"
@@ -45,11 +44,11 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
4544
var line string
4645
var err error
4746

48-
inFile, valid := cmd.InOrStdin().(*os.File)
49-
if opts.Secret && valid && isatty.IsTerminal(inFile.Fd()) {
47+
inFile, isInputFile := cmd.InOrStdin().(*os.File)
48+
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
5049
line, err = speakeasy.Ask("")
5150
} else {
52-
if !opts.IsConfirm && runtime.GOOS == "darwin" && valid {
51+
if !opts.IsConfirm && runtime.GOOS == "darwin" && isInputFile {
5352
var restore func()
5453
restore, err = removeLineLengthLimit(int(inFile.Fd()))
5554
if err != nil {
@@ -66,22 +65,7 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
6665
// This enables multiline JSON to be pasted into an input, and have
6766
// it parse properly.
6867
if err == nil && (strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[")) {
69-
pipeReader, pipeWriter := io.Pipe()
70-
defer pipeWriter.Close()
71-
defer pipeReader.Close()
72-
go func() {
73-
_, _ = pipeWriter.Write([]byte(line))
74-
_, _ = reader.WriteTo(pipeWriter)
75-
}()
76-
var rawMessage json.RawMessage
77-
err := json.NewDecoder(pipeReader).Decode(&rawMessage)
78-
if err == nil {
79-
var buf bytes.Buffer
80-
err = json.Compact(&buf, rawMessage)
81-
if err == nil {
82-
line = buf.String()
83-
}
84-
}
68+
line, err = promptJSON(reader, line)
8569
}
8670
}
8771
if err != nil {
@@ -118,3 +102,39 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
118102
return "", Canceled
119103
}
120104
}
105+
106+
func promptJSON(reader *bufio.Reader, line string) (string, error) {
107+
var data bytes.Buffer
108+
for {
109+
_, _ = data.WriteString(line)
110+
var rawMessage json.RawMessage
111+
err := json.Unmarshal(data.Bytes(), &rawMessage)
112+
if err != nil {
113+
if err.Error() != "unexpected end of JSON input" {
114+
// If a real syntax error occurs in JSON,
115+
// we want to return that partial line to the user.
116+
err = nil
117+
line = data.String()
118+
break
119+
}
120+
121+
// Read line-by-line. We can't use a JSON decoder
122+
// here because it doesn't work by newline, so
123+
// reads will block.
124+
line, err = reader.ReadString('\n')
125+
if err != nil {
126+
break
127+
}
128+
continue
129+
}
130+
// Compacting the JSON makes it easier for parsing and testing.
131+
rawJSON := data.Bytes()
132+
data.Reset()
133+
err = json.Compact(&data, rawJSON)
134+
if err != nil {
135+
return line, xerrors.Errorf("compact json: %w", err)
136+
}
137+
return data.String(), nil
138+
}
139+
return line, nil
140+
}

cli/cliui/resources.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package cliui
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"sort"
7+
"strconv"
8+
9+
"github.com/jedib0t/go-pretty/v6/table"
10+
11+
"github.com/coder/coder/coderd/database"
12+
"github.com/coder/coder/codersdk"
13+
)
14+
15+
type WorkspaceResourcesOptions struct {
16+
WorkspaceName string
17+
HideAgentState bool
18+
HideAccess bool
19+
Title string
20+
}
21+
22+
// WorkspaceResources displays the connection status and tree-view of provided resources.
23+
// ┌────────────────────────────────────────────────────────────────────────────┐
24+
// │ RESOURCE STATUS ACCESS │
25+
// ├────────────────────────────────────────────────────────────────────────────┤
26+
// │ google_compute_disk.root persistent │
27+
// ├────────────────────────────────────────────────────────────────────────────┤
28+
// │ google_compute_instance.dev ephemeral │
29+
// │ └─ dev (linux, amd64) ⦾ connecting [10s] coder ssh dev.dev │
30+
// ├────────────────────────────────────────────────────────────────────────────┤
31+
// │ kubernetes_pod.dev ephemeral │
32+
// │ ├─ go (linux, amd64) ⦿ connected coder ssh dev.go │
33+
// │ └─ postgres (linux, amd64) ⦾ disconnected [4s] coder ssh dev.postgres │
34+
// └────────────────────────────────────────────────────────────────────────────┘
35+
func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource, options WorkspaceResourcesOptions) error {
36+
// Sort resources by type for consistent output.
37+
sort.Slice(resources, func(i, j int) bool {
38+
return resources[i].Type < resources[j].Type
39+
})
40+
41+
// Address on stop indexes whether a resource still exists when in the stopped transition.
42+
addressOnStop := map[string]codersdk.WorkspaceResource{}
43+
for _, resource := range resources {
44+
if resource.Transition != database.WorkspaceTransitionStop {
45+
continue
46+
}
47+
addressOnStop[resource.Address] = resource
48+
}
49+
// Displayed stores whether a resource has already been shown.
50+
// Resources can be stored with numerous states, which we
51+
// process prior to display.
52+
displayed := map[string]struct{}{}
53+
54+
tableWriter := table.NewWriter()
55+
if options.Title != "" {
56+
tableWriter.SetTitle(options.Title)
57+
}
58+
tableWriter.SetStyle(table.StyleLight)
59+
tableWriter.Style().Options.SeparateColumns = false
60+
row := table.Row{"Resource", "Status"}
61+
if !options.HideAccess {
62+
row = append(row, "Access")
63+
}
64+
tableWriter.AppendHeader(row)
65+
66+
totalAgents := 0
67+
for _, resource := range resources {
68+
totalAgents += len(resource.Agents)
69+
}
70+
71+
for _, resource := range resources {
72+
if resource.Type == "random_string" {
73+
// Hide resources that aren't substantial to a user!
74+
// This is an unfortunate case, and we should allow
75+
// callers to hide resources eventually.
76+
continue
77+
}
78+
if _, shown := displayed[resource.Address]; shown {
79+
// The same resource can have multiple transitions.
80+
continue
81+
}
82+
displayed[resource.Address] = struct{}{}
83+
84+
// Sort agents by name for consistent output.
85+
sort.Slice(resource.Agents, func(i, j int) bool {
86+
return resource.Agents[i].Name < resource.Agents[j].Name
87+
})
88+
_, existsOnStop := addressOnStop[resource.Address]
89+
resourceState := "ephemeral"
90+
if existsOnStop {
91+
resourceState = "persistent"
92+
}
93+
// Display a line for the resource.
94+
tableWriter.AppendRow(table.Row{
95+
Styles.Bold.Render(resource.Type + "." + resource.Name),
96+
Styles.Placeholder.Render(resourceState),
97+
"",
98+
})
99+
// Display all agents associated with the resource.
100+
for index, agent := range resource.Agents {
101+
sshCommand := "coder ssh " + options.WorkspaceName
102+
if totalAgents > 1 {
103+
sshCommand += "." + agent.Name
104+
}
105+
sshCommand = Styles.Code.Render(sshCommand)
106+
var agentStatus string
107+
if !options.HideAgentState {
108+
switch agent.Status {
109+
case codersdk.WorkspaceAgentConnecting:
110+
since := database.Now().Sub(agent.CreatedAt)
111+
agentStatus = Styles.Warn.Render("⦾ connecting") + " " +
112+
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
113+
case codersdk.WorkspaceAgentDisconnected:
114+
since := database.Now().Sub(*agent.DisconnectedAt)
115+
agentStatus = Styles.Error.Render("⦾ disconnected") + " " +
116+
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
117+
case codersdk.WorkspaceAgentConnected:
118+
agentStatus = Styles.Keyword.Render("⦿ connected")
119+
}
120+
}
121+
122+
pipe := "├"
123+
if index == len(resource.Agents)-1 {
124+
pipe = "└"
125+
}
126+
row := table.Row{
127+
// These tree from a resource!
128+
fmt.Sprintf("%s─ %s (%s, %s)", pipe, agent.Name, agent.OperatingSystem, agent.Architecture),
129+
agentStatus,
130+
}
131+
if !options.HideAccess {
132+
row = append(row, sshCommand)
133+
}
134+
tableWriter.AppendRow(row)
135+
}
136+
tableWriter.AppendSeparator()
137+
}
138+
_, err := fmt.Fprintln(writer, tableWriter.Render())
139+
return err
140+
}

0 commit comments

Comments
 (0)