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
31 changes: 22 additions & 9 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import (
"github.com/localstack/lstk/internal/telemetry"
)

const envPersistenceEnabled = "LOCALSTACK_PERSISTENCE=1"

type postStartSetupFunc func(ctx context.Context, sink output.Sink, interactive bool, resolvedHost string) error

// StartOptions groups the user-provided options for starting an emulator.
Expand Down Expand Up @@ -107,7 +109,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start
env = append(env, hostEnv...)

if opts.Persist {
env = append(env, "LOCALSTACK_PERSISTENCE=1")
env = append(env, envPersistenceEnabled)
}

var binds []runtime.BindMount
Expand Down Expand Up @@ -190,10 +192,10 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start
setups := map[config.EmulatorType]postStartSetupFunc{
config.EmulatorAWS: awsconfig.EnsureProfile,
}
return runPostStartSetups(ctx, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, setups)
return runPostStartSetups(ctx, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, opts.Persist, setups)
}

func runPostStartSetups(ctx context.Context, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost, webAppURL string, setups map[config.EmulatorType]postStartSetupFunc) error {
func runPostStartSetups(ctx context.Context, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost, webAppURL string, persist bool, setups map[config.EmulatorType]postStartSetupFunc) error {
// build ordered list of unique types, keeping the first container config for each
firstByType := map[config.EmulatorType]config.ContainerConfig{}
var uniqueEmulatorTypes []config.EmulatorType
Expand All @@ -214,26 +216,37 @@ func runPostStartSetups(ctx context.Context, sink output.Sink, containers []conf
return err
}
}
emitPostStartPointers(sink, t, resolvedHost, webAppURL)
emitPostStartPointers(sink, t, resolvedHost, webAppURL, persist)
}
return nil
}

func emitAlreadyRunning(ctx context.Context, sink output.Sink, c runtime.ContainerConfig, localStackHost, webAppURL string) {
func emitAlreadyRunning(ctx context.Context, sink output.Sink, c runtime.ContainerConfig, localStackHost, webAppURL string, persist bool) {
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: fmt.Sprintf("%s is already running", c.EmulatorType.DisplayName())})
resolvedHost, dnsOK := endpoint.ResolveHost(ctx, c.Port, localStackHost)
if !dnsOK {
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: endpoint.DNSRebindNote})
}
emitPostStartPointers(sink, c.EmulatorType, resolvedHost, webAppURL)
emitPostStartPointers(sink, c.EmulatorType, resolvedHost, webAppURL, persist)
}

func isPersistenceEnabled(ctx context.Context, rt runtime.Runtime, containerName string) bool {
env, err := rt.ContainerEnv(ctx, containerName)
if err != nil {
return false
}
return slices.Contains(env, envPersistenceEnabled)
}

func emitPostStartPointers(sink output.Sink, emulatorType config.EmulatorType, resolvedHost, webAppURL string) {
func emitPostStartPointers(sink output.Sink, emulatorType config.EmulatorType, resolvedHost, webAppURL string, persist bool) {
if sfHost := snowflake.Hostname(resolvedHost); emulatorType == config.EmulatorSnowflake && sfHost != "" {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Snowflake endpoint: http://%s", sfHost)})
} else {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Endpoint: %s", resolvedHost)})
}
if persist && emulatorType == config.EmulatorAWS {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "• Persistence: Enabled"})
}
if webAppURL != "" {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Web app: %s", strings.TrimRight(webAppURL, "/"))})
}
Expand Down Expand Up @@ -416,7 +429,7 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu
return nil, fmt.Errorf("failed to check container status: %w", err)
}
if running {
emitAlreadyRunning(ctx, sink, c, localStackHost, webAppURL)
emitAlreadyRunning(ctx, sink, c, localStackHost, webAppURL, isPersistenceEnabled(ctx, rt, c.Name))
continue
}

Expand Down Expand Up @@ -460,7 +473,7 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu
})
return nil, output.NewSilentError(fmt.Errorf("LocalStack already running on port %s", found.BoundPort))
}
emitAlreadyRunning(ctx, sink, c, localStackHost, webAppURL)
emitAlreadyRunning(ctx, sink, c, localStackHost, webAppURL, isPersistenceEnabled(ctx, rt, found.Name))
continue
}

Expand Down
36 changes: 31 additions & 5 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,45 @@ func TestEmitPostStartPointers_WithWebApp(t *testing.T) {
var out bytes.Buffer
sink := output.NewPlainSink(&out)

emitPostStartPointers(sink, config.EmulatorAWS, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/")
emitPostStartPointers(sink, config.EmulatorAWS, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false)

got := out.String()
assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n")
assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n")
assert.Contains(t, got, "> Tip:")
assert.NotContains(t, got, "• Snowflake endpoint:",
"AWS path must not show the snowflake-prefixed endpoint")
assert.NotContains(t, got, "• Persistence:",
"persistence bullet must be omitted when persist is false")
}

func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) {
var out bytes.Buffer
sink := output.NewPlainSink(&out)

emitPostStartPointers(sink, config.EmulatorAWS, "127.0.0.1:4566", "")
emitPostStartPointers(sink, config.EmulatorAWS, "127.0.0.1:4566", "", false)

got := out.String()
assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n")
assert.Contains(t, got, "> Tip:")
}

func TestEmitPostStartPointers_WithPersist(t *testing.T) {
var out bytes.Buffer
sink := output.NewPlainSink(&out)

emitPostStartPointers(sink, config.EmulatorAWS, "127.0.0.1:4566", "https://app.localstack.cloud/", true)

got := out.String()
assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n• Persistence: Enabled\n• Web app: https://app.localstack.cloud\n",
"persistence bullet must sit between the endpoint and web app lines")
}

func TestEmitPostStartPointers_Snowflake_ReplacesEndpointWithSnowflakeEndpoint(t *testing.T) {
var out bytes.Buffer
sink := output.NewPlainSink(&out)

emitPostStartPointers(sink, config.EmulatorSnowflake, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/")
emitPostStartPointers(sink, config.EmulatorSnowflake, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false)

got := out.String()
assert.Contains(t, got, "• Snowflake endpoint: http://snowflake.localhost.localstack.cloud:4566\n")
Expand All @@ -73,11 +86,22 @@ func TestEmitPostStartPointers_Snowflake_ReplacesEndpointWithSnowflakeEndpoint(t
assert.Contains(t, got, "> Tip:")
}

func TestEmitPostStartPointers_Snowflake_OmitsPersistenceBullet(t *testing.T) {
var out bytes.Buffer
sink := output.NewPlainSink(&out)

emitPostStartPointers(sink, config.EmulatorSnowflake, "localhost.localstack.cloud:4566", "", true)

got := out.String()
assert.NotContains(t, got, "• Persistence:",
"snowflake does not support persistence; the bullet must be suppressed even when --persist is set")
}

func TestEmitPostStartPointers_Snowflake_FallsBackToBareEndpointForIPHost(t *testing.T) {
var out bytes.Buffer
sink := output.NewPlainSink(&out)

emitPostStartPointers(sink, config.EmulatorSnowflake, "127.0.0.1:4566", "")
emitPostStartPointers(sink, config.EmulatorSnowflake, "127.0.0.1:4566", "", false)

got := out.String()
assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n",
Expand All @@ -102,6 +126,7 @@ func TestSelectContainersToStart_AttachesWhenExternalContainerOnConfiguredPort(t
mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp").
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)
mockRT.EXPECT().ContainerEnv(gomock.Any(), "external-container").Return(nil, nil)

var out bytes.Buffer
sink := output.NewPlainSink(&out)
Expand Down Expand Up @@ -130,6 +155,7 @@ func TestSelectContainersToStart_AttachesWhenExternalContainerVersionDiffers(t *
mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp").
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)
mockRT.EXPECT().ContainerEnv(gomock.Any(), "external-container").Return(nil, nil)

var out bytes.Buffer
sink := output.NewPlainSink(&out)
Expand Down Expand Up @@ -208,7 +234,7 @@ func TestEmitPostStartPointers_UnknownEmulator_NoTip(t *testing.T) {
var out bytes.Buffer
sink := output.NewPlainSink(&out)

emitPostStartPointers(sink, config.EmulatorType("other"), "localhost.localstack.cloud:4566", "https://app.localstack.cloud/")
emitPostStartPointers(sink, config.EmulatorType("other"), "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false)

got := out.String()
assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n")
Expand Down
1 change: 1 addition & 0 deletions internal/container/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain
Host: host,
ContainerName: name,
Uptime: uptime,
Persistence: c.Type == config.EmulatorAWS && isPersistenceEnabled(ctx, rt, name),
})

if c.Type == config.EmulatorAWS {
Expand Down
1 change: 1 addition & 0 deletions internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type InstanceInfoEvent struct {
Host string
ContainerName string
Uptime time.Duration
Persistence bool
}

type TableEvent struct {
Expand Down
3 changes: 3 additions & 0 deletions internal/output/plain_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ func formatInstanceInfo(e InstanceInfoEvent) string {
if e.Host != "" {
sb.WriteString("\n• Endpoint: " + e.Host)
}
if e.Persistence {
sb.WriteString("\n• Persistence: Enabled")
}
if e.ContainerName != "" {
sb.WriteString("\n• Container: " + e.ContainerName)
}
Expand Down
14 changes: 13 additions & 1 deletion internal/output/plain_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,9 @@ func TestFormatEventLine(t *testing.T) {
Host: "localhost.localstack.cloud:4566",
ContainerName: "localstack-aws",
Uptime: 4*time.Minute + 23*time.Second,
Persistence: true,
},
want: SuccessMarker() + " LocalStack AWS Emulator is running\n• Endpoint: localhost.localstack.cloud:4566\n• Container: localstack-aws\n• Version: 4.14.1\n• Uptime: 4m 23s",
want: SuccessMarker() + " LocalStack AWS Emulator is running\n• Endpoint: localhost.localstack.cloud:4566\n• Persistence: Enabled\n• Container: localstack-aws\n• Version: 4.14.1\n• Uptime: 4m 23s",
wantOK: true,
},
{
Expand All @@ -132,6 +133,17 @@ func TestFormatEventLine(t *testing.T) {
want: SuccessMarker() + " LocalStack AWS Emulator is running\n• Endpoint: 127.0.0.1:4566",
wantOK: true,
},
{
name: "instance info omits persistence when disabled",
event: InstanceInfoEvent{
EmulatorName: "LocalStack AWS Emulator",
Host: "127.0.0.1:4566",
ContainerName: "localstack-aws",
Persistence: false,
},
want: SuccessMarker() + " LocalStack AWS Emulator is running\n• Endpoint: 127.0.0.1:4566\n• Container: localstack-aws",
wantOK: true,
},
{
name: "table with entries",
event: TableEvent{
Expand Down
3 changes: 2 additions & 1 deletion internal/output/plain_sink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,10 @@ func TestPlainSink_EmitsInstanceInfoEvent(t *testing.T) {
Host: "localhost.localstack.cloud:4566",
ContainerName: "localstack-aws",
Uptime: 4*time.Minute + 23*time.Second,
Persistence: true,
})

expected := SuccessMarker() + " LocalStack AWS Emulator is running\n• Endpoint: localhost.localstack.cloud:4566\n• Container: localstack-aws\n• Version: 4.14.1\n• Uptime: 4m 23s\n"
expected := SuccessMarker() + " LocalStack AWS Emulator is running\n• Endpoint: localhost.localstack.cloud:4566\n• Persistence: Enabled\n• Container: localstack-aws\n• Version: 4.14.1\n• Uptime: 4m 23s\n"
assert.Equal(t, expected, out.String())
assert.NoError(t, sink.Err())
})
Expand Down
11 changes: 11 additions & 0 deletions internal/runtime/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,17 @@ func (d *DockerRuntime) ContainerStartedAt(ctx context.Context, containerName st
return t, nil
}

func (d *DockerRuntime) ContainerEnv(ctx context.Context, containerName string) ([]string, error) {
inspect, err := d.client.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{})
if err != nil {
return nil, fmt.Errorf("failed to inspect container: %w", err)
}
if inspect.Container.Config == nil {
return nil, nil
}
return inspect.Container.Config.Env, nil
}

func (d *DockerRuntime) Logs(ctx context.Context, containerID string, tail int) (string, error) {
options := client.ContainerLogsOptions{
ShowStdout: true,
Expand Down
15 changes: 15 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.

1 change: 1 addition & 0 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type Runtime interface {
Remove(ctx context.Context, containerName string) error
IsRunning(ctx context.Context, containerID string) (bool, error)
ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error)
ContainerEnv(ctx context.Context, containerName string) ([]string, error)
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)
Expand Down
15 changes: 13 additions & 2 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@ func TestStartCommandSucceedsWithValidToken(t *testing.T) {
defer mockServer.Close()

ctx := testContext(t)
_, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "start")
stdout, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "start")
require.NoError(t, err, "lstk start failed: %s", stderr)
requireExitCode(t, 0, err)

inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{})
require.NoError(t, err, "failed to inspect container")
assert.True(t, inspect.Container.State.Running, "container should be running")

assert.NotContains(t, stdout, "• Persistence:",
"persistence bullet must be omitted when --persist is not set")
}

func TestStartCommandSucceedsWithKeyringToken(t *testing.T) {
Expand Down Expand Up @@ -416,7 +419,7 @@ func TestStartCommandPersistFlagSetsPersistenceEnv(t *testing.T) {
defer mockServer.Close()

ctx := testContext(t)
_, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "start", "--persist")
stdout, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "start", "--persist")
require.NoError(t, err, "lstk start --persist failed: %s", stderr)
requireExitCode(t, 0, err)

Expand All @@ -426,6 +429,14 @@ func TestStartCommandPersistFlagSetsPersistenceEnv(t *testing.T) {

envVars := containerEnvToMap(inspect.Container.Config.Env)
assert.Equal(t, "1", envVars["LOCALSTACK_PERSISTENCE"])

assert.Contains(t, stdout, "• Persistence: Enabled",
"lstk start --persist should surface persistence state in the header")

statusStdout, statusStderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "status")
require.NoError(t, err, "lstk status failed: %s", statusStderr)
assert.Contains(t, statusStdout, "• Persistence: Enabled",
"lstk status should surface persistence state when the running container has it enabled")
}

func TestStartCommandForwardsPersistenceEnvFromHost(t *testing.T) {
Expand Down
Loading