From bec97c8fb23ed01fb409ca23c47d09b290dbe885 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Fri, 30 Jan 2026 11:55:37 +0100 Subject: [PATCH] return error when no auth --- Makefile | 2 +- cmd/root.go | 14 ++++- cmd/start.go | 60 ++++------------------ internal/container/start.go | 93 ++++++++++++++++++++++++++++++++++ internal/runtime/docker.go | 36 ++++++++++--- internal/runtime/runtime.go | 8 +-- test/integration/start_test.go | 9 ++-- 7 files changed, 152 insertions(+), 70 deletions(-) create mode 100644 internal/container/start.go diff --git a/Makefile b/Makefile index 66b703f..90d2903 100644 --- a/Makefile +++ b/Makefile @@ -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 . diff --git a/cmd/root.go b/cmd/root.go index 02f3ee4..f334059 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,8 @@ import ( "fmt" "os" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/runtime" "github.com/spf13/cobra" ) @@ -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) + } + + if err := container.Start(cmd.Context(), rt, onProgress); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } diff --git a/cmd/start.go b/cmd/start.go index a2bb443..3514d90 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -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" ) @@ -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 + }, } diff --git a/internal/container/start.go b/internal/container/start.go new file mode 100644 index 0000000..41266bd --- /dev/null +++ b/internal/container/start.go @@ -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): + } + } +} diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index d5b7cf1..d2f493a 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "strconv" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" @@ -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{ @@ -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 +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 882531c..224db62 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -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 } type PullProgress struct { @@ -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) } diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 08a8088..55a7208 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -13,7 +13,7 @@ import ( const containerName = "localstack-aws" -func TestStartCommand(t *testing.T) { +func TestStartCommandFailsWithoutAuth(t *testing.T) { cleanup() t.Cleanup(cleanup) @@ -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() {