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
40 changes: 37 additions & 3 deletions internal/container/running.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,55 @@ package container
import (
"context"
"fmt"
"strings"

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

func AnyRunning(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig) (bool, error) {
for _, c := range containers {
running, err := rt.IsRunning(ctx, c.Name())
name, err := resolveRunningContainerName(ctx, rt, c)
if err != nil {
return false, fmt.Errorf("checking %s running: %w", c.Name(), err)
return false, err
}
if running {
if name != "" {
return true, nil
}
}

return false, nil
}

// First checks the configured name,
// then falls back to FindRunningByImage for containers started outside lstk
func resolveRunningContainerName(ctx context.Context, rt runtime.Runtime, c config.ContainerConfig) (string, error) {
running, err := rt.IsRunning(ctx, c.Name())
if err != nil {
return "", fmt.Errorf("checking %s running: %w", c.Name(), err)
}
if running {
return c.Name(), nil
}

image, err := c.Image()
if err != nil {
return "", err
}
imageRepo, _, _ := strings.Cut(image, ":")

containerPort, err := c.ContainerPort()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue: Not sure if this is a good line to comment on or if the network I'm is more restrictive, but running on a different port when an instance is running on the default port, users get this error.

# CLIv1 command
localstack start

# CLIv2 config
port = '4567'
✗ failed to start LocalStack: Error response from daemon: failed to set up
  container networking: driver failed programming external connectivity on endpoint
  localstack-aws
  (f52076ed620d767b8dd174c90008fdee66568bed361521e808214e72712e7529): Bind for
  127.0.0.1:443 failed: port is already allocated
Image

if err != nil {
return "", err
}

found, err := rt.FindRunningByImage(ctx, imageRepo, containerPort, c.Port)
if err != nil {
return "", fmt.Errorf("failed to scan for running containers: %w", err)
}
if found != nil {
return found.Name, nil
}

return "", nil
}
49 changes: 49 additions & 0 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,34 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu
output.EmitInfo(sink, "LocalStack is already running")
continue
}

imageRepo, _, _ := strings.Cut(c.Image, ":")
found, err := rt.FindRunningByImage(ctx, imageRepo, c.ContainerPort, c.Port)
if err != nil {
return nil, fmt.Errorf("failed to scan for running containers: %w", err)
}
if found != nil {
runningTag := imageTagFrom(found.Image)
configTag := c.Tag
if configTag == "" {
configTag = "latest"
}
if runningTag != configTag {
output.EmitWarning(sink, fmt.Sprintf(
"Found running LocalStack %s, config specifies %s — using the running instance",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue: Saying using the running instance does not make much sense for start and stop commands. It makes more sense for status or similar commands we add later but even then does not add much value I think. Also the wrapping and intendation used on the command output looks off a bit. 💭

Image

runningTag, configTag,
Comment on lines +363 to +364
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue: When using 2026.03 with CLIv1 and CLIv2 I see the warning showing up. Maybe the tag is using the commit suffix and confuses the CLI?

# CLIv1 command
localstack start -s localstack:2026.03

# CLIv2 config
tag = '2026.03'
> Warning: LocalStack 2026.3.0:e28f8cfa4 is already running on port 4566 (config specifies 2026.03) — using the running instance

))
} else {
output.EmitInfo(sink, "LocalStack is already running")
}
continue
}

if err := ports.CheckAvailable(c.Port); err != nil {
if info, infoErr := fetchLocalStackInfo(ctx, c.Port); infoErr == nil {
emitLocalStackAlreadyRunningWarning(sink, c.Port, info.Version, c.Tag)
continue
}
emitPortInUseError(sink, c.Port)
emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodePortConflict, err.Error())
return nil, output.NewSilentError(err)
Expand All @@ -356,6 +383,28 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu
return filtered, nil
}

func imageTagFrom(image string) string {
_, tag, found := strings.Cut(image, ":")
if !found || tag == "" {
return "latest"
}
return tag
}

func emitLocalStackAlreadyRunningWarning(sink output.Sink, port, runningVersion, configTag string) {
if configTag == "" {
configTag = "latest"
}
if runningVersion != configTag {
output.EmitWarning(sink, fmt.Sprintf(
"LocalStack %s is already running on port %s (config specifies %s) — using the running instance",
runningVersion, port, configTag,
))
} else {
output.EmitInfo(sink, fmt.Sprintf("LocalStack %s is already running on port %s", runningVersion, port))
}
}

func emitPortInUseError(sink output.Sink, port string) {
actions := []output.ErrorAction{
{Label: "Stop existing emulator:", Value: "lstk stop"},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion: Not the best line to comment on, but stop and status should never refuse to operate on a found container because of a version mismatch.

Expand Down
86 changes: 86 additions & 0 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"context"
"errors"
"io"
"net"
"strconv"
"testing"

"github.com/localstack/lstk/internal/log"
Expand Down Expand Up @@ -53,6 +55,90 @@ func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) {
assert.Contains(t, got, "> Tip:")
}

func TestSelectContainersToStart_AttachesExternalContainerWithMatchingVersion(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)

c := runtime.ContainerConfig{
Image: "localstack/localstack-pro:3.5.0",
Name: "localstack-aws-3.5.0",
Tag: "3.5.0",
Port: "4566",
ContainerPort: "4566/tcp",
}

mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), "localstack/localstack-pro", "4566/tcp", "4566").
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)

var out bytes.Buffer
sink := output.NewPlainSink(&out)

result, err := selectContainersToStart(context.Background(), mockRT, sink, nil, []runtime.ContainerConfig{c})

require.NoError(t, err)
assert.Empty(t, result, "container should be skipped (already running)")
assert.Contains(t, out.String(), "already running")
assert.NotContains(t, out.String(), "config specifies")
}

func TestSelectContainersToStart_WarnsAndAttachesOnVersionMismatch(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)

c := runtime.ContainerConfig{
Image: "localstack/localstack-pro:3.4.0",
Name: "localstack-aws-3.4.0",
Tag: "3.4.0",
Port: "4566",
ContainerPort: "4566/tcp",
}

mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), "localstack/localstack-pro", "4566/tcp", "4566").
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)

var out bytes.Buffer
sink := output.NewPlainSink(&out)

result, err := selectContainersToStart(context.Background(), mockRT, sink, nil, []runtime.ContainerConfig{c})

require.NoError(t, err)
assert.Empty(t, result, "container should be skipped (already running)")
assert.Contains(t, out.String(), "3.5.0", "should mention running version")
assert.Contains(t, out.String(), "3.4.0", "should mention configured version")
}

func TestSelectContainersToStart_QueuesContainerWhenNoneRunningOnPort(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)

// Use a free port by binding one and immediately releasing it.
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
freePort := ln.Addr().(*net.TCPAddr).Port
require.NoError(t, ln.Close())

c := runtime.ContainerConfig{
Image: "localstack/localstack-pro:3.5.0",
Name: "localstack-aws-3.5.0",
Tag: "3.5.0",
Port: strconv.Itoa(freePort),
ContainerPort: "4566/tcp",
}

mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), "localstack/localstack-pro", "4566/tcp", c.Port).
Return(nil, nil)

sink := output.NewPlainSink(io.Discard)

result, err := selectContainersToStart(context.Background(), mockRT, sink, nil, []runtime.ContainerConfig{c})

require.NoError(t, err)
assert.Equal(t, []runtime.ContainerConfig{c}, result, "container should be queued for start")
}

func TestServicePortRange_ReturnsExpectedPorts(t *testing.T) {
ports := servicePortRange()

Expand Down
9 changes: 4 additions & 5 deletions internal/container/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,19 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain
defer cancel()

for _, c := range containers {
name := c.Name()
running, err := rt.IsRunning(ctx, name)
name, err := resolveRunningContainerName(ctx, rt, c)
if err != nil {
return fmt.Errorf("checking %s running: %w", name, err)
return fmt.Errorf("checking %s running: %w", c.Name(), err)
}
if !running {
if name == "" {
output.EmitError(sink, output.ErrorEvent{
Title: fmt.Sprintf("%s is not running", c.DisplayName()),
Actions: []output.ErrorAction{
{Label: "Start LocalStack:", Value: "lstk"},
{Label: "See help:", Value: "lstk -h"},
},
})
return output.NewSilentError(fmt.Errorf("%s is not running", name))
return output.NewSilentError(fmt.Errorf("%s is not running", c.Name()))
}

// status makes direct HTTP calls to LocalStack, so it needs the actual host port.
Expand Down
1 change: 1 addition & 0 deletions internal/container/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func TestStatus_MultipleContainers_StopsAtFirstNotRunning(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), "localstack/localstack-pro", "4566/tcp", gomock.Any()).Return(nil, nil)

containers := []config.ContainerConfig{
{Type: config.EmulatorAWS},
Expand Down
10 changes: 3 additions & 7 deletions internal/container/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,11 @@ type StopOptions struct {
func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, opts StopOptions) error {
const stopTimeout = 30 * time.Second
for _, c := range containers {
name := c.Name()

checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second)
running, err := rt.IsRunning(checkCtx, name)
checkCancel()
name, err := resolveRunningContainerName(ctx, rt, c)
if err != nil {
return fmt.Errorf("checking %s running: %w", name, err)
return err
}
if !running {
if name == "" {
return fmt.Errorf("LocalStack is not running")
}

Expand Down
47 changes: 46 additions & 1 deletion internal/runtime/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
Expand Down Expand Up @@ -230,7 +231,12 @@ 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{})
err := d.client.ContainerRemove(ctx, containerName, container.RemoveOptions{})
// Ignore conflict and not-found: container is gone, which is the goal.
if err != nil && !errdefs.IsConflict(err) && !errdefs.IsNotFound(err) {
return err
}
return nil
}

func (d *DockerRuntime) Remove(ctx context.Context, containerName string) error {
Expand Down Expand Up @@ -329,6 +335,45 @@ func (d *DockerRuntime) GetBoundPort(ctx context.Context, containerName string,
return bindings[0].HostPort, nil
}

func (d *DockerRuntime) FindRunningByImage(ctx context.Context, imageRepo string, containerPort string, hostPort string) (*RunningContainer, error) {
list, err := d.client.ContainerList(ctx, container.ListOptions{
Filters: filters.NewArgs(filters.Arg("status", "running")),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue: Not sure if this line is best line to commment on, but would expect status to run normally when the version is different.

# CLIv1 command
localstack start -s localstack:2026.03

# CLIv2 config
tag = '2026.03'
START STATUS
Image Image

Copy link
Copy Markdown
Member

@gtsiolis gtsiolis Apr 16, 2026

Choose a reason for hiding this comment

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

On this scope, there are some UX changes we can improve in the existing status command output, but can pick this up in a follow up PR.

  • Wrapping early
  • Tone down labels
  • Replace emulator terminology
  • [...]
Screenshot 2026-04-16 at 19 10 04

})
if err != nil {
return nil, err
}

portStr, proto, found := strings.Cut(containerPort, "/")
if !found {
proto = "tcp"
}
privatePort, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid container port %q: %w", containerPort, err)
}

prefix := imageRepo + ":"
for _, c := range list {
if c.Image != imageRepo && !strings.HasPrefix(c.Image, prefix) {
continue
}
for _, p := range c.Ports {
if p.PrivatePort == uint16(privatePort) && p.Type == proto && strconv.Itoa(int(p.PublicPort)) == hostPort {
name := ""
if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/")
}
return &RunningContainer{
Name: name,
Image: c.Image,
BoundPort: hostPort,
}, nil
}
}
}
return nil, nil
}

func (d *DockerRuntime) GetImageVersion(ctx context.Context, imageName string) (string, error) {
inspect, err := d.client.ImageInspect(ctx, imageName)
if err != nil {
Expand Down
15 changes: 15 additions & 0 deletions internal/runtime/mock_runtime.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ type PullProgress struct {
Total int64
}

type RunningContainer struct {
Name string
Image string // full image with tag, e.g. "localstack/localstack-pro:3.5.0"
BoundPort string // host port bound to the queried container port
}

// Runtime abstracts container runtime operations (Docker, Podman, Kubernetes, etc.)
type Runtime interface {
IsHealthy(ctx context.Context) error
Expand All @@ -58,5 +64,6 @@ type Runtime interface {
GetImageVersion(ctx context.Context, imageName string) (string, error)
// GetBoundPort returns the host port bound to the given container port (e.g. "4566/tcp").
GetBoundPort(ctx context.Context, containerName string, containerPort string) (string, error)
FindRunningByImage(ctx context.Context, imageRepo string, containerPort string, hostPort string) (*RunningContainer, error)
SocketPath() string
}
Loading