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
55 changes: 34 additions & 21 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ import (
)

func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
var firstRun bool
root := &cobra.Command{
Use: "lstk",
Short: "LocalStack CLI",
Long: "lstk is the command-line interface for LocalStack.",
PreRunE: initConfig,
PreRunE: initConfigCapturingFirstRun(&firstRun),
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
Expand All @@ -44,7 +45,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
if err != nil {
return err
}
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist)
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun)
},
}

Expand Down Expand Up @@ -152,8 +153,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger
}
}

func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool) error {

func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool) error {
appConfig, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
Expand All @@ -174,27 +174,25 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
}

if isInteractiveMode(cfg) {
labelCh := make(chan string, 1)
go func() {
label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, appConfig.Containers, cfg.AuthToken, logger)
if ok {
config.CachePlanLabel(label)
}
labelCh <- label
}()

return ui.Run(ctx, ui.RunOptions{
Runtime: rt,
Version: version.Version(),
StartOptions: opts,
NotifyOptions: notifyOpts,
ConfigPath: configPath,
EmulatorLabel: config.CachedPlanLabel(),
LabelCh: labelCh,
Runtime: rt,
Version: version.Version(),
StartOptions: opts,
NotifyOptions: notifyOpts,
ConfigPath: configPath,
EmulatorLabel: config.CachedPlanLabel(),
NeedsEmulatorSelection: firstRun,
})
}

sink := output.NewPlainSink(os.Stdout)
if firstRun && len(appConfig.Containers) > 0 {
emName := appConfig.Containers[0].Type.DisplayName()
sink.Emit(output.MessageEvent{
Severity: output.SeverityNote,
Text: fmt.Sprintf("Configured with default emulator %s.", emName),
})
}
update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken})
return container.Start(ctx, rt, sink, opts, false)
}
Expand Down Expand Up @@ -296,5 +294,20 @@ func initConfig(cmd *cobra.Command, _ []string) error {
if path != "" {
return config.InitFromPath(path)
}
return config.Init()
_, err = config.Init()
return err
}

func initConfigCapturingFirstRun(firstRun *bool) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, _ []string) error {
path, err := cmd.Flags().GetString("config")
if err != nil {
return err
}
if path != "" {
return config.InitFromPath(path)
}
*firstRun, err = config.Init()
return err
}
}
9 changes: 5 additions & 4 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,22 @@ import (
)

func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
var firstRun bool
cmd := &cobra.Command{
Use: "start",
Short: "Start emulator",
Long: "Start emulator and services.",
PreRunE: initConfig,
RunE: func(cmd *cobra.Command, args []string) error {
PreRunE: initConfigCapturingFirstRun(&firstRun),
RunE: func(c *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}
persist, err := cmd.Flags().GetBool("persist")
persist, err := c.Flags().GetBool("persist")
if err != nil {
return err
}
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist)
return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun)
},
}
cmd.Flags().Bool("persist", false, "Enable local persistence (sets LOCALSTACK_PERSISTENCE=1)")
Expand Down
26 changes: 14 additions & 12 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,17 @@ func InitFromPath(path string) error {
return loadConfig(path)
}

func Init() error {
// Init loads the config file, searching the standard paths. If no config file
// exists, it creates one from the default template and returns firstRun=true.
func Init() (firstRun bool, err error) {
viper.Reset()
setDefaults()
viper.SetConfigName(configName)
viper.SetConfigType(configType)

dirs, err := configSearchDirs()
if err != nil {
return err
return false, err
}
for _, dir := range dirs {
viper.AddConfigPath(dir)
Expand All @@ -70,43 +72,43 @@ func Init() error {
var notFoundErr viper.ConfigFileNotFoundError
if !errors.As(err, &notFoundErr) {
if used := viper.ConfigFileUsed(); filepath.Ext(used) == ".yaml" || filepath.Ext(used) == ".yml" {
return fmt.Errorf("%s is from an old lstk version; lstk now uses TOML format — remove it or replace it with a config.toml file", used)
return false, fmt.Errorf("%s is from an old lstk version; lstk now uses TOML format — remove it or replace it with a config.toml file", used)
}
return fmt.Errorf("failed to read config file: %w", err)
return false, fmt.Errorf("failed to read config file: %w", err)
}

// No config found anywhere, create one using creation policy.
creationDir, err := configCreationDir()
if err != nil {
return err
return false, err
}

if err := os.MkdirAll(creationDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
return false, fmt.Errorf("failed to create config directory: %w", err)
}

configPath := filepath.Join(creationDir, configFileName)
f, err := os.OpenFile(configPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
if errors.Is(err, os.ErrExist) {
return loadConfig(configPath)
return false, loadConfig(configPath)
}
return fmt.Errorf("failed to create config file: %w", err)
return false, fmt.Errorf("failed to create config file: %w", err)
}
_, writeErr := f.WriteString(defaultConfigTemplate)
closeErr := f.Close()
if writeErr != nil {
_ = os.Remove(configPath)
return fmt.Errorf("failed to write config file: %w", writeErr)
return false, fmt.Errorf("failed to write config file: %w", writeErr)
}
if closeErr != nil {
_ = os.Remove(configPath)
return fmt.Errorf("failed to close config file: %w", closeErr)
return false, fmt.Errorf("failed to close config file: %w", closeErr)
}

return loadConfig(configPath)
return true, loadConfig(configPath)
}
return nil
return false, nil
}

func resolvedConfigPath() string {
Expand Down
6 changes: 6 additions & 0 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ var emulatorDisplayNames = map[EmulatorType]string{
EmulatorAzure: "Azure",
}

func (e EmulatorType) DisplayName() string {
if name, ok := emulatorDisplayNames[e]; ok {
return name
}
return string(e)
}
var emulatorHealthPaths = map[EmulatorType]string{
EmulatorAWS: "/_localstack/health",
EmulatorSnowflake: "/_localstack/health",
Expand Down
34 changes: 34 additions & 0 deletions internal/config/emulator_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package config

import (
"fmt"
"os"
"regexp"
)

var typeLineRe = regexp.MustCompile(`type\s*=\s*["'](\w+)["']`)

// SetEmulatorType rewrites the emulator type in the config file and reloads.
// No-op if the requested type is already set.
func SetEmulatorType(to EmulatorType) error {
path := resolvedConfigPath()
if path == "" {
return fmt.Errorf("no config file loaded")
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
m := typeLineRe.FindStringSubmatch(string(data))
if m == nil {
return fmt.Errorf("no emulator type field found in config")
}
if EmulatorType(m[1]) == to {
return nil
}
updated := typeLineRe.ReplaceAllString(string(data), `type = "`+string(to)+`"`)
if err := os.WriteFile(path, []byte(updated), 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return loadConfig(path)
}
61 changes: 61 additions & 0 deletions internal/config/emulator_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package config

import (
"os"
"path/filepath"
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: these tests seem to be a good fit for table-driven tests, WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

great observation, done: abc89ac

func TestSetEmulatorType_WritesAndReloads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
require.NoError(t, os.WriteFile(path, []byte("[[containers]]\ntype = \"aws\"\nport = \"4566\"\n"), 0644))
require.NoError(t, loadConfig(path))
t.Cleanup(func() { viper.Reset() })

require.NoError(t, SetEmulatorType(EmulatorSnowflake))

got, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(got), `type = "snowflake"`)
assert.NotContains(t, string(got), `type = "aws"`)

cfg, err := Get()
require.NoError(t, err)
require.Len(t, cfg.Containers, 1)
assert.Equal(t, EmulatorSnowflake, cfg.Containers[0].Type)
}

func TestSetEmulatorType_NoOpWhenSameEmulator(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n"
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
require.NoError(t, loadConfig(path))
t.Cleanup(func() { viper.Reset() })

require.NoError(t, SetEmulatorType(EmulatorAWS))

got, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, content, string(got))
}

func TestSetEmulatorType_PreservesInlineComments(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := "[[containers]]\ntype = \"aws\" # Emulator type\ntag = \"latest\"\n"
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
require.NoError(t, loadConfig(path))
t.Cleanup(func() { viper.Reset() })

require.NoError(t, SetEmulatorType(EmulatorSnowflake))

got, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(got), `type = "snowflake" # Emulator type`)
}
Loading
Loading