Skip to content
Open
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
36 changes: 36 additions & 0 deletions cmd/stop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cmd

import (
"fmt"
"os"

"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/runtime"
"github.com/spf13/cobra"
)

var stopCmd = &cobra.Command{
Use: "stop",
Short: "Stop LocalStack",
Long: "Stop the LocalStack emulator.",
Run: func(cmd *cobra.Command, args []string) {
rt, err := runtime.NewDockerRuntime()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

onProgress := func(msg string) {
fmt.Println(msg)
}

if err := container.Stop(cmd.Context(), rt, onProgress); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
},
}

func init() {
rootCmd.AddCommand(stopCmd)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.25.6

require (
github.com/99designs/keyring v1.2.2
github.com/containerd/errdefs v1.0.0
github.com/docker/docker v28.2.2+incompatible
github.com/docker/go-connections v0.5.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
Expand All @@ -17,7 +18,6 @@ require (
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
Expand Down
6 changes: 6 additions & 0 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"time"

"github.com/containerd/errdefs"
"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/runtime"
Expand Down Expand Up @@ -50,6 +51,11 @@ func Start(ctx context.Context, rt runtime.Runtime, onProgress func(string)) err
}

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)
}
Comment on lines 53 to +57
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).


onProgress(fmt.Sprintf("Pulling %s...", config.Image))
progress := make(chan runtime.PullProgress)
go func() {
Expand Down
27 changes: 27 additions & 0 deletions internal/container/stop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package container

import (
"context"
"fmt"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/runtime"
)

func Stop(ctx context.Context, rt runtime.Runtime, onProgress func(string)) error {
cfg, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}

for _, c := range cfg.Containers {
name := c.Name()
onProgress(fmt.Sprintf("Stopping %s...", name))
if err := rt.Stop(ctx, name); err != nil {
return fmt.Errorf("failed to stop %s: %w", name, err)
}
onProgress(fmt.Sprintf("%s stopped", name))
}

return nil
}
11 changes: 11 additions & 0 deletions internal/runtime/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ func (d *DockerRuntime) Start(ctx context.Context, config ContainerConfig) (stri
return resp.ID, nil
}

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{})
Comment on lines +99 to +107
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.

}

func (d *DockerRuntime) IsRunning(ctx context.Context, containerID string) (bool, error) {
inspect, err := d.client.ContainerInspect(ctx, containerID)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type PullProgress struct {
type Runtime interface {
PullImage(ctx context.Context, image string, progress chan<- PullProgress) error
Start(ctx context.Context, config ContainerConfig) (string, error)
Stop(ctx context.Context, containerName string) error
Remove(ctx context.Context, containerName string) error
IsRunning(ctx context.Context, containerID string) (bool, error)
Logs(ctx context.Context, containerID string, tail int) (string, error)
}
82 changes: 82 additions & 0 deletions test/integration/stop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package integration_test

import (
"context"
"os/exec"
"testing"
"time"

"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

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")
Comment on lines +14 to +22
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).

}

func TestStopCommandSucceeds(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

startTestContainer(t, ctx)

stopCmd := exec.CommandContext(ctx, binaryPath(), "stop")
output, err := stopCmd.CombinedOutput()
require.NoError(t, err, "lstk stop failed: %s", output)

outputStr := string(output)
assert.Contains(t, outputStr, "Stopping", "should show stopping message")
assert.Contains(t, outputStr, "stopped", "should show stopped message")

_, err = dockerClient.ContainerInspect(ctx, containerName)
assert.Error(t, err, "container should not exist after stop")
}

func TestStopCommandFailsWhenNotRunning(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, binaryPath(), "stop")
output, err := cmd.CombinedOutput()

require.Error(t, err, "expected lstk stop to fail when container not running")
assert.Contains(t, string(output), "is not running")
}

func TestStopCommandIsIdempotent(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

startTestContainer(t, ctx)

stopCmd := exec.CommandContext(ctx, binaryPath(), "stop")
output, err := stopCmd.CombinedOutput()
require.NoError(t, err, "first lstk stop failed: %s", output)

_, err = dockerClient.ContainerInspect(ctx, containerName)
require.Error(t, err, "container should not exist after first stop")

stopCmd2 := exec.CommandContext(ctx, binaryPath(), "stop")
_, err = stopCmd2.CombinedOutput()
assert.Error(t, err, "second lstk stop should fail since container already removed")
}
Loading