From 0798baea0ec9e839b340322b528397921a5fe83e Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Wed, 11 Mar 2026 18:53:40 +0100 Subject: [PATCH 1/2] Mount Docker socket and add service port range --- cmd/logs.go | 5 ++- cmd/root.go | 4 +- cmd/start.go | 2 +- cmd/stop.go | 2 +- internal/auth/mock_token_storage.go | 4 +- internal/container/start.go | 29 +++++++++++++- internal/env/env.go | 2 + internal/runtime/docker.go | 59 ++++++++++++++++++++++++++++- internal/runtime/mock_runtime.go | 14 +++++++ internal/runtime/runtime.go | 31 +++++++++++---- 10 files changed, 133 insertions(+), 19 deletions(-) diff --git a/cmd/logs.go b/cmd/logs.go index 5cfc2c6..22751d2 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -4,12 +4,13 @@ import ( "os" "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" "github.com/spf13/cobra" ) -func newLogsCmd() *cobra.Command { +func newLogsCmd(cfg *env.Env) *cobra.Command { cmd := &cobra.Command{ Use: "logs", Short: "Show emulator logs", @@ -20,7 +21,7 @@ func newLogsCmd() *cobra.Command { if err != nil { return err } - rt, err := runtime.NewDockerRuntime() + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index 103bca0..806eb3b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,7 +24,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { Long: "lstk is the command-line interface for LocalStack.", PreRunE: initConfig, RunE: func(cmd *cobra.Command, args []string) error { - rt, err := runtime.NewDockerRuntime() + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err } @@ -50,7 +50,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { newStopCmd(cfg), newLoginCmd(cfg), newLogoutCmd(cfg), - newLogsCmd(), + newLogsCmd(cfg), newConfigCmd(), newVersionCmd(), newUpdateCmd(cfg), diff --git a/cmd/start.go b/cmd/start.go index e188bf2..084b220 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -14,7 +14,7 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { Long: "Start emulator and services.", PreRunE: initConfig, RunE: func(cmd *cobra.Command, args []string) error { - rt, err := runtime.NewDockerRuntime() + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err } diff --git a/cmd/stop.go b/cmd/stop.go index f8aa46d..5881b68 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -18,7 +18,7 @@ func newStopCmd(cfg *env.Env) *cobra.Command { Long: "Stop emulator and services", PreRunE: initConfig, RunE: func(cmd *cobra.Command, args []string) error { - rt, err := runtime.NewDockerRuntime() + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err } diff --git a/internal/auth/mock_token_storage.go b/internal/auth/mock_token_storage.go index 6653a5a..1f58e09 100644 --- a/internal/auth/mock_token_storage.go +++ b/internal/auth/mock_token_storage.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: internal/auth/token_storage.go +// Source: token_storage.go // // Generated by this command: // -// mockgen -source=internal/auth/token_storage.go -destination=internal/auth/mock_token_storage.go -package=auth +// mockgen -source=token_storage.go -destination=mock_token_storage.go -package=auth // // Package auth is a generated GoMock package. diff --git a/internal/container/start.go b/internal/container/start.go index 60a14e2..593b682 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -76,15 +76,29 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start if err != nil { return err } - env := append(resolvedEnv, "LOCALSTACK_AUTH_TOKEN="+token) + + containerName := c.Name() + env := append(resolvedEnv, + "LOCALSTACK_AUTH_TOKEN="+token, + "GATEWAY_LISTEN=:"+c.Port, + "MAIN_CONTAINER_NAME="+containerName, + ) + + socketPath := rt.SocketPath() + env = append(env, "DOCKER_HOST=unix:///var/run/docker.sock") + containers[i] = runtime.ContainerConfig{ Image: image, - Name: c.Name(), + Name: containerName, Port: c.Port, HealthPath: healthPath, Env: env, Tag: c.Tag, ProductName: productName, + Binds: []runtime.BindMount{ + {HostPath: socketPath, ContainerPath: "/var/run/docker.sock"}, + }, + ExtraPorts: servicePortRange(), } } @@ -321,3 +335,14 @@ func hasDuplicateContainerTypes(containers []config.ContainerConfig) bool { } return false } + +func servicePortRange() []runtime.PortMapping { + const start = 4510 + const end = 4559 + var ports []runtime.PortMapping + for p := start; p <= end; p++ { + ps := fmt.Sprintf("%d", p) + ports = append(ports, runtime.PortMapping{ContainerPort: ps, HostPort: ps}) + } + return ports +} diff --git a/internal/env/env.go b/internal/env/env.go index 80e480f..c1775c0 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -10,6 +10,7 @@ import ( type Env struct { AuthToken string LocalStackHost string + DockerHost string DisableEvents bool APIEndpoint string @@ -35,6 +36,7 @@ func Init() *Env { return &Env{ AuthToken: os.Getenv("LOCALSTACK_AUTH_TOKEN"), LocalStackHost: os.Getenv("LOCALSTACK_HOST"), + DockerHost: os.Getenv("DOCKER_HOST"), DisableEvents: os.Getenv("LOCALSTACK_DISABLE_EVENTS") == "1", APIEndpoint: viper.GetString("api_endpoint"), WebAppURL: viper.GetString("web_app_url"), diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index dddb9af..cd5b233 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "log" + "os" + "path/filepath" stdruntime "runtime" "strconv" "strings" @@ -24,14 +26,47 @@ type DockerRuntime struct { client *client.Client } -func NewDockerRuntime() (*DockerRuntime, error) { - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) +func NewDockerRuntime(dockerHost string) (*DockerRuntime, error) { + opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()} + + // When DOCKER_HOST is not set, the Docker SDK defaults to /var/run/docker.sock. + // If that socket doesn't exist, probe known alternative locations (e.g. Colima). + if dockerHost == "" { + if sock := findDockerSocket(); sock != "" { + opts = append(opts, client.WithHost("unix://"+sock)) + } + } + + cli, err := client.NewClientWithOpts(opts...) if err != nil { return nil, err } return &DockerRuntime{client: cli}, nil } +func findDockerSocket() string { + home, _ := os.UserHomeDir() + candidates := []string{ + "/var/run/docker.sock", + filepath.Join(home, ".colima", "default", "docker.sock"), + filepath.Join(home, ".colima", "docker.sock"), + } + for _, sock := range candidates { + if _, err := os.Stat(sock); err == nil { + return sock + } + } + return "" +} + +func (d *DockerRuntime) SocketPath() string { + host := d.client.DaemonHost() + if strings.HasPrefix(host, "unix://") { + return strings.TrimPrefix(host, "unix://") + } + return "/var/run/docker.sock" +} + func (d *DockerRuntime) IsHealthy(ctx context.Context) error { _, err := d.client.Ping(ctx) if err != nil { @@ -112,6 +147,25 @@ func (d *DockerRuntime) Start(ctx context.Context, config ContainerConfig) (stri exposedPorts := nat.PortSet{port: struct{}{}} portBindings := nat.PortMap{port: []nat.PortBinding{{HostPort: config.Port}}} + for _, ep := range config.ExtraPorts { + proto := ep.Protocol + if proto == "" { + proto = "tcp" + } + p := nat.Port(ep.ContainerPort + "/" + proto) + exposedPorts[p] = struct{}{} + portBindings[p] = []nat.PortBinding{{HostPort: ep.HostPort}} + } + + var binds []string + for _, b := range config.Binds { + bind := b.HostPath + ":" + b.ContainerPath + if b.ReadOnly { + bind += ":ro" + } + binds = append(binds, bind) + } + resp, err := d.client.ContainerCreate(ctx, &container.Config{ Image: config.Image, @@ -120,6 +174,7 @@ func (d *DockerRuntime) Start(ctx context.Context, config ContainerConfig) (stri }, &container.HostConfig{ PortBindings: portBindings, + Binds: binds, }, nil, nil, config.Name, ) diff --git a/internal/runtime/mock_runtime.go b/internal/runtime/mock_runtime.go index 9848ca0..3740ed6 100644 --- a/internal/runtime/mock_runtime.go +++ b/internal/runtime/mock_runtime.go @@ -141,6 +141,20 @@ func (mr *MockRuntimeMockRecorder) Remove(ctx, containerName any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockRuntime)(nil).Remove), ctx, containerName) } +// SocketPath mocks base method. +func (m *MockRuntime) SocketPath() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SocketPath") + ret0, _ := ret[0].(string) + return ret0 +} + +// SocketPath indicates an expected call of SocketPath. +func (mr *MockRuntimeMockRecorder) SocketPath() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SocketPath", reflect.TypeOf((*MockRuntime)(nil).SocketPath)) +} + // Start mocks base method. func (m *MockRuntime) Start(ctx context.Context, config ContainerConfig) (string, error) { m.ctrl.T.Helper() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7a54b5d..110daca 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -7,14 +7,30 @@ import ( "github.com/localstack/lstk/internal/output" ) +// BindMount represents a host-to-container bind mount. +type BindMount struct { + HostPath string + ContainerPath string + ReadOnly bool +} + +// PortMapping represents a container-to-host port mapping. +type PortMapping struct { + ContainerPort string + HostPort string + Protocol string // "tcp" (default) or "udp" +} + type ContainerConfig struct { - Image string - Name string - Port string - HealthPath string - Env []string // e.g., ["KEY=value", "FOO=bar"] - Tag string - ProductName string + Image string + Name string + Port string + HealthPath string + Env []string // e.g., ["KEY=value", "FOO=bar"] + Tag string + ProductName string + Binds []BindMount + ExtraPorts []PortMapping } type PullProgress struct { @@ -36,4 +52,5 @@ type Runtime interface { Logs(ctx context.Context, containerID string, tail int) (string, error) StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool) error GetImageVersion(ctx context.Context, imageName string) (string, error) + SocketPath() string } From a0534b590de6b620b18a549d705dce472b5bab1a Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Wed, 11 Mar 2026 19:00:21 +0100 Subject: [PATCH 2/2] Address claude --- internal/container/start.go | 16 +++++---- internal/container/start_test.go | 17 ++++++++++ internal/runtime/docker.go | 10 +++--- internal/runtime/docker_test.go | 56 ++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 internal/runtime/docker_test.go diff --git a/internal/container/start.go b/internal/container/start.go index 593b682..f8dc047 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -7,6 +7,7 @@ import ( "os" stdruntime "runtime" "slices" + "strconv" "time" "github.com/containerd/errdefs" @@ -84,8 +85,11 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start "MAIN_CONTAINER_NAME="+containerName, ) - socketPath := rt.SocketPath() - env = append(env, "DOCKER_HOST=unix:///var/run/docker.sock") + var binds []runtime.BindMount + if socketPath := rt.SocketPath(); socketPath != "" { + binds = append(binds, runtime.BindMount{HostPath: socketPath, ContainerPath: "/var/run/docker.sock"}) + env = append(env, "DOCKER_HOST=unix:///var/run/docker.sock") + } containers[i] = runtime.ContainerConfig{ Image: image, @@ -95,10 +99,8 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start Env: env, Tag: c.Tag, ProductName: productName, - Binds: []runtime.BindMount{ - {HostPath: socketPath, ContainerPath: "/var/run/docker.sock"}, - }, - ExtraPorts: servicePortRange(), + Binds: binds, + ExtraPorts: servicePortRange(), } } @@ -341,7 +343,7 @@ func servicePortRange() []runtime.PortMapping { const end = 4559 var ports []runtime.PortMapping for p := start; p <= end; p++ { - ps := fmt.Sprintf("%d", p) + ps := strconv.Itoa(p) ports = append(ports, runtime.PortMapping{ContainerPort: ps, HostPort: ps}) } return ports diff --git a/internal/container/start_test.go b/internal/container/start_test.go index f422baa..0c55f45 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "strconv" "testing" "github.com/localstack/lstk/internal/output" @@ -27,3 +28,19 @@ func TestStart_ReturnsEarlyIfRuntimeUnhealthy(t *testing.T) { assert.Contains(t, err.Error(), "runtime not healthy") assert.True(t, output.IsSilent(err), "error should be silent since it was already emitted") } + +func TestServicePortRange_Returns50Entries(t *testing.T) { + ports := servicePortRange() + + require.Len(t, ports, 50) + assert.Equal(t, "4510", ports[0].ContainerPort) + assert.Equal(t, "4510", ports[0].HostPort) + assert.Equal(t, "4559", ports[49].ContainerPort) + assert.Equal(t, "4559", ports[49].HostPort) + + for i, p := range ports { + expected := strconv.Itoa(4510 + i) + assert.Equal(t, expected, p.ContainerPort) + assert.Equal(t, expected, p.HostPort) + } +} diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index cd5b233..a72027d 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -46,11 +46,13 @@ func NewDockerRuntime(dockerHost string) (*DockerRuntime, error) { func findDockerSocket() string { home, _ := os.UserHomeDir() - candidates := []string{ - "/var/run/docker.sock", + return probeSocket( filepath.Join(home, ".colima", "default", "docker.sock"), filepath.Join(home, ".colima", "docker.sock"), - } + ) +} + +func probeSocket(candidates ...string) string { for _, sock := range candidates { if _, err := os.Stat(sock); err == nil { return sock @@ -64,7 +66,7 @@ func (d *DockerRuntime) SocketPath() string { if strings.HasPrefix(host, "unix://") { return strings.TrimPrefix(host, "unix://") } - return "/var/run/docker.sock" + return "" } func (d *DockerRuntime) IsHealthy(ctx context.Context) error { diff --git a/internal/runtime/docker_test.go b/internal/runtime/docker_test.go new file mode 100644 index 0000000..df4387f --- /dev/null +++ b/internal/runtime/docker_test.go @@ -0,0 +1,56 @@ +package runtime + +import ( + "os" + "path/filepath" + "testing" + + "github.com/docker/docker/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProbeSocket_ReturnsFirstExisting(t *testing.T) { + dir := t.TempDir() + sock1 := filepath.Join(dir, "first.sock") + sock2 := filepath.Join(dir, "second.sock") + + require.NoError(t, os.WriteFile(sock1, nil, 0o600)) + require.NoError(t, os.WriteFile(sock2, nil, 0o600)) + + assert.Equal(t, sock1, probeSocket(sock1, sock2)) +} + +func TestProbeSocket_SkipsMissingAndReturnsExisting(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "missing.sock") + existing := filepath.Join(dir, "existing.sock") + + require.NoError(t, os.WriteFile(existing, nil, 0o600)) + + assert.Equal(t, existing, probeSocket(missing, existing)) +} + +func TestProbeSocket_ReturnsEmptyWhenNoneExist(t *testing.T) { + assert.Equal(t, "", probeSocket("/no/such/path.sock", "/also/missing.sock")) +} + +func TestProbeSocket_ReturnsEmptyForNoCandidates(t *testing.T) { + assert.Equal(t, "", probeSocket()) +} + +func TestSocketPath_ExtractsUnixPath(t *testing.T) { + cli, err := client.NewClientWithOpts(client.WithHost("unix:///home/user/.colima/default/docker.sock")) + require.NoError(t, err) + rt := &DockerRuntime{client: cli} + + assert.Equal(t, "/home/user/.colima/default/docker.sock", rt.SocketPath()) +} + +func TestSocketPath_ReturnsEmptyForTCPHost(t *testing.T) { + cli, err := client.NewClientWithOpts(client.WithHost("tcp://192.168.1.100:2375")) + require.NoError(t, err) + rt := &DockerRuntime{client: cli} + + assert.Equal(t, "", rt.SocketPath()) +}