Skip to content

Commit 676bd97

Browse files
aledbfclaude
andcommitted
feat(cli): disambiguate multiple devcontainer.json configs by config_file
A project can hold several dev container configs (the spec's .devcontainer/<folder>/devcontainer.json), and `up` already gives each its own container stamped with a devcontainer.config_file label. But exec / read-configuration / stop / down resolved the target by devcontainer.local_folder ONLY, so with two configs up at once they grabbed whichever container came first — the wrong one half the time. Add a shared resolveContainerID that prefers the [local_folder, config_file] label filter (falling back to local_folder for legacy/label-less containers), mirroring the TS CLI's old/new id-label scheme, and route exec/read-configuration/stop/down through it. Add --config to stop/down so a specific config can be targeted. Verified end-to-end: two configs (frontend/backend) up simultaneously; exec --config and stop --config each hit the correct container. Unit-tested via a narrow containerLister seam. Useful for driving several dev containers of one project on a remote host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 28958f9 commit 676bd97

7 files changed

Lines changed: 148 additions & 49 deletions

File tree

docs/go-only-features.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,15 @@ devcontainer down . # stop AND remove the container (a later `up` builds fres
121121
- The target is resolved from `--workspace-folder` (or the `[path]` arg), or given
122122
directly with `--id-label` / `--container-id`. Idempotent: a no-op success when
123123
no matching container exists.
124-
125-
Flags: `--workspace-folder`, `--id-label`, `--container-id`, `--docker-path`
126-
(plus `--remove-volumes` on `down`).
124+
- **Multiple configs in one project** (`.devcontainer/frontend/devcontainer.json`,
125+
`.devcontainer/backend/devcontainer.json`, …) are disambiguated with `--config`:
126+
each config produces its own container (stamped with the `config_file` label), and
127+
`stop`/`down`/`exec`/`read-configuration` resolve the right one via
128+
`[local_folder, config_file]` (falling back to `local_folder` for legacy
129+
containers). Without `--config` on a multi-config project, the first match wins.
130+
131+
Flags: `--workspace-folder`, `--config`, `--id-label`, `--container-id`,
132+
`--docker-path` (plus `--remove-volumes` on `down`).
127133

128134
## `up --cache-image`
129135

docs/parity/cli-flags-inventory.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,9 @@ commands:
10931093
workspace-folder:
10941094
type: string
10951095
description: "Workspace folder path (defaults to [path] or the current directory)."
1096+
config:
1097+
type: string
1098+
description: "devcontainer.json path — disambiguates a project with multiple configs."
10961099
id-label:
10971100
type: string
10981101
array: true
@@ -1111,6 +1114,9 @@ commands:
11111114
workspace-folder:
11121115
type: string
11131116
description: "Workspace folder path (defaults to [path] or the current directory)."
1117+
config:
1118+
type: string
1119+
description: "devcontainer.json path — disambiguates a project with multiple configs."
11141120
id-label:
11151121
type: string
11161122
array: true

internal/cli/container_lookup.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
)
7+
8+
// containerLister is the narrow seam resolveContainerID needs (satisfied by
9+
// *docker.EngineClient), so the lookup is unit-testable without a daemon.
10+
type containerLister interface {
11+
ListContainers(ctx context.Context, all bool, labelFilters []string) ([]string, error)
12+
}
13+
14+
// resolveContainerID finds a workspace's dev container. Explicit idLabels win.
15+
// Otherwise it prefers the [local_folder, config_file] label filter so that
16+
// multiple devcontainer.json configs in one project (e.g. .devcontainer/frontend
17+
// and .devcontainer/backend, each its own container) resolve to the RIGHT one,
18+
// and falls back to local_folder alone for a container created without a
19+
// config_file label (older runs / --id-label-only) — mirroring the TS CLI's
20+
// old/new id-label scheme. Returns "" when nothing matches.
21+
func resolveContainerID(ctx context.Context, engine containerLister, workspaceFolder, configFile string, idLabels []string) string {
22+
if len(idLabels) > 0 {
23+
if ids, _ := engine.ListContainers(ctx, true, idLabels); len(ids) > 0 {
24+
return ids[0]
25+
}
26+
return ""
27+
}
28+
if workspaceFolder == "" {
29+
return ""
30+
}
31+
local := fmt.Sprintf("devcontainer.local_folder=%s", resolvePath(workspaceFolder))
32+
if configFile != "" {
33+
labels := []string{local, fmt.Sprintf("devcontainer.config_file=%s", configFile)}
34+
if ids, _ := engine.ListContainers(ctx, true, labels); len(ids) > 0 {
35+
return ids[0]
36+
}
37+
}
38+
if ids, _ := engine.ListContainers(ctx, true, []string{local}); len(ids) > 0 {
39+
return ids[0]
40+
}
41+
return ""
42+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// fakeLister answers ListContainers from a map of "sorted,label,filter" -> ids.
10+
type fakeLister struct{ byFilter map[string][]string }
11+
12+
func (f *fakeLister) ListContainers(_ context.Context, _ bool, labels []string) ([]string, error) {
13+
return f.byFilter[strings.Join(labels, "|")], nil
14+
}
15+
16+
func TestResolveContainerID(t *testing.T) {
17+
const (
18+
ws = "/proj"
19+
cfgA = "/proj/.devcontainer/a/devcontainer.json"
20+
cfgB = "/proj/.devcontainer/b/devcontainer.json"
21+
local = "devcontainer.local_folder=/proj"
22+
)
23+
key := func(labels ...string) string { return strings.Join(labels, "|") }
24+
25+
t.Run("config_file disambiguates multiple configs", func(t *testing.T) {
26+
f := &fakeLister{byFilter: map[string][]string{
27+
key(local, "devcontainer.config_file="+cfgA): {"idA"},
28+
key(local, "devcontainer.config_file="+cfgB): {"idB"},
29+
key(local): {"idA", "idB"}, // both share local_folder
30+
}}
31+
if got := resolveContainerID(context.Background(), f, ws, cfgB, nil); got != "idB" {
32+
t.Errorf("cfgB -> %q, want idB", got)
33+
}
34+
if got := resolveContainerID(context.Background(), f, ws, cfgA, nil); got != "idA" {
35+
t.Errorf("cfgA -> %q, want idA", got)
36+
}
37+
})
38+
39+
t.Run("falls back to local_folder for legacy container (no config_file match)", func(t *testing.T) {
40+
f := &fakeLister{byFilter: map[string][]string{
41+
key(local): {"legacy"}, // no config_file-filtered entry
42+
}}
43+
if got := resolveContainerID(context.Background(), f, ws, cfgA, nil); got != "legacy" {
44+
t.Errorf("-> %q, want legacy (fallback)", got)
45+
}
46+
})
47+
48+
t.Run("explicit id-labels win", func(t *testing.T) {
49+
f := &fakeLister{byFilter: map[string][]string{
50+
key("x=y"): {"bylabel"},
51+
key(local): {"byfolder"},
52+
}}
53+
if got := resolveContainerID(context.Background(), f, ws, cfgA, []string{"x=y"}); got != "bylabel" {
54+
t.Errorf("-> %q, want bylabel", got)
55+
}
56+
})
57+
58+
t.Run("no match", func(t *testing.T) {
59+
if got := resolveContainerID(context.Background(), &fakeLister{}, ws, cfgA, nil); got != "" {
60+
t.Errorf("-> %q, want empty", got)
61+
}
62+
})
63+
}

internal/cli/exec.go

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,11 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error {
174174
// 2. Find the container
175175
containerID := opts.containerID
176176
if containerID == "" {
177-
containerID = findContainerByOpts(ctx, engine, opts, logger)
177+
configFile := ""
178+
if loadResult != nil {
179+
configFile = loadResult.Config.ConfigFilePath
180+
}
181+
containerID = findContainerByOpts(ctx, engine, opts, configFile, logger)
178182
}
179183

180184
if containerID == "" {
@@ -369,27 +373,13 @@ func (w *rawLogWriter) Write(p []byte) (int, error) {
369373
return len(p), nil
370374
}
371375

372-
func findContainerByOpts(ctx context.Context, engine *docker.EngineClient, opts *execOpts, logger log.Logger) string {
373-
labels := opts.idLabels
374-
if len(labels) == 0 && opts.workspaceFolder != "" {
375-
labels = []string{
376-
fmt.Sprintf("devcontainer.local_folder=%s", resolvePath(opts.workspaceFolder)),
377-
}
378-
}
379-
380-
if len(labels) == 0 {
381-
return ""
376+
func findContainerByOpts(ctx context.Context, engine *docker.EngineClient, opts *execOpts, configFile string, logger log.Logger) string {
377+
// [local_folder, config_file] disambiguates when a project has multiple
378+
// devcontainer.json configs; falls back to local_folder for legacy containers.
379+
if id := resolveContainerID(ctx, engine, opts.workspaceFolder, configFile, opts.idLabels); id != "" {
380+
return id
382381
}
383-
384-
ids, err := engine.ListContainers(ctx, false, labels)
385-
if err != nil || len(ids) == 0 {
386-
ids, err = engine.ListContainers(ctx, true, labels)
387-
if err != nil || len(ids) == 0 {
388-
return findComposeContainer(ctx, engine, opts, logger)
389-
}
390-
}
391-
392-
return ids[0]
382+
return findComposeContainer(ctx, engine, opts, logger)
393383
}
394384

395385
func findComposeContainer(ctx context.Context, engine *docker.EngineClient, opts *execOpts, logger log.Logger) string {

internal/cli/read_configuration.go

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -214,18 +214,14 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts)
214214
}
215215

216216
containerID := opts.containerID
217-
if containerID == "" && engine != nil && len(opts.idLabels) > 0 {
218-
ids, _ := engine.ListContainers(ctx, true, opts.idLabels)
219-
if len(ids) > 0 {
220-
containerID = ids[0]
221-
}
222-
}
223-
if containerID == "" && engine != nil && workspaceFolder != "" {
224-
labels := []string{fmt.Sprintf("devcontainer.local_folder=%s", workspaceFolder)}
225-
ids, _ := engine.ListContainers(ctx, false, labels)
226-
if len(ids) > 0 {
227-
containerID = ids[0]
217+
if containerID == "" && engine != nil {
218+
configFile := ""
219+
if result != nil {
220+
configFile = result.Config.ConfigFilePath
228221
}
222+
// [local_folder, config_file] so multiple configs in one project resolve
223+
// to the right container (fallback to local_folder for legacy ones).
224+
containerID = resolveContainerID(ctx, engine, workspaceFolder, configFile, opts.idLabels)
229225
}
230226

231227
if containerID != "" && engine != nil {

internal/cli/stop.go

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
type stopOpts struct {
1414
workspaceFolder string
15+
configPath string
1516
idLabels []string
1617
containerID string
1718
dockerPath string
@@ -68,6 +69,7 @@ Go-only command; not part of the upstream @devcontainers/cli.`,
6869
func addStopFlags(cmd *cobra.Command, opts *stopOpts) {
6970
f := cmd.Flags()
7071
f.StringVar(&opts.workspaceFolder, "workspace-folder", "", "Workspace folder path (defaults to [path] or the current directory).")
72+
f.StringVar(&opts.configPath, "config", "", "devcontainer.json path — disambiguates a project with multiple configs.")
7173
f.StringArrayVar(&opts.idLabels, "id-label", nil, "id label(s) of the target container (name=value); repeatable.")
7274
f.StringVar(&opts.containerID, "container-id", "", "Target container id directly.")
7375
f.StringVar(&opts.dockerPath, "docker-path", "", "Docker CLI path.")
@@ -131,23 +133,17 @@ func runStopOrDown(ctx context.Context, out Output, opts stopOpts, remove bool)
131133
}
132134

133135
// resolveWorkspaceContainer finds the dev container for a workspace: a directly
134-
// given --container-id, else the first container matching --id-label or the
135-
// workspace's devcontainer.local_folder label. all=true so a stopped container
136-
// is still found (down after a prior stop).
136+
// given --container-id, else --id-label, else the workspace's container —
137+
// disambiguated by --config (config_file label) when a project has multiple
138+
// devcontainer.json configs, falling back to local_folder. all=true so a stopped
139+
// container is still found (down after a prior stop).
137140
func resolveWorkspaceContainer(ctx context.Context, engine *docker.EngineClient, opts stopOpts) string {
138141
if opts.containerID != "" {
139142
return opts.containerID
140143
}
141-
labels := opts.idLabels
142-
if len(labels) == 0 && opts.workspaceFolder != "" {
143-
labels = []string{fmt.Sprintf("devcontainer.local_folder=%s", resolvePath(opts.workspaceFolder))}
144+
configFile := ""
145+
if opts.configPath != "" {
146+
configFile = resolvePath(opts.configPath)
144147
}
145-
if len(labels) == 0 {
146-
return ""
147-
}
148-
ids, err := engine.ListContainers(ctx, true, labels)
149-
if err != nil || len(ids) == 0 {
150-
return ""
151-
}
152-
return ids[0]
148+
return resolveContainerID(ctx, engine, opts.workspaceFolder, configFile, opts.idLabels)
153149
}

0 commit comments

Comments
 (0)