Skip to content
Open
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
119 changes: 84 additions & 35 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/ports"
"github.com/localstack/lstk/internal/runtime"
)

Expand Down Expand Up @@ -43,66 +44,118 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformCl
if err != nil {
return err
}
productName, err := c.ProductName()
if err != nil {
return err
}

env := append(c.Env, "LOCALSTACK_AUTH_TOKEN="+token)
containers[i] = runtime.ContainerConfig{
Image: image,
Name: c.Name(),
Port: c.Port,
HealthPath: healthPath,
Env: env,
Image: image,
Name: c.Name(),
Port: c.Port,
HealthPath: healthPath,
Env: env,
Tag: c.Tag,
ProductName: productName,
}
}

// Pull all images first
for _, config := range containers {
containers, err = selectContainersToStart(ctx, rt, sink, containers)
if err != nil {
return err
}
if len(containers) == 0 {
return nil
}

// TODO validate license for tag "latest" without resolving the actual image version,
// and avoid pulling all images first
if err := pullImages(ctx, rt, sink, containers); err != nil {
return err
}

if err := validateLicenses(ctx, rt, sink, platformClient, containers, token); err != nil {
return err
}

return startContainers(ctx, rt, sink, containers)
}

func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) error {
for _, c := range containers {
// Remove any existing stopped container with the same name
if err := rt.Remove(ctx, config.Name); err != nil && !errdefs.IsNotFound(err) {
return fmt.Errorf("failed to remove existing container %s: %w", config.Name, err)
if err := rt.Remove(ctx, c.Name); err != nil && !errdefs.IsNotFound(err) {
return fmt.Errorf("failed to remove existing container %s: %w", c.Name, err)
}

output.EmitStatus(sink, "pulling", config.Image, "")
output.EmitStatus(sink, "pulling", c.Image, "")
progress := make(chan runtime.PullProgress)
go func() {
for p := range progress {
output.EmitProgress(sink, config.Image, p.LayerID, p.Status, p.Current, p.Total)
output.EmitProgress(sink, c.Image, p.LayerID, p.Status, p.Current, p.Total)
}
}()
if err := rt.PullImage(ctx, config.Image, progress); err != nil {
return fmt.Errorf("failed to pull image %s: %w", config.Image, err)
if err := rt.PullImage(ctx, c.Image, progress); err != nil {
return fmt.Errorf("failed to pull image %s: %w", c.Image, err)
}
}
return nil
}

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

// Start containers
for _, config := range containers {
output.EmitStatus(sink, "starting", config.Name, "")
containerID, err := rt.Start(ctx, config)
func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) error {
for _, c := range containers {
output.EmitStatus(sink, "starting", c.Name, "")
containerID, err := rt.Start(ctx, c)
if err != nil {
return fmt.Errorf("failed to start %s: %w", config.Name, err)
return fmt.Errorf("failed to start %s: %w", c.Name, err)
}

output.EmitStatus(sink, "waiting", config.Name, "")
healthURL := fmt.Sprintf("http://localhost:%s%s", config.Port, config.HealthPath)
if err := awaitStartup(ctx, rt, sink, containerID, config.Name, healthURL); err != nil {
output.EmitStatus(sink, "waiting", c.Name, "")
healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath)
if err := awaitStartup(ctx, rt, sink, containerID, c.Name, healthURL); err != nil {
return err
}

output.EmitStatus(sink, "ready", config.Name, fmt.Sprintf("containerId: %s", containerID[:12]))
output.EmitStatus(sink, "ready", c.Name, fmt.Sprintf("containerId: %s", containerID[:12]))
}

return nil
}

func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, cfgContainer *config.ContainerConfig, token string) error {
version := cfgContainer.Tag
func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) ([]runtime.ContainerConfig, error) {
var filtered []runtime.ContainerConfig
for _, c := range containers {
running, err := rt.IsRunning(ctx, c.Name)
if err != nil && !errdefs.IsNotFound(err) {
return nil, fmt.Errorf("failed to check container status: %w", err)
}
if running {
output.EmitLog(sink, fmt.Sprintf("%s is already running", c.Name))
continue
}
if err := ports.CheckAvailable(c.Port); err != nil {
configPath, pathErr := config.ConfigFilePath()
if pathErr != nil {
return nil, err
}
return nil, fmt.Errorf("%w\nTo use a different port, edit %s", err, configPath)
}
filtered = append(filtered, c)
}
return filtered, nil
}

func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, token string) error {
version := containerConfig.Tag
if version == "" || version == "latest" {
actualVersion, err := rt.GetImageVersion(ctx, containerConfig.Image)
if err != nil {
Expand All @@ -111,16 +164,12 @@ func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink,
version = actualVersion
}

productName, err := cfgContainer.ProductName()
if err != nil {
return err
}
output.EmitStatus(sink, "validating license", containerConfig.Name, version)

hostname, _ := os.Hostname()
licenseReq := &api.LicenseRequest{
Product: api.ProductInfo{
Name: productName,
Name: containerConfig.ProductName,
Version: version,
},
Credentials: api.CredentialsInfo{
Expand All @@ -134,7 +183,7 @@ func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink,
}

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

return nil
Expand Down
16 changes: 16 additions & 0 deletions internal/ports/ports.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package ports

import (
"fmt"
"net"
"time"
)

func CheckAvailable(port string) error {
conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second)
if err != nil {
return nil
}
_ = conn.Close()
return fmt.Errorf("port %s already in use", port)
}
12 changes: 7 additions & 5 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package runtime
import "context"

type ContainerConfig struct {
Image string
Name string
Port string
HealthPath string
Env []string // e.g., ["KEY=value", "FOO=bar"]
Image string
Name string
Port string
HealthPath string
Env []string // e.g., ["KEY=value", "FOO=bar"]
Tag string
ProductName string
}

type PullProgress struct {
Expand Down
10 changes: 4 additions & 6 deletions test/integration/license_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import (
"github.com/stretchr/testify/require"
)

const licenseContainerName = "localstack-aws"

func TestLicenseValidationSuccess(t *testing.T) {
requireDocker(t)
authToken := env.Require(t, env.AuthToken)
Expand Down Expand Up @@ -74,7 +72,7 @@ func TestLicenseValidationSuccess(t *testing.T) {

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

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

// Verify container was not started
_, err = dockerClient.ContainerInspect(ctx, licenseContainerName)
_, err = dockerClient.ContainerInspect(ctx, containerName)
assert.Error(t, err, "container should not exist after license failure")
}

func cleanupLicense() {
ctx := context.Background()
_ = dockerClient.ContainerStop(ctx, licenseContainerName, container.StopOptions{})
_ = dockerClient.ContainerRemove(ctx, licenseContainerName, container.RemoveOptions{Force: true})
_ = dockerClient.ContainerStop(ctx, containerName, container.StopOptions{})
_ = dockerClient.ContainerRemove(ctx, containerName, container.RemoveOptions{Force: true})
}
26 changes: 26 additions & 0 deletions test/integration/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
Expand All @@ -14,8 +15,11 @@ import (
"testing"

"github.com/99designs/keyring"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/localstack/lstk/test/integration/env"
"github.com/stretchr/testify/require"
)

// syncBuffer is a thread-safe buffer for concurrent read/write access.
Expand Down Expand Up @@ -137,6 +141,28 @@ func DeleteAuthTokenFromKeyring() error {
return err
}

const (
containerName = "localstack-aws"
testImage = "alpine:latest"
)

func startTestContainer(t *testing.T, ctx context.Context) {
t.Helper()

reader, err := dockerClient.ImagePull(ctx, testImage, image.PullOptions{})
require.NoError(t, err, "failed to pull test image")
_, _ = io.Copy(io.Discard, reader)
_ = reader.Close()

resp, err := dockerClient.ContainerCreate(ctx, &container.Config{
Image: testImage,
Cmd: []string{"sleep", "infinity"},
}, nil, nil, nil, containerName)
require.NoError(t, err, "failed to create test container")
err = dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{})
require.NoError(t, err, "failed to start test container")
}

func createMockLicenseServer(success bool) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/license/request" {
Expand Down
42 changes: 40 additions & 2 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package integration_test

import (
"context"
"net"
"os"
"os/exec"
"testing"
"time"
Expand All @@ -12,8 +14,6 @@ import (
"github.com/stretchr/testify/require"
)

const containerName = "localstack-aws"

func TestStartCommandSucceedsWithValidToken(t *testing.T) {
requireDocker(t)
_ = env.Require(t, env.AuthToken)
Expand Down Expand Up @@ -85,6 +85,44 @@ func TestStartCommandFailsWithInvalidToken(t *testing.T) {
assert.Contains(t, string(output), "license validation failed")
}

func TestStartCommandDoesNothingWhenAlreadyRunning(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

startTestContainer(t, ctx)

cmd := exec.CommandContext(ctx, binaryPath(), "start")
cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")
output, err := cmd.CombinedOutput()

require.NoError(t, err, "lstk start should succeed when container is already running: %s", output)
assert.Contains(t, string(output), "already running")
}
Comment on lines +88 to +104
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for env helper and AuthToken definition
fd -t f "env" test/integration/ | head -20

Repository: localstack/lstk

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Look for the env package structure
find test/integration -type f -name "*.go" | xargs grep -l "env\\.With\\|env\\.AuthToken" | head -10

Repository: localstack/lstk

Length of output: 153


🏁 Script executed:

#!/bin/bash
# Search for AuthToken definition in the env helper
rg -n "AuthToken\s*=" test/integration/ --type go

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if the start command uses Bubble Tea
rg -n "Bubble Tea|bubbletea" --type go | head -20

Repository: localstack/lstk

Length of output: 512


🏁 Script executed:

#!/bin/bash
# Check other tests in start_test.go to see env.With usage
head -100 test/integration/start_test.go | rg -n "env\\.With\\|os\\.Environ"

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Read the env helper file to see what AuthToken maps to
cat test/integration/env/env.go

Repository: localstack/lstk

Length of output: 1110


🏁 Script executed:

#!/bin/bash
# Check the start_test.go file to see env.With and os.Environ patterns
cat test/integration/start_test.go

Repository: localstack/lstk

Length of output: 4026


🏁 Script executed:

#!/bin/bash
# Search for where LOCALSTACK_AUTH_TOKEN or similar env vars are used
rg -n "LOCALSTACK_AUTH_TOKEN|LSTK_AUTH_TOKEN" --type go

Repository: localstack/lstk

Length of output: 937


🏁 Script executed:

#!/bin/bash
# Check if start command has Bubble Tea UI
rg -n "tea\\.|bubbletea" cmd/ internal/ --type go | grep -i start

Repository: localstack/lstk

Length of output: 41


Use env.With() for environment variable consistency and correct env var name.

This test (and the one at line 106) use append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token"), inconsistent with earlier tests in the file that use env.With(). More importantly, LOCALSTACK_AUTH_TOKEN is incorrect—that's the variable the CLI passes to the container. The CLI reads from LSTK_AUTH_TOKEN (via env.AuthToken). Use env.With(env.AuthToken, "fake-token") instead.

♻️ Suggested fix
-	cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")
+	cmd.Env = env.With(env.AuthToken, "fake-token")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestStartCommandDoesNothingWhenAlreadyRunning(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
startTestContainer(t, ctx)
cmd := exec.CommandContext(ctx, binaryPath(), "start")
cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")
output, err := cmd.CombinedOutput()
require.NoError(t, err, "lstk start should succeed when container is already running: %s", output)
assert.Contains(t, string(output), "already running")
}
func TestStartCommandDoesNothingWhenAlreadyRunning(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
startTestContainer(t, ctx)
cmd := exec.CommandContext(ctx, binaryPath(), "start")
cmd.Env = env.With(env.AuthToken, "fake-token")
output, err := cmd.CombinedOutput()
require.NoError(t, err, "lstk start should succeed when container is already running: %s", output)
assert.Contains(t, string(output), "already running")
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/integration/start_test.go` around lines 88 - 104, The test
TestStartCommandDoesNothingWhenAlreadyRunning uses append(os.Environ(), ...) and
the wrong env var name; replace that with the project's env helper so the CLI
reads the correct token: use env.With(env.AuthToken, "fake-token") for the
cmd.Env setup (the same approach used in other tests), i.e. update the
exec.CommandContext invocation in TestStartCommandDoesNothingWhenAlreadyRunning
to set cmd.Env via env.With and reference env.AuthToken instead of
"LOCALSTACK_AUTH_TOKEN" so startTestContainer and the CLI see the correct
LSTK_AUTH_TOKEN value.


func TestStartCommandFailsWhenPortInUse(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ln, err := net.Listen("tcp", ":4566")
require.NoError(t, err, "failed to bind port 4566 for test")
defer func() { _ = ln.Close() }()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, binaryPath(), "start")
cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")
output, err := cmd.CombinedOutput()

require.Error(t, err, "expected lstk start to fail when port is in use")
assert.Contains(t, string(output), "port 4566 already in use")
}

func cleanup() {
ctx := context.Background()
_ = dockerClient.ContainerStop(ctx, containerName, container.StopOptions{})
Expand Down
Loading