From 8be10811656c0635cc9181051d52b06cd5ba2c5b Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 2 Apr 2026 13:53:28 +0300 Subject: [PATCH 1/4] Add volume path command to print resolved volume directory --- cmd/root.go | 1 + cmd/volume.go | 45 ++++++++++++++++++++++++ test/integration/volume_test.go | 61 +++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 cmd/volume.go create mode 100644 test/integration/volume_test.go diff --git a/cmd/root.go b/cmd/root.go index 0f10505..2f835ca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,6 +60,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newStatusCmd(cfg, tel), newLogsCmd(cfg, tel), newConfigCmd(cfg, tel), + newVolumeCmd(cfg, tel), newUpdateCmd(cfg, tel), newDocsCmd(), ) diff --git a/cmd/volume.go b/cmd/volume.go new file mode 100644 index 0000000..c77b107 --- /dev/null +++ b/cmd/volume.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/telemetry" + "github.com/spf13/cobra" +) + +func newVolumeCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "volume", + Short: "Manage emulator volume", + } + cmd.AddCommand(newVolumePathCmd(cfg, tel)) + return cmd +} + +func newVolumePathCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { + return &cobra.Command{ + Use: "path", + Short: "Print the volume directory path", + PreRunE: initConfig, + RunE: commandWithTelemetry("volume path", tel, func(cmd *cobra.Command, args []string) error { + appConfig, err := config.Get() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + for _, c := range appConfig.Containers { + volumeDir, err := c.VolumeDir() + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), volumeDir) + if err != nil { + return err + } + } + return nil + }), + } +} diff --git a/test/integration/volume_test.go b/test/integration/volume_test.go new file mode 100644 index 0000000..15cfc26 --- /dev/null +++ b/test/integration/volume_test.go @@ -0,0 +1,61 @@ +package integration_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVolumePathCommand(t *testing.T) { + t.Run("prints default volume path", func(t *testing.T) { + tmpHome := t.TempDir() + xdgOverride := filepath.Join(tmpHome, "xdg-config-home") + configFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") + writeConfigFile(t, configFile) + + e := testEnvWithHome(tmpHome, xdgOverride) + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "volume", "path") + require.NoError(t, err, stderr) + requireExitCode(t, 0, err) + + assert.Contains(t, stdout, filepath.Join("lstk", "volume", "localstack-aws")) + }) + + t.Run("prints custom volume path from config", func(t *testing.T) { + customVolume := filepath.Join(t.TempDir(), "my-volume") + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +volume = "` + customVolume + `" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), os.Environ(), "--config", configFile, "volume", "path") + require.NoError(t, err, stderr) + requireExitCode(t, 0, err) + + assertSamePath(t, customVolume, stdout) + }) + + t.Run("emits telemetry", func(t *testing.T) { + tmpHome := t.TempDir() + xdgOverride := filepath.Join(tmpHome, "xdg-config-home") + configFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") + writeConfigFile(t, configFile) + + analyticsSrv, events := mockAnalyticsServer(t) + e := env.Environ(testEnvWithHome(tmpHome, xdgOverride)).With(env.AnalyticsEndpoint, analyticsSrv.URL) + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "volume", "path") + require.NoError(t, err, stderr) + requireExitCode(t, 0, err) + + assertCommandTelemetry(t, events, "volume path", 0) + }) +} From abda2a83caa495d9659abcded568e62e43796ad4 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 2 Apr 2026 14:02:18 +0300 Subject: [PATCH 2/4] Add volume clear subcommand to reset emulator volume data --- cmd/volume.go | 64 +++++++++++++++++ internal/ui/run_volume_clear.go | 47 +++++++++++++ internal/volume/clear.go | 120 ++++++++++++++++++++++++++++++++ test/integration/volume_test.go | 114 ++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 internal/ui/run_volume_clear.go create mode 100644 internal/volume/clear.go diff --git a/cmd/volume.go b/cmd/volume.go index c77b107..4337bd4 100644 --- a/cmd/volume.go +++ b/cmd/volume.go @@ -2,10 +2,14 @@ package cmd import ( "fmt" + "os" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/telemetry" + "github.com/localstack/lstk/internal/ui" + "github.com/localstack/lstk/internal/volume" "github.com/spf13/cobra" ) @@ -15,6 +19,7 @@ func newVolumeCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { Short: "Manage emulator volume", } cmd.AddCommand(newVolumePathCmd(cfg, tel)) + cmd.AddCommand(newVolumeClearCmd(cfg, tel)) return cmd } @@ -43,3 +48,62 @@ func newVolumePathCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { }), } } + +func newVolumeClearCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { + var force bool + var containerName string + + cmd := &cobra.Command{ + Use: "clear", + Short: "Clear emulator volume data", + Long: "Remove all data from the emulator volume directory. This resets cached state such as certificates, downloaded tools, and persistence data.", + PreRunE: initConfig, + RunE: commandWithTelemetry("volume clear", tel, func(cmd *cobra.Command, args []string) error { + appConfig, err := config.Get() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + containers := appConfig.Containers + if containerName != "" { + containers, err = filterContainers(appConfig.Containers, containerName) + if err != nil { + return err + } + } + + if !isInteractiveMode(cfg) { + if !force { + return fmt.Errorf("volume clear requires confirmation; use --force to skip in non-interactive mode") + } + sink := output.NewPlainSink(os.Stdout) + return volume.Clear(cmd.Context(), sink, containers, true) + } + + if force { + sink := output.NewPlainSink(os.Stdout) + return volume.Clear(cmd.Context(), sink, containers, true) + } + + return ui.RunVolumeClear(cmd.Context(), containers) + }), + } + + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + cmd.Flags().StringVar(&containerName, "container", "", "Target a specific emulator container name") + + return cmd +} + +func filterContainers(containers []config.ContainerConfig, name string) ([]config.ContainerConfig, error) { + for _, c := range containers { + if c.Name() == name { + return []config.ContainerConfig{c}, nil + } + } + var names []string + for _, c := range containers { + names = append(names, c.Name()) + } + return nil, fmt.Errorf("container %q not found in config; available: %v", name, names) +} diff --git a/internal/ui/run_volume_clear.go b/internal/ui/run_volume_clear.go new file mode 100644 index 0000000..819d193 --- /dev/null +++ b/internal/ui/run_volume_clear.go @@ -0,0 +1,47 @@ +package ui + +import ( + "context" + "errors" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/volume" +) + +func RunVolumeClear(parentCtx context.Context, containers []config.ContainerConfig) error { + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + app := NewApp("", "", "", cancel, withoutHeader()) + p := tea.NewProgram(app, tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout)) + runErrCh := make(chan error, 1) + + go func() { + err := volume.Clear(ctx, output.NewTUISink(programSender{p: p}), containers, false) + runErrCh <- err + if err != nil && !errors.Is(err, context.Canceled) { + p.Send(runErrMsg{err: err}) + return + } + p.Send(runDoneMsg{}) + }() + + model, err := p.Run() + if err != nil { + return err + } + + if app, ok := model.(App); ok && app.Err() != nil { + return output.NewSilentError(app.Err()) + } + + runErr := <-runErrCh + if runErr != nil && !errors.Is(runErr, context.Canceled) { + return runErr + } + + return nil +} diff --git a/internal/volume/clear.go b/internal/volume/clear.go new file mode 100644 index 0000000..fdcb12b --- /dev/null +++ b/internal/volume/clear.go @@ -0,0 +1,120 @@ +package volume + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" +) + +func Clear(ctx context.Context, sink output.Sink, containers []config.ContainerConfig, force bool) error { + type target struct { + name string + path string + size int64 + } + + var targets []target + for _, c := range containers { + volumeDir, err := c.VolumeDir() + if err != nil { + return err + } + size, err := dirSize(volumeDir) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read volume directory %s: %w", volumeDir, err) + } + targets = append(targets, target{name: c.DisplayName(), path: volumeDir, size: size}) + } + + for _, t := range targets { + output.EmitInfo(sink, fmt.Sprintf("%s: %s (%s)", t.name, t.path, formatSize(t.size))) + } + + if !force { + responseCh := make(chan output.InputResponse, 1) + output.EmitUserInputRequest(sink, output.UserInputRequestEvent{ + Prompt: "Clear volume data? This cannot be undone", + Options: []output.InputOption{ + {Key: "y", Label: "Yes"}, + {Key: "n", Label: "NO"}, + }, + ResponseCh: responseCh, + }) + + select { + case resp := <-responseCh: + if resp.Cancelled || resp.SelectedKey != "y" { + output.EmitNote(sink, "Cancelled") + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + for _, t := range targets { + if err := clearDir(t.path); err != nil { + return fmt.Errorf("failed to clear %s: %w", t.path, err) + } + } + + output.EmitSuccess(sink, "Volume data cleared") + return nil +} + +func clearDir(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { + return err + } + } + return nil +} + +func dirSize(path string) (int64, error) { + var size int64 + err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + info, err := d.Info() + if err != nil { + return err + } + size += info.Size() + } + return nil + }) + return size, err +} + +func formatSize(bytes int64) string { + const ( + kb = 1024 + mb = 1024 * kb + gb = 1024 * mb + ) + switch { + case bytes >= gb: + return fmt.Sprintf("%.1f GB", float64(bytes)/float64(gb)) + case bytes >= mb: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) + case bytes >= kb: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) + default: + return fmt.Sprintf("%d B", bytes) + } +} diff --git a/test/integration/volume_test.go b/test/integration/volume_test.go index 15cfc26..851aeb2 100644 --- a/test/integration/volume_test.go +++ b/test/integration/volume_test.go @@ -59,3 +59,117 @@ volume = "` + customVolume + `" assertCommandTelemetry(t, events, "volume path", 0) }) } + +func TestVolumeClearCommand(t *testing.T) { + t.Run("clears volume with force flag", func(t *testing.T) { + volumeDir := t.TempDir() + // Create some files in the volume directory + require.NoError(t, os.MkdirAll(filepath.Join(volumeDir, "cache", "certs"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(volumeDir, "cache", "certs", "cert.pem"), []byte("fake cert"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(volumeDir, "cache", "machine.json"), []byte("{}"), 0644)) + + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +volume = "` + volumeDir + `" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), os.Environ(), "--config", configFile, "--non-interactive", "volume", "clear", "--force") + require.NoError(t, err, "lstk volume clear failed: %s\nstdout: %s", stderr, stdout) + requireExitCode(t, 0, err) + + assert.Contains(t, stdout, "Volume data cleared") + + // Directory itself should still exist + _, err = os.Stat(volumeDir) + require.NoError(t, err, "volume directory should still exist") + + // But contents should be gone + entries, err := os.ReadDir(volumeDir) + require.NoError(t, err) + assert.Empty(t, entries, "volume directory should be empty") + }) + + t.Run("fails without force in non-interactive mode", func(t *testing.T) { + tmpHome := t.TempDir() + xdgOverride := filepath.Join(tmpHome, "xdg-config-home") + configFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") + writeConfigFile(t, configFile) + + e := testEnvWithHome(tmpHome, xdgOverride) + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "--non-interactive", "volume", "clear") + require.Error(t, err) + requireExitCode(t, 1, err) + + assert.Contains(t, stderr, "--force") + }) + + t.Run("handles nonexistent volume directory", func(t *testing.T) { + volumeDir := filepath.Join(t.TempDir(), "does-not-exist") + + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +volume = "` + volumeDir + `" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), os.Environ(), "--config", configFile, "--non-interactive", "volume", "clear", "--force") + require.NoError(t, err, "lstk volume clear failed: %s\nstdout: %s", stderr, stdout) + requireExitCode(t, 0, err) + + assert.Contains(t, stdout, "Volume data cleared") + }) + + t.Run("filters by container name", func(t *testing.T) { + volumeDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(volumeDir, "data.json"), []byte("{}"), 0644)) + + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +volume = "` + volumeDir + `" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + // Wrong container name should fail + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), os.Environ(), "--config", configFile, "--non-interactive", "volume", "clear", "--force", "--container", "localstack-snowflake") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "not found") + + // Correct container name should succeed + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), os.Environ(), "--config", configFile, "--non-interactive", "volume", "clear", "--force", "--container", "localstack-aws") + require.NoError(t, err, "lstk volume clear failed: %s\nstdout: %s", stderr, stdout) + requireExitCode(t, 0, err) + + entries, err := os.ReadDir(volumeDir) + require.NoError(t, err) + assert.Empty(t, entries) + }) + + t.Run("emits telemetry", func(t *testing.T) { + tmpHome := t.TempDir() + xdgOverride := filepath.Join(tmpHome, "xdg-config-home") + configFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") + writeConfigFile(t, configFile) + + analyticsSrv, events := mockAnalyticsServer(t) + e := env.Environ(testEnvWithHome(tmpHome, xdgOverride)).With(env.AnalyticsEndpoint, analyticsSrv.URL) + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "--non-interactive", "volume", "clear", "--force") + require.NoError(t, err, stderr) + requireExitCode(t, 0, err) + + assertCommandTelemetry(t, events, "volume clear", 0) + }) +} From 22c0db4264d90a0e7a05da4d9cf256c79a8bb5dc Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 2 Apr 2026 14:11:02 +0300 Subject: [PATCH 3/4] Fix Windows TOML parsing by escaping backslashes in test volume paths --- test/integration/volume_test.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/integration/volume_test.go b/test/integration/volume_test.go index 851aeb2..e83d858 100644 --- a/test/integration/volume_test.go +++ b/test/integration/volume_test.go @@ -3,6 +3,7 @@ package integration_test import ( "os" "path/filepath" + "strings" "testing" "github.com/localstack/lstk/test/integration/env" @@ -32,7 +33,7 @@ func TestVolumePathCommand(t *testing.T) { type = "aws" tag = "latest" port = "4566" -volume = "` + customVolume + `" +volume = "` + escapeTomlPath(customVolume) + `" ` configFile := filepath.Join(t.TempDir(), "config.toml") require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) @@ -73,7 +74,7 @@ func TestVolumeClearCommand(t *testing.T) { type = "aws" tag = "latest" port = "4566" -volume = "` + volumeDir + `" +volume = "` + escapeTomlPath(volumeDir) + `" ` configFile := filepath.Join(t.TempDir(), "config.toml") require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) @@ -116,7 +117,7 @@ volume = "` + volumeDir + `" type = "aws" tag = "latest" port = "4566" -volume = "` + volumeDir + `" +volume = "` + escapeTomlPath(volumeDir) + `" ` configFile := filepath.Join(t.TempDir(), "config.toml") require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) @@ -137,7 +138,7 @@ volume = "` + volumeDir + `" type = "aws" tag = "latest" port = "4566" -volume = "` + volumeDir + `" +volume = "` + escapeTomlPath(volumeDir) + `" ` configFile := filepath.Join(t.TempDir(), "config.toml") require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) @@ -173,3 +174,10 @@ volume = "` + volumeDir + `" assertCommandTelemetry(t, events, "volume clear", 0) }) } + +// escapeTomlPath escapes backslashes in a path for embedding in a TOML quoted string. +// On Windows, t.TempDir() returns paths like D:\Users\... where \U is parsed as a +// Unicode escape sequence by the TOML parser. +func escapeTomlPath(path string) string { + return strings.ReplaceAll(path, `\`, `\\`) +} From e5137dfa06cafccb0e3e47a31852026f162bfe77 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 2 Apr 2026 14:14:23 +0300 Subject: [PATCH 4/4] Simplify volume clear branching and clean up test comments --- cmd/volume.go | 10 +++------- test/integration/volume_test.go | 5 +---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/cmd/volume.go b/cmd/volume.go index 4337bd4..9e999fd 100644 --- a/cmd/volume.go +++ b/cmd/volume.go @@ -72,15 +72,11 @@ func newVolumeClearCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { } } - if !isInteractiveMode(cfg) { - if !force { - return fmt.Errorf("volume clear requires confirmation; use --force to skip in non-interactive mode") - } - sink := output.NewPlainSink(os.Stdout) - return volume.Clear(cmd.Context(), sink, containers, true) + if !isInteractiveMode(cfg) && !force { + return fmt.Errorf("volume clear requires confirmation; use --force to skip in non-interactive mode") } - if force { + if !isInteractiveMode(cfg) || force { sink := output.NewPlainSink(os.Stdout) return volume.Clear(cmd.Context(), sink, containers, true) } diff --git a/test/integration/volume_test.go b/test/integration/volume_test.go index e83d858..8b4586b 100644 --- a/test/integration/volume_test.go +++ b/test/integration/volume_test.go @@ -64,7 +64,6 @@ volume = "` + escapeTomlPath(customVolume) + `" func TestVolumeClearCommand(t *testing.T) { t.Run("clears volume with force flag", func(t *testing.T) { volumeDir := t.TempDir() - // Create some files in the volume directory require.NoError(t, os.MkdirAll(filepath.Join(volumeDir, "cache", "certs"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(volumeDir, "cache", "certs", "cert.pem"), []byte("fake cert"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(volumeDir, "cache", "machine.json"), []byte("{}"), 0644)) @@ -175,9 +174,7 @@ volume = "` + escapeTomlPath(volumeDir) + `" }) } -// escapeTomlPath escapes backslashes in a path for embedding in a TOML quoted string. -// On Windows, t.TempDir() returns paths like D:\Users\... where \U is parsed as a -// Unicode escape sequence by the TOML parser. +// escapeTomlPath escapes backslashes for Windows paths in TOML quoted strings. func escapeTomlPath(path string) string { return strings.ReplaceAll(path, `\`, `\\`) }