From 4ae42b5dcc439b35c89283a1ec9d93f816dc9ee4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 7 Jul 2025 12:00:24 +0000 Subject: [PATCH 1/4] export devcontainercli method options --- agent/agentcontainers/devcontainercli.go | 64 ++++++++++++------------ 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 55e4708d46134..d7cd25f85a7b3 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -106,63 +106,63 @@ type DevcontainerCLI interface { // DevcontainerCLIUpOptions are options for the devcontainer CLI Up // command. -type DevcontainerCLIUpOptions func(*devcontainerCLIUpConfig) +type DevcontainerCLIUpOptions func(*DevcontainerCLIUpConfig) -type devcontainerCLIUpConfig struct { - args []string // Additional arguments for the Up command. - stdout io.Writer - stderr io.Writer +type DevcontainerCLIUpConfig struct { + Args []string // Additional arguments for the Up command. + Stdout io.Writer + Stderr io.Writer } // WithRemoveExistingContainer is an option to remove the existing // container. func WithRemoveExistingContainer() DevcontainerCLIUpOptions { - return func(o *devcontainerCLIUpConfig) { - o.args = append(o.args, "--remove-existing-container") + return func(o *DevcontainerCLIUpConfig) { + o.Args = append(o.Args, "--remove-existing-container") } } // WithUpOutput sets additional stdout and stderr writers for logs // during Up operations. func WithUpOutput(stdout, stderr io.Writer) DevcontainerCLIUpOptions { - return func(o *devcontainerCLIUpConfig) { - o.stdout = stdout - o.stderr = stderr + return func(o *DevcontainerCLIUpConfig) { + o.Stdout = stdout + o.Stderr = stderr } } // DevcontainerCLIExecOptions are options for the devcontainer CLI Exec // command. -type DevcontainerCLIExecOptions func(*devcontainerCLIExecConfig) +type DevcontainerCLIExecOptions func(*DevcontainerCLIExecConfig) -type devcontainerCLIExecConfig struct { - args []string // Additional arguments for the Exec command. - stdout io.Writer - stderr io.Writer +type DevcontainerCLIExecConfig struct { + Args []string // Additional arguments for the Exec command. + Stdout io.Writer + Stderr io.Writer } // WithExecOutput sets additional stdout and stderr writers for logs // during Exec operations. func WithExecOutput(stdout, stderr io.Writer) DevcontainerCLIExecOptions { - return func(o *devcontainerCLIExecConfig) { - o.stdout = stdout - o.stderr = stderr + return func(o *DevcontainerCLIExecConfig) { + o.Stdout = stdout + o.Stderr = stderr } } // WithExecContainerID sets the container ID to target a specific // container. func WithExecContainerID(id string) DevcontainerCLIExecOptions { - return func(o *devcontainerCLIExecConfig) { - o.args = append(o.args, "--container-id", id) + return func(o *DevcontainerCLIExecConfig) { + o.Args = append(o.Args, "--container-id", id) } } // WithRemoteEnv sets environment variables for the Exec command. func WithRemoteEnv(env ...string) DevcontainerCLIExecOptions { - return func(o *devcontainerCLIExecConfig) { + return func(o *DevcontainerCLIExecConfig) { for _, e := range env { - o.args = append(o.args, "--remote-env", e) + o.Args = append(o.Args, "--remote-env", e) } } } @@ -185,8 +185,8 @@ func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOpt } } -func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig { - conf := devcontainerCLIUpConfig{stdout: io.Discard, stderr: io.Discard} +func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) DevcontainerCLIUpConfig { + conf := DevcontainerCLIUpConfig{Stdout: io.Discard, Stderr: io.Discard} for _, opt := range opts { if opt != nil { opt(&conf) @@ -195,8 +195,8 @@ func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainer return conf } -func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devcontainerCLIExecConfig { - conf := devcontainerCLIExecConfig{stdout: io.Discard, stderr: io.Discard} +func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) DevcontainerCLIExecConfig { + conf := DevcontainerCLIExecConfig{Stdout: io.Discard, Stderr: io.Discard} for _, opt := range opts { if opt != nil { opt(&conf) @@ -241,7 +241,7 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st if configPath != "" { args = append(args, "--config", configPath) } - args = append(args, conf.args...) + args = append(args, conf.Args...) cmd := d.execer.CommandContext(ctx, "devcontainer", args...) // Capture stdout for parsing and stream logs for both default and provided writers. @@ -251,14 +251,14 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st &devcontainerCLILogWriter{ ctx: ctx, logger: logger.With(slog.F("stdout", true)), - writer: conf.stdout, + writer: conf.Stdout, }, ) // Stream stderr logs and provided writer if any. cmd.Stderr = &devcontainerCLILogWriter{ ctx: ctx, logger: logger.With(slog.F("stderr", true)), - writer: conf.stderr, + writer: conf.Stderr, } if err := cmd.Run(); err != nil { @@ -293,17 +293,17 @@ func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath if configPath != "" { args = append(args, "--config", configPath) } - args = append(args, conf.args...) + args = append(args, conf.Args...) args = append(args, cmd) args = append(args, cmdArgs...) c := d.execer.CommandContext(ctx, "devcontainer", args...) - c.Stdout = io.MultiWriter(conf.stdout, &devcontainerCLILogWriter{ + c.Stdout = io.MultiWriter(conf.Stdout, &devcontainerCLILogWriter{ ctx: ctx, logger: logger.With(slog.F("stdout", true)), writer: io.Discard, }) - c.Stderr = io.MultiWriter(conf.stderr, &devcontainerCLILogWriter{ + c.Stderr = io.MultiWriter(conf.Stderr, &devcontainerCLILogWriter{ ctx: ctx, logger: logger.With(slog.F("stderr", true)), writer: io.Discard, From 627957c63def272cfb7708436fc3e71a8b6593ab Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 7 Jul 2025 12:01:44 +0000 Subject: [PATCH 2/4] cli: return other agents from getWorkspaceAndAgent --- cli/exp_rpty.go | 2 +- cli/open.go | 6 ++--- cli/ping.go | 2 +- cli/portforward.go | 2 +- cli/speedtest.go | 2 +- cli/ssh.go | 53 +++++++++++++++++++++------------------- cli/ssh_internal_test.go | 15 +++++++----- cli/vscodessh.go | 2 +- 8 files changed, 45 insertions(+), 39 deletions(-) diff --git a/cli/exp_rpty.go b/cli/exp_rpty.go index 48074c7ef5fb9..70154c57ea9bc 100644 --- a/cli/exp_rpty.go +++ b/cli/exp_rpty.go @@ -97,7 +97,7 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT reconnectID = uuid.New() } - ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace) + ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace) if err != nil { return err } diff --git a/cli/open.go b/cli/open.go index ff950b552a853..c8401f18490a2 100644 --- a/cli/open.go +++ b/cli/open.go @@ -71,7 +71,7 @@ func (r *RootCmd) openVSCode() *serpent.Command { // need to wait for the agent to start. workspaceQuery := inv.Args[0] autostart := true - workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery) + workspace, workspaceAgent, otherWorkspaceAgents, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery) if err != nil { return xerrors.Errorf("get workspace and agent: %w", err) } @@ -288,7 +288,7 @@ func (r *RootCmd) openApp() *serpent.Command { } workspaceName := inv.Args[0] - ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName) + ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName) if err != nil { var sdkErr *codersdk.Error if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { @@ -469,7 +469,7 @@ func waitForAgentCond(ctx context.Context, client *codersdk.Client, workspace co } for workspace = range wc { - workspaceAgent, err = getWorkspaceAgent(workspace, workspaceAgent.Name) + workspaceAgent, _, err = getWorkspaceAgent(workspace, workspaceAgent.Name) if err != nil { return workspace, workspaceAgent, xerrors.Errorf("get workspace agent: %w", err) } diff --git a/cli/ping.go b/cli/ping.go index ec094ea1a317b..0836aa8a135db 100644 --- a/cli/ping.go +++ b/cli/ping.go @@ -110,7 +110,7 @@ func (r *RootCmd) ping() *serpent.Command { defer notifyCancel() workspaceName := inv.Args[0] - _, workspaceAgent, err := getWorkspaceAndAgent( + _, workspaceAgent, _, err := getWorkspaceAndAgent( ctx, inv, client, false, // Do not autostart for a ping. workspaceName, diff --git a/cli/portforward.go b/cli/portforward.go index e6ef2eb11bca8..7a7723213f760 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -84,7 +84,7 @@ func (r *RootCmd) portForward() *serpent.Command { return xerrors.New("no port-forwards requested") } - workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0]) + workspace, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0]) if err != nil { return err } diff --git a/cli/speedtest.go b/cli/speedtest.go index 0d9f839d6b458..08112f50cce2c 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -83,7 +83,7 @@ func (r *RootCmd) speedtest() *serpent.Command { return xerrors.Errorf("--direct (-d) is incompatible with --%s", varDisableDirect) } - _, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0]) + _, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0]) if err != nil { return err } diff --git a/cli/ssh.go b/cli/ssh.go index 56ab0b2a0d3af..9327a0101c0cf 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -754,7 +754,8 @@ func findWorkspaceAndAgentByHostname( hostname = strings.TrimSuffix(hostname, qualifiedSuffix) } hostname = normalizeWorkspaceInput(hostname) - return getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname) + ws, agent, _, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname) + return ws, agent, err } // watchAndClose ensures closer is called if the context is canceled or @@ -827,9 +828,10 @@ startWatchLoop: } // getWorkspaceAgent returns the workspace and agent selected using either the -// `[.]` syntax via `in`. +// `[.]` syntax via `in`. It will also return any other agents +// in the workspace as a slice for use in child->parent lookups. // If autoStart is true, the workspace will be started if it is not already running. -func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, input string) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive +func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, input string) (codersdk.Workspace, codersdk.WorkspaceAgent, []codersdk.WorkspaceAgent, error) { //nolint:revive var ( workspace codersdk.Workspace // The input will be `owner/name.agent` @@ -840,27 +842,27 @@ func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client * workspace, err = namedWorkspace(ctx, client, workspaceParts[0]) if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, err } if workspace.LatestBuild.Transition != codersdk.WorkspaceTransitionStart { if !autostart { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("workspace must be started") + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.New("workspace must be started") } // Autostart the workspace for the user. // For some failure modes, return a better message. if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionDelete { // Any sort of deleting status, we should reject with a nicer error. - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is deleted", workspace.Name) + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("workspace %q is deleted", workspace.Name) } if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobFailed { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("workspace %q is in failed state, unable to autostart the workspace", workspace.Name) } // The workspace needs to be stopped before we can start it. // It cannot be in any pending or failed state. if workspace.LatestBuild.Status != codersdk.WorkspaceStatusStopped { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("workspace must be started; was unable to autostart as the last build job is %q, expected %q", workspace.LatestBuild.Status, codersdk.WorkspaceStatusStopped, @@ -881,48 +883,48 @@ func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client * case http.StatusForbidden: _, err = startWorkspace(inv, client, workspace, workspaceParameterFlags{}, buildFlags{}, WorkspaceUpdate) if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("start workspace with active template version: %w", err) + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with active template version: %w", err) } _, _ = fmt.Fprintln(inv.Stdout, "Unable to start the workspace with template version from last build. Your workspace has been updated to the current active template version.") } } else if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("start workspace with current template version: %w", err) + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with current template version: %w", err) } // Refresh workspace state so that `outdated`, `build`,`template_*` fields are up-to-date. workspace, err = namedWorkspace(ctx, client, workspaceParts[0]) if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, err } } if workspace.LatestBuild.Job.CompletedAt == nil { err := cliui.WorkspaceBuild(ctx, inv.Stderr, client, workspace.LatestBuild.ID) if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, err } // Fetch up-to-date build information after completion. workspace.LatestBuild, err = client.WorkspaceBuild(ctx, workspace.LatestBuild.ID) if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, err } } if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionDelete { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is being deleted", workspace.Name) + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("workspace %q is being deleted", workspace.Name) } var agentName string if len(workspaceParts) >= 2 { agentName = workspaceParts[1] } - workspaceAgent, err := getWorkspaceAgent(workspace, agentName) + workspaceAgent, otherWorkspaceAgents, err := getWorkspaceAgent(workspace, agentName) if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, err } - return workspace, workspaceAgent, nil + return workspace, workspaceAgent, otherWorkspaceAgents, nil } -func getWorkspaceAgent(workspace codersdk.Workspace, agentName string) (workspaceAgent codersdk.WorkspaceAgent, err error) { +func getWorkspaceAgent(workspace codersdk.Workspace, agentName string) (workspaceAgent codersdk.WorkspaceAgent, otherAgents []codersdk.WorkspaceAgent, err error) { resources := workspace.LatestBuild.Resources var ( @@ -936,22 +938,23 @@ func getWorkspaceAgent(workspace codersdk.Workspace, agentName string) (workspac } } if len(agents) == 0 { - return codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q has no agents", workspace.Name) + return codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("workspace %q has no agents", workspace.Name) } slices.Sort(availableNames) if agentName != "" { - for _, otherAgent := range agents { - if otherAgent.Name != agentName { + for i, agent := range agents { + if agent.Name != agentName || agent.ID.String() == agentName { continue } - return otherAgent, nil + otherAgents := slices.Delete(agents, i, i+1) + return agent, otherAgents, nil } - return codersdk.WorkspaceAgent{}, xerrors.Errorf("agent not found by name %q, available agents: %v", agentName, availableNames) + return codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("agent not found by name %q, available agents: %v", agentName, availableNames) } if len(agents) == 1 { - return agents[0], nil + return agents[0], nil, nil } - return codersdk.WorkspaceAgent{}, xerrors.Errorf("multiple agents found, please specify the agent name, available agents: %v", availableNames) + return codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("multiple agents found, please specify the agent name, available agents: %v", availableNames) } // Attempt to poll workspace autostop. We write a per-workspace lockfile to diff --git a/cli/ssh_internal_test.go b/cli/ssh_internal_test.go index 0d956def68938..a7fac11c7254c 100644 --- a/cli/ssh_internal_test.go +++ b/cli/ssh_internal_test.go @@ -376,7 +376,7 @@ func Test_getWorkspaceAgent(t *testing.T) { agent := createAgent("main") workspace := createWorkspaceWithAgents([]codersdk.WorkspaceAgent{agent}) - result, err := getWorkspaceAgent(workspace, "") + result, _, err := getWorkspaceAgent(workspace, "") require.NoError(t, err) assert.Equal(t, agent.ID, result.ID) assert.Equal(t, "main", result.Name) @@ -388,7 +388,7 @@ func Test_getWorkspaceAgent(t *testing.T) { agent2 := createAgent("main2") workspace := createWorkspaceWithAgents([]codersdk.WorkspaceAgent{agent1, agent2}) - _, err := getWorkspaceAgent(workspace, "") + _, _, err := getWorkspaceAgent(workspace, "") require.Error(t, err) assert.Contains(t, err.Error(), "multiple agents found") assert.Contains(t, err.Error(), "available agents: [main1 main2]") @@ -400,10 +400,13 @@ func Test_getWorkspaceAgent(t *testing.T) { agent2 := createAgent("main2") workspace := createWorkspaceWithAgents([]codersdk.WorkspaceAgent{agent1, agent2}) - result, err := getWorkspaceAgent(workspace, "main1") + result, other, err := getWorkspaceAgent(workspace, "main1") require.NoError(t, err) assert.Equal(t, agent1.ID, result.ID) assert.Equal(t, "main1", result.Name) + assert.Len(t, other, 1) + assert.Equal(t, agent2.ID, other[0].ID) + assert.Equal(t, "main2", other[0].Name) }) t.Run("AgentNameSpecified_NotFound", func(t *testing.T) { @@ -412,7 +415,7 @@ func Test_getWorkspaceAgent(t *testing.T) { agent2 := createAgent("main2") workspace := createWorkspaceWithAgents([]codersdk.WorkspaceAgent{agent1, agent2}) - _, err := getWorkspaceAgent(workspace, "nonexistent") + _, _, err := getWorkspaceAgent(workspace, "nonexistent") require.Error(t, err) assert.Contains(t, err.Error(), `agent not found by name "nonexistent"`) assert.Contains(t, err.Error(), "available agents: [main1 main2]") @@ -422,7 +425,7 @@ func Test_getWorkspaceAgent(t *testing.T) { t.Parallel() workspace := createWorkspaceWithAgents([]codersdk.WorkspaceAgent{}) - _, err := getWorkspaceAgent(workspace, "") + _, _, err := getWorkspaceAgent(workspace, "") require.Error(t, err) assert.Contains(t, err.Error(), `workspace "test-workspace" has no agents`) }) @@ -435,7 +438,7 @@ func Test_getWorkspaceAgent(t *testing.T) { agent3 := createAgent("krypton") workspace := createWorkspaceWithAgents([]codersdk.WorkspaceAgent{agent2, agent1, agent3}) - _, err := getWorkspaceAgent(workspace, "nonexistent") + _, _, err := getWorkspaceAgent(workspace, "nonexistent") require.Error(t, err) // Available agents should be sorted alphabetically. assert.Contains(t, err.Error(), "available agents: [clark krypton zod]") diff --git a/cli/vscodessh.go b/cli/vscodessh.go index 872f7d837c0cd..e0b963b7ed80d 100644 --- a/cli/vscodessh.go +++ b/cli/vscodessh.go @@ -102,7 +102,7 @@ func (r *RootCmd) vscodeSSH() *serpent.Command { // will call this command after the workspace is started. autostart := false - workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name)) + workspace, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name)) if err != nil { return xerrors.Errorf("find workspace and agent: %w", err) } From 6c3d31d2c0e45fce6fb2827095cdf94610ece216 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 7 Jul 2025 12:03:40 +0000 Subject: [PATCH 3/4] cli: replace open vscode container with devcontainer sub agent --- cli/open.go | 119 ++++++++++------- cli/open_test.go | 330 ++++++++++++++++++----------------------------- 2 files changed, 197 insertions(+), 252 deletions(-) diff --git a/cli/open.go b/cli/open.go index c8401f18490a2..a72d6088a92ce 100644 --- a/cli/open.go +++ b/cli/open.go @@ -11,7 +11,9 @@ import ( "runtime" "slices" "strings" + "time" + "github.com/google/uuid" "github.com/skratchdot/open-golang/open" "golang.org/x/xerrors" @@ -42,7 +44,6 @@ func (r *RootCmd) openVSCode() *serpent.Command { generateToken bool testOpenError bool appearanceConfig codersdk.AppearanceConfig - containerName string ) client := new(codersdk.Client) @@ -79,6 +80,61 @@ func (r *RootCmd) openVSCode() *serpent.Command { workspaceName := workspace.Name + "." + workspaceAgent.Name insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName + var parentWorkspaceAgent codersdk.WorkspaceAgent + var devcontainer codersdk.WorkspaceAgentDevcontainer + if workspaceAgent.ParentID.Valid { + // This is likely a devcontainer agent, so we need to find the + // parent workspace agent as well as the devcontainer. + for _, otherAgent := range otherWorkspaceAgents { + if otherAgent.ID == workspaceAgent.ParentID.UUID { + parentWorkspaceAgent = otherAgent + break + } + } + if parentWorkspaceAgent.ID == uuid.Nil { + return xerrors.Errorf("parent workspace agent %s not found", workspaceAgent.ParentID.UUID) + } + + printedWaiting := false + for { + resp, err := client.WorkspaceAgentListContainers(ctx, parentWorkspaceAgent.ID, nil) + if err != nil { + return xerrors.Errorf("list parent workspace agent containers: %w", err) + } + + for _, dc := range resp.Devcontainers { + if dc.Agent.ID == workspaceAgent.ID { + devcontainer = dc + break + } + } + if devcontainer.ID == uuid.Nil { + cliui.Warnf(inv.Stderr, "Devcontainer for agent %q not found, opening as a regular workspace", workspaceAgent.Name) + parentWorkspaceAgent = codersdk.WorkspaceAgent{} // Reset to empty, so we don't use it later. + break + } + + // Precondition, the devcontainer must be running to enter + // it. Once running, devcontainer.Container will be set. + if devcontainer.Status == codersdk.WorkspaceAgentDevcontainerStatusRunning { + break + } + if devcontainer.Status != codersdk.WorkspaceAgentDevcontainerStatusStarting { + return xerrors.Errorf("devcontainer %q is in unexpected status %q, expected %q or %q", + devcontainer.Name, devcontainer.Status, + codersdk.WorkspaceAgentDevcontainerStatusRunning, + codersdk.WorkspaceAgentDevcontainerStatusStarting, + ) + } + + if !printedWaiting { + _, _ = fmt.Fprintf(inv.Stderr, "Waiting for devcontainer %q status to change from %q to %q...\n", devcontainer.Name, devcontainer.Status, codersdk.WorkspaceAgentDevcontainerStatusRunning) + printedWaiting = true + } + time.Sleep(5 * time.Second) // Wait a bit before retrying. + } + } + if !insideThisWorkspace { // Wait for the agent to connect, we don't care about readiness // otherwise (e.g. wait). @@ -99,6 +155,9 @@ func (r *RootCmd) openVSCode() *serpent.Command { // 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. + // + // Note that this is irrelevant for devcontainer sub agents, as + // they always have a directory set. if workspaceAgent.Directory != "" { workspace, workspaceAgent, err = waitForAgentCond(ctx, client, workspace, workspaceAgent, func(_ codersdk.WorkspaceAgent) bool { return workspaceAgent.LifecycleState != codersdk.WorkspaceAgentLifecycleCreated @@ -114,41 +173,6 @@ func (r *RootCmd) openVSCode() *serpent.Command { directory = inv.Args[1] } - 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) - } - - var foundContainer bool - - 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) @@ -174,14 +198,16 @@ func (r *RootCmd) openVSCode() *serpent.Command { u *url.URL qp url.Values ) - if containerName != "" { + if devcontainer.ID != uuid.Nil { u, qp = buildVSCodeWorkspaceDevContainerLink( token, client.URL.String(), workspace, - workspaceAgent, - containerName, + parentWorkspaceAgent, + devcontainer.Container.FriendlyName, directory, + devcontainer.WorkspaceFolder, + devcontainer.ConfigPath, ) } else { u, qp = buildVSCodeWorkspaceLink( @@ -247,13 +273,6 @@ 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.", @@ -430,8 +449,14 @@ func buildVSCodeWorkspaceDevContainerLink( workspaceAgent codersdk.WorkspaceAgent, containerName string, containerFolder string, + localWorkspaceFolder string, + localConfigFile string, ) (*url.URL, url.Values) { containerFolder = filepath.ToSlash(containerFolder) + localWorkspaceFolder = filepath.ToSlash(localWorkspaceFolder) + if localConfigFile != "" { + localConfigFile = filepath.ToSlash(localConfigFile) + } qp := url.Values{} qp.Add("url", clientURL) @@ -440,6 +465,8 @@ func buildVSCodeWorkspaceDevContainerLink( qp.Add("agent", workspaceAgent.Name) qp.Add("devContainerName", containerName) qp.Add("devContainerFolder", containerFolder) + qp.Add("localWorkspaceFolder", localWorkspaceFolder) + qp.Add("localConfigFile", localConfigFile) if token != "" { qp.Add("token", token) diff --git a/cli/open_test.go b/cli/open_test.go index b76b603d35b1e..e8d4aa3e65b2e 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -1,8 +1,10 @@ package cli_test import ( + "context" "net/url" "os" + "path" "path/filepath" "runtime" "strings" @@ -11,11 +13,11 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" + "golang.org/x/xerrors" "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agentcontainers" - "github.com/coder/coder/v2/agent/agentcontainers/acmock" + "github.com/coder/coder/v2/agent/agentcontainers/watcher" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" @@ -289,238 +291,145 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) { } } -func TestOpenVSCodeDevContainer(t *testing.T) { - t.Parallel() +type fakeContainerCLI struct { + resp codersdk.WorkspaceAgentListContainersResponse +} - if runtime.GOOS != "linux" { - t.Skip("DevContainers are only supported for agents on Linux") - } +func (f *fakeContainerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { + return f.resp, nil +} - agentName := "agent1" - agentDir, err := filepath.Abs(filepath.FromSlash("/tmp")) - require.NoError(t, err) +func (*fakeContainerCLI) DetectArchitecture(ctx context.Context, containerID string) (string, error) { + return runtime.GOARCH, nil +} - containerName := testutil.GetRandomName(t) - containerFolder := "/workspace/coder" +func (*fakeContainerCLI) Copy(ctx context.Context, containerID, src, dst string) error { + return nil +} - ctrl := gomock.NewController(t) - mccli := acmock.NewMockContainerCLI(ctrl) - mccli.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, - ).AnyTimes() +func (*fakeContainerCLI) ExecAs(ctx context.Context, containerID, user string, args ...string) ([]byte, error) { + return nil, 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 - }) +type fakeDevcontainerCLI struct { + config agentcontainers.DevcontainerConfig + execAgent func(ctx context.Context, token string) error +} - _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.Devcontainers = true - o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, - agentcontainers.WithContainerCLI(mccli), - agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), - ) - }) - _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() +func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configFile string, env []string, opts ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) { + return f.config, nil +} - insideWorkspaceEnv := map[string]string{ - "CODER": "true", - "CODER_WORKSPACE_NAME": workspace.Name, - "CODER_WORKSPACE_AGENT_NAME": agentName, +func (f *fakeDevcontainerCLI) Exec(ctx context.Context, workspaceFolder, configFile string, name string, args []string, opts ...agentcontainers.DevcontainerCLIExecOptions) error { + var opt agentcontainers.DevcontainerCLIExecConfig + for _, o := range opts { + o(&opt) } - - 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, - }, + var token string + for _, arg := range opt.Args { + if strings.HasPrefix(arg, "CODER_AGENT_TOKEN=") { + token = strings.TrimPrefix(arg, "CODER_AGENT_TOKEN=") + break + } } - - for _, tt := range tests { - 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() - }) + if token == "" { + return xerrors.New("no agent token provided in args") + } + if f.execAgent == nil { + return nil } + return f.execAgent(ctx, token) +} + +func (*fakeDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configFile string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { + return "", nil } -func TestOpenVSCodeDevContainer_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" + parentAgentName := "agent1" + devcontainerID := uuid.New() + devcontainerName := "wilson" + workspaceFolder := "/home/coder/wilson" + configFile := path.Join(workspaceFolder, ".devcontainer", "devcontainer.json") + + containerID := uuid.NewString() containerName := testutil.GetRandomName(t) - containerFolder := "/workspace/coder" + containerFolder := "/workspaces/wilson" + + client, workspace, agentToken := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent { + agents[0].Name = parentAgentName + agents[0].OperatingSystem = runtime.GOOS + return agents + }) - ctrl := gomock.NewController(t) - mccli := acmock.NewMockContainerCLI(ctrl) - mccli.EXPECT().List(gomock.Any()).Return( - codersdk.WorkspaceAgentListContainersResponse{ + fCCLI := &fakeContainerCLI{ + resp: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { - ID: uuid.NewString(), + ID: containerID, CreatedAt: dbtime.Now(), FriendlyName: containerName, Image: "busybox:latest", Labels: map[string]string{ - "devcontainer.local_folder": "/home/coder/coder", + agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder, + agentcontainers.DevcontainerConfigFileLabel: configFile, + agentcontainers.DevcontainerIsTestRunLabel: "true", + "coder.test": t.Name(), }, Running: true, Status: "running", - Volumes: map[string]string{ - "/home/coder/coder": containerFolder, - }, }, }, - }, nil, - ).AnyTimes() - - client, workspace, agentToken := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent { - agents[0].Name = agentName - agents[0].OperatingSystem = runtime.GOOS - return agents - }) + }, + } + fDCCLI := &fakeDevcontainerCLI{ + config: agentcontainers.DevcontainerConfig{ + Workspace: agentcontainers.DevcontainerWorkspace{ + WorkspaceFolder: containerFolder, + }, + }, + execAgent: func(ctx context.Context, token string) error { + t.Logf("Starting devcontainer subagent with token: %s", token) + _ = agenttest.New(t, client.URL, token) + <-ctx.Done() + return ctx.Err() + }, + } _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.Devcontainers = true o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, - agentcontainers.WithContainerCLI(mccli), - agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + agentcontainers.WithContainerCLI(fCCLI), + agentcontainers.WithDevcontainerCLI(fDCCLI), + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithDevcontainers( + []codersdk.WorkspaceAgentDevcontainer{{ + ID: devcontainerID, + Name: devcontainerName, + WorkspaceFolder: workspaceFolder, + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }}, + []codersdk.WorkspaceAgentScript{{ + ID: devcontainerID, + LogSourceID: uuid.New(), + }}, + ), + agentcontainers.WithContainerLabelIncludeFilter("coder.test", t.Name()), ) }) - _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).AgentNames([]string{parentAgentName, devcontainerName}).Wait() insideWorkspaceEnv := map[string]string{ "CODER": "true", "CODER_WORKSPACE_NAME": workspace.Name, - "CODER_WORKSPACE_AGENT_NAME": agentName, + "CODER_WORKSPACE_AGENT_NAME": devcontainerName, } wd, err := os.Getwd() @@ -535,41 +444,48 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { wantToken bool }{ { - name: "ok", - args: []string{"--test.open-error", workspace.Name, "--container", containerName}, + name: "nonexistent container", + args: []string{"--test.open-error", workspace.Name + "." + devcontainerName + "bad"}, + wantError: true, }, { - 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", + args: []string{"--test.open-error", workspace.Name + "." + devcontainerName}, + wantError: false, }, { - name: "ok with absolute path", - args: []string{"--test.open-error", workspace.Name, "--container", containerName, "/home/coder"}, - wantDir: "/home/coder", + name: "ok with absolute path", + args: []string{"--test.open-error", workspace.Name + "." + devcontainerName, containerFolder}, + wantError: false, + }, + { + name: "ok with relative path", + args: []string{"--test.open-error", workspace.Name + "." + devcontainerName, "my/relative/path"}, + wantDir: path.Join(containerFolder, "my/relative/path"), + wantError: false, }, { name: "ok with token", - args: []string{"--test.open-error", workspace.Name, "--container", containerName, "--generate-token"}, + args: []string{"--test.open-error", workspace.Name + "." + devcontainerName, "--generate-token"}, + wantError: false, wantToken: true, }, // Inside workspace, does not require --test.open-error { name: "ok inside workspace", env: insideWorkspaceEnv, - args: []string{workspace.Name, "--container", containerName}, + args: []string{workspace.Name + "." + devcontainerName}, }, { name: "ok inside workspace relative path", env: insideWorkspaceEnv, - args: []string{workspace.Name, "--container", containerName, "foo"}, + args: []string{workspace.Name + "." + devcontainerName, "foo"}, wantDir: filepath.Join(wd, "foo"), }, { name: "ok inside workspace token", env: insideWorkspaceEnv, - args: []string{workspace.Name, "--container", containerName, "--generate-token"}, + args: []string{workspace.Name + "." + devcontainerName, "--generate-token"}, wantToken: true, }, } @@ -610,8 +526,10 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { 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, parentAgentName, qp.Get("agent")) assert.Equal(t, containerName, qp.Get("devContainerName")) + assert.Equal(t, workspaceFolder, qp.Get("localWorkspaceFolder")) + assert.Equal(t, configFile, qp.Get("localConfigFile")) if tt.wantDir != "" { assert.Equal(t, tt.wantDir, qp.Get("devContainerFolder")) From a9c6794eedf117ca3a5dee3a274d71ee9eba56a4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 7 Jul 2025 13:27:06 +0000 Subject: [PATCH 4/4] docs --- cli/open.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cli/open.go b/cli/open.go index a72d6088a92ce..cc21ea863430d 100644 --- a/cli/open.go +++ b/cli/open.go @@ -80,6 +80,15 @@ func (r *RootCmd) openVSCode() *serpent.Command { workspaceName := workspace.Name + "." + workspaceAgent.Name insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName + // To properly work with devcontainers, VS Code has to connect to + // parent workspace agent. It will then proceed to enter the + // container given the correct parameters. There is inherently no + // dependency on the devcontainer agent in this scenario, but + // relying on it simplifies the logic and ensures the devcontainer + // is ready. To eliminate the dependency we would need to know that + // a sub-agent that hasn't been created yet may be a devcontainer, + // and thus will be created at a later time as well as expose the + // container folder on the API response. var parentWorkspaceAgent codersdk.WorkspaceAgent var devcontainer codersdk.WorkspaceAgentDevcontainer if workspaceAgent.ParentID.Valid { @@ -109,7 +118,7 @@ func (r *RootCmd) openVSCode() *serpent.Command { } } if devcontainer.ID == uuid.Nil { - cliui.Warnf(inv.Stderr, "Devcontainer for agent %q not found, opening as a regular workspace", workspaceAgent.Name) + cliui.Warnf(inv.Stderr, "Devcontainer %q not found, opening as a regular workspace...", workspaceAgent.Name) parentWorkspaceAgent = codersdk.WorkspaceAgent{} // Reset to empty, so we don't use it later. break } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy