Skip to content
Draft
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
109 changes: 90 additions & 19 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,22 @@ 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 {
emulator, err := cmd.Flags().GetString("emulator")
if err != nil {
return err
}
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}
return startEmulator(cmd.Context(), rt, cfg, tel, logger)
return startEmulator(cmd.Context(), rt, cfg, tel, logger, firstRun, emulator)
},
}

Expand All @@ -50,6 +55,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C

root.PersistentFlags().String("config", "", "Path to config file")
root.PersistentFlags().BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive mode")
root.Flags().String("emulator", "", "Emulator to use (aws|snowflake)")

configureHelp(root)

Expand Down Expand Up @@ -146,13 +152,28 @@ 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) error {

func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, firstRun bool, requestedEmulator string) error {
appConfig, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}

if requestedEmulator != "" {
emType, err := parseEmulatorType(requestedEmulator)
if err != nil {
return err
}
if len(appConfig.Containers) == 0 || appConfig.Containers[0].Type != emType {
if err := config.SwitchEmulator(emType); err != nil {
return fmt.Errorf("failed to switch emulator: %w", err)
}
appConfig, err = config.Get()
if err != nil {
return fmt.Errorf("failed to reload config: %w", err)
}
}
}

opts := buildStartOptions(cfg, appConfig, logger, tel)

notifyOpts := update.NotifyOptions{
Expand All @@ -167,24 +188,46 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
logger.Info("could not resolve friendly config path: %v", err)
}

needsEmulatorSelection := firstRun && requestedEmulator == "" && isInteractiveMode(cfg)

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
}()
if !needsEmulatorSelection {
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(),
LabelCh: labelCh,
NeedsEmulatorSelection: needsEmulatorSelection,
OnEmulatorSelected: func(emType config.EmulatorType) ([]config.ContainerConfig, error) {
if err := config.SwitchEmulator(emType); err != nil {
return nil, fmt.Errorf("failed to switch emulator: %w", err)
}
newCfg, err := config.Get()
if err != nil {
return nil, err
}
go func() {
label, ok := container.ResolveEmulatorLabel(ctx, opts.PlatformClient, newCfg.Containers, cfg.AuthToken, logger)
if ok {
config.CachePlanLabel(label)
}
labelCh <- label
}()
return newCfg.Containers, nil
},
})
}

Expand All @@ -193,6 +236,17 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
return container.Start(ctx, rt, sink, opts, false)
}

func parseEmulatorType(s string) (config.EmulatorType, error) {
switch config.EmulatorType(strings.ToLower(s)) {
case config.EmulatorAWS:
return config.EmulatorAWS, nil
case config.EmulatorSnowflake:
return config.EmulatorSnowflake, nil
default:
return "", fmt.Errorf("unsupported emulator %q: must be 'aws' or 'snowflake'", s)
}
}

// instrumentCommands walks the Cobra command tree and wraps every RunE with telemetry emission.
func instrumentCommands(cmd *cobra.Command, tel *telemetry.Client) {
if cmd.RunE != nil {
Expand Down Expand Up @@ -291,5 +345,22 @@ func initConfig(cmd *cobra.Command, _ []string) error {
if path != "" {
return config.InitFromPath(path)
}
return config.Init()
_, err = config.Init()
return err
}

// initConfigCapturingFirstRun returns a PreRunE that initialises config and
// writes whether this is the first run into the provided pointer.
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
}
}
15 changes: 11 additions & 4 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,24 @@ import (
)

func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
return &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 {
emulator, err := c.Flags().GetString("emulator")
if err != nil {
return err
}
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}
return startEmulator(cmd.Context(), rt, cfg, tel, logger)
return startEmulator(c.Context(), rt, cfg, tel, logger, firstRun, emulator)
},
}
cmd.Flags().String("emulator", "", "Emulator to use (aws|snowflake)")
return cmd
}
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
7 changes: 7 additions & 0 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ var emulatorDisplayNames = map[EmulatorType]string{
EmulatorAzure: "Azure",
}

func (e EmulatorType) DisplayName() string {
if name, ok := emulatorDisplayNames[e]; ok {
return name
}
return string(e)
}

var emulatorImages = map[EmulatorType]string{
EmulatorAWS: "localstack-pro",
EmulatorSnowflake: "snowflake",
Expand Down
Loading
Loading