diff --git a/cmd/stop.go b/cmd/stop.go new file mode 100644 index 0000000..02b6791 --- /dev/null +++ b/cmd/stop.go @@ -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) +} diff --git a/go.mod b/go.mod index 627f577..ceeb388 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/internal/container/start.go b/internal/container/start.go index feb969e..73df1d2 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -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" @@ -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) + } + onProgress(fmt.Sprintf("Pulling %s...", config.Image)) progress := make(chan runtime.PullProgress) go func() { diff --git a/internal/container/stop.go b/internal/container/stop.go new file mode 100644 index 0000000..cdac02d --- /dev/null +++ b/internal/container/stop.go @@ -0,0 +1,31 @@ +package container + +import ( + "context" + "fmt" + + "github.com/containerd/errdefs" + "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 { + if errdefs.IsNotFound(err) { + return fmt.Errorf("%s is not running", name) + } + return fmt.Errorf("failed to stop %s: %w", name, err) + } + onProgress(fmt.Sprintf("%s stopped", name)) + } + + return nil +} diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index 59c73bc..739e65f 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -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{}) +} + func (d *DockerRuntime) IsRunning(ctx context.Context, containerID string) (bool, error) { inspect, err := d.client.ContainerInspect(ctx, containerID) if err != nil { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7a95884..caeb6af 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -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) } diff --git a/test/integration/stop_test.go b/test/integration/stop_test.go new file mode 100644 index 0000000..01cad28 --- /dev/null +++ b/test/integration/stop_test.go @@ -0,0 +1,92 @@ +package integration_test + +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" +) + +const testImage = "alpine:latest" + +func startTestContainer(t *testing.T, ctx context.Context) { + t.Helper() + + reader, err := dockerClient.ImagePull(ctx, testImage, image.PullOptions{}) + require.NoError(t, err, "failed to pull test image") + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + + resp, err := dockerClient.ContainerCreate(ctx, &container.Config{ + Image: testImage, + 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") +} + +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") +}