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
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
Expand Down
105 changes: 105 additions & 0 deletions cmd/volume.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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"
)

func newVolumeCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "volume",
Short: "Manage emulator volume",
}
cmd.AddCommand(newVolumePathCmd(cfg, tel))
cmd.AddCommand(newVolumeClearCmd(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
}),
}
}

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) && !force {
return fmt.Errorf("volume clear requires confirmation; use --force to skip in non-interactive mode")
}

if !isInteractiveMode(cfg) || 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)
}
47 changes: 47 additions & 0 deletions internal/ui/run_volume_clear.go
Original file line number Diff line number Diff line change
@@ -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
}
120 changes: 120 additions & 0 deletions internal/volume/clear.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading