Skip to content

Commit 273c34e

Browse files
Attach to externally-started LS container (#190)
1 parent fba236c commit 273c34e

15 files changed

Lines changed: 576 additions & 24 deletions

File tree

internal/container/running.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,53 @@ package container
33
import (
44
"context"
55
"fmt"
6+
"strings"
67

78
"github.com/localstack/lstk/internal/config"
89
"github.com/localstack/lstk/internal/runtime"
910
)
1011

1112
func AnyRunning(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig) (bool, error) {
1213
for _, c := range containers {
13-
running, err := rt.IsRunning(ctx, c.Name())
14+
name, err := resolveRunningContainerName(ctx, rt, c)
1415
if err != nil {
15-
return false, fmt.Errorf("checking %s running: %w", c.Name(), err)
16+
return false, err
1617
}
17-
if running {
18+
if name != "" {
1819
return true, nil
1920
}
2021
}
2122

2223
return false, nil
2324
}
25+
26+
func resolveRunningContainerName(ctx context.Context, rt runtime.Runtime, c config.ContainerConfig) (string, error) {
27+
running, err := rt.IsRunning(ctx, c.Name())
28+
if err != nil {
29+
return "", fmt.Errorf("checking %s running: %w", c.Name(), err)
30+
}
31+
if running {
32+
return c.Name(), nil
33+
}
34+
35+
image, err := c.Image()
36+
if err != nil {
37+
return "", err
38+
}
39+
imageRepo, _, _ := strings.Cut(image, ":")
40+
41+
containerPort, err := c.ContainerPort()
42+
if err != nil {
43+
return "", err
44+
}
45+
46+
found, err := rt.FindRunningByImage(ctx, []string{imageRepo, "localstack/localstack"}, containerPort)
47+
if err != nil {
48+
return "", fmt.Errorf("failed to scan for running containers: %w", err)
49+
}
50+
if found != nil {
51+
return found.Name, nil
52+
}
53+
54+
return "", nil
55+
}

internal/container/start.go

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -372,27 +372,82 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu
372372
emitPostStartPointers(sink, resolvedHost, webAppURL)
373373
continue
374374
}
375-
if err := ports.CheckAvailable(c.Port); err != nil {
375+
376+
imageRepo, _, _ := strings.Cut(c.Image, ":")
377+
found, err := rt.FindRunningByImage(ctx, []string{imageRepo, "localstack/localstack"}, c.ContainerPort)
378+
if err != nil {
379+
return nil, fmt.Errorf("failed to scan for running containers: %w", err)
380+
}
381+
if found != nil {
382+
if found.BoundPort != c.Port {
383+
output.EmitError(sink, output.ErrorEvent{
384+
Title: fmt.Sprintf("LocalStack is already running on port %s", found.BoundPort),
385+
Summary: fmt.Sprintf("Config expects port %s. Only one instance can run at a time.", c.Port),
386+
Actions: []output.ErrorAction{
387+
{Label: "Stop existing emulator:", Value: "lstk stop"},
388+
},
389+
})
390+
emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodePortConflict, fmt.Sprintf("running on port %s, configured port %s", found.BoundPort, c.Port))
391+
return nil, output.NewSilentError(fmt.Errorf("LocalStack already running on port %s", found.BoundPort))
392+
}
393+
output.EmitInfo(sink, "LocalStack is already running")
394+
continue
395+
}
396+
397+
if _, err := ports.CheckAvailable(c.Port); err != nil {
398+
if info, infoErr := fetchLocalStackInfo(ctx, c.Port); infoErr == nil {
399+
emitLocalStackAlreadyRunningWarning(sink, c.Port, info.Version, c.Tag)
400+
continue
401+
}
376402
emitPortInUseError(sink, c.Port)
377403
emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodePortConflict, err.Error())
378404
return nil, output.NewSilentError(err)
379405
}
406+
407+
// Check extra ports required by this emulator (443 for HTTPS, 4510-4559 for
408+
// the service port range). These are singletons: if any is taken, another
409+
// LocalStack instance is likely running and we cannot start a new one.
410+
extraSpecs := make([]string, len(c.ExtraPorts))
411+
for i, ep := range c.ExtraPorts {
412+
extraSpecs[i] = ep.HostPort
413+
}
414+
if conflictPort, err := ports.CheckAvailable(extraSpecs...); err != nil {
415+
output.EmitError(sink, output.ErrorEvent{
416+
Title: fmt.Sprintf("Port %s is already in use", conflictPort),
417+
Summary: "LocalStack requires this port. Free it before starting.",
418+
})
419+
emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodePortConflict, err.Error())
420+
return nil, output.NewSilentError(err)
421+
}
422+
380423
filtered = append(filtered, c)
381424
}
382425
return filtered, nil
383426
}
384427

385-
func emitPortInUseError(sink output.Sink, port string) {
386-
actions := []output.ErrorAction{
387-
{Label: "Stop existing emulator:", Value: "lstk stop"},
428+
func emitLocalStackAlreadyRunningWarning(sink output.Sink, port, runningVersion, configTag string) {
429+
if configTag == "" {
430+
configTag = "latest"
431+
}
432+
if runningVersion != configTag {
433+
output.EmitWarning(sink, fmt.Sprintf(
434+
"LocalStack %s is already running on port %s (config specifies %s) — using the running instance",
435+
runningVersion, port, configTag,
436+
))
437+
} else {
438+
output.EmitInfo(sink, fmt.Sprintf("LocalStack %s is already running on port %s", runningVersion, port))
388439
}
440+
}
441+
442+
func emitPortInUseError(sink output.Sink, port string) {
443+
actions := []output.ErrorAction{}
389444
configPath, pathErr := config.ConfigFilePath()
390445
if pathErr == nil {
391446
actions = append(actions, output.ErrorAction{Label: "Use another port in the configuration:", Value: configPath})
392447
}
393448
output.EmitError(sink, output.ErrorEvent{
394449
Title: fmt.Sprintf("Port %s already in use", port),
395-
Summary: "LocalStack may already be running.",
450+
Summary: "Free the port or configure a different one.",
396451
Actions: actions,
397452
})
398453
}

internal/container/start_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"context"
66
"errors"
77
"io"
8+
"net"
9+
"strconv"
810
"testing"
911

1012
"github.com/localstack/lstk/internal/log"
@@ -53,6 +55,89 @@ func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) {
5355
assert.Contains(t, got, "> Tip:")
5456
}
5557

58+
func TestSelectContainersToStart_AttachesWhenExternalContainerOnConfiguredPort(t *testing.T) {
59+
ctrl := gomock.NewController(t)
60+
mockRT := runtime.NewMockRuntime(ctrl)
61+
62+
c := runtime.ContainerConfig{
63+
Image: "localstack/localstack-pro:3.5.0",
64+
Name: "localstack-aws-3.5.0",
65+
Tag: "3.5.0",
66+
Port: "4566",
67+
ContainerPort: "4566/tcp",
68+
}
69+
70+
mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
71+
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack"}, "4566/tcp").
72+
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)
73+
74+
var out bytes.Buffer
75+
sink := output.NewPlainSink(&out)
76+
77+
result, err := selectContainersToStart(context.Background(), mockRT, sink, nil, []runtime.ContainerConfig{c}, "", "")
78+
79+
require.NoError(t, err)
80+
assert.Empty(t, result, "container should be skipped (already running)")
81+
assert.Contains(t, out.String(), "already running")
82+
assert.NotContains(t, out.String(), "config specifies")
83+
}
84+
85+
func TestSelectContainersToStart_AttachesWhenExternalContainerVersionDiffers(t *testing.T) {
86+
ctrl := gomock.NewController(t)
87+
mockRT := runtime.NewMockRuntime(ctrl)
88+
89+
c := runtime.ContainerConfig{
90+
Image: "localstack/localstack-pro:3.4.0",
91+
Name: "localstack-aws-3.4.0",
92+
Tag: "3.4.0",
93+
Port: "4566",
94+
ContainerPort: "4566/tcp",
95+
}
96+
97+
mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
98+
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack"}, "4566/tcp").
99+
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)
100+
101+
var out bytes.Buffer
102+
sink := output.NewPlainSink(&out)
103+
104+
result, err := selectContainersToStart(context.Background(), mockRT, sink, nil, []runtime.ContainerConfig{c}, "", "")
105+
106+
require.NoError(t, err)
107+
assert.Empty(t, result, "container should be skipped (already running)")
108+
assert.Contains(t, out.String(), "already running")
109+
}
110+
111+
func TestSelectContainersToStart_QueuesContainerWhenNoneRunningOnPort(t *testing.T) {
112+
ctrl := gomock.NewController(t)
113+
mockRT := runtime.NewMockRuntime(ctrl)
114+
115+
// Use a free port by binding one and immediately releasing it.
116+
ln, err := net.Listen("tcp", "127.0.0.1:0")
117+
require.NoError(t, err)
118+
freePort := ln.Addr().(*net.TCPAddr).Port
119+
require.NoError(t, ln.Close())
120+
121+
c := runtime.ContainerConfig{
122+
Image: "localstack/localstack-pro:3.5.0",
123+
Name: "localstack-aws-3.5.0",
124+
Tag: "3.5.0",
125+
Port: strconv.Itoa(freePort),
126+
ContainerPort: "4566/tcp",
127+
}
128+
129+
mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
130+
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack"}, "4566/tcp").
131+
Return(nil, nil)
132+
133+
sink := output.NewPlainSink(io.Discard)
134+
135+
result, err := selectContainersToStart(context.Background(), mockRT, sink, nil, []runtime.ContainerConfig{c}, "", "")
136+
137+
require.NoError(t, err)
138+
assert.Equal(t, []runtime.ContainerConfig{c}, result, "container should be queued for start")
139+
}
140+
56141
func TestServicePortRange_ReturnsExpectedPorts(t *testing.T) {
57142
ports := servicePortRange()
58143

internal/container/status.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,19 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain
2424
defer cancel()
2525

2626
for _, c := range containers {
27-
name := c.Name()
28-
running, err := rt.IsRunning(ctx, name)
27+
name, err := resolveRunningContainerName(ctx, rt, c)
2928
if err != nil {
30-
return fmt.Errorf("checking %s running: %w", name, err)
29+
return fmt.Errorf("checking %s running: %w", c.Name(), err)
3130
}
32-
if !running {
31+
if name == "" {
3332
output.EmitError(sink, output.ErrorEvent{
3433
Title: fmt.Sprintf("%s is not running", c.DisplayName()),
3534
Actions: []output.ErrorAction{
3635
{Label: "Start LocalStack:", Value: "lstk"},
3736
{Label: "See help:", Value: "lstk -h"},
3837
},
3938
})
40-
return output.NewSilentError(fmt.Errorf("%s is not running", name))
39+
return output.NewSilentError(fmt.Errorf("%s is not running", c.Name()))
4140
}
4241

4342
// status makes direct HTTP calls to LocalStack, so it needs the actual host port.

internal/container/status_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func TestStatus_MultipleContainers_StopsAtFirstNotRunning(t *testing.T) {
3434
mockRT := runtime.NewMockRuntime(ctrl)
3535
mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil)
3636
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil)
37+
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack"}, "4566/tcp").Return(nil, nil)
3738

3839
containers := []config.ContainerConfig{
3940
{Type: config.EmulatorAWS},

internal/container/stop.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,11 @@ func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers
2424

2525
const stopTimeout = 30 * time.Second
2626
for _, c := range containers {
27-
name := c.Name()
28-
29-
checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second)
30-
running, err := rt.IsRunning(checkCtx, name)
31-
checkCancel()
27+
name, err := resolveRunningContainerName(ctx, rt, c)
3228
if err != nil {
33-
return fmt.Errorf("checking %s running: %w", name, err)
29+
return err
3430
}
35-
if !running {
31+
if name == "" {
3632
return fmt.Errorf("LocalStack is not running")
3733
}
3834

internal/ports/ports.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,47 @@ package ports
33
import (
44
"fmt"
55
"net"
6+
"strconv"
7+
"strings"
68
"time"
79
)
810

9-
func CheckAvailable(port string) error {
11+
// CheckAvailable reports whether all given port specs are free to bind.
12+
// Each spec is a single port ("443") or an inclusive range ("4510-4559").
13+
// Returns the first port found to be in use and a non-nil error; returns an
14+
// empty string and nil if all ports are available.
15+
func CheckAvailable(specs ...string) (string, error) {
16+
for _, spec := range specs {
17+
if lo, hi, ok := parseRange(spec); ok {
18+
for p := lo; p <= hi; p++ {
19+
port := strconv.Itoa(p)
20+
if err := dial(port); err != nil {
21+
return port, err
22+
}
23+
}
24+
} else {
25+
if err := dial(spec); err != nil {
26+
return spec, err
27+
}
28+
}
29+
}
30+
return "", nil
31+
}
32+
33+
func parseRange(spec string) (lo, hi int, ok bool) {
34+
loStr, hiStr, found := strings.Cut(spec, "-")
35+
if !found {
36+
return 0, 0, false
37+
}
38+
lo, err1 := strconv.Atoi(loStr)
39+
hi, err2 := strconv.Atoi(hiStr)
40+
if err1 != nil || err2 != nil || lo > hi {
41+
return 0, 0, false
42+
}
43+
return lo, hi, true
44+
}
45+
46+
func dial(port string) error {
1047
conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second)
1148
if err != nil {
1249
return nil

0 commit comments

Comments
 (0)