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

Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Apr 11, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/coder.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ jobs:
terraform_version: 1.1.2
terraform_wrapper: false

- name: Install socat
if: runner.os == 'Linux'
run: sudo apt-get install -y socat

- name: Test with Mock Database
shell: bash
env:
Expand Down
4 changes: 4 additions & 0 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ func (a *agent) run(ctx context.Context) {
}

func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
go func() {
<-a.closed
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels strange to be doing this in one goroutine and waiting for conn.Closed() in another goroutine, just to call Done(). Can we not do it in one?

_ = conn.Close()
}()
go func() {
<-conn.Closed()
a.connCloseWait.Done()
Expand Down
88 changes: 77 additions & 11 deletions agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ package agent_test

import (
"context"
"fmt"
"io"
"net"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"

Expand All @@ -29,7 +35,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"
Expand All @@ -41,7 +48,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%"
Expand All @@ -53,7 +60,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" {
Expand All @@ -76,9 +83,73 @@ 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)
socket := filepath.Join(t.TempDir(), "ssh")
listener, err := net.Listen("unix", socket)
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()
})
args := append(beforeArgs, "-o", "ProxyCommand socat - UNIX-CLIENT:"+socket, "-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)
Expand All @@ -100,14 +171,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
}
2 changes: 2 additions & 0 deletions cli/cliui/cliui.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ var Styles = struct {
Checkmark,
Code,
Crossmark,
Error,
Field,
Keyword,
Paragraph,
Expand All @@ -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,
Expand Down
50 changes: 32 additions & 18 deletions cli/cliui/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"runtime"
Expand Down Expand Up @@ -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 {
Expand All @@ -66,21 +65,36 @@ 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()
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" {
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.
bytes := data.Bytes()
data.Reset()
err = json.Compact(&data, bytes)
if err != nil {
break
}
line = data.String()
break
}
}
}
Expand Down
140 changes: 140 additions & 0 deletions cli/cliui/resources.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading