From 9806cb97b3cca8abe999bec1206cd486fd1c89b7 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 13 May 2026 16:33:20 +0300 Subject: [PATCH 1/3] Surface persistence in start and status commands --- internal/container/start.go | 31 ++++++++++++++++++++-------- internal/container/start_test.go | 25 +++++++++++++++++----- internal/container/status.go | 1 + internal/output/events.go | 1 + internal/output/plain_format.go | 3 +++ internal/output/plain_format_test.go | 14 ++++++++++++- internal/output/plain_sink_test.go | 3 ++- internal/runtime/docker.go | 11 ++++++++++ internal/runtime/mock_runtime.go | 15 ++++++++++++++ internal/runtime/runtime.go | 1 + test/integration/start_test.go | 15 ++++++++++++-- 11 files changed, 102 insertions(+), 18 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index d2e1e968..90b034b9 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -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. @@ -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 @@ -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 @@ -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 { + 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, "/"))}) } @@ -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 } @@ -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 } diff --git a/internal/container/start_test.go b/internal/container/start_test.go index c9d46c96..0f58dab6 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -38,7 +38,7 @@ 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") @@ -46,24 +46,37 @@ func TestEmitPostStartPointers_WithWebApp(t *testing.T) { 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") @@ -77,7 +90,7 @@ func TestEmitPostStartPointers_Snowflake_FallsBackToBareEndpointForIPHost(t *tes 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", @@ -102,6 +115,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) @@ -130,6 +144,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) @@ -208,7 +223,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") diff --git a/internal/container/status.go b/internal/container/status.go index d139638e..36d877f1 100644 --- a/internal/container/status.go +++ b/internal/container/status.go @@ -85,6 +85,7 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain Host: host, ContainerName: name, Uptime: uptime, + Persistence: isPersistenceEnabled(ctx, rt, name), }) if c.Type == config.EmulatorAWS { diff --git a/internal/output/events.go b/internal/output/events.go index 0059fe47..1f6e3c5a 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -64,6 +64,7 @@ type InstanceInfoEvent struct { Host string ContainerName string Uptime time.Duration + Persistence bool } type TableEvent struct { diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 2225a07c..ec34a9a6 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -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) } diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index aadbd776..3c052e37 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -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, }, { @@ -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{ diff --git a/internal/output/plain_sink_test.go b/internal/output/plain_sink_test.go index b686a081..c0b3026e 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -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()) }) diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index d8d6cf5f..6040228b 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -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, diff --git a/internal/runtime/mock_runtime.go b/internal/runtime/mock_runtime.go index c8afee1b..b47a6b7b 100644 --- a/internal/runtime/mock_runtime.go +++ b/internal/runtime/mock_runtime.go @@ -58,6 +58,21 @@ func (mr *MockRuntimeMockRecorder) FindRunningByImage(ctx, imageRepos, container return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindRunningByImage", reflect.TypeOf((*MockRuntime)(nil).FindRunningByImage), ctx, imageRepos, containerPort) } +// ContainerEnv mocks base method. +func (m *MockRuntime) ContainerEnv(ctx context.Context, containerName string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ContainerEnv", ctx, containerName) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ContainerEnv indicates an expected call of ContainerEnv. +func (mr *MockRuntimeMockRecorder) ContainerEnv(ctx, containerName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerEnv", reflect.TypeOf((*MockRuntime)(nil).ContainerEnv), ctx, containerName) +} + // ContainerStartedAt mocks base method. func (m *MockRuntime) ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) { m.ctrl.T.Helper() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 04f3aa76..c8bfe37b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -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) diff --git a/test/integration/start_test.go b/test/integration/start_test.go index b03bd7f7..83211a9d 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -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) { @@ -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) @@ -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) { From 0dcacdc026f9b78ae1a8bebfdbfa739557345bc8 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 13 May 2026 20:57:20 +0300 Subject: [PATCH 2/3] Supress persistence metadata for non-AWS emulation --- internal/container/start.go | 2 +- internal/container/start_test.go | 11 +++++++++++ internal/container/status.go | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index 90b034b9..28928067 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -244,7 +244,7 @@ func emitPostStartPointers(sink output.Sink, emulatorType config.EmulatorType, r } else { sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Endpoint: %s", resolvedHost)}) } - if persist { + if persist && emulatorType == config.EmulatorAWS { sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "• Persistence: Enabled"}) } if webAppURL != "" { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 0f58dab6..792561e0 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -86,6 +86,17 @@ 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) diff --git a/internal/container/status.go b/internal/container/status.go index 36d877f1..7d6697d1 100644 --- a/internal/container/status.go +++ b/internal/container/status.go @@ -85,7 +85,7 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain Host: host, ContainerName: name, Uptime: uptime, - Persistence: isPersistenceEnabled(ctx, rt, name), + Persistence: c.Type == config.EmulatorAWS && isPersistenceEnabled(ctx, rt, name), }) if c.Type == config.EmulatorAWS { From 082c7e18b2a2d05b2ac8fc4e58cce4ea56f66889 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 14 May 2026 21:11:26 +0300 Subject: [PATCH 3/3] Detect persistence from container env in post-start pointers --- internal/container/start.go | 6 +++--- internal/container/start_test.go | 33 ++++++++++++++++++++++++++++++++ test/integration/start_test.go | 14 ++++++++++---- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index 28928067..e7062c6e 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -192,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, opts.Persist, setups) + return runPostStartSetups(ctx, rt, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, setups) } -func runPostStartSetups(ctx context.Context, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost, webAppURL string, persist bool, setups map[config.EmulatorType]postStartSetupFunc) error { +func runPostStartSetups(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost, webAppURL string, 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 @@ -216,7 +216,7 @@ func runPostStartSetups(ctx context.Context, sink output.Sink, containers []conf return err } } - emitPostStartPointers(sink, t, resolvedHost, webAppURL, persist) + emitPostStartPointers(sink, t, resolvedHost, webAppURL, isPersistenceEnabled(ctx, rt, c.Name())) } return nil } diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 792561e0..9d77ce54 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -72,6 +72,39 @@ func TestEmitPostStartPointers_WithPersist(t *testing.T) { "persistence bullet must sit between the endpoint and web app lines") } +func TestRunPostStartSetups_EmitsPersistenceFromContainerEnv(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + + cfg := config.ContainerConfig{Type: config.EmulatorAWS, Tag: "latest", Port: "4566"} + mockRT.EXPECT().ContainerEnv(gomock.Any(), cfg.Name()).Return([]string{"LOCALSTACK_PERSISTENCE=1"}, nil) + + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + err := runPostStartSetups(context.Background(), mockRT, sink, []config.ContainerConfig{cfg}, false, "", "", nil) + require.NoError(t, err) + + assert.Contains(t, out.String(), "• Persistence: Enabled", + "persistence bullet must be emitted whenever the container env carries LOCALSTACK_PERSISTENCE=1, regardless of how it got there") +} + +func TestRunPostStartSetups_OmitsPersistenceWhenContainerEnvLacksFlag(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + + cfg := config.ContainerConfig{Type: config.EmulatorAWS, Tag: "latest", Port: "4566"} + mockRT.EXPECT().ContainerEnv(gomock.Any(), cfg.Name()).Return([]string{"OTHER=1"}, nil) + + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + err := runPostStartSetups(context.Background(), mockRT, sink, []config.ContainerConfig{cfg}, false, "", "", nil) + require.NoError(t, err) + + assert.NotContains(t, out.String(), "• Persistence:") +} + func TestEmitPostStartPointers_Snowflake_ReplacesEndpointWithSnowflakeEndpoint(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 83211a9d..f384c710 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -450,7 +450,7 @@ func TestStartCommandForwardsPersistenceEnvFromHost(t *testing.T) { defer mockServer.Close() ctx := testContext(t) - _, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL). + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL). With(env.Persistence, "1"), "start") require.NoError(t, err, "lstk start failed: %s", stderr) @@ -462,6 +462,9 @@ func TestStartCommandForwardsPersistenceEnvFromHost(t *testing.T) { envVars := containerEnvToMap(inspect.Container.Config.Env) assert.Equal(t, "1", envVars["LOCALSTACK_PERSISTENCE"]) + + assert.Contains(t, stdout, "• Persistence: Enabled", + "lstk start should surface persistence state when LOCALSTACK_PERSISTENCE=1 is set in the shell") } func TestStartCommandSetsPersistenceEnvFromConfig(t *testing.T) { @@ -476,7 +479,7 @@ func TestStartCommandSetsPersistenceEnvFromConfig(t *testing.T) { configContent := ` [env.persistence] -PERSISTENCE = "1" +LOCALSTACK_PERSISTENCE = "1" [[containers]] type = "aws" @@ -488,7 +491,7 @@ env = ["persistence"] require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) ctx := testContext(t) - _, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "--config", configFile, "start") + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "--config", configFile, "start") require.NoError(t, err, "lstk start failed: %s", stderr) requireExitCode(t, 0, err) @@ -497,7 +500,10 @@ env = ["persistence"] require.True(t, inspect.Container.State.Running) envVars := containerEnvToMap(inspect.Container.Config.Env) - assert.Equal(t, "1", envVars["PERSISTENCE"]) + assert.Equal(t, "1", envVars["LOCALSTACK_PERSISTENCE"]) + + assert.Contains(t, stdout, "• Persistence: Enabled", + "lstk start should surface persistence state when LOCALSTACK_PERSISTENCE=1 is set in the config profile") } // hasBindTarget checks if any bind mount targets the given container path.