mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
+336
@@ -0,0 +1,336 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/skratchdot/open-golang/open"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func (r *RootCmd) open() *clibase.Cmd {
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "open",
|
||||
Short: "Open a workspace",
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Children: []*clibase.Cmd{
|
||||
r.openVSCode(),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
const vscodeDesktopName = "VS Code Desktop"
|
||||
|
||||
func (r *RootCmd) openVSCode() *clibase.Cmd {
|
||||
var (
|
||||
generateToken bool
|
||||
testOpenError bool
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "vscode <workspace> [<directory in workspace>]",
|
||||
Short: fmt.Sprintf("Open a workspace in %s", vscodeDesktopName),
|
||||
Middleware: clibase.Chain(
|
||||
clibase.RequireRangeArgs(1, 2),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
// Check if we're inside a workspace, and especially inside _this_
|
||||
// workspace so we can perform path resolution/expansion. Generally,
|
||||
// we know that if we're inside a workspace, `open` can't be used.
|
||||
insideAWorkspace := inv.Environ.Get("CODER") == "true"
|
||||
inWorkspaceName := inv.Environ.Get("CODER_WORKSPACE_NAME") + "." + inv.Environ.Get("CODER_WORKSPACE_AGENT_NAME")
|
||||
|
||||
// We need a started workspace to figure out e.g. expanded directory.
|
||||
// Pehraps the vscode-coder extension could handle this by accepting
|
||||
// default_directory=true, then probing the agent. Then we wouldn't
|
||||
// need to wait for the agent to start.
|
||||
workspaceQuery := inv.Args[0]
|
||||
autostart := true
|
||||
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceQuery)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace and agent: %w", err)
|
||||
}
|
||||
|
||||
workspaceName := workspace.Name + "." + workspaceAgent.Name
|
||||
insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName
|
||||
|
||||
if !insideThisWorkspace {
|
||||
// Wait for the agent to connect, we don't care about readiness
|
||||
// otherwise (e.g. wait).
|
||||
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
|
||||
Fetch: client.WorkspaceAgent,
|
||||
FetchLogs: nil,
|
||||
Wait: false,
|
||||
})
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
return cliui.Canceled
|
||||
}
|
||||
return xerrors.Errorf("agent: %w", err)
|
||||
}
|
||||
|
||||
// The agent will report it's expanded directory before leaving
|
||||
// the created state, so we need to wait for that to happen.
|
||||
// However, if no directory is set, the expanded directory will
|
||||
// not be set either.
|
||||
if workspaceAgent.Directory != "" {
|
||||
workspace, workspaceAgent, err = waitForAgentCond(ctx, client, workspace, workspaceAgent, func(a codersdk.WorkspaceAgent) bool {
|
||||
return workspaceAgent.LifecycleState != codersdk.WorkspaceAgentLifecycleCreated
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("wait for agent: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var directory string
|
||||
if len(inv.Args) > 1 {
|
||||
directory = inv.Args[1]
|
||||
}
|
||||
directory, err = resolveAgentAbsPath(workspaceAgent.ExpandedDirectory, directory, workspaceAgent.OperatingSystem, insideThisWorkspace)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve agent path: %w", err)
|
||||
}
|
||||
|
||||
u := &url.URL{
|
||||
Scheme: "vscode",
|
||||
Host: "coder.coder-remote",
|
||||
Path: "/open",
|
||||
}
|
||||
|
||||
qp := url.Values{}
|
||||
|
||||
qp.Add("url", client.URL.String())
|
||||
qp.Add("owner", workspace.OwnerName)
|
||||
qp.Add("workspace", workspace.Name)
|
||||
qp.Add("agent", workspaceAgent.Name)
|
||||
if directory != "" {
|
||||
qp.Add("folder", directory)
|
||||
}
|
||||
|
||||
// We always set the token if we believe we can open without
|
||||
// printing the URI, otherwise the token must be explicitly
|
||||
// requested as it will be printed in plain text.
|
||||
if !insideAWorkspace || generateToken {
|
||||
// Prepare an API key. This is for automagical configuration of
|
||||
// VS Code, however, if running on a local machine we could try
|
||||
// to probe VS Code settings to see if the current configuration
|
||||
// is valid. Future improvement idea.
|
||||
apiKey, err := client.CreateAPIKey(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create API key: %w", err)
|
||||
}
|
||||
qp.Add("token", apiKey.Key)
|
||||
}
|
||||
|
||||
u.RawQuery = qp.Encode()
|
||||
|
||||
openingPath := workspaceName
|
||||
if directory != "" {
|
||||
openingPath += ":" + directory
|
||||
}
|
||||
|
||||
if insideAWorkspace {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s is not supported inside a workspace, please open the following URI on your local machine instead:\n\n", openingPath, vscodeDesktopName)
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
|
||||
return nil
|
||||
}
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s\n", openingPath, vscodeDesktopName)
|
||||
|
||||
if !testOpenError {
|
||||
err = open.Run(u.String())
|
||||
} else {
|
||||
err = xerrors.New("test.open-error")
|
||||
}
|
||||
if err != nil {
|
||||
if !generateToken {
|
||||
// This is not an important step, so we don't want
|
||||
// to block the user here.
|
||||
token := qp.Get("token")
|
||||
wait := doAsync(func() {
|
||||
// Best effort, we don't care if this fails.
|
||||
apiKeyID := strings.SplitN(token, "-", 2)[0]
|
||||
_ = client.DeleteAPIKey(ctx, codersdk.Me, apiKeyID)
|
||||
})
|
||||
defer wait()
|
||||
|
||||
qp.Del("token")
|
||||
u.RawQuery = qp.Encode()
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Could not automatically open %s in %s: %s\n", openingPath, vscodeDesktopName, err)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI instead:\n\n")
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = clibase.OptionSet{
|
||||
{
|
||||
Flag: "generate-token",
|
||||
Env: "CODER_OPEN_VSCODE_GENERATE_TOKEN",
|
||||
Description: fmt.Sprintf(
|
||||
"Generate an auth token and include it in the vscode:// URI. This is for automagical configuration of %s and not needed if already configured. "+
|
||||
"This flag does not need to be specified when running this command on a local machine unless automatic open fails.",
|
||||
vscodeDesktopName,
|
||||
),
|
||||
Value: clibase.BoolOf(&generateToken),
|
||||
},
|
||||
{
|
||||
Flag: "test.open-error",
|
||||
Description: "Don't run the open command.",
|
||||
Value: clibase.BoolOf(&testOpenError),
|
||||
Hidden: true, // This is for testing!
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// waitForAgentCond uses the watch workspace API to update the agent information
|
||||
// until the condition is met.
|
||||
func waitForAgentCond(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace, workspaceAgent codersdk.WorkspaceAgent, cond func(codersdk.WorkspaceAgent) bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
if cond(workspaceAgent) {
|
||||
return workspace, workspaceAgent, nil
|
||||
}
|
||||
|
||||
wc, err := client.WatchWorkspace(ctx, workspace.ID)
|
||||
if err != nil {
|
||||
return workspace, workspaceAgent, xerrors.Errorf("watch workspace: %w", err)
|
||||
}
|
||||
|
||||
for workspace = range wc {
|
||||
workspaceAgent, err = getWorkspaceAgent(workspace, workspaceAgent.Name)
|
||||
if err != nil {
|
||||
return workspace, workspaceAgent, xerrors.Errorf("get workspace agent: %w", err)
|
||||
}
|
||||
if cond(workspaceAgent) {
|
||||
return workspace, workspaceAgent, nil
|
||||
}
|
||||
}
|
||||
|
||||
return workspace, workspaceAgent, xerrors.New("watch workspace: unexpected closed channel")
|
||||
}
|
||||
|
||||
// isWindowsAbsPath does a simplistic check for if the path is an absolute path
|
||||
// on Windows. Drive letter or preceding `\` is interpreted as absolute.
|
||||
func isWindowsAbsPath(p string) bool {
|
||||
// Remove the drive letter, if present.
|
||||
if len(p) >= 2 && p[1] == ':' {
|
||||
p = p[2:]
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(p) == 0:
|
||||
return false
|
||||
case p[0] == '\\':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// windowsJoinPath joins the elements into a path, using Windows path separator
|
||||
// and converting forward slashes to backslashes.
|
||||
func windowsJoinPath(elem ...string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(elem...)
|
||||
}
|
||||
|
||||
var s string
|
||||
for _, e := range elem {
|
||||
e = unixToWindowsPath(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if s == "" {
|
||||
s = e
|
||||
continue
|
||||
}
|
||||
s += "\\" + strings.TrimSuffix(e, "\\")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func unixToWindowsPath(p string) string {
|
||||
return strings.ReplaceAll(p, "/", "\\")
|
||||
}
|
||||
|
||||
// resolveAgentAbsPath resolves the absolute path to a file or directory in the
|
||||
// workspace. If the path is relative, it will be resolved relative to the
|
||||
// workspace's expanded directory. If the path is absolute, it will be returned
|
||||
// as-is. If the path is relative and the workspace directory is not expanded,
|
||||
// an error will be returned.
|
||||
//
|
||||
// If the path is being resolved within the workspace, the path will be resolved
|
||||
// relative to the current working directory.
|
||||
func resolveAgentAbsPath(workingDirectory, relOrAbsPath, agentOS string, local bool) (string, error) {
|
||||
switch {
|
||||
case relOrAbsPath == "":
|
||||
return workingDirectory, nil
|
||||
|
||||
case relOrAbsPath == "~" || strings.HasPrefix(relOrAbsPath, "~/"):
|
||||
return "", xerrors.Errorf("path %q requires expansion and is not supported, use an absolute path instead", relOrAbsPath)
|
||||
|
||||
case local:
|
||||
p, err := filepath.Abs(relOrAbsPath)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("expand path: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
|
||||
case agentOS == "windows":
|
||||
relOrAbsPath = unixToWindowsPath(relOrAbsPath)
|
||||
switch {
|
||||
case workingDirectory != "" && !isWindowsAbsPath(relOrAbsPath):
|
||||
return windowsJoinPath(workingDirectory, relOrAbsPath), nil
|
||||
case isWindowsAbsPath(relOrAbsPath):
|
||||
return relOrAbsPath, nil
|
||||
default:
|
||||
return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath)
|
||||
}
|
||||
|
||||
// Note that we use `path` instead of `filepath` since we want Unix behavior.
|
||||
case workingDirectory != "" && !path.IsAbs(relOrAbsPath):
|
||||
return path.Join(workingDirectory, relOrAbsPath), nil
|
||||
case path.IsAbs(relOrAbsPath):
|
||||
return relOrAbsPath, nil
|
||||
default:
|
||||
return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath)
|
||||
}
|
||||
}
|
||||
|
||||
func doAsync(f func()) (wait func()) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
f()
|
||||
}()
|
||||
return func() {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_resolveAgentAbsPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type args struct {
|
||||
workingDirectory string
|
||||
relOrAbsPath string
|
||||
agentOS string
|
||||
local bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok no args", args{}, "", false},
|
||||
{"ok only working directory", args{workingDirectory: "/workdir"}, "/workdir", false},
|
||||
{"ok with working directory and rel path", args{workingDirectory: "/workdir", relOrAbsPath: "my/path"}, "/workdir/my/path", false},
|
||||
{"ok with working directory and abs path", args{workingDirectory: "/workdir", relOrAbsPath: "/my/path"}, "/my/path", false},
|
||||
{"ok with no working directory and abs path", args{relOrAbsPath: "/my/path"}, "/my/path", false},
|
||||
|
||||
{"fail tilde", args{relOrAbsPath: "~"}, "", true},
|
||||
{"fail tilde with working directory", args{workingDirectory: "/workdir", relOrAbsPath: "~"}, "", true},
|
||||
{"fail tilde path", args{relOrAbsPath: "~/workdir"}, "", true},
|
||||
{"fail tilde path with working directory", args{workingDirectory: "/workdir", relOrAbsPath: "~/workdir"}, "", true},
|
||||
{"fail relative dot with no working directory", args{relOrAbsPath: "."}, "", true},
|
||||
{"fail relative with no working directory", args{relOrAbsPath: "workdir"}, "", true},
|
||||
|
||||
{"ok with working directory and rel path on windows", args{workingDirectory: "C:\\workdir", relOrAbsPath: "my\\path", agentOS: "windows"}, "C:\\workdir\\my\\path", false},
|
||||
{"ok with working directory and abs path on windows", args{workingDirectory: "C:\\workdir", relOrAbsPath: "C:\\my\\path", agentOS: "windows"}, "C:\\my\\path", false},
|
||||
{"ok with no working directory and abs path on windows", args{relOrAbsPath: "C:\\my\\path", agentOS: "windows"}, "C:\\my\\path", false},
|
||||
{"ok abs unix path on windows", args{workingDirectory: "C:\\workdir", relOrAbsPath: "/my/path", agentOS: "windows"}, "\\my\\path", false},
|
||||
{"ok rel unix path on windows", args{workingDirectory: "C:\\workdir", relOrAbsPath: "my/path", agentOS: "windows"}, "C:\\workdir\\my\\path", false},
|
||||
|
||||
{"fail with no working directory and rel path on windows", args{relOrAbsPath: "my\\path", agentOS: "windows"}, "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := resolveAgentAbsPath(tt.args.workingDirectory, tt.args.relOrAbsPath, tt.args.agentOS, tt.args.local)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("resolveAgentAbsPath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveAgentAbsPath() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestOpenVSCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
agentName := "agent1"
|
||||
agentDir, err := filepath.Abs(filepath.FromSlash("/tmp"))
|
||||
require.NoError(t, err)
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent {
|
||||
agents[0].Directory = agentDir
|
||||
agents[0].Name = agentName
|
||||
agents[0].OperatingSystem = runtime.GOOS
|
||||
return agents
|
||||
})
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
insideWorkspaceEnv := map[string]string{
|
||||
"CODER": "true",
|
||||
"CODER_WORKSPACE_NAME": workspace.Name,
|
||||
"CODER_WORKSPACE_AGENT_NAME": agentName,
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
env map[string]string
|
||||
wantDir string
|
||||
wantToken bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "no args",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "nonexistent workspace",
|
||||
args: []string{"--test.open-error", workspace.Name + "bad"},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
args: []string{"--test.open-error", workspace.Name},
|
||||
wantDir: agentDir,
|
||||
},
|
||||
{
|
||||
name: "ok relative path",
|
||||
args: []string{"--test.open-error", workspace.Name, "my/relative/path"},
|
||||
wantDir: filepath.Join(agentDir, filepath.FromSlash("my/relative/path")),
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "ok with absolute path",
|
||||
args: []string{"--test.open-error", workspace.Name, agentDir},
|
||||
wantDir: agentDir,
|
||||
},
|
||||
{
|
||||
name: "ok with token",
|
||||
args: []string{"--test.open-error", workspace.Name, "--generate-token"},
|
||||
wantDir: agentDir,
|
||||
wantToken: true,
|
||||
},
|
||||
// Inside workspace, does not require --test.open-error.
|
||||
{
|
||||
name: "ok inside workspace",
|
||||
env: insideWorkspaceEnv,
|
||||
args: []string{workspace.Name},
|
||||
wantDir: agentDir,
|
||||
},
|
||||
{
|
||||
name: "ok inside workspace relative path",
|
||||
env: insideWorkspaceEnv,
|
||||
args: []string{workspace.Name, "foo"},
|
||||
wantDir: filepath.Join(wd, "foo"),
|
||||
},
|
||||
{
|
||||
name: "ok inside workspace token",
|
||||
env: insideWorkspaceEnv,
|
||||
args: []string{workspace.Name, "--generate-token"},
|
||||
wantDir: agentDir,
|
||||
wantToken: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv, root := clitest.New(t, append([]string{"open", "vscode"}, tt.args...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv = inv.WithContext(ctx)
|
||||
for k, v := range tt.env {
|
||||
inv.Environ.Set(k, v)
|
||||
}
|
||||
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
|
||||
if tt.wantError {
|
||||
w.RequireError()
|
||||
return
|
||||
}
|
||||
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
line := pty.ReadLine(ctx)
|
||||
u, err := url.ParseRequestURI(line)
|
||||
require.NoError(t, err, "line: %q", line)
|
||||
|
||||
qp := u.Query()
|
||||
assert.Equal(t, client.URL.String(), qp.Get("url"))
|
||||
assert.Equal(t, me.Username, qp.Get("owner"))
|
||||
assert.Equal(t, workspace.Name, qp.Get("workspace"))
|
||||
assert.Equal(t, agentName, qp.Get("agent"))
|
||||
if tt.wantDir != "" {
|
||||
assert.Contains(t, qp.Get("folder"), tt.wantDir)
|
||||
} else {
|
||||
assert.Empty(t, qp.Get("folder"))
|
||||
}
|
||||
if tt.wantToken {
|
||||
assert.NotEmpty(t, qp.Get("token"))
|
||||
} else {
|
||||
assert.Empty(t, qp.Get("token"))
|
||||
}
|
||||
|
||||
w.RequireSuccess()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenVSCode_NoAgentDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
agentName := "agent1"
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent {
|
||||
agents[0].Name = agentName
|
||||
agents[0].OperatingSystem = runtime.GOOS
|
||||
return agents
|
||||
})
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
insideWorkspaceEnv := map[string]string{
|
||||
"CODER": "true",
|
||||
"CODER_WORKSPACE_NAME": workspace.Name,
|
||||
"CODER_WORKSPACE_AGENT_NAME": agentName,
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
absPath := "/home/coder"
|
||||
if runtime.GOOS == "windows" {
|
||||
absPath = "C:\\home\\coder"
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
env map[string]string
|
||||
wantDir string
|
||||
wantToken bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "ok",
|
||||
args: []string{"--test.open-error", workspace.Name},
|
||||
},
|
||||
{
|
||||
name: "no agent dir error relative path",
|
||||
args: []string{"--test.open-error", workspace.Name, "my/relative/path"},
|
||||
wantDir: filepath.FromSlash("my/relative/path"),
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "ok with absolute path",
|
||||
args: []string{"--test.open-error", workspace.Name, absPath},
|
||||
wantDir: absPath,
|
||||
},
|
||||
{
|
||||
name: "ok with token",
|
||||
args: []string{"--test.open-error", workspace.Name, "--generate-token"},
|
||||
wantToken: true,
|
||||
},
|
||||
// Inside workspace, does not require --test.open-error.
|
||||
{
|
||||
name: "ok inside workspace",
|
||||
env: insideWorkspaceEnv,
|
||||
args: []string{workspace.Name},
|
||||
},
|
||||
{
|
||||
name: "ok inside workspace relative path",
|
||||
env: insideWorkspaceEnv,
|
||||
args: []string{workspace.Name, "foo"},
|
||||
wantDir: filepath.Join(wd, "foo"),
|
||||
},
|
||||
{
|
||||
name: "ok inside workspace token",
|
||||
env: insideWorkspaceEnv,
|
||||
args: []string{workspace.Name, "--generate-token"},
|
||||
wantToken: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv, root := clitest.New(t, append([]string{"open", "vscode"}, tt.args...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv = inv.WithContext(ctx)
|
||||
for k, v := range tt.env {
|
||||
inv.Environ.Set(k, v)
|
||||
}
|
||||
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
|
||||
if tt.wantError {
|
||||
w.RequireError()
|
||||
return
|
||||
}
|
||||
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
line := pty.ReadLine(ctx)
|
||||
u, err := url.ParseRequestURI(line)
|
||||
require.NoError(t, err, "line: %q", line)
|
||||
|
||||
qp := u.Query()
|
||||
assert.Equal(t, client.URL.String(), qp.Get("url"))
|
||||
assert.Equal(t, me.Username, qp.Get("owner"))
|
||||
assert.Equal(t, workspace.Name, qp.Get("workspace"))
|
||||
assert.Equal(t, agentName, qp.Get("agent"))
|
||||
if tt.wantDir != "" {
|
||||
assert.Contains(t, qp.Get("folder"), tt.wantDir)
|
||||
} else {
|
||||
assert.Empty(t, qp.Get("folder"))
|
||||
}
|
||||
if tt.wantToken {
|
||||
assert.NotEmpty(t, qp.Get("token"))
|
||||
} else {
|
||||
assert.Empty(t, qp.Get("token"))
|
||||
}
|
||||
|
||||
w.RequireSuccess()
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -101,17 +101,18 @@ func (r *RootCmd) Core() []*clibase.Cmd {
|
||||
r.create(),
|
||||
r.deleteWorkspace(),
|
||||
r.list(),
|
||||
r.open(),
|
||||
r.ping(),
|
||||
r.rename(),
|
||||
r.restart(),
|
||||
r.schedules(),
|
||||
r.show(),
|
||||
r.speedtest(),
|
||||
r.ssh(),
|
||||
r.start(),
|
||||
r.stat(),
|
||||
r.stop(),
|
||||
r.update(),
|
||||
r.restart(),
|
||||
r.stat(),
|
||||
|
||||
// Hidden
|
||||
r.gitssh(),
|
||||
|
||||
+20
-8
@@ -205,6 +205,7 @@ func (r *RootCmd) ssh() *clibase.Cmd {
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
return cliui.Canceled
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if r.disableDirect {
|
||||
@@ -593,6 +594,19 @@ func getWorkspaceAndAgent(ctx context.Context, inv *clibase.Invocation, client *
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is being deleted", workspace.Name)
|
||||
}
|
||||
|
||||
var agentName string
|
||||
if len(workspaceParts) >= 2 {
|
||||
agentName = workspaceParts[1]
|
||||
}
|
||||
workspaceAgent, err := getWorkspaceAgent(workspace, agentName)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
|
||||
return workspace, workspaceAgent, nil
|
||||
}
|
||||
|
||||
func getWorkspaceAgent(workspace codersdk.Workspace, agentName string) (workspaceAgent codersdk.WorkspaceAgent, err error) {
|
||||
resources := workspace.LatestBuild.Resources
|
||||
|
||||
agents := make([]codersdk.WorkspaceAgent, 0)
|
||||
@@ -600,33 +614,31 @@ func getWorkspaceAndAgent(ctx context.Context, inv *clibase.Invocation, client *
|
||||
agents = append(agents, resource.Agents...)
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q has no agents", workspace.Name)
|
||||
return codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q has no agents", workspace.Name)
|
||||
}
|
||||
var workspaceAgent codersdk.WorkspaceAgent
|
||||
if len(workspaceParts) >= 2 {
|
||||
if agentName != "" {
|
||||
for _, otherAgent := range agents {
|
||||
if otherAgent.Name != workspaceParts[1] {
|
||||
if otherAgent.Name != agentName {
|
||||
continue
|
||||
}
|
||||
workspaceAgent = otherAgent
|
||||
break
|
||||
}
|
||||
if workspaceAgent.ID == uuid.Nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("agent not found by name %q", workspaceParts[1])
|
||||
return codersdk.WorkspaceAgent{}, xerrors.Errorf("agent not found by name %q", agentName)
|
||||
}
|
||||
}
|
||||
if workspaceAgent.ID == uuid.Nil {
|
||||
if len(agents) > 1 {
|
||||
workspaceAgent, err = cryptorand.Element(agents)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
return codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
} else {
|
||||
workspaceAgent = agents[0]
|
||||
}
|
||||
}
|
||||
|
||||
return workspace, workspaceAgent, nil
|
||||
return workspaceAgent, nil
|
||||
}
|
||||
|
||||
// Attempt to poll workspace autostop. We write a per-workspace lockfile to
|
||||
|
||||
Vendored
+1
@@ -26,6 +26,7 @@ SUBCOMMANDS:
|
||||
login Authenticate with Coder deployment
|
||||
logout Unauthenticate your local session
|
||||
netcheck Print network debug information for DERP and STUN
|
||||
open Open a workspace
|
||||
ping Ping a workspace
|
||||
port-forward Forward ports from a workspace to the local machine. For
|
||||
reverse port forwarding, use "coder ssh -R".
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder open
|
||||
|
||||
Open a workspace
|
||||
|
||||
SUBCOMMANDS:
|
||||
vscode Open a workspace in VS Code Desktop
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder open vscode [flags] <workspace> [<directory in workspace>]
|
||||
|
||||
Open a workspace in VS Code Desktop
|
||||
|
||||
OPTIONS:
|
||||
--generate-token bool, $CODER_OPEN_VSCODE_GENERATE_TOKEN
|
||||
Generate an auth token and include it in the vscode:// URI. This is
|
||||
for automagical configuration of VS Code Desktop and not needed if
|
||||
already configured. This flag does not need to be specified when
|
||||
running this command on a local machine unless automatic open fails.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
Reference in New Issue
Block a user