Skip to content

Conversation

@gtsiolis
Copy link
Member

@gtsiolis gtsiolis commented Feb 11, 2026

Fix DES-108

Screenshot 2026-02-11 at 17 43 13 (2)

Summary by CodeRabbit

Release Notes

  • New Features
    • Added a new CLI "stop" command to shut down the LocalStack emulator. Users can now easily stop running instances with a simple command.

msg := e.Err.Error()

if strings.Contains(msg, "No such container") || strings.Contains(msg, "not found") {
return fmt.Sprintf("%s is not running", e.Name)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: It seems like the docker sdk is defining functions that can be used to check the error instead of doing string comparison: https://pkg.go.dev/github.com/containerd/errdefs#IsNotFound

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in 1f8e3eb.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this change I don't think there is a need to define a custom error StopError anymore

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch! Changed in a55d50a.

@coderabbitai
Copy link

coderabbitai bot commented Feb 11, 2026

📝 Walkthrough

Walkthrough

This PR introduces a new "stop" subcommand for the CLI that stops and removes LocalStack containers. The implementation spans infrastructure layers: a new Stop and Remove interface contract in the runtime abstraction, corresponding Docker implementations, container orchestration logic, and integration tests that validate the stop behavior.

Changes

Cohort / File(s) Summary
CLI Command Layer
cmd/stop.go
Adds a new stopCmd subcommand that initializes a Docker runtime and invokes the container stop orchestration via a progress callback.
Container Orchestration
internal/container/start.go, internal/container/stop.go
Adds pre-cleanup in Start to remove existing containers before pulling images; introduces new Stop function that iterates over configured containers and stops them with progress reporting.
Runtime Abstraction
internal/runtime/runtime.go, internal/runtime/docker.go
Expands Runtime interface with Stop and Remove methods; implements these methods in DockerRuntime to stop and remove containers via Docker daemon calls.
Dependency Management
go.mod
Promotes github.com/containerd/errdefs from indirect to direct dependency.
Integration Tests
test/integration/stop_test.go
Adds three test functions validating stop command success, failure when container is not running, and idempotent behavior.

Sequence Diagram

sequenceDiagram
    actor User
    participant CLI as CLI Command<br/>(stop.go)
    participant Orchestration as Container<br/>Orchestration<br/>(container/stop.go)
    participant Runtime as Docker<br/>Runtime<br/>(runtime/docker.go)
    participant Docker as Docker<br/>Daemon

    User->>CLI: Execute stop command
    CLI->>CLI: Create DockerRuntime
    CLI->>Orchestration: Call Stop(ctx, rt, onProgress)
    Orchestration->>Orchestration: Load configuration
    Orchestration->>Orchestration: Iterate containers
    loop For each container
        Orchestration->>Orchestration: Emit onProgress (starting)
        Orchestration->>Runtime: Stop(ctx, containerName)
        Runtime->>Docker: ContainerStop
        Docker->>Docker: Stop container
        Docker-->>Runtime: OK
        Runtime->>Docker: ContainerRemove
        Docker->>Docker: Remove container
        Docker-->>Runtime: OK
        Orchestration->>Orchestration: Emit onProgress (stopped)
    end
    Orchestration-->>CLI: Return result
    CLI-->>User: Output progress/status
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Poem

🐰 The containers now rest at our command,
With Stop and Remove working hand in hand,
A hop through the layers, from CLI down deep,
We've crafted a way to make cleanup so sweet! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "Add stop command" clearly and specifically summarizes the main change: introducing a new CLI stop subcommand to stop the LocalStack emulator.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch george/des-108

Comment @coderabbitai help to get the list of available commands and usage tips.

@gtsiolis
Copy link
Member Author

Some of the checks also fail in main. 👀

@carole-lavillonniere, could you take another look when you get a chance? 😁

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@test/integration/stop_test.go`:
- Around line 14-22: The test fails when the image is missing because
startTestContainer calls dockerClient.ContainerCreate without pulling the image;
update startTestContainer to explicitly pull "alpine:latest" before calling
dockerClient.ContainerCreate by invoking dockerClient.ImagePull(ctx,
"alpine:latest", types.ImagePullOptions{}), reading and closing the returned
io.ReadCloser to completion, checking for errors, then proceed with
dockerClient.ContainerCreate and dockerClient.ContainerStart (use the same
containerName/resp.ID as before).

Comment on lines +14 to +22
func startTestContainer(t *testing.T, ctx context.Context) {
t.Helper()
resp, err := dockerClient.ContainerCreate(ctx, &container.Config{
Image: "alpine:latest",
Cmd: []string{"sleep", "infinity"},
}, nil, nil, nil, containerName)
require.NoError(t, err, "failed to create test container")
err = dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{})
require.NoError(t, err, "failed to start test container")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the test file
find . -name "stop_test.go" -path "*/test/integration/*" | head -5

Repository: localstack/lstk

Length of output: 90


🏁 Script executed:

# Read the test file to see the current implementation
cat -n test/integration/stop_test.go

Repository: localstack/lstk

Length of output: 3076


🏁 Script executed:

# Check the imports already present in the file
head -20 test/integration/stop_test.go

Repository: localstack/lstk

Length of output: 563


🏁 Script executed:

# Search for any CI configuration or workflow files that might show failures
find . -name "*.yml" -o -name "*.yaml" | grep -E "(workflow|ci|github)" | head -10

Repository: localstack/lstk

Length of output: 85


🏁 Script executed:

# Check the CI workflow for test execution and any indicators of failures
cat -n ./.github/workflows/ci.yml | head -100

Repository: localstack/lstk

Length of output: 3019


🏁 Script executed:

# Search for any other references to alpine or docker image pulling in test files
rg "ImagePull|PullOptions" test/

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

# Check if there's a setup function or init in the test file that might handle image pulling
rg -B5 -A5 "func.*dockerClient" test/integration/stop_test.go

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

# Look at the entire stop_test.go file to understand the test setup
tail -n +1 test/integration/stop_test.go | head -100

Repository: localstack/lstk

Length of output: 2502


🏁 Script executed:

# Find where dockerClient is defined
rg "var dockerClient|dockerClient\s*=" test/integration/

Repository: localstack/lstk

Length of output: 120


🏁 Script executed:

# Search for any test setup or init functions
rg "func init|func setup|func Setup" test/integration/

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

# Check if there are other files in the integration test directory
ls -la test/integration/

Repository: localstack/lstk

Length of output: 759


🏁 Script executed:

# Look for the dockerClient initialization
rg -B10 "dockerClient" test/integration/stop_test.go | head -30

Repository: localstack/lstk

Length of output: 1013


🏁 Script executed:

# Check the main_test.go file for dockerClient initialization and setup
cat -n test/integration/main_test.go

Repository: localstack/lstk

Length of output: 3051


🏁 Script executed:

# Search for docker image pulling in the entire test directory
rg "ImagePull|PullOptions" test/

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

# Check if alpine image is pre-pulled or if there's a makefile target that sets it up
cat -n Makefile | grep -A10 "test-integration"

Repository: localstack/lstk

Length of output: 1018


🏁 Script executed:

# Check if there's any docker image pre-pulling or setup in CI or any other helper scripts
rg "pull.*alpine|docker.*pull" .github/ Makefile scripts/ 2>/dev/null || echo "No docker pull found in CI/Makefile"

Repository: localstack/lstk

Length of output: 94


🏁 Script executed:

# Verify the docker client API - check if ImagePull is the correct method
rg "ImagePull" test/integration/ || echo "ImagePull not used yet"

Repository: localstack/lstk

Length of output: 81


🏁 Script executed:

# Check start_test.go to see if it has a similar pattern
cat -n test/integration/start_test.go | grep -A20 "startTestContainer"

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

# Check git history to see if there were recent test failures or commits related to this
git log --oneline test/integration/stop_test.go 2>/dev/null | head -10

Repository: localstack/lstk

Length of output: 82


🏁 Script executed:

# Look for any docker-compose files that might pre-pull images
find . -name "docker-compose*" -o -name ".dockerignore" | head -10

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

# Verify the docker client library version and check if ImagePull behavior is as described
rg "github.com/docker/docker" test/integration/go.mod

Repository: localstack/lstk

Length of output: 105


🏁 Script executed:

# Verify the Docker Go client ImagePull API signature
rg -A5 "func.*ImagePull" test/integration/ || echo "Checking Docker client docs..."

Repository: localstack/lstk

Length of output: 89


🏁 Script executed:

# Check if there are any recent test runs or logs that show failures
find . -name "*test*results*" -o -name "*junit*" 2>/dev/null | head -5

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

# Verify the proposed fix compiles by checking if the imports are correct for this version
rg "github.com/docker/docker/api/types/image" test/integration/ || echo "image package not yet imported"

Repository: localstack/lstk

Length of output: 89


Pull the test image before creating the container.

ContainerCreate doesn't auto-pull missing images. Without pulling alpine:latest, the test container creation will fail on systems where the image isn't pre-cached (such as CI runners). Add the image pull before container creation.

Proposed fix
@@
 import (
 	"context"
+	"io"
 	"os/exec"
 	"testing"
 	"time"
 
 	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/image"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@
 func startTestContainer(t *testing.T, ctx context.Context) {
 	t.Helper()
+	reader, err := dockerClient.ImagePull(ctx, "alpine:latest", image.PullOptions{})
+	require.NoError(t, err, "failed to pull test image")
+	defer func() {
+		_ = reader.Close()
+	}()
+	_, err = io.Copy(io.Discard, reader)
+	require.NoError(t, err, "failed to read image pull output")
+
 	resp, err := dockerClient.ContainerCreate(ctx, &container.Config{
 		Image: "alpine:latest",
 		Cmd:   []string{"sleep", "infinity"},
 	}, nil, nil, nil, containerName)
 	require.NoError(t, err, "failed to create test container")
 	err = dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{})
 	require.NoError(t, err, "failed to start test container")
 }
🤖 Prompt for AI Agents
In `@test/integration/stop_test.go` around lines 14 - 22, The test fails when the
image is missing because startTestContainer calls dockerClient.ContainerCreate
without pulling the image; update startTestContainer to explicitly pull
"alpine:latest" before calling dockerClient.ContainerCreate by invoking
dockerClient.ImagePull(ctx, "alpine:latest", types.ImagePullOptions{}), reading
and closing the returned io.ReadCloser to completion, checking for errors, then
proceed with dockerClient.ContainerCreate and dockerClient.ContainerStart (use
the same containerName/resp.ID as before).

@carole-lavillonniere
Copy link
Collaborator

Some of the checks also fail in main. 👀

@carole-lavillonniere, could you take another look when you get a chance? 😁

We are aware of the integration tests failing on windows/main, just created an issue: DRG-505

However there are more tests failing in this PR, namely:

=== RUN   TestStopCommandSucceeds
    stop_test.go:33: 
        	Error Trace:	/home/runner/work/lstk/lstk/test/integration/stop_test.go:20
        	            				/home/runner/work/lstk/lstk/test/integration/stop_test.go:33
        	Error:      	Received unexpected error:
        	            	Error response from daemon: No such image: alpine:latest
        	Test:       	TestStopCommandSucceeds
        	Messages:   	failed to create test container
--- FAIL: TestStopCommandSucceeds (0.00s)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@internal/container/start.go`:
- Around line 53-57: The error check in the container removal loop uses
containerd's errdefs, causing Docker-specific errors to be misclassified; update
the import and check to use Docker's errdefs: replace usages of
containerd/errdefs.IsNotFound(err) with
github.com/docker/docker/errdefs.IsNotFound(err) (the removal loop calling
rt.Remove and the DockerRuntime.Remove implementation will then correctly allow
not-found errors to be ignored).

In `@internal/runtime/docker.go`:
- Around line 99-107: The Stop implementation currently calls ContainerRemove
with empty RemoveOptions which leaves volumes behind; update DockerRuntime.Stop
to call ContainerRemove(ctx, containerName,
container.RemoveOptions{RemoveVolumes: true}) so volumes are removed when
stopping a container (and consider making the same change for
DockerRuntime.Remove if removal should always include volumes), or alternatively
add a comment/docstring on DockerRuntime.Stop and DockerRuntime.Remove
clarifying that volumes are intentionally retained.
🧹 Nitpick comments (1)
cmd/stop.go (1)

12-31: Use RunE to return errors instead of calling os.Exit directly.

This is the recommended Cobra pattern for commands that can fail. It improves testability and lets Cobra handle error output and exit codes consistently. Errors returned from RunE are processed at the top-level Execute() call, which handles the exit code once instead of scattered throughout the command logic.

♻️ Proposed refactor
 var stopCmd = &cobra.Command{
 	Use:   "stop",
 	Short: "Stop LocalStack",
 	Long:  "Stop the LocalStack emulator.",
-	Run: func(cmd *cobra.Command, args []string) {
+	RunE: func(cmd *cobra.Command, args []string) error {
 		rt, err := runtime.NewDockerRuntime()
 		if err != nil {
-			fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-			os.Exit(1)
+			return err
 		}
 
 		onProgress := func(msg string) {
-			fmt.Println(msg)
+			fmt.Fprintln(cmd.OutOrStdout(), msg)
 		}
 
 		if err := container.Stop(cmd.Context(), rt, onProgress); err != nil {
-			fmt.Fprintln(os.Stderr, err)
-			os.Exit(1)
+			return err
 		}
+		return nil
 	},
 }

Comment on lines 53 to +57
for _, config := range containers {
// Remove any existing stopped container with the same name
if err := rt.Remove(ctx, config.Name); err != nil && !errdefs.IsNotFound(err) {
return fmt.Errorf("failed to remove existing container %s: %w", config.Name, err)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the file structure and locate the relevant code
head -60 internal/container/start.go | cat -n

Repository: localstack/lstk

Length of output: 1912


🏁 Script executed:

# Check imports in the start.go file to understand what errdefs is being used
head -20 internal/container/start.go | cat -n

Repository: localstack/lstk

Length of output: 630


🏁 Script executed:

# Search for the rt.Remove definition and understand what rt is
rg -B 10 "rt.Remove" internal/container/start.go

Repository: localstack/lstk

Length of output: 364


🏁 Script executed:

# Look for the runtime type definition in the codebase
rg "type.*Runtime" internal/container/ --max-count 5

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

# Check for Docker/containerd imports across the codebase
rg "containerd|docker" internal/container/start.go

Repository: localstack/lstk

Length of output: 91


🏁 Script executed:

# Find the runtime package and interface definition
find internal/runtime -type f -name "*.go" | head -10

Repository: localstack/lstk

Length of output: 113


🏁 Script executed:

# Look for the Runtime interface definition
rg "type Runtime interface" internal/runtime/

Repository: localstack/lstk

Length of output: 111


🏁 Script executed:

# Search for Remove method implementation
rg -A 10 "func.*Remove" internal/runtime/ --max-count 5

Repository: localstack/lstk

Length of output: 749


🏁 Script executed:

# Check what Docker client is being used in the runtime implementation
rg "docker|Docker" internal/runtime/ | head -20

Repository: localstack/lstk

Length of output: 1448


🏁 Script executed:

# Let's check the complete Remove method and error handling in docker.go
cat -n internal/runtime/docker.go | sed -n '/func (d \*DockerRuntime) Remove/,/^[0-9]*\tFunc/p' | head -20

Repository: localstack/lstk

Length of output: 840


🏁 Script executed:

# Check if there's any error handling or conversion in the docker.go file
cat internal/runtime/docker.go | head -50

Repository: localstack/lstk

Length of output: 1236


🏁 Script executed:

# Look for any other error handling in docker.go to see patterns
rg "errdefs\|error" internal/runtime/docker.go

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

# Check the complete docker.go file size and content
wc -l internal/runtime/docker.go && echo "---" && cat internal/runtime/docker.go

Repository: localstack/lstk

Length of output: 3568


Replace containerd/errdefs.IsNotFound() with docker/errdefs.IsNotFound() for Docker error compatibility.

The Remove() method in DockerRuntime returns errors directly from the Docker client's ContainerRemove(), which returns Docker-specific error types from the docker/errdefs package. However, the error check at line 55 uses containerd/errdefs.IsNotFound(), which cannot recognize Docker errors. This causes the code to fail when a container doesn't exist instead of gracefully skipping the removal, breaking fresh starts.

Import github.com/docker/docker/errdefs and use errdefs.IsNotFound(err) from the Docker package instead of the containerd version.

🤖 Prompt for AI Agents
In `@internal/container/start.go` around lines 53 - 57, The error check in the
container removal loop uses containerd's errdefs, causing Docker-specific errors
to be misclassified; update the import and check to use Docker's errdefs:
replace usages of containerd/errdefs.IsNotFound(err) with
github.com/docker/docker/errdefs.IsNotFound(err) (the removal loop calling
rt.Remove and the DockerRuntime.Remove implementation will then correctly allow
not-found errors to be ignored).

Comment on lines +99 to +107
func (d *DockerRuntime) Stop(ctx context.Context, containerName string) error {
if err := d.client.ContainerStop(ctx, containerName, container.StopOptions{}); err != nil {
return err
}
return d.client.ContainerRemove(ctx, containerName, container.RemoveOptions{})
}

func (d *DockerRuntime) Remove(ctx context.Context, containerName string) error {
return d.client.ContainerRemove(ctx, containerName, container.RemoveOptions{})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

github.com/docker/docker v28 ContainerStopOptions defaults timeout

💡 Result:

In github.com/docker/docker v28, ContainerStopOptions (API type container.StopOptions) has:

  • Timeout *int (seconds). If you don’t set it, it stays nil (so it’s omitted). [1]

What happens when Timeout == nil:

  • The client won’t send the t/timeout query parameter at all. [2]
  • The daemon then uses the container’s configured StopTimeout (if set); otherwise it falls back to the engine default. [3]

Engine default stop timeout (when neither API timeout nor per-container StopTimeout is set):

  • non-Windows: 10 seconds (defaultStopTimeout = 10) [4]
  • Windows: 30 seconds (defaultStopTimeout = 30) [5]

Sources
[1] (pkg.go.dev)
[2] (fuchsia.googlesource.com)
[3] (pkg.go.dev)
[4] (fossies.org)
[5] (fossies.org)

Citations:


🌐 Web query:

github.com/docker/docker v28 ContainerRemoveOptions defaults volume removal

💡 Result:

In github.com/docker/docker v28, ContainerRemoveOptions.RemoveVolumes is a bool with no explicit default set in code, so its zero-value default is false (i.e., volumes are not removed unless you set it). [1]

This matches the Docker Engine API: DELETE /containers/{id} has query param v (“remove volumes”) with default false. [2]

So, to remove volumes you must opt in, e.g.:

  • Go: ContainerRemoveOptions{RemoveVolumes: true}
  • API: DELETE .../containers/{id}?v=1
  • CLI: docker rm -v ... / --volumes [3]

Sources: [1] [2] [3]


Clarify whether volumes should be removed in the Stop operation.

The Stop() method uses empty StopOptions{}, which defers to the Docker daemon's default timeout (10 seconds on non-Windows, 30 seconds on Windows)—a reasonable default. However, ContainerRemove() with empty RemoveOptions{} leaves volumes behind by default (equivalent to docker rm without -v). If lstk stop is meant to fully clean up the container and its associated resources, set RemoveVolumes: true in the RemoveOptions:

container.RemoveOptions{RemoveVolumes: true}

Otherwise, document the volume retention behavior if it is intentional.

🤖 Prompt for AI Agents
In `@internal/runtime/docker.go` around lines 99 - 107, The Stop implementation
currently calls ContainerRemove with empty RemoveOptions which leaves volumes
behind; update DockerRuntime.Stop to call ContainerRemove(ctx, containerName,
container.RemoveOptions{RemoveVolumes: true}) so volumes are removed when
stopping a container (and consider making the same change for
DockerRuntime.Remove if removal should always include volumes), or alternatively
add a comment/docstring on DockerRuntime.Stop and DockerRuntime.Remove
clarifying that volumes are intentionally retained.

@gtsiolis gtsiolis self-assigned this Feb 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants