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
38 changes: 35 additions & 3 deletions internal/container/running.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,53 @@ 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
}

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()
Comment thread
carole-lavillonniere marked this conversation as resolved.
if err != nil {
return "", err
}

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

return "", nil
}
65 changes: 60 additions & 5 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,27 +372,82 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu
emitPostStartPointers(sink, resolvedHost, webAppURL)
continue
}
if err := ports.CheckAvailable(c.Port); err != nil {

imageRepo, _, _ := strings.Cut(c.Image, ":")
found, err := rt.FindRunningByImage(ctx, []string{imageRepo, "localstack/localstack"}, c.ContainerPort)
if err != nil {
return nil, fmt.Errorf("failed to scan for running containers: %w", err)
}
if found != nil {
if found.BoundPort != c.Port {
output.EmitError(sink, output.ErrorEvent{
Title: fmt.Sprintf("LocalStack is already running on port %s", found.BoundPort),
Comment thread
gtsiolis marked this conversation as resolved.
Summary: fmt.Sprintf("Config expects port %s. Only one instance can run at a time.", c.Port),
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.

thought: We previously didn't have this restriction.

Only one instance can run at a time.

I'd expect to be able to spin up more than one instance as long as the port is different. This is how it works now, you can set GATEWAY_LISTEN="0.0.0.0:4567" and start a second instance side by side. See also relevant feedback.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reporting here e the conversation @gtsiolis and I had privately: Because of the fact that the aws emulator also uses other ports (443 and 4510–4559), we are not able to run multiple aws emulators simultaneously at the moment (ports would conflict).

Actions: []output.ErrorAction{
{Label: "Stop existing emulator:", Value: "lstk stop"},
},
})
emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodePortConflict, fmt.Sprintf("running on port %s, configured port %s", found.BoundPort, c.Port))
return nil, output.NewSilentError(fmt.Errorf("LocalStack already running on port %s", found.BoundPort))
}
output.EmitInfo(sink, "LocalStack is already running")
Comment thread
gtsiolis marked this conversation as resolved.
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)
}

// Check extra ports required by this emulator (443 for HTTPS, 4510-4559 for
// the service port range). These are singletons: if any is taken, another
// LocalStack instance is likely running and we cannot start a new one.
extraSpecs := make([]string, len(c.ExtraPorts))
for i, ep := range c.ExtraPorts {
extraSpecs[i] = ep.HostPort
}
if conflictPort, err := ports.CheckAvailable(extraSpecs...); err != nil {
output.EmitError(sink, output.ErrorEvent{
Title: fmt.Sprintf("Port %s is already in use", conflictPort),
Comment thread
carole-lavillonniere marked this conversation as resolved.
Summary: "LocalStack requires this port. Free it before starting.",
})
emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodePortConflict, err.Error())
return nil, output.NewSilentError(err)
}

filtered = append(filtered, c)
}
return filtered, nil
}

func emitPortInUseError(sink output.Sink, port string) {
actions := []output.ErrorAction{
{Label: "Stop existing emulator:", Value: "lstk stop"},
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{}
configPath, pathErr := config.ConfigFilePath()
if pathErr == nil {
actions = append(actions, output.ErrorAction{Label: "Use another port in the configuration:", Value: configPath})
}
output.EmitError(sink, output.ErrorEvent{
Title: fmt.Sprintf("Port %s already in use", port),
Summary: "LocalStack may already be running.",
Summary: "Free the port or configure a different one.",
Actions: actions,
})
}
Expand Down
85 changes: 85 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,89 @@ func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) {
assert.Contains(t, got, "> Tip:")
}

func TestSelectContainersToStart_AttachesWhenExternalContainerOnConfiguredPort(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(), []string{"localstack/localstack-pro", "localstack/localstack"}, "4566/tcp").
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_AttachesWhenExternalContainerVersionDiffers(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(), []string{"localstack/localstack-pro", "localstack/localstack"}, "4566/tcp").
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")
}

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(), []string{"localstack/localstack-pro", "localstack/localstack"}, "4566/tcp").
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 @@ -24,20 +24,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 @@ -34,6 +34,7 @@ func TestStatus_MultipleContainers_StopsAtFirstNotRunning(t *testing.T) {
mockRT := runtime.NewMockRuntime(ctrl)
mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil)
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack"}, "4566/tcp").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 @@ -24,15 +24,11 @@ func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers

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
39 changes: 38 additions & 1 deletion internal/ports/ports.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,47 @@ package ports
import (
"fmt"
"net"
"strconv"
"strings"
"time"
)

func CheckAvailable(port string) error {
// CheckAvailable reports whether all given port specs are free to bind.
// Each spec is a single port ("443") or an inclusive range ("4510-4559").
// Returns the first port found to be in use and a non-nil error; returns an
// empty string and nil if all ports are available.
func CheckAvailable(specs ...string) (string, error) {
for _, spec := range specs {
if lo, hi, ok := parseRange(spec); ok {
for p := lo; p <= hi; p++ {
port := strconv.Itoa(p)
if err := dial(port); err != nil {
return port, err
}
}
} else {
if err := dial(spec); err != nil {
return spec, err
}
}
}
return "", nil
}

func parseRange(spec string) (lo, hi int, ok bool) {
loStr, hiStr, found := strings.Cut(spec, "-")
if !found {
return 0, 0, false
}
lo, err1 := strconv.Atoi(loStr)
hi, err2 := strconv.Atoi(hiStr)
if err1 != nil || err2 != nil || lo > hi {
return 0, 0, false
}
return lo, hi, true
}

func dial(port string) error {
conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second)
if err != nil {
return nil
Expand Down
Loading