Skip to content

Commit 6c57d01

Browse files
aledbfclaude
andcommitted
feat(cli): add stop and down for workspace lifecycle
DevPod has stop/delete; the upstream @devcontainers/cli leaves teardown to the editor. Add Go-only `stop` (graceful docker stop, container + data kept, restart with `up`) and `down` (stop and remove; --remove-volumes to also drop named volumes) — most useful when driving the CLI on a remote host. Both resolve the target from --workspace-folder / --id-label / --container-id, inspect it, and if it carries the Compose project label act on the whole project (`docker compose stop`/`down`) so sibling services are handled too; otherwise they use the engine API (new EngineClient.StopContainer). Idempotent: a no-op success when no container matches. Verified end-to-end: up → stop (exited, kept) → up (restarts the same container) → down (removed). Registered in the flag inventory (parity test green) and documented. New ComposeClient.Stop/Down wrap the compose subcommands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3d18593 commit 6c57d01

9 files changed

Lines changed: 301 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ devcontainer build --workspace-folder . --secrets-file secrets.json
101101

102102
# Provision here, then open VS Code attached to the container (reconnects, doesn't rebuild)
103103
devcontainer up . && devcontainer open .
104+
105+
# Pause / tear down a workspace (great on a remote host); `up` restarts a stopped one
106+
devcontainer stop . # graceful stop, keep the container + data
107+
devcontainer down . # stop and remove it
104108
```
105109

106110
Everything else — `up`, `build`, `exec`, `read-configuration`, `set-up`,

‎docs/DIVERGENCES.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ touches a compared surface, reflected in the parity matrix.
1717
(deterministic content hash; additive — default output is byte-identical to TS),
1818
`build --secrets-file` (BuildKit build secrets; TS `build` has no such flag), `open`
1919
(launch VS Code attached to the workspace's dev container via a vscode-remote:// URI),
20-
and the automatic credential bridge that hands the CLI's resolved auth to `docker build`.
20+
`stop`/`down` (pause, or stop+remove, a workspace's container — Compose-aware; the
21+
upstream CLI leaves teardown to the editor), and the automatic credential bridge that
22+
hands the CLI's resolved auth to `docker build`.
2123
- **`--override-config` deep-merges** the override onto the base config, whereas TS
2224
replaces the config wholesale (`readDocument(overrideConfigFile ?? configFile)`). With
2325
no readable base, the override stands alone — identical to TS. This lets an orchestrator

‎docs/go-only-features.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ divergences, each covered by tests.
88
- [`devcontainer check`](#devcontainer-check) — host preflight
99
- [`devcontainer setup`](#devcontainer-setup) — apply safe host fixes
1010
- [`devcontainer open`](#devcontainer-open) — launch VS Code attached to the dev container
11+
- [`devcontainer stop` / `down`](#devcontainer-stop--devcontainer-down) — pause or tear down a workspace's container
1112
- [`up --cache-image`](#up---cache-image) — boot from a prebuilt image
1213
- [`read-configuration --cache-key`](#read-configuration---cache-key) — deterministic cache key
1314
- [`build --secrets-file`](#build---secrets-file) — BuildKit build secrets
@@ -98,6 +99,32 @@ command without opening anything (handy for scripting or debugging).
9899
Flags: `--workspace-folder`, `--config`, `--editor` (default `code`; e.g. `code-insiders`,
99100
`cursor`), `--dry-run`.
100101

102+
## `devcontainer stop` / `devcontainer down`
103+
104+
Lifecycle management the upstream CLI leaves to the editor. `up` provisions; these
105+
tear back down — useful when you drive the CLI on a **remote host** and want to
106+
pause or clean up without leaving anything running.
107+
108+
```sh
109+
devcontainer stop . # graceful `docker stop`; container + data kept, restart with `up`
110+
devcontainer up . # ← restarts the SAME stopped container
111+
devcontainer down . # stop AND remove the container (a later `up` builds fresh)
112+
```
113+
114+
- **`stop`** stops the container without removing it, so it stops consuming CPU/RAM
115+
but its filesystem and state survive. `up` restarts the same container.
116+
- **`down`** stops and removes the container. Named-volume data persists unless you
117+
pass `--remove-volumes` (destructive).
118+
- Both detect **Docker Compose** configs via the container's project label and act
119+
on the whole project (`docker compose stop` / `down`), so sibling services (db,
120+
cache, …) are handled too — not just the dev container.
121+
- The target is resolved from `--workspace-folder` (or the `[path]` arg), or given
122+
directly with `--id-label` / `--container-id`. Idempotent: a no-op success when
123+
no matching container exists.
124+
125+
Flags: `--workspace-folder`, `--id-label`, `--container-id`, `--docker-path`
126+
(plus `--remove-volumes` on `down`).
127+
101128
## `up --cache-image`
102129

103130
Boot the container from an already-built image (features baked in), **skipping the image

‎docs/parity/cli-flags-inventory.yaml‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,46 @@ commands:
10641064
type: boolean
10651065
default: false
10661066
description: "Print the folder URI and launch command without opening the editor."
1067+
# Go-only: stop a workspace's dev container without removing it.
1068+
stop:
1069+
description: "Stop a workspace's dev container without removing it"
1070+
handler: stopHandler
1071+
flags:
1072+
workspace-folder:
1073+
type: string
1074+
description: "Workspace folder path (defaults to [path] or the current directory)."
1075+
id-label:
1076+
type: string
1077+
array: true
1078+
description: "id label(s) of the target container (name=value); repeatable."
1079+
container-id:
1080+
type: string
1081+
description: "Target container id directly."
1082+
docker-path:
1083+
type: string
1084+
description: "Docker CLI path."
1085+
# Go-only: stop and remove a workspace's dev container.
1086+
down:
1087+
description: "Stop and remove a workspace's dev container"
1088+
handler: downHandler
1089+
flags:
1090+
workspace-folder:
1091+
type: string
1092+
description: "Workspace folder path (defaults to [path] or the current directory)."
1093+
id-label:
1094+
type: string
1095+
array: true
1096+
description: "id label(s) of the target container (name=value); repeatable."
1097+
container-id:
1098+
type: string
1099+
description: "Target container id directly."
1100+
docker-path:
1101+
type: string
1102+
description: "Docker CLI path."
1103+
remove-volumes:
1104+
type: boolean
1105+
default: false
1106+
description: "Also remove named volumes (Compose). Destructive: deletes persisted data."
10671107

10681108
# =============================================================================
10691109
# JSON OUTPUT ENVELOPES SUMMARY

‎internal/cli/root.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ func NewRootCommand() *cobra.Command {
4949
newCheckCmd(),
5050
newSetupCmd(),
5151
newOpenCmd(),
52+
newStopCmd(),
53+
newDownCmd(),
5254
)
5355

5456
return root

‎internal/cli/stop.go‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
8+
"github.com/devcontainers/cli/internal/docker"
9+
"github.com/devcontainers/cli/internal/log"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
type stopOpts struct {
14+
workspaceFolder string
15+
idLabels []string
16+
containerID string
17+
dockerPath string
18+
removeVolumes bool // down only
19+
}
20+
21+
func newStopCmd() *cobra.Command {
22+
var opts stopOpts
23+
cmd := &cobra.Command{
24+
Use: "stop [path]",
25+
Short: "Stop a workspace's dev container without removing it",
26+
Long: `Gracefully stop the dev container for a workspace so it stops consuming
27+
resources, leaving it and its data intact to be restarted with 'up'. For a
28+
Docker Compose config all of the project's services are stopped.
29+
30+
Useful on a remote host: pause the container without tearing anything down.
31+
32+
Go-only command; not part of the upstream @devcontainers/cli.`,
33+
Args: cobra.MaximumNArgs(1),
34+
RunE: func(cmd *cobra.Command, args []string) error {
35+
if len(args) > 0 && opts.workspaceFolder == "" {
36+
opts.workspaceFolder = args[0]
37+
}
38+
return runStopOrDown(cmd.Context(), outputFor(cmd), opts, false)
39+
},
40+
}
41+
addStopFlags(cmd, &opts)
42+
return cmd
43+
}
44+
45+
func newDownCmd() *cobra.Command {
46+
var opts stopOpts
47+
cmd := &cobra.Command{
48+
Use: "down [path]",
49+
Short: "Stop and remove a workspace's dev container",
50+
Long: `Stop and remove the dev container for a workspace (Compose: 'docker compose
51+
down'). Data in named volumes persists unless --remove-volumes is given; a later
52+
'up' then provisions a fresh container.
53+
54+
Go-only command; not part of the upstream @devcontainers/cli.`,
55+
Args: cobra.MaximumNArgs(1),
56+
RunE: func(cmd *cobra.Command, args []string) error {
57+
if len(args) > 0 && opts.workspaceFolder == "" {
58+
opts.workspaceFolder = args[0]
59+
}
60+
return runStopOrDown(cmd.Context(), outputFor(cmd), opts, true)
61+
},
62+
}
63+
addStopFlags(cmd, &opts)
64+
cmd.Flags().BoolVar(&opts.removeVolumes, "remove-volumes", false, "Also remove named volumes (Compose). Destructive: deletes persisted data.")
65+
return cmd
66+
}
67+
68+
func addStopFlags(cmd *cobra.Command, opts *stopOpts) {
69+
f := cmd.Flags()
70+
f.StringVar(&opts.workspaceFolder, "workspace-folder", "", "Workspace folder path (defaults to [path] or the current directory).")
71+
f.StringArrayVar(&opts.idLabels, "id-label", nil, "id label(s) of the target container (name=value); repeatable.")
72+
f.StringVar(&opts.containerID, "container-id", "", "Target container id directly.")
73+
f.StringVar(&opts.dockerPath, "docker-path", "", "Docker CLI path.")
74+
}
75+
76+
func runStopOrDown(ctx context.Context, out Output, opts stopOpts, remove bool) error {
77+
if opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.containerID == "" {
78+
opts.workspaceFolder, _ = os.Getwd()
79+
}
80+
81+
logger := log.New(log.Options{Writer: out.Stderr(), Format: "text"})
82+
engine, err := docker.NewEngineClient(logger)
83+
if err != nil {
84+
return writeErrorResult(out, fmt.Sprintf("Docker engine: %v", err))
85+
}
86+
defer engine.Close()
87+
88+
id := resolveWorkspaceContainer(ctx, engine, opts)
89+
if id == "" {
90+
// Nothing to do — already gone. Not an error (idempotent).
91+
return writeSuccessJSON(out, map[string]interface{}{"outcome": "success", "result": "no-container-found"})
92+
}
93+
94+
// Compose services carry the project label; operate on the whole project so
95+
// sibling services (db, cache, …) are handled too, not just the dev container.
96+
project := ""
97+
if insp, ierr := engine.InspectContainer(ctx, id); ierr == nil && insp.Config != nil {
98+
project = insp.Config.Labels["com.docker.compose.project"]
99+
}
100+
101+
action := "stopped"
102+
if remove {
103+
action = "removed"
104+
}
105+
106+
if project != "" {
107+
compose, cerr := docker.NewComposeClient(opts.dockerPath, "", nil, logger)
108+
if cerr != nil {
109+
return writeErrorResult(out, cerr.Error())
110+
}
111+
if remove {
112+
err = compose.Down(ctx, nil, project, opts.removeVolumes)
113+
} else {
114+
err = compose.Stop(ctx, nil, project)
115+
}
116+
} else {
117+
err = engine.StopContainer(ctx, id)
118+
if err == nil && remove {
119+
err = engine.RemoveContainer(ctx, id)
120+
}
121+
}
122+
if err != nil {
123+
return writeErrorResult(out, fmt.Sprintf("%s: %v", action, err))
124+
}
125+
126+
return writeSuccessJSON(out, map[string]interface{}{
127+
"outcome": "success",
128+
"containerId": id,
129+
"result": action,
130+
})
131+
}
132+
133+
// 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).
137+
func resolveWorkspaceContainer(ctx context.Context, engine *docker.EngineClient, opts stopOpts) string {
138+
if opts.containerID != "" {
139+
return opts.containerID
140+
}
141+
labels := opts.idLabels
142+
if len(labels) == 0 && opts.workspaceFolder != "" {
143+
labels = []string{fmt.Sprintf("devcontainer.local_folder=%s", resolvePath(opts.workspaceFolder))}
144+
}
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]
153+
}

‎internal/docker/compose.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,47 @@ func (c *ComposeClient) Up(ctx context.Context, composeFiles []string, envFile s
137137
return nil
138138
}
139139

140+
// Stop stops the project's services without removing them (`docker compose
141+
// stop`), so `up` can restart them. Preserves containers, networks and volumes.
142+
func (c *ComposeClient) Stop(ctx context.Context, composeFiles []string, projectName string) error {
143+
args := c.buildGlobalArgs(composeFiles, "")
144+
if projectName != "" {
145+
args = append(args, "--project-name", projectName)
146+
}
147+
args = append(args, "stop")
148+
149+
res, err := c.Run(ctx, args...)
150+
if err != nil {
151+
return err
152+
}
153+
if res.ExitCode != 0 {
154+
return fmt.Errorf("compose stop failed (exit %d): %s", res.ExitCode, string(res.Stderr))
155+
}
156+
return nil
157+
}
158+
159+
// Down stops and removes the project's containers and networks (`docker compose
160+
// down`). When removeVolumes is set, named volumes are removed too (destructive).
161+
func (c *ComposeClient) Down(ctx context.Context, composeFiles []string, projectName string, removeVolumes bool) error {
162+
args := c.buildGlobalArgs(composeFiles, "")
163+
if projectName != "" {
164+
args = append(args, "--project-name", projectName)
165+
}
166+
args = append(args, "down")
167+
if removeVolumes {
168+
args = append(args, "--volumes")
169+
}
170+
171+
res, err := c.Run(ctx, args...)
172+
if err != nil {
173+
return err
174+
}
175+
if res.ExitCode != 0 {
176+
return fmt.Errorf("compose down failed (exit %d): %s", res.ExitCode, string(res.Stderr))
177+
}
178+
return nil
179+
}
180+
140181
// SupportsAdditionalContexts returns true if compose version >= 2.17.0.
141182
func (c *ComposeClient) SupportsAdditionalContexts() bool {
142183
if len(c.Version) < 2 {

‎internal/docker/engine.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type API interface {
2828
ContainerList(ctx context.Context, options mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error)
2929
ContainerRemove(ctx context.Context, containerID string, options mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
3030
ContainerStart(ctx context.Context, containerID string, options mobyclient.ContainerStartOptions) (mobyclient.ContainerStartResult, error)
31+
ContainerStop(ctx context.Context, containerID string, options mobyclient.ContainerStopOptions) (mobyclient.ContainerStopResult, error)
3132
Events(ctx context.Context, options mobyclient.EventsListOptions) mobyclient.EventsResult
3233
ExecCreate(ctx context.Context, containerID string, options mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error)
3334
ExecAttach(ctx context.Context, execID string, options mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error)
@@ -234,6 +235,14 @@ func (e *EngineClient) StartContainer(ctx context.Context, id string) error {
234235
return err
235236
}
236237

238+
// StopContainer gracefully stops a container (SIGTERM, then SIGKILL after the
239+
// daemon's default grace period) without removing it, so it can be restarted
240+
// with `up`. Stopping an already-stopped container is a no-op.
241+
func (e *EngineClient) StopContainer(ctx context.Context, id string) error {
242+
_, err := e.API.ContainerStop(ctx, id, mobyclient.ContainerStopOptions{})
243+
return err
244+
}
245+
237246
// CreateContainer creates a new container and returns its ID.
238247
func (e *EngineClient) CreateContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (string, error) {
239248
res, err := e.API.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{Config: config, HostConfig: hostConfig})

‎internal/docker/engine_test.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type mockAPI struct {
2525
containerListFn func(ctx context.Context, opts mobyclient.ContainerListOptions) ([]container.Summary, error)
2626
containerRemoveFn func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) error
2727
containerStartFn func(ctx context.Context, id string, opts mobyclient.ContainerStartOptions) error
28+
containerStopFn func(ctx context.Context, id string, opts mobyclient.ContainerStopOptions) error
2829
eventsFn func(ctx context.Context, opts mobyclient.EventsListOptions) (<-chan events.Message, <-chan error)
2930
imageInspectFn func(ctx context.Context, id string, opts ...mobyclient.ImageInspectOption) (image.InspectResponse, error)
3031
imagePullFn func(ctx context.Context, ref string, opts mobyclient.ImagePullOptions) (mobyclient.ImagePullResponse, error)
@@ -72,6 +73,13 @@ func (m *mockAPI) ContainerStart(ctx context.Context, id string, opts mobyclient
7273
return mobyclient.ContainerStartResult{}, errors.New("not implemented")
7374
}
7475

76+
func (m *mockAPI) ContainerStop(ctx context.Context, id string, opts mobyclient.ContainerStopOptions) (mobyclient.ContainerStopResult, error) {
77+
if m.containerStopFn != nil {
78+
return mobyclient.ContainerStopResult{}, m.containerStopFn(ctx, id, opts)
79+
}
80+
return mobyclient.ContainerStopResult{}, nil
81+
}
82+
7583
func (m *mockAPI) ImagePull(ctx context.Context, ref string, opts mobyclient.ImagePullOptions) (mobyclient.ImagePullResponse, error) {
7684
if m.imagePullFn != nil {
7785
return m.imagePullFn(ctx, ref, opts)
@@ -227,6 +235,20 @@ func TestListContainers(t *testing.T) {
227235
}
228236
}
229237

238+
func TestStopContainer(t *testing.T) {
239+
var gotID string
240+
api := &mockAPI{containerStopFn: func(_ context.Context, id string, _ mobyclient.ContainerStopOptions) error {
241+
gotID = id
242+
return nil
243+
}}
244+
if err := newTestEngine(api).StopContainer(t.Context(), "abc123"); err != nil {
245+
t.Fatalf("StopContainer: %v", err)
246+
}
247+
if gotID != "abc123" {
248+
t.Errorf("stopped %q, want abc123", gotID)
249+
}
250+
}
251+
230252
func TestRemoveContainer(t *testing.T) {
231253
// Each case shares the same call shape (increment counter, return an error
232254
// derived from the current call count) but varies the retry-driving events

0 commit comments

Comments
 (0)