Skip to content
Draft
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
5 changes: 3 additions & 2 deletions cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions internal/auth/mock_token_storage.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 29 additions & 2 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
stdruntime "runtime"
"slices"
"strconv"
"time"

"github.com/containerd/errdefs"
Expand Down Expand Up @@ -76,15 +77,30 @@ 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,
)

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,
Name: c.Name(),
Name: containerName,
Port: c.Port,
HealthPath: healthPath,
Env: env,
Tag: c.Tag,
ProductName: productName,
Binds: binds,
ExtraPorts: servicePortRange(),
}
}

Expand Down Expand Up @@ -321,3 +337,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 := strconv.Itoa(p)
ports = append(ports, runtime.PortMapping{ContainerPort: ps, HostPort: ps})
}
return ports
}
17 changes: 17 additions & 0 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"io"
"strconv"
"testing"

"github.com/localstack/lstk/internal/output"
Expand All @@ -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)
}
}
2 changes: 2 additions & 0 deletions internal/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
type Env struct {
AuthToken string
LocalStackHost string
DockerHost string
DisableEvents bool

APIEndpoint string
Expand All @@ -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"),
Expand Down
61 changes: 59 additions & 2 deletions internal/runtime/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"io"
"log"
"os"
"path/filepath"
stdruntime "runtime"
"strconv"
"strings"
Expand All @@ -24,14 +26,49 @@ 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()
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
}
}
return ""
}

func (d *DockerRuntime) SocketPath() string {
host := d.client.DaemonHost()
if strings.HasPrefix(host, "unix://") {
return strings.TrimPrefix(host, "unix://")
}
return ""
}

func (d *DockerRuntime) IsHealthy(ctx context.Context) error {
_, err := d.client.Ping(ctx)
if err != nil {
Expand Down Expand Up @@ -112,6 +149,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,
Expand All @@ -120,6 +176,7 @@ func (d *DockerRuntime) Start(ctx context.Context, config ContainerConfig) (stri
},
&container.HostConfig{
PortBindings: portBindings,
Binds: binds,
},
nil, nil, config.Name,
)
Expand Down
56 changes: 56 additions & 0 deletions internal/runtime/docker_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
14 changes: 14 additions & 0 deletions internal/runtime/mock_runtime.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 24 additions & 7 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Loading