Skip to content

Commit 6c4db7a

Browse files
authored
feat(cli): replace open vscode container with devcontainer subagent (#18765)
This change allows a devcontainer to be opened via the agent syntax, `coder open vscode <workspace>.<agent>` and removes the `--container` option to simplify the subcommand. Accessing the subagent will behave similarly to how the `--container` option behaved. Fixes coder/internal#748
1 parent 5f50dcc commit 6c4db7a

File tree

10 files changed

+283
-323
lines changed

10 files changed

+283
-323
lines changed

agent/agentcontainers/devcontainercli.go

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -106,63 +106,63 @@ type DevcontainerCLI interface {
106106

107107
// DevcontainerCLIUpOptions are options for the devcontainer CLI Up
108108
// command.
109-
type DevcontainerCLIUpOptions func(*devcontainerCLIUpConfig)
109+
type DevcontainerCLIUpOptions func(*DevcontainerCLIUpConfig)
110110

111-
type devcontainerCLIUpConfig struct {
112-
args []string // Additional arguments for the Up command.
113-
stdout io.Writer
114-
stderr io.Writer
111+
type DevcontainerCLIUpConfig struct {
112+
Args []string // Additional arguments for the Up command.
113+
Stdout io.Writer
114+
Stderr io.Writer
115115
}
116116

117117
// WithRemoveExistingContainer is an option to remove the existing
118118
// container.
119119
func WithRemoveExistingContainer() DevcontainerCLIUpOptions {
120-
return func(o *devcontainerCLIUpConfig) {
121-
o.args = append(o.args, "--remove-existing-container")
120+
return func(o *DevcontainerCLIUpConfig) {
121+
o.Args = append(o.Args, "--remove-existing-container")
122122
}
123123
}
124124

125125
// WithUpOutput sets additional stdout and stderr writers for logs
126126
// during Up operations.
127127
func WithUpOutput(stdout, stderr io.Writer) DevcontainerCLIUpOptions {
128-
return func(o *devcontainerCLIUpConfig) {
129-
o.stdout = stdout
130-
o.stderr = stderr
128+
return func(o *DevcontainerCLIUpConfig) {
129+
o.Stdout = stdout
130+
o.Stderr = stderr
131131
}
132132
}
133133

134134
// DevcontainerCLIExecOptions are options for the devcontainer CLI Exec
135135
// command.
136-
type DevcontainerCLIExecOptions func(*devcontainerCLIExecConfig)
136+
type DevcontainerCLIExecOptions func(*DevcontainerCLIExecConfig)
137137

138-
type devcontainerCLIExecConfig struct {
139-
args []string // Additional arguments for the Exec command.
140-
stdout io.Writer
141-
stderr io.Writer
138+
type DevcontainerCLIExecConfig struct {
139+
Args []string // Additional arguments for the Exec command.
140+
Stdout io.Writer
141+
Stderr io.Writer
142142
}
143143

144144
// WithExecOutput sets additional stdout and stderr writers for logs
145145
// during Exec operations.
146146
func WithExecOutput(stdout, stderr io.Writer) DevcontainerCLIExecOptions {
147-
return func(o *devcontainerCLIExecConfig) {
148-
o.stdout = stdout
149-
o.stderr = stderr
147+
return func(o *DevcontainerCLIExecConfig) {
148+
o.Stdout = stdout
149+
o.Stderr = stderr
150150
}
151151
}
152152

153153
// WithExecContainerID sets the container ID to target a specific
154154
// container.
155155
func WithExecContainerID(id string) DevcontainerCLIExecOptions {
156-
return func(o *devcontainerCLIExecConfig) {
157-
o.args = append(o.args, "--container-id", id)
156+
return func(o *DevcontainerCLIExecConfig) {
157+
o.Args = append(o.Args, "--container-id", id)
158158
}
159159
}
160160

161161
// WithRemoteEnv sets environment variables for the Exec command.
162162
func WithRemoteEnv(env ...string) DevcontainerCLIExecOptions {
163-
return func(o *devcontainerCLIExecConfig) {
163+
return func(o *DevcontainerCLIExecConfig) {
164164
for _, e := range env {
165-
o.args = append(o.args, "--remote-env", e)
165+
o.Args = append(o.Args, "--remote-env", e)
166166
}
167167
}
168168
}
@@ -185,8 +185,8 @@ func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOpt
185185
}
186186
}
187187

188-
func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig {
189-
conf := devcontainerCLIUpConfig{stdout: io.Discard, stderr: io.Discard}
188+
func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) DevcontainerCLIUpConfig {
189+
conf := DevcontainerCLIUpConfig{Stdout: io.Discard, Stderr: io.Discard}
190190
for _, opt := range opts {
191191
if opt != nil {
192192
opt(&conf)
@@ -195,8 +195,8 @@ func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainer
195195
return conf
196196
}
197197

198-
func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devcontainerCLIExecConfig {
199-
conf := devcontainerCLIExecConfig{stdout: io.Discard, stderr: io.Discard}
198+
func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) DevcontainerCLIExecConfig {
199+
conf := DevcontainerCLIExecConfig{Stdout: io.Discard, Stderr: io.Discard}
200200
for _, opt := range opts {
201201
if opt != nil {
202202
opt(&conf)
@@ -241,7 +241,7 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st
241241
if configPath != "" {
242242
args = append(args, "--config", configPath)
243243
}
244-
args = append(args, conf.args...)
244+
args = append(args, conf.Args...)
245245
cmd := d.execer.CommandContext(ctx, "devcontainer", args...)
246246

247247
// 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
251251
&devcontainerCLILogWriter{
252252
ctx: ctx,
253253
logger: logger.With(slog.F("stdout", true)),
254-
writer: conf.stdout,
254+
writer: conf.Stdout,
255255
},
256256
)
257257
// Stream stderr logs and provided writer if any.
258258
cmd.Stderr = &devcontainerCLILogWriter{
259259
ctx: ctx,
260260
logger: logger.With(slog.F("stderr", true)),
261-
writer: conf.stderr,
261+
writer: conf.Stderr,
262262
}
263263

264264
if err := cmd.Run(); err != nil {
@@ -293,17 +293,17 @@ func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath
293293
if configPath != "" {
294294
args = append(args, "--config", configPath)
295295
}
296-
args = append(args, conf.args...)
296+
args = append(args, conf.Args...)
297297
args = append(args, cmd)
298298
args = append(args, cmdArgs...)
299299
c := d.execer.CommandContext(ctx, "devcontainer", args...)
300300

301-
c.Stdout = io.MultiWriter(conf.stdout, &devcontainerCLILogWriter{
301+
c.Stdout = io.MultiWriter(conf.Stdout, &devcontainerCLILogWriter{
302302
ctx: ctx,
303303
logger: logger.With(slog.F("stdout", true)),
304304
writer: io.Discard,
305305
})
306-
c.Stderr = io.MultiWriter(conf.stderr, &devcontainerCLILogWriter{
306+
c.Stderr = io.MultiWriter(conf.Stderr, &devcontainerCLILogWriter{
307307
ctx: ctx,
308308
logger: logger.With(slog.F("stderr", true)),
309309
writer: io.Discard,

cli/exp_rpty.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT
9797
reconnectID = uuid.New()
9898
}
9999

100-
ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace)
100+
ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace)
101101
if err != nil {
102102
return err
103103
}

cli/open.go

Lines changed: 85 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import (
1111
"runtime"
1212
"slices"
1313
"strings"
14+
"time"
1415

16+
"github.com/google/uuid"
1517
"github.com/skratchdot/open-golang/open"
1618
"golang.org/x/xerrors"
1719

@@ -42,7 +44,6 @@ func (r *RootCmd) openVSCode() *serpent.Command {
4244
generateToken bool
4345
testOpenError bool
4446
appearanceConfig codersdk.AppearanceConfig
45-
containerName string
4647
)
4748

4849
client := new(codersdk.Client)
@@ -71,14 +72,78 @@ func (r *RootCmd) openVSCode() *serpent.Command {
7172
// need to wait for the agent to start.
7273
workspaceQuery := inv.Args[0]
7374
autostart := true
74-
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery)
75+
workspace, workspaceAgent, otherWorkspaceAgents, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery)
7576
if err != nil {
7677
return xerrors.Errorf("get workspace and agent: %w", err)
7778
}
7879

7980
workspaceName := workspace.Name + "." + workspaceAgent.Name
8081
insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName
8182

83+
// To properly work with devcontainers, VS Code has to connect to
84+
// parent workspace agent. It will then proceed to enter the
85+
// container given the correct parameters. There is inherently no
86+
// dependency on the devcontainer agent in this scenario, but
87+
// relying on it simplifies the logic and ensures the devcontainer
88+
// is ready. To eliminate the dependency we would need to know that
89+
// a sub-agent that hasn't been created yet may be a devcontainer,
90+
// and thus will be created at a later time as well as expose the
91+
// container folder on the API response.
92+
var parentWorkspaceAgent codersdk.WorkspaceAgent
93+
var devcontainer codersdk.WorkspaceAgentDevcontainer
94+
if workspaceAgent.ParentID.Valid {
95+
// This is likely a devcontainer agent, so we need to find the
96+
// parent workspace agent as well as the devcontainer.
97+
for _, otherAgent := range otherWorkspaceAgents {
98+
if otherAgent.ID == workspaceAgent.ParentID.UUID {
99+
parentWorkspaceAgent = otherAgent
100+
break
101+
}
102+
}
103+
if parentWorkspaceAgent.ID == uuid.Nil {
104+
return xerrors.Errorf("parent workspace agent %s not found", workspaceAgent.ParentID.UUID)
105+
}
106+
107+
printedWaiting := false
108+
for {
109+
resp, err := client.WorkspaceAgentListContainers(ctx, parentWorkspaceAgent.ID, nil)
110+
if err != nil {
111+
return xerrors.Errorf("list parent workspace agent containers: %w", err)
112+
}
113+
114+
for _, dc := range resp.Devcontainers {
115+
if dc.Agent.ID == workspaceAgent.ID {
116+
devcontainer = dc
117+
break
118+
}
119+
}
120+
if devcontainer.ID == uuid.Nil {
121+
cliui.Warnf(inv.Stderr, "Devcontainer %q not found, opening as a regular workspace...", workspaceAgent.Name)
122+
parentWorkspaceAgent = codersdk.WorkspaceAgent{} // Reset to empty, so we don't use it later.
123+
break
124+
}
125+
126+
// Precondition, the devcontainer must be running to enter
127+
// it. Once running, devcontainer.Container will be set.
128+
if devcontainer.Status == codersdk.WorkspaceAgentDevcontainerStatusRunning {
129+
break
130+
}
131+
if devcontainer.Status != codersdk.WorkspaceAgentDevcontainerStatusStarting {
132+
return xerrors.Errorf("devcontainer %q is in unexpected status %q, expected %q or %q",
133+
devcontainer.Name, devcontainer.Status,
134+
codersdk.WorkspaceAgentDevcontainerStatusRunning,
135+
codersdk.WorkspaceAgentDevcontainerStatusStarting,
136+
)
137+
}
138+
139+
if !printedWaiting {
140+
_, _ = fmt.Fprintf(inv.Stderr, "Waiting for devcontainer %q status to change from %q to %q...\n", devcontainer.Name, devcontainer.Status, codersdk.WorkspaceAgentDevcontainerStatusRunning)
141+
printedWaiting = true
142+
}
143+
time.Sleep(5 * time.Second) // Wait a bit before retrying.
144+
}
145+
}
146+
82147
if !insideThisWorkspace {
83148
// Wait for the agent to connect, we don't care about readiness
84149
// otherwise (e.g. wait).
@@ -99,6 +164,9 @@ func (r *RootCmd) openVSCode() *serpent.Command {
99164
// the created state, so we need to wait for that to happen.
100165
// However, if no directory is set, the expanded directory will
101166
// not be set either.
167+
//
168+
// Note that this is irrelevant for devcontainer sub agents, as
169+
// they always have a directory set.
102170
if workspaceAgent.Directory != "" {
103171
workspace, workspaceAgent, err = waitForAgentCond(ctx, client, workspace, workspaceAgent, func(_ codersdk.WorkspaceAgent) bool {
104172
return workspaceAgent.LifecycleState != codersdk.WorkspaceAgentLifecycleCreated
@@ -114,41 +182,6 @@ func (r *RootCmd) openVSCode() *serpent.Command {
114182
directory = inv.Args[1]
115183
}
116184

117-
if containerName != "" {
118-
containers, err := client.WorkspaceAgentListContainers(ctx, workspaceAgent.ID, map[string]string{"devcontainer.local_folder": ""})
119-
if err != nil {
120-
return xerrors.Errorf("list workspace agent containers: %w", err)
121-
}
122-
123-
var foundContainer bool
124-
125-
for _, container := range containers.Containers {
126-
if container.FriendlyName != containerName {
127-
continue
128-
}
129-
130-
foundContainer = true
131-
132-
if directory == "" {
133-
localFolder, ok := container.Labels["devcontainer.local_folder"]
134-
if !ok {
135-
return xerrors.New("container missing `devcontainer.local_folder` label")
136-
}
137-
138-
directory, ok = container.Volumes[localFolder]
139-
if !ok {
140-
return xerrors.New("container missing volume for `devcontainer.local_folder`")
141-
}
142-
}
143-
144-
break
145-
}
146-
147-
if !foundContainer {
148-
return xerrors.New("no container found")
149-
}
150-
}
151-
152185
directory, err = resolveAgentAbsPath(workspaceAgent.ExpandedDirectory, directory, workspaceAgent.OperatingSystem, insideThisWorkspace)
153186
if err != nil {
154187
return xerrors.Errorf("resolve agent path: %w", err)
@@ -174,14 +207,16 @@ func (r *RootCmd) openVSCode() *serpent.Command {
174207
u *url.URL
175208
qp url.Values
176209
)
177-
if containerName != "" {
210+
if devcontainer.ID != uuid.Nil {
178211
u, qp = buildVSCodeWorkspaceDevContainerLink(
179212
token,
180213
client.URL.String(),
181214
workspace,
182-
workspaceAgent,
183-
containerName,
215+
parentWorkspaceAgent,
216+
devcontainer.Container.FriendlyName,
184217
directory,
218+
devcontainer.WorkspaceFolder,
219+
devcontainer.ConfigPath,
185220
)
186221
} else {
187222
u, qp = buildVSCodeWorkspaceLink(
@@ -247,13 +282,6 @@ func (r *RootCmd) openVSCode() *serpent.Command {
247282
),
248283
Value: serpent.BoolOf(&generateToken),
249284
},
250-
{
251-
Flag: "container",
252-
FlagShorthand: "c",
253-
Description: "Container name to connect to in the workspace.",
254-
Value: serpent.StringOf(&containerName),
255-
Hidden: true, // Hidden until this features is at least in beta.
256-
},
257285
{
258286
Flag: "test.open-error",
259287
Description: "Don't run the open command.",
@@ -288,7 +316,7 @@ func (r *RootCmd) openApp() *serpent.Command {
288316
}
289317

290318
workspaceName := inv.Args[0]
291-
ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName)
319+
ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName)
292320
if err != nil {
293321
var sdkErr *codersdk.Error
294322
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
@@ -430,8 +458,14 @@ func buildVSCodeWorkspaceDevContainerLink(
430458
workspaceAgent codersdk.WorkspaceAgent,
431459
containerName string,
432460
containerFolder string,
461+
localWorkspaceFolder string,
462+
localConfigFile string,
433463
) (*url.URL, url.Values) {
434464
containerFolder = filepath.ToSlash(containerFolder)
465+
localWorkspaceFolder = filepath.ToSlash(localWorkspaceFolder)
466+
if localConfigFile != "" {
467+
localConfigFile = filepath.ToSlash(localConfigFile)
468+
}
435469

436470
qp := url.Values{}
437471
qp.Add("url", clientURL)
@@ -440,6 +474,8 @@ func buildVSCodeWorkspaceDevContainerLink(
440474
qp.Add("agent", workspaceAgent.Name)
441475
qp.Add("devContainerName", containerName)
442476
qp.Add("devContainerFolder", containerFolder)
477+
qp.Add("localWorkspaceFolder", localWorkspaceFolder)
478+
qp.Add("localConfigFile", localConfigFile)
443479

444480
if token != "" {
445481
qp.Add("token", token)
@@ -469,7 +505,7 @@ func waitForAgentCond(ctx context.Context, client *codersdk.Client, workspace co
469505
}
470506

471507
for workspace = range wc {
472-
workspaceAgent, err = getWorkspaceAgent(workspace, workspaceAgent.Name)
508+
workspaceAgent, _, err = getWorkspaceAgent(workspace, workspaceAgent.Name)
473509
if err != nil {
474510
return workspace, workspaceAgent, xerrors.Errorf("get workspace agent: %w", err)
475511
}

0 commit comments

Comments
 (0)