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)
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