Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ clean:
rm -rf $(BUILD_DIR)

test-integration: build
cd test/integration && go test -v .
cd test/integration && go test -count=1 -v .
Copy link
Collaborator Author

@carole-lavillonniere carole-lavillonniere Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This disables test caching.

 ❯ go help testflag | grep "disable test caching" -C 2
When 'go test' runs in package list mode, 'go test' caches successful
package test results to avoid unnecessary repeated running of tests. To
disable test caching, use any test flag or argument other than the
cacheable flags. The idiomatic way to disable test caching explicitly
is to use -count=1.

14 changes: 13 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"os"

"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/runtime"
"github.com/spf13/cobra"
)

Expand All @@ -13,7 +15,17 @@ var rootCmd = &cobra.Command{
Short: "LocalStack CLI",
Long: "lstk is the command-line interface for LocalStack.",
Run: func(cmd *cobra.Command, args []string) {
if err := runStart(cmd.Context()); err != nil {
rt, err := runtime.NewDockerRuntime()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

onProgress := func(msg string) {
fmt.Println(msg)
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onProgress callback decouples the business logic from how output is displayed. Without it, the internal/container package would need to import and depend on UI (bubbletea).


if err := container.Start(cmd.Context(), rt, onProgress); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
Expand Down
60 changes: 9 additions & 51 deletions cmd/start.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package cmd

import (
"context"
"fmt"
"os"

"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/runtime"
"github.com/spf13/cobra"
)
Expand All @@ -14,61 +14,19 @@ var startCmd = &cobra.Command{
Short: "Start LocalStack",
Long: "Start the LocalStack emulator.",
Run: func(cmd *cobra.Command, args []string) {
if err := runStart(cmd.Context()); err != nil {
rt, err := runtime.NewDockerRuntime()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}

func runStart(ctx context.Context) error {
rt, err := runtime.NewDockerRuntime()
if err != nil {
return fmt.Errorf("failed to create runtime: %w", err)
}

// TODO: hardcoded for now, later should be configurable
containers := []runtime.ContainerConfig{
{
Image: "localstack/localstack-pro:latest",
Name: "localstack-aws",
Ports: map[string]string{"4566/tcp": "4566"},
},
}

for _, config := range containers {
fmt.Printf("Pulling %s...\n", config.Image)
progress := make(chan runtime.PullProgress)
go func() {
for p := range progress {
if p.Total > 0 {
fmt.Printf(" %s: %s %.1f%%\n", p.LayerID, p.Status, float64(p.Current)/float64(p.Total)*100)
} else if p.Status != "" {
fmt.Printf(" %s: %s\n", p.LayerID, p.Status)
}
}
}()
if err := rt.PullImage(ctx, config.Image, progress); err != nil {
return fmt.Errorf("failed to pull image %s: %w", config.Image, err)
}

fmt.Printf("Starting %s...\n", config.Name)
containerID, err := rt.Start(ctx, config)
if err != nil {
return fmt.Errorf("failed to start %s: %w", config.Name, err)
}

running, err := rt.IsRunning(ctx, containerID)
if err != nil {
return fmt.Errorf("failed to check status of %s: %w", config.Name, err)
onProgress := func(msg string) {
fmt.Println(msg)
}

if !running {
return fmt.Errorf("container %s failed to start", config.Name)
if err := container.Start(cmd.Context(), rt, onProgress); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

fmt.Printf("%s running (container: %s)\n", config.Name, containerID[:12])
}

return nil
},
}
93 changes: 93 additions & 0 deletions internal/container/start.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package container

import (
"context"
"fmt"
"net/http"
"time"

"github.com/localstack/lstk/internal/runtime"
)

func Start(ctx context.Context, rt runtime.Runtime, onProgress func(string)) error {
// TODO: hardcoded for now, later should be configurable
containers := []runtime.ContainerConfig{
{
Image: "localstack/localstack-pro:latest",
Name: "localstack-aws",
Port: "4566",
HealthPath: "/_localstack/health",
},
}

for _, config := range containers {
onProgress(fmt.Sprintf("Pulling %s...", config.Image))
progress := make(chan runtime.PullProgress)
go func() {
for p := range progress {
if p.Total > 0 {
onProgress(fmt.Sprintf(" %s: %s %.1f%%", p.LayerID, p.Status, float64(p.Current)/float64(p.Total)*100))
} else if p.Status != "" {
onProgress(fmt.Sprintf(" %s: %s", p.LayerID, p.Status))
}
}
}()
if err := rt.PullImage(ctx, config.Image, progress); err != nil {
return fmt.Errorf("failed to pull image %s: %w", config.Image, err)
}

onProgress(fmt.Sprintf("Starting %s...", config.Name))
containerID, err := rt.Start(ctx, config)
if err != nil {
return fmt.Errorf("failed to start %s: %w", config.Name, err)
}

onProgress(fmt.Sprintf("Waiting for %s to be ready...", config.Name))
healthURL := fmt.Sprintf("http://localhost:%s%s", config.Port, config.HealthPath)
if err := awaitStartup(ctx, rt, containerID, config.Name, healthURL); err != nil {
return err
}

onProgress(fmt.Sprintf("%s ready (container: %s)", config.Name, containerID[:12]))
}

return nil
}

// awaitStartup polls until one of two outcomes:
// - Success: health endpoint returns 200 (license is valid, LocalStack is ready)
// - Failure: container stops running (e.g., license activation failed), returns error with container logs
//
// TODO: move to Runtime interface if other runtimes (k8s?) need native readiness probes
func awaitStartup(ctx context.Context, rt runtime.Runtime, containerID, name, healthURL string) error {
client := &http.Client{Timeout: 2 * time.Second}

for {
running, err := rt.IsRunning(ctx, containerID)
if err != nil {
return fmt.Errorf("failed to check container status: %w", err)
}
if !running {
logs, logsErr := rt.Logs(ctx, containerID, 20)
if logsErr != nil || logs == "" {
return fmt.Errorf("%s exited unexpectedly", name)
}
return fmt.Errorf("%s exited unexpectedly:\n%s", name, logs)
}

resp, err := client.Get(healthURL)
if err == nil && resp.StatusCode == http.StatusOK {
resp.Body.Close()
return nil
}
if resp != nil {
resp.Body.Close()
}

select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
}
}
36 changes: 28 additions & 8 deletions internal/runtime/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"io"
"strconv"

"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
Expand Down Expand Up @@ -64,14 +65,9 @@ func (d *DockerRuntime) PullImage(ctx context.Context, imageName string, progres
}

func (d *DockerRuntime) Start(ctx context.Context, config ContainerConfig) (string, error) {
portBindings := nat.PortMap{}
exposedPorts := nat.PortSet{}

for containerPort, hostPort := range config.Ports {
port := nat.Port(containerPort)
exposedPorts[port] = struct{}{}
portBindings[port] = []nat.PortBinding{{HostPort: hostPort}}
}
port := nat.Port(config.Port + "/tcp")
exposedPorts := nat.PortSet{port: struct{}{}}
portBindings := nat.PortMap{port: []nat.PortBinding{{HostPort: config.Port}}}

resp, err := d.client.ContainerCreate(ctx,
&container.Config{
Expand Down Expand Up @@ -101,3 +97,27 @@ func (d *DockerRuntime) IsRunning(ctx context.Context, containerID string) (bool
}
return inspect.State.Running, nil
}

func (d *DockerRuntime) Logs(ctx context.Context, containerID string, tail int) (string, error) {
options := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Tail: "50",
}
if tail > 0 {
options.Tail = strconv.Itoa(tail)
}

reader, err := d.client.ContainerLogs(ctx, containerID, options)
if err != nil {
return "", err
}
defer reader.Close()

logs, err := io.ReadAll(reader)
if err != nil {
return "", err
}

return string(logs), nil
}
8 changes: 5 additions & 3 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ package runtime
import "context"

type ContainerConfig struct {
Image string
Name string
Ports map[string]string
Image string
Name string
Port string
HealthPath string
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to configure multiple ports yet, so simplified and made Port a string instead of a map to keep things simple

}

type PullProgress struct {
Expand All @@ -20,4 +21,5 @@ type Runtime interface {
PullImage(ctx context.Context, image string, progress chan<- PullProgress) error
Start(ctx context.Context, config ContainerConfig) (string, error)
IsRunning(ctx context.Context, containerID string) (bool, error)
Logs(ctx context.Context, containerID string, tail int) (string, error)
}
9 changes: 3 additions & 6 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

const containerName = "localstack-aws"

func TestStartCommand(t *testing.T) {
func TestStartCommandFailsWithoutAuth(t *testing.T) {
cleanup()
t.Cleanup(cleanup)

Expand All @@ -22,12 +22,9 @@ func TestStartCommand(t *testing.T) {

cmd := exec.CommandContext(ctx, "../../bin/lstk", "start")
output, err := cmd.CombinedOutput()
require.NoError(t, err, "lstk start failed: %s", output)

inspect, err := dockerClient.ContainerInspect(ctx, containerName)
require.NoError(t, err, "failed to inspect container")

assert.True(t, inspect.State.Running, "container is not running, state: %s", inspect.State.Status)
require.Error(t, err, "expected lstk start to fail without auth")
assert.Contains(t, string(output), "License activation failed")
}

func cleanup() {
Expand Down