diff --git a/internal/container/running.go b/internal/container/running.go index 7decfce0..6f28a12f 100644 --- a/internal/container/running.go +++ b/internal/container/running.go @@ -3,6 +3,7 @@ package container import ( "context" "fmt" + "strings" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/runtime" @@ -10,14 +11,45 @@ import ( 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() + 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 +} diff --git a/internal/container/start.go b/internal/container/start.go index d650a30d..b157e938 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -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), + Summary: fmt.Sprintf("Config expects port %s. Only one instance can run at a time.", c.Port), + 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") + 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), + 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, }) } diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 69c2e4c1..e0325b77 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -5,6 +5,8 @@ import ( "context" "errors" "io" + "net" + "strconv" "testing" "github.com/localstack/lstk/internal/log" @@ -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() diff --git a/internal/container/status.go b/internal/container/status.go index 052c1dce..9a4c0f98 100644 --- a/internal/container/status.go +++ b/internal/container/status.go @@ -24,12 +24,11 @@ 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{ @@ -37,7 +36,7 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain {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. diff --git a/internal/container/status_test.go b/internal/container/status_test.go index 8d394e0e..7d0694d6 100644 --- a/internal/container/status_test.go +++ b/internal/container/status_test.go @@ -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}, diff --git a/internal/container/stop.go b/internal/container/stop.go index 3cad5a30..aa224dd8 100644 --- a/internal/container/stop.go +++ b/internal/container/stop.go @@ -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") } diff --git a/internal/ports/ports.go b/internal/ports/ports.go index e155b5ed..c10a5e00 100644 --- a/internal/ports/ports.go +++ b/internal/ports/ports.go @@ -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 diff --git a/internal/ports/ports_test.go b/internal/ports/ports_test.go new file mode 100644 index 00000000..bbcf7df1 --- /dev/null +++ b/internal/ports/ports_test.go @@ -0,0 +1,94 @@ +package ports + +import ( + "net" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseRange(t *testing.T) { + tests := []struct { + spec string + wantLo int + wantHi int + wantOk bool + }{ + {"4510-4559", 4510, 4559, true}, + {"443-443", 443, 443, true}, + {"4566", 0, 0, false}, + {"4566/tcp", 0, 0, false}, + {"4559-4510", 0, 0, false}, // reversed range + {"abc-def", 0, 0, false}, + {"", 0, 0, false}, + } + + for _, tt := range tests { + t.Run(tt.spec, func(t *testing.T) { + lo, hi, ok := parseRange(tt.spec) + assert.Equal(t, tt.wantOk, ok) + if tt.wantOk { + assert.Equal(t, tt.wantLo, lo) + assert.Equal(t, tt.wantHi, hi) + } + }) + } +} + +func TestCheckAvailable(t *testing.T) { + busy1 := bindPort(t) + busy2 := bindPort(t) + free1 := freePort(t) + + freeRange := free1 + "-" + free1 + busyRange := busy1 + "-" + busy1 + + tests := []struct { + name string + specs []string + wantErr bool + wantConflict string + }{ + {"free port", []string{free1}, false, ""}, + {"busy port", []string{busy1}, true, busy1}, + {"free range", []string{freeRange}, false, ""}, + {"busy range", []string{busyRange}, true, busy1}, + {"multiple specs: stops at first conflict", []string{busy1, busy2}, true, busy1}, + {"free then busy", []string{free1, busy1}, true, busy1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conflict, err := CheckAvailable(tt.specs...) + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.wantConflict, conflict) + } else { + assert.NoError(t, err) + assert.Empty(t, conflict) + } + }) + } +} + +// bindPort opens a listener on a random port and keeps it open for the test duration. +func bindPort(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + return strconv.Itoa(ln.Addr().(*net.TCPAddr).Port) +} + +// freePort allocates and immediately releases a port, returning it as a string. +func freePort(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port) + require.NoError(t, ln.Close()) + return port +} + diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index 512c183b..d0e456a9 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -17,6 +17,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" @@ -271,7 +272,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 { @@ -370,6 +376,53 @@ func (d *DockerRuntime) GetBoundPort(ctx context.Context, containerName string, return bindings[0].HostPort, nil } +func (d *DockerRuntime) FindRunningByImage(ctx context.Context, imageRepos []string, containerPort string) (*RunningContainer, error) { + list, err := d.client.ContainerList(ctx, container.ListOptions{ + Filters: filters.NewArgs(filters.Arg("status", "running")), + }) + 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) + } + + for _, c := range list { + if !matchesAnyImageRepo(c.Image, imageRepos) { + continue + } + for _, p := range c.Ports { + if p.PrivatePort == uint16(privatePort) && p.Type == proto { + name := "" + if len(c.Names) > 0 { + name = strings.TrimPrefix(c.Names[0], "/") + } + return &RunningContainer{ + Name: name, + Image: c.Image, + BoundPort: strconv.Itoa(int(p.PublicPort)), + }, nil + } + } + } + return nil, nil +} + +func matchesAnyImageRepo(image string, repos []string) bool { + for _, repo := range repos { + if image == repo || strings.HasPrefix(image, repo+":") { + return true + } + } + return false +} + func (d *DockerRuntime) GetImageVersion(ctx context.Context, imageName string) (string, error) { inspect, err := d.client.ImageInspect(ctx, imageName) if err != nil { diff --git a/internal/runtime/mock_runtime.go b/internal/runtime/mock_runtime.go index d39b3ec9..c8afee1b 100644 --- a/internal/runtime/mock_runtime.go +++ b/internal/runtime/mock_runtime.go @@ -43,6 +43,21 @@ func (m *MockRuntime) EXPECT() *MockRuntimeMockRecorder { return m.recorder } +// FindRunningByImage mocks base method. +func (m *MockRuntime) FindRunningByImage(ctx context.Context, imageRepos []string, containerPort string) (*RunningContainer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindRunningByImage", ctx, imageRepos, containerPort) + ret0, _ := ret[0].(*RunningContainer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindRunningByImage indicates an expected call of FindRunningByImage. +func (mr *MockRuntimeMockRecorder) FindRunningByImage(ctx, imageRepos, containerPort any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindRunningByImage", reflect.TypeOf((*MockRuntime)(nil).FindRunningByImage), ctx, imageRepos, containerPort) +} + // ContainerStartedAt mocks base method. func (m *MockRuntime) ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) { m.ctrl.T.Helper() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index d397d79e..e74336a0 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -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 @@ -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, imageRepos []string, containerPort string) (*RunningContainer, error) SocketPath() string } diff --git a/test/integration/main_test.go b/test/integration/main_test.go index e72087b0..2c5e0717 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -203,6 +203,33 @@ func startTestContainer(t *testing.T, ctx context.Context, hostPort ...string) { require.NoError(t, err, "failed to start test container") } +// Use this to simulate a LocalStack container started outside lstk. +func startExternalContainer(t *testing.T, ctx context.Context, imgName, name, hostPort string) { + t.Helper() + + const containerPort = nat.Port("4566/tcp") + resp, err := dockerClient.ContainerCreate(ctx, + &container.Config{ + Image: imgName, + Cmd: []string{"sleep", "infinity"}, + ExposedPorts: nat.PortSet{containerPort: struct{}{}}, + }, + &container.HostConfig{ + PortBindings: nat.PortMap{ + containerPort: []nat.PortBinding{{HostPort: hostPort}}, + }, + }, + nil, nil, name, + ) + require.NoError(t, err, "failed to create external container") + err = dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{}) + require.NoError(t, err, "failed to start external container") + t.Cleanup(func() { + _ = dockerClient.ContainerStop(context.Background(), name, container.StopOptions{}) + _ = dockerClient.ContainerRemove(context.Background(), name, container.RemoveOptions{Force: true}) + }) +} + func testContext(t *testing.T) context.Context { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 41953a6a..8cf2fe08 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/http" + "net/http/httptest" "os" "path/filepath" "strconv" @@ -13,6 +14,7 @@ import ( "testing" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" "github.com/docker/go-connections/nat" "github.com/localstack/lstk/test/integration/env" "github.com/stretchr/testify/assert" @@ -98,6 +100,7 @@ func TestStartCommandFailsWhenPortInUse(t *testing.T) { cleanup() t.Cleanup(cleanup) + // Simulates port in use by non-LocalStack process (/_localstack/info will fail) ln, err := net.Listen("tcp", ":4566") require.NoError(t, err, "failed to bind port 4566 for test") defer func() { _ = ln.Close() }() @@ -107,8 +110,8 @@ func TestStartCommandFailsWhenPortInUse(t *testing.T) { require.Error(t, err, "expected lstk start to fail when port is in use") requireExitCode(t, 1, err) assert.Contains(t, stdout, "Port 4566 already in use") - assert.Contains(t, stdout, "LocalStack may already be running.") - assert.Contains(t, stdout, "lstk stop") + assert.Contains(t, stdout, "Free the port or configure a different one.") + assert.Contains(t, stdout, "Use another port in the configuration:") // Both lstk_lifecycle (start_error) and lstk_command events should be emitted. byName := collectTelemetryByName(t, events, 2) @@ -116,6 +119,90 @@ func TestStartCommandFailsWhenPortInUse(t *testing.T) { assert.Contains(t, byName, "lstk_command") } +func TestStartCommandAttachesToExternalContainer(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + const fakeImage = "localstack/localstack-pro:test-fake" + require.NoError(t, dockerClient.ImageTag(ctx, testImage, fakeImage)) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), fakeImage, image.RemoveOptions{}) + }) + + // Start a container with a different name to simulate an externally-managed instance. + startExternalContainer(t, ctx, fakeImage, "localstack-external", "4566") + + analyticsSrv, events := mockAnalyticsServer(t) + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.AuthToken, "fake-token").With(env.AnalyticsEndpoint, analyticsSrv.URL), "start") + require.NoError(t, err, "lstk start should succeed when external container is running: %s", stderr) + requireExitCode(t, 0, err) + assert.Contains(t, stdout, "already running") + assertCommandTelemetry(t, events, "start", 0) +} + +func TestStartCommandAttachesWhenLocalStackRespondingOnPort(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + // Serve a mock /_localstack/info on port 4566 so lstk can identify the running version. + ln, err := net.Listen("tcp", ":4566") + require.NoError(t, err, "failed to bind port 4566 for test") + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/_localstack/info" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"3.4.0","edition":"pro"}`)) + return + } + http.NotFound(w, r) + })) + srv.Listener = ln + srv.Start() + defer srv.Close() + + stdout, stderr, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "fake-token"), "start") + require.NoError(t, err, "lstk start should succeed when LocalStack is already running: %s", stderr) + requireExitCode(t, 0, err) + assert.Contains(t, stdout, "3.4.0") + assert.Contains(t, stdout, "already running") +} + +func TestStartCommandFailsWhenLocalStackRunningOnDifferentPort(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + // Tag the test image as a LocalStack pro image to simulate an instance running. + const fakeImage = "localstack/localstack-pro:test-fake" + require.NoError(t, dockerClient.ImageTag(ctx, testImage, fakeImage)) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), fakeImage, image.RemoveOptions{}) + }) + + // Start it on another port + startExternalContainer(t, ctx, fakeImage, "localstack-external", "4566") + + configContent := ` +[[containers]] +type = "aws" +port = "4567" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + analyticsSrv, events := mockAnalyticsServer(t) + stdout, _, err := runLstk(t, ctx, "", env.With(env.AuthToken, "fake-token").With(env.AnalyticsEndpoint, analyticsSrv.URL), "--config", configFile, "start") + require.Error(t, err, "expected lstk start to fail when LS is already running on a different port") + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "already running") + assertCommandTelemetry(t, events, "start", 1) +} + func TestStartCommandSucceedsWithNonDefaultPort(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) diff --git a/test/integration/status_test.go b/test/integration/status_test.go index 87a5266f..c86f08d3 100644 --- a/test/integration/status_test.go +++ b/test/integration/status_test.go @@ -1,6 +1,7 @@ package integration_test import ( + "context" "fmt" "net" "net/http" @@ -15,6 +16,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/sqs" + "github.com/docker/docker/api/types/image" "github.com/localstack/lstk/test/integration/env" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -117,6 +119,42 @@ func TestStatusCommandWorksWithNonDefaultPort(t *testing.T) { assert.Contains(t, stdout, "4.14.1") } +func TestStatusCommandWorksWithExternalContainer(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + const fakeImage = "localstack/localstack-pro:test-fake" + require.NoError(t, dockerClient.ImageTag(ctx, testImage, fakeImage)) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), fakeImage, image.RemoveOptions{}) + }) + + startExternalContainer(t, ctx, fakeImage, "localstack-external", "4566") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_localstack/health": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "3.5.0", "services": {}}`) + case "/_localstack/resources": + w.Header().Set("Content-Type", "application/x-ndjson") + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "http://") + + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.LocalStackHost, host), "status") + require.NoError(t, err, "lstk status should work with external container: %s", stderr) + requireExitCode(t, 0, err) + assert.Contains(t, stdout, "3.5.0") +} + func TestStatusCommandShowsNoResourcesWhenEmpty(t *testing.T) { requireDocker(t) cleanup() diff --git a/test/integration/stop_test.go b/test/integration/stop_test.go index ae8f1749..5c5f7480 100644 --- a/test/integration/stop_test.go +++ b/test/integration/stop_test.go @@ -1,8 +1,10 @@ package integration_test import ( + "context" "testing" + "github.com/docker/docker/api/types/image" "github.com/localstack/lstk/test/integration/env" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -45,6 +47,30 @@ func TestStopCommandFailsWhenNotRunning(t *testing.T) { assertCommandTelemetry(t, events, "stop", 1) } +func TestStopCommandStopsExternalContainer(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + const fakeImage = "localstack/localstack-pro:test-fake" + require.NoError(t, dockerClient.ImageTag(ctx, testImage, fakeImage)) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), fakeImage, image.RemoveOptions{}) + }) + + startExternalContainer(t, ctx, fakeImage, "localstack-external", "4566") + + stdout, stderr, err := runLstk(t, ctx, "", nil, "stop") + require.NoError(t, err, "lstk stop should stop external container: %s", stderr) + requireExitCode(t, 0, err) + assert.Contains(t, stdout, "stopped") + + _, err = dockerClient.ContainerInspect(ctx, "localstack-external") + assert.Error(t, err, "external container should be gone after lstk stop") +} + func TestStopCommandIsIdempotent(t *testing.T) { requireDocker(t) cleanup()