Skip to content

Commit ceb2095

Browse files
Handle already-running containers and port conflicts (#40)
1 parent 888bb35 commit ceb2095

File tree

7 files changed

+177
-70
lines changed

7 files changed

+177
-70
lines changed

internal/container/start.go

Lines changed: 84 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/localstack/lstk/internal/auth"
1414
"github.com/localstack/lstk/internal/config"
1515
"github.com/localstack/lstk/internal/output"
16+
"github.com/localstack/lstk/internal/ports"
1617
"github.com/localstack/lstk/internal/runtime"
1718
)
1819

@@ -43,66 +44,118 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformCl
4344
if err != nil {
4445
return err
4546
}
47+
productName, err := c.ProductName()
48+
if err != nil {
49+
return err
50+
}
4651

4752
env := append(c.Env, "LOCALSTACK_AUTH_TOKEN="+token)
4853
containers[i] = runtime.ContainerConfig{
49-
Image: image,
50-
Name: c.Name(),
51-
Port: c.Port,
52-
HealthPath: healthPath,
53-
Env: env,
54+
Image: image,
55+
Name: c.Name(),
56+
Port: c.Port,
57+
HealthPath: healthPath,
58+
Env: env,
59+
Tag: c.Tag,
60+
ProductName: productName,
5461
}
5562
}
5663

57-
// Pull all images first
58-
for _, config := range containers {
64+
containers, err = selectContainersToStart(ctx, rt, sink, containers)
65+
if err != nil {
66+
return err
67+
}
68+
if len(containers) == 0 {
69+
return nil
70+
}
71+
72+
// TODO validate license for tag "latest" without resolving the actual image version,
73+
// and avoid pulling all images first
74+
if err := pullImages(ctx, rt, sink, containers); err != nil {
75+
return err
76+
}
77+
78+
if err := validateLicenses(ctx, rt, sink, platformClient, containers, token); err != nil {
79+
return err
80+
}
81+
82+
return startContainers(ctx, rt, sink, containers)
83+
}
84+
85+
func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) error {
86+
for _, c := range containers {
5987
// Remove any existing stopped container with the same name
60-
if err := rt.Remove(ctx, config.Name); err != nil && !errdefs.IsNotFound(err) {
61-
return fmt.Errorf("failed to remove existing container %s: %w", config.Name, err)
88+
if err := rt.Remove(ctx, c.Name); err != nil && !errdefs.IsNotFound(err) {
89+
return fmt.Errorf("failed to remove existing container %s: %w", c.Name, err)
6290
}
6391

64-
output.EmitStatus(sink, "pulling", config.Image, "")
92+
output.EmitStatus(sink, "pulling", c.Image, "")
6593
progress := make(chan runtime.PullProgress)
6694
go func() {
6795
for p := range progress {
68-
output.EmitProgress(sink, config.Image, p.LayerID, p.Status, p.Current, p.Total)
96+
output.EmitProgress(sink, c.Image, p.LayerID, p.Status, p.Current, p.Total)
6997
}
7098
}()
71-
if err := rt.PullImage(ctx, config.Image, progress); err != nil {
72-
return fmt.Errorf("failed to pull image %s: %w", config.Image, err)
99+
if err := rt.PullImage(ctx, c.Image, progress); err != nil {
100+
return fmt.Errorf("failed to pull image %s: %w", c.Image, err)
73101
}
74102
}
103+
return nil
104+
}
75105

76-
// TODO validate license for tag "latest" without resolving the actual image version,
77-
// and avoid pulling all images first
78-
for i, c := range cfg.Containers {
79-
if err := validateLicense(ctx, rt, sink, platformClient, containers[i], &c, token); err != nil {
106+
func validateLicenses(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containers []runtime.ContainerConfig, token string) error {
107+
for _, c := range containers {
108+
if err := validateLicense(ctx, rt, sink, platformClient, c, token); err != nil {
80109
return err
81110
}
82111
}
112+
return nil
113+
}
83114

84-
// Start containers
85-
for _, config := range containers {
86-
output.EmitStatus(sink, "starting", config.Name, "")
87-
containerID, err := rt.Start(ctx, config)
115+
func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) error {
116+
for _, c := range containers {
117+
output.EmitStatus(sink, "starting", c.Name, "")
118+
containerID, err := rt.Start(ctx, c)
88119
if err != nil {
89-
return fmt.Errorf("failed to start %s: %w", config.Name, err)
120+
return fmt.Errorf("failed to start %s: %w", c.Name, err)
90121
}
91122

92-
output.EmitStatus(sink, "waiting", config.Name, "")
93-
healthURL := fmt.Sprintf("http://localhost:%s%s", config.Port, config.HealthPath)
94-
if err := awaitStartup(ctx, rt, sink, containerID, config.Name, healthURL); err != nil {
123+
output.EmitStatus(sink, "waiting", c.Name, "")
124+
healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath)
125+
if err := awaitStartup(ctx, rt, sink, containerID, c.Name, healthURL); err != nil {
95126
return err
96127
}
97128

98-
output.EmitStatus(sink, "ready", config.Name, fmt.Sprintf("containerId: %s", containerID[:12]))
129+
output.EmitStatus(sink, "ready", c.Name, fmt.Sprintf("containerId: %s", containerID[:12]))
99130
}
100-
101131
return nil
102132
}
103133

104-
func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, cfgContainer *config.ContainerConfig, token string) error {
105-
version := cfgContainer.Tag
134+
func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) ([]runtime.ContainerConfig, error) {
135+
var filtered []runtime.ContainerConfig
136+
for _, c := range containers {
137+
running, err := rt.IsRunning(ctx, c.Name)
138+
if err != nil && !errdefs.IsNotFound(err) {
139+
return nil, fmt.Errorf("failed to check container status: %w", err)
140+
}
141+
if running {
142+
output.EmitLog(sink, fmt.Sprintf("%s is already running", c.Name))
143+
continue
144+
}
145+
if err := ports.CheckAvailable(c.Port); err != nil {
146+
configPath, pathErr := config.ConfigFilePath()
147+
if pathErr != nil {
148+
return nil, err
149+
}
150+
return nil, fmt.Errorf("%w\nTo use a different port, edit %s", err, configPath)
151+
}
152+
filtered = append(filtered, c)
153+
}
154+
return filtered, nil
155+
}
156+
157+
func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, token string) error {
158+
version := containerConfig.Tag
106159
if version == "" || version == "latest" {
107160
actualVersion, err := rt.GetImageVersion(ctx, containerConfig.Image)
108161
if err != nil {
@@ -111,16 +164,12 @@ func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink,
111164
version = actualVersion
112165
}
113166

114-
productName, err := cfgContainer.ProductName()
115-
if err != nil {
116-
return err
117-
}
118167
output.EmitStatus(sink, "validating license", containerConfig.Name, version)
119168

120169
hostname, _ := os.Hostname()
121170
licenseReq := &api.LicenseRequest{
122171
Product: api.ProductInfo{
123-
Name: productName,
172+
Name: containerConfig.ProductName,
124173
Version: version,
125174
},
126175
Credentials: api.CredentialsInfo{
@@ -134,7 +183,7 @@ func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink,
134183
}
135184

136185
if err := platformClient.GetLicense(ctx, licenseReq); err != nil {
137-
return fmt.Errorf("license validation failed for %s:%s: %w", productName, version, err)
186+
return fmt.Errorf("license validation failed for %s:%s: %w", containerConfig.ProductName, version, err)
138187
}
139188

140189
return nil

internal/ports/ports.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package ports
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"time"
7+
)
8+
9+
func CheckAvailable(port string) error {
10+
conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second)
11+
if err != nil {
12+
return nil
13+
}
14+
_ = conn.Close()
15+
return fmt.Errorf("port %s already in use", port)
16+
}

internal/runtime/runtime.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
)
77

88
type ContainerConfig struct {
9-
Image string
10-
Name string
11-
Port string
12-
HealthPath string
13-
Env []string // e.g., ["KEY=value", "FOO=bar"]
9+
Image string
10+
Name string
11+
Port string
12+
HealthPath string
13+
Env []string // e.g., ["KEY=value", "FOO=bar"]
14+
Tag string
15+
ProductName string
1416
}
1517

1618
type PullProgress struct {

test/integration/license_test.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import (
1616
"github.com/stretchr/testify/require"
1717
)
1818

19-
const licenseContainerName = "localstack-aws"
20-
2119
func TestLicenseValidationSuccess(t *testing.T) {
2220
requireDocker(t)
2321
authToken := env.Require(t, env.AuthToken)
@@ -74,7 +72,7 @@ func TestLicenseValidationSuccess(t *testing.T) {
7472

7573
require.NoError(t, err, "lstk start failed: %s", output)
7674

77-
inspect, err := dockerClient.ContainerInspect(ctx, licenseContainerName)
75+
inspect, err := dockerClient.ContainerInspect(ctx, containerName)
7876
require.NoError(t, err, "failed to inspect container")
7977
assert.True(t, inspect.State.Running, "container should be running")
8078
}
@@ -99,12 +97,12 @@ func TestLicenseValidationFailure(t *testing.T) {
9997
assert.Contains(t, string(output), "invalid, inactive, or expired")
10098

10199
// Verify container was not started
102-
_, err = dockerClient.ContainerInspect(ctx, licenseContainerName)
100+
_, err = dockerClient.ContainerInspect(ctx, containerName)
103101
assert.Error(t, err, "container should not exist after license failure")
104102
}
105103

106104
func cleanupLicense() {
107105
ctx := context.Background()
108-
_ = dockerClient.ContainerStop(ctx, licenseContainerName, container.StopOptions{})
109-
_ = dockerClient.ContainerRemove(ctx, licenseContainerName, container.RemoveOptions{Force: true})
106+
_ = dockerClient.ContainerStop(ctx, containerName, container.StopOptions{})
107+
_ = dockerClient.ContainerRemove(ctx, containerName, container.RemoveOptions{Force: true})
110108
}

test/integration/main_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"fmt"
8+
"io"
89
"net/http"
910
"net/http/httptest"
1011
"os"
@@ -14,8 +15,11 @@ import (
1415
"testing"
1516

1617
"github.com/99designs/keyring"
18+
"github.com/docker/docker/api/types/container"
19+
"github.com/docker/docker/api/types/image"
1720
"github.com/docker/docker/client"
1821
"github.com/localstack/lstk/test/integration/env"
22+
"github.com/stretchr/testify/require"
1923
)
2024

2125
// syncBuffer is a thread-safe buffer for concurrent read/write access.
@@ -146,6 +150,28 @@ func DeleteAuthTokenFromKeyring() error {
146150
return err
147151
}
148152

153+
const (
154+
containerName = "localstack-aws"
155+
testImage = "alpine:latest"
156+
)
157+
158+
func startTestContainer(t *testing.T, ctx context.Context) {
159+
t.Helper()
160+
161+
reader, err := dockerClient.ImagePull(ctx, testImage, image.PullOptions{})
162+
require.NoError(t, err, "failed to pull test image")
163+
_, _ = io.Copy(io.Discard, reader)
164+
_ = reader.Close()
165+
166+
resp, err := dockerClient.ContainerCreate(ctx, &container.Config{
167+
Image: testImage,
168+
Cmd: []string{"sleep", "infinity"},
169+
}, nil, nil, nil, containerName)
170+
require.NoError(t, err, "failed to create test container")
171+
err = dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{})
172+
require.NoError(t, err, "failed to start test container")
173+
}
174+
149175
func createMockLicenseServer(success bool) *httptest.Server {
150176
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151177
if r.Method == "POST" && r.URL.Path == "/v1/license/request" {

test/integration/start_test.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package integration_test
22

33
import (
44
"context"
5+
"net"
6+
"os"
57
"os/exec"
68
"testing"
79
"time"
@@ -12,8 +14,6 @@ import (
1214
"github.com/stretchr/testify/require"
1315
)
1416

15-
const containerName = "localstack-aws"
16-
1717
func TestStartCommandSucceedsWithValidToken(t *testing.T) {
1818
requireDocker(t)
1919
_ = env.Require(t, env.AuthToken)
@@ -85,6 +85,44 @@ func TestStartCommandFailsWithInvalidToken(t *testing.T) {
8585
assert.Contains(t, string(output), "license validation failed")
8686
}
8787

88+
func TestStartCommandDoesNothingWhenAlreadyRunning(t *testing.T) {
89+
requireDocker(t)
90+
cleanup()
91+
t.Cleanup(cleanup)
92+
93+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
94+
defer cancel()
95+
96+
startTestContainer(t, ctx)
97+
98+
cmd := exec.CommandContext(ctx, binaryPath(), "start")
99+
cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")
100+
output, err := cmd.CombinedOutput()
101+
102+
require.NoError(t, err, "lstk start should succeed when container is already running: %s", output)
103+
assert.Contains(t, string(output), "already running")
104+
}
105+
106+
func TestStartCommandFailsWhenPortInUse(t *testing.T) {
107+
requireDocker(t)
108+
cleanup()
109+
t.Cleanup(cleanup)
110+
111+
ln, err := net.Listen("tcp", ":4566")
112+
require.NoError(t, err, "failed to bind port 4566 for test")
113+
defer func() { _ = ln.Close() }()
114+
115+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
116+
defer cancel()
117+
118+
cmd := exec.CommandContext(ctx, binaryPath(), "start")
119+
cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")
120+
output, err := cmd.CombinedOutput()
121+
122+
require.Error(t, err, "expected lstk start to fail when port is in use")
123+
assert.Contains(t, string(output), "port 4566 already in use")
124+
}
125+
88126
func cleanup() {
89127
ctx := context.Background()
90128
_ = dockerClient.ContainerStop(ctx, containerName, container.StopOptions{})

0 commit comments

Comments
 (0)