diff --git a/cli/open.go b/cli/open.go index d0946854ddb25..ff950b552a853 100644 --- a/cli/open.go +++ b/cli/open.go @@ -42,6 +42,7 @@ func (r *RootCmd) openVSCode() *serpent.Command { generateToken bool testOpenError bool appearanceConfig codersdk.AppearanceConfig + containerName string ) client := new(codersdk.Client) @@ -112,27 +113,48 @@ func (r *RootCmd) openVSCode() *serpent.Command { 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", - } + if containerName != "" { + containers, err := client.WorkspaceAgentListContainers(ctx, workspaceAgent.ID, map[string]string{"devcontainer.local_folder": ""}) + if err != nil { + return xerrors.Errorf("list workspace agent containers: %w", err) + } - qp := url.Values{} + var foundContainer bool - 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) + for _, container := range containers.Containers { + if container.FriendlyName != containerName { + continue + } + + foundContainer = true + + if directory == "" { + localFolder, ok := container.Labels["devcontainer.local_folder"] + if !ok { + return xerrors.New("container missing `devcontainer.local_folder` label") + } + + directory, ok = container.Volumes[localFolder] + if !ok { + return xerrors.New("container missing volume for `devcontainer.local_folder`") + } + } + + break + } + + if !foundContainer { + return xerrors.New("no container found") + } + } + + directory, err = resolveAgentAbsPath(workspaceAgent.ExpandedDirectory, directory, workspaceAgent.OperatingSystem, insideThisWorkspace) + if err != nil { + return xerrors.Errorf("resolve agent path: %w", err) } + var token string // 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. @@ -145,10 +167,31 @@ func (r *RootCmd) openVSCode() *serpent.Command { if err != nil { return xerrors.Errorf("create API key: %w", err) } - qp.Add("token", apiKey.Key) + token = apiKey.Key } - u.RawQuery = qp.Encode() + var ( + u *url.URL + qp url.Values + ) + if containerName != "" { + u, qp = buildVSCodeWorkspaceDevContainerLink( + token, + client.URL.String(), + workspace, + workspaceAgent, + containerName, + directory, + ) + } else { + u, qp = buildVSCodeWorkspaceLink( + token, + client.URL.String(), + workspace, + workspaceAgent, + directory, + ) + } openingPath := workspaceName if directory != "" { @@ -204,6 +247,13 @@ func (r *RootCmd) openVSCode() *serpent.Command { ), Value: serpent.BoolOf(&generateToken), }, + { + Flag: "container", + FlagShorthand: "c", + Description: "Container name to connect to in the workspace.", + Value: serpent.StringOf(&containerName), + Hidden: true, // Hidden until this features is at least in beta. + }, { Flag: "test.open-error", Description: "Don't run the open command.", @@ -344,6 +394,65 @@ func (r *RootCmd) openApp() *serpent.Command { return cmd } +func buildVSCodeWorkspaceLink( + token string, + clientURL string, + workspace codersdk.Workspace, + workspaceAgent codersdk.WorkspaceAgent, + directory string, +) (*url.URL, url.Values) { + qp := url.Values{} + qp.Add("url", clientURL) + qp.Add("owner", workspace.OwnerName) + qp.Add("workspace", workspace.Name) + qp.Add("agent", workspaceAgent.Name) + + if directory != "" { + qp.Add("folder", directory) + } + + if token != "" { + qp.Add("token", token) + } + + return &url.URL{ + Scheme: "vscode", + Host: "coder.coder-remote", + Path: "/open", + RawQuery: qp.Encode(), + }, qp +} + +func buildVSCodeWorkspaceDevContainerLink( + token string, + clientURL string, + workspace codersdk.Workspace, + workspaceAgent codersdk.WorkspaceAgent, + containerName string, + containerFolder string, +) (*url.URL, url.Values) { + containerFolder = filepath.ToSlash(containerFolder) + + qp := url.Values{} + qp.Add("url", clientURL) + qp.Add("owner", workspace.OwnerName) + qp.Add("workspace", workspace.Name) + qp.Add("agent", workspaceAgent.Name) + qp.Add("devContainerName", containerName) + qp.Add("devContainerFolder", containerFolder) + + if token != "" { + qp.Add("token", token) + } + + return &url.URL{ + Scheme: "vscode", + Host: "coder.coder-remote", + Path: "/openDevContainer", + RawQuery: qp.Encode(), + }, qp +} + // 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) { diff --git a/cli/open_test.go b/cli/open_test.go index e36d20a59aaf4..f0183022782d9 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -8,12 +8,17 @@ import ( "strings" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agentcontainers/acmock" "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/coderd/database/dbtime" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/pty/ptytest" @@ -285,6 +290,343 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) { } } +func TestOpenVSCodeDevContainer(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skip("DevContainers are only supported for agents on Linux") + } + + agentName := "agent1" + agentDir, err := filepath.Abs(filepath.FromSlash("/tmp")) + require.NoError(t, err) + + containerName := testutil.GetRandomName(t) + containerFolder := "/workspace/coder" + + ctrl := gomock.NewController(t) + mcl := acmock.NewMockLister(ctrl) + mcl.EXPECT().List(gomock.Any()).Return( + codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{ + { + ID: uuid.NewString(), + CreatedAt: dbtime.Now(), + FriendlyName: containerName, + Image: "busybox:latest", + Labels: map[string]string{ + "devcontainer.local_folder": "/home/coder/coder", + }, + Running: true, + Status: "running", + Volumes: map[string]string{ + "/home/coder/coder": containerFolder, + }, + }, + }, + }, nil, + ) + + 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, func(o *agent.Options) { + o.ContainerLister = mcl + }) + _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + + 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 + env map[string]string + args []string + wantDir string + wantError bool + wantToken bool + }{ + { + name: "nonexistent container", + args: []string{"--test.open-error", workspace.Name, "--container", containerName + "bad"}, + wantError: true, + }, + { + name: "ok", + args: []string{"--test.open-error", workspace.Name, "--container", containerName}, + wantDir: containerFolder, + wantError: false, + }, + { + name: "ok with absolute path", + args: []string{"--test.open-error", workspace.Name, "--container", containerName, containerFolder}, + wantDir: containerFolder, + wantError: false, + }, + { + name: "ok with relative path", + args: []string{"--test.open-error", workspace.Name, "--container", containerName, "my/relative/path"}, + wantDir: filepath.Join(agentDir, filepath.FromSlash("my/relative/path")), + wantError: false, + }, + { + name: "ok with token", + args: []string{"--test.open-error", workspace.Name, "--container", containerName, "--generate-token"}, + wantDir: containerFolder, + wantError: false, + wantToken: true, + }, + // Inside workspace, does not require --test.open-error + { + name: "ok inside workspace", + env: insideWorkspaceEnv, + args: []string{workspace.Name, "--container", containerName}, + wantDir: containerFolder, + }, + { + name: "ok inside workspace relative path", + env: insideWorkspaceEnv, + args: []string{workspace.Name, "--container", containerName, "foo"}, + wantDir: filepath.Join(wd, "foo"), + }, + { + name: "ok inside workspace token", + env: insideWorkspaceEnv, + args: []string{workspace.Name, "--container", containerName, "--generate-token"}, + wantDir: containerFolder, + 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")) + assert.Equal(t, containerName, qp.Get("devContainerName")) + + if tt.wantDir != "" { + assert.Equal(t, tt.wantDir, qp.Get("devContainerFolder")) + } else { + assert.Equal(t, containerFolder, qp.Get("devContainerFolder")) + } + + if tt.wantToken { + assert.NotEmpty(t, qp.Get("token")) + } else { + assert.Empty(t, qp.Get("token")) + } + + w.RequireSuccess() + }) + } +} + +func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skip("DevContainers are only supported for agents on Linux") + } + + agentName := "agent1" + + containerName := testutil.GetRandomName(t) + containerFolder := "/workspace/coder" + + ctrl := gomock.NewController(t) + mcl := acmock.NewMockLister(ctrl) + mcl.EXPECT().List(gomock.Any()).Return( + codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{ + { + ID: uuid.NewString(), + CreatedAt: dbtime.Now(), + FriendlyName: containerName, + Image: "busybox:latest", + Labels: map[string]string{ + "devcontainer.local_folder": "/home/coder/coder", + }, + Running: true, + Status: "running", + Volumes: map[string]string{ + "/home/coder/coder": containerFolder, + }, + }, + }, + }, nil, + ) + + 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, func(o *agent.Options) { + o.ContainerLister = mcl + }) + _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + + 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 + env map[string]string + args []string + wantDir string + wantError bool + wantToken bool + }{ + { + name: "ok", + args: []string{"--test.open-error", workspace.Name, "--container", containerName}, + }, + { + name: "no agent dir error relative path", + args: []string{"--test.open-error", workspace.Name, "--container", containerName, "my/relative/path"}, + wantDir: filepath.FromSlash("my/relative/path"), + wantError: true, + }, + { + name: "ok with absolute path", + args: []string{"--test.open-error", workspace.Name, "--container", containerName, "/home/coder"}, + wantDir: "/home/coder", + }, + { + name: "ok with token", + args: []string{"--test.open-error", workspace.Name, "--container", containerName, "--generate-token"}, + wantToken: true, + }, + // Inside workspace, does not require --test.open-error + { + name: "ok inside workspace", + env: insideWorkspaceEnv, + args: []string{workspace.Name, "--container", containerName}, + }, + { + name: "ok inside workspace relative path", + env: insideWorkspaceEnv, + args: []string{workspace.Name, "--container", containerName, "foo"}, + wantDir: filepath.Join(wd, "foo"), + }, + { + name: "ok inside workspace token", + env: insideWorkspaceEnv, + args: []string{workspace.Name, "--container", containerName, "--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")) + assert.Equal(t, containerName, qp.Get("devContainerName")) + + if tt.wantDir != "" { + assert.Equal(t, tt.wantDir, qp.Get("devContainerFolder")) + } else { + assert.Equal(t, containerFolder, qp.Get("devContainerFolder")) + } + + if tt.wantToken { + assert.NotEmpty(t, qp.Get("token")) + } else { + assert.Empty(t, qp.Get("token")) + } + + w.RequireSuccess() + }) + } +} + func TestOpenApp(t *testing.T) { t.Parallel()
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: