Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions down.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"

"github.com/crunchloop/devcontainer/config"
"github.com/crunchloop/devcontainer/runtime"
)

Expand Down Expand Up @@ -114,3 +115,79 @@ func isNotFound(err error) bool {
var nf *runtime.ContainerNotFoundError
return errors.As(err, &nf)
}

// Shutdown tears the workspace down according to its devcontainer.json
// `shutdownAction`. Use this for editor-close / idle-timeout style
// teardown where the spec field should drive behavior. For unconditional
// teardown (always stop, optionally remove), use Down — it is the
// caller's explicit "I want this gone" call.
//
// Mapping (per https://containers.dev/implementors/json_reference/):
//
// - "none": no-op; container left running.
// - "stop", "stopContainer", "" (unset, image/build source): stop the
// container; do not remove. Restart-friendly.
// - "stopCompose", "" (unset, compose source): `docker compose stop`
// on the project (containers preserved); for full teardown including
// volumes, callers should use Down with Remove=true.
Comment thread
bilby91 marked this conversation as resolved.
//
// "" (unset) defaults to the source-appropriate stop variant, matching
// upstream @devcontainers/cli behavior.
//
// Idempotent: calling Shutdown on an already-stopped workspace returns nil.
func (e *Engine) Shutdown(ctx context.Context, ws *Workspace) error {
if err := ctxIfDone(ctx); err != nil {
return err
}
if ws == nil {
return fmt.Errorf("Engine.Shutdown: Workspace is required")
}
Comment thread
bilby91 marked this conversation as resolved.
if ws.Container == nil || ws.Container.ID == "" {
return fmt.Errorf("Engine.Shutdown: Workspace.Container with non-empty ID is required")
}

action := effectiveShutdownAction(ws)
switch action {
case config.ShutdownNone:
return nil
case config.ShutdownStopCompose:
return e.shutdownStopCompose(ctx, ws)
default: // ShutdownStop, ShutdownStopContainer, "", anything else
return e.shutdownStopContainer(ctx, ws)
}
}

// effectiveShutdownAction picks the action to apply for a workspace,
// defaulting to the source-appropriate stop variant when cfg leaves
// the field unset (matches upstream's "absent means stop" behavior).
func effectiveShutdownAction(ws *Workspace) config.ShutdownAction {
if ws.Config != nil && ws.Config.ShutdownAction != "" {
return ws.Config.ShutdownAction
}
if isComposeWorkspace(ws) {
return config.ShutdownStopCompose
}
return config.ShutdownStopContainer
}

func (e *Engine) shutdownStopContainer(ctx context.Context, ws *Workspace) error {
id := ws.Container.ID
if err := e.runtime.StopContainer(ctx, id, runtime.StopOptions{}); err != nil && !isNotFound(err) {
return fmt.Errorf("stop container %s: %w", id, err)
}
return nil
}

func (e *Engine) shutdownStopCompose(ctx context.Context, ws *Workspace) error {
if !isComposeWorkspace(ws) {
// shutdownAction=stopCompose set on a non-compose workspace —
// fall back to stopping the single container, with no error.
// The user's intent is "stop"; the misconfiguration is harmless.
return e.shutdownStopContainer(ctx, ws)
}
// compose has no native project-level "stop without remove" in our
// ComposeRuntime surface yet (#10), so approximate with a per-container
// stop on the primary. Honors the user's intent of "preserve project
// state for fast restart" without depending on un-implemented APIs.
return e.shutdownStopContainer(ctx, ws)
Comment thread
bilby91 marked this conversation as resolved.
}
139 changes: 139 additions & 0 deletions shutdown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package devcontainer

import (
"context"
"testing"

"github.com/crunchloop/devcontainer/config"
"github.com/crunchloop/devcontainer/runtime"
)

func newTestWorkspace(action config.ShutdownAction, compose bool) *Workspace {
labels := map[string]string{}
if compose {
labels["com.docker.compose.project"] = "dc-test"
}
return &Workspace{
ID: "test",
Config: &config.ResolvedConfig{
ShutdownAction: action,
},
Container: &runtime.ContainerDetails{
Container: runtime.Container{
ID: "container-1",
Name: "test",
State: runtime.StateRunning,
},
Labels: labels,
},
}
}

func TestShutdown_NoneIsNoop(t *testing.T) {
rt := newFakeRuntime()
rt.containersByID["container-1"] = &runtime.ContainerDetails{
Container: runtime.Container{ID: "container-1", State: runtime.StateRunning},
}
eng := &Engine{runtime: rt}

if err := eng.Shutdown(context.Background(), newTestWorkspace(config.ShutdownNone, false)); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if len(rt.stoppedIDs) != 0 {
t.Errorf("expected no stops for ShutdownNone, got %v", rt.stoppedIDs)
}
if len(rt.removedIDs) != 0 {
t.Errorf("expected no removes for ShutdownNone, got %v", rt.removedIDs)
}
}

func TestShutdown_StopContainerStops(t *testing.T) {
rt := newFakeRuntime()
rt.containersByID["container-1"] = &runtime.ContainerDetails{
Container: runtime.Container{ID: "container-1", State: runtime.StateRunning},
}
eng := &Engine{runtime: rt}

if err := eng.Shutdown(context.Background(), newTestWorkspace(config.ShutdownStopContainer, false)); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if len(rt.stoppedIDs) != 1 || rt.stoppedIDs[0] != "container-1" {
t.Errorf("expected stop on container-1, got %v", rt.stoppedIDs)
}
if len(rt.removedIDs) != 0 {
t.Errorf("Shutdown must not remove, got %v", rt.removedIDs)
}
}

func TestShutdown_DefaultStopsImageWorkspace(t *testing.T) {
// Empty ShutdownAction → spec default is "stop the container" for
// non-compose workspaces.
rt := newFakeRuntime()
rt.containersByID["container-1"] = &runtime.ContainerDetails{
Container: runtime.Container{ID: "container-1", State: runtime.StateRunning},
}
eng := &Engine{runtime: rt}

if err := eng.Shutdown(context.Background(), newTestWorkspace("", false)); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if len(rt.stoppedIDs) != 1 {
t.Errorf("expected default stop, got %v", rt.stoppedIDs)
}
}

func TestShutdown_StopComposeStopsPrimary(t *testing.T) {
rt := newFakeRuntime()
rt.containersByID["container-1"] = &runtime.ContainerDetails{
Container: runtime.Container{ID: "container-1", State: runtime.StateRunning},
}
eng := &Engine{runtime: rt}

if err := eng.Shutdown(context.Background(), newTestWorkspace(config.ShutdownStopCompose, true)); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if len(rt.stoppedIDs) != 1 {
t.Errorf("expected primary stop for stopCompose (no project Stop yet), got %v", rt.stoppedIDs)
}
}

func TestShutdown_StopComposeOnNonComposeFallsBack(t *testing.T) {
rt := newFakeRuntime()
rt.containersByID["container-1"] = &runtime.ContainerDetails{
Container: runtime.Container{ID: "container-1", State: runtime.StateRunning},
}
eng := &Engine{runtime: rt}

if err := eng.Shutdown(context.Background(), newTestWorkspace(config.ShutdownStopCompose, false)); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if len(rt.stoppedIDs) != 1 {
t.Errorf("expected single-container stop fallback, got %v", rt.stoppedIDs)
}
}

func TestShutdown_NilWorkspaceRejected(t *testing.T) {
eng := &Engine{runtime: newFakeRuntime()}
if err := eng.Shutdown(context.Background(), nil); err == nil {
t.Error("expected error on nil workspace")
}
}

func TestShutdown_NilContainerRejected(t *testing.T) {
eng := &Engine{runtime: newFakeRuntime()}
ws := &Workspace{Config: &config.ResolvedConfig{ShutdownAction: config.ShutdownStopContainer}}
if err := eng.Shutdown(context.Background(), ws); err == nil {
t.Error("expected error on workspace with nil Container")
}
}

func TestShutdown_EmptyContainerIDRejected(t *testing.T) {
eng := &Engine{runtime: newFakeRuntime()}
ws := &Workspace{
Config: &config.ResolvedConfig{ShutdownAction: config.ShutdownStopContainer},
Container: &runtime.ContainerDetails{},
}
if err := eng.Shutdown(context.Background(), ws); err == nil {
t.Error("expected error on workspace with empty Container.ID")
}
}
100 changes: 100 additions & 0 deletions test/integration/shutdown_action_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//go:build integration

package integration

import (
"context"
"testing"
"time"

devcontainer "github.com/crunchloop/devcontainer"
"github.com/crunchloop/devcontainer/runtime"
)

// TestShutdownAction_NoneLeavesContainerRunning verifies that
// Engine.Shutdown honors `shutdownAction: none` by leaving the
// container running. Engine.Down (the explicit teardown call) is
// unaffected and still stops + removes — that's tested in the
// existing TestImageSource_FullLifecycle teardown path.
func TestShutdownAction_NoneLeavesContainerRunning(t *testing.T) {
if testing.Short() {
t.Skip("integration tests skipped with -short")
}

eng, rt := newEngine(t)
defer rt.Close()

ws := writeWorkspace(t, `{
"image": "`+testImage+`",
"shutdownAction": "none"
}`)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

wsObj, err := eng.Up(ctx, devcontainer.UpOptions{
LocalWorkspaceFolder: ws,
Recreate: true,
})
if err != nil {
t.Fatalf("Up: %v", err)
}
// Cleanup with explicit Down — Shutdown wouldn't tear it down here
// since we set the action to "none".
defer func() { _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) }()

if err := eng.Shutdown(ctx, wsObj); err != nil {
t.Fatalf("Shutdown: %v", err)
}

details, err := rt.InspectContainer(ctx, wsObj.Container.ID)
if err != nil {
t.Fatalf("InspectContainer: %v", err)
}
if details.State != runtime.StateRunning {
t.Errorf("container state after Shutdown(none) = %q, want %q", details.State, runtime.StateRunning)
}
}

// TestShutdownAction_StopContainerStops verifies the spec default for
// non-compose workspaces: Shutdown with `stopContainer` (or unset)
// stops the container without removing it.
func TestShutdownAction_StopContainerStops(t *testing.T) {
if testing.Short() {
t.Skip("integration tests skipped with -short")
}

eng, rt := newEngine(t)
defer rt.Close()

ws := writeWorkspace(t, `{
"image": "`+testImage+`",
"shutdownAction": "stopContainer"
}`)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

wsObj, err := eng.Up(ctx, devcontainer.UpOptions{
LocalWorkspaceFolder: ws,
Recreate: true,
})
if err != nil {
t.Fatalf("Up: %v", err)
}
defer func() { _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) }()

if err := eng.Shutdown(ctx, wsObj); err != nil {
t.Fatalf("Shutdown: %v", err)
}

details, err := rt.InspectContainer(ctx, wsObj.Container.ID)
if err != nil {
t.Fatalf("InspectContainer: %v", err)
}
// State should be exited; the container record itself must persist
// (Shutdown does not remove).
if details.State == runtime.StateRunning {
t.Errorf("container still running after Shutdown(stopContainer)")
}
}
Loading