From d4e60a563127125f1127c5841f18aa53e66c23ac Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Fri, 30 Jan 2026 13:24:40 +0100 Subject: [PATCH 1/9] Add configuration handling with viper --- cmd/root.go | 4 ++ go.mod | 13 +++++- go.sum | 32 ++++++++++++- internal/config/config.go | 90 +++++++++++++++++++++++++++++++++++++ internal/container/start.go | 24 ++++++---- 5 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 internal/config/config.go diff --git a/cmd/root.go b/cmd/root.go index f334059..9b56503 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/container" "github.com/localstack/lstk/internal/runtime" "github.com/spf13/cobra" @@ -14,6 +15,9 @@ var rootCmd = &cobra.Command{ Use: "lstk", Short: "LocalStack CLI", Long: "lstk is the command-line interface for LocalStack.", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return config.Init() + }, Run: func(cmd *cobra.Command, args []string) { rt, err := runtime.NewDockerRuntime() if err != nil { diff --git a/go.mod b/go.mod index 1db8aa1..f81f7c7 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/docker/go-connections v0.5.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 ) require ( @@ -18,8 +19,10 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -28,15 +31,23 @@ require ( github.com/morikuni/aec v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index da98e49..2839340 100644 --- a/go.sum +++ b/go.sum @@ -25,11 +25,17 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -43,6 +49,10 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -57,6 +67,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -64,18 +76,33 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -98,6 +125,7 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -144,6 +172,8 @@ google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHh google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b3a5368 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,90 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +// Config holds the application configuration. +type Config struct { + Containers []ContainerConfig `mapstructure:"containers"` +} + +// ContainerConfig holds the configuration for a single container. +type ContainerConfig struct { + Image string `mapstructure:"image"` + Name string `mapstructure:"name"` + Port string `mapstructure:"port"` + HealthPath string `mapstructure:"health_path"` + Env []string `mapstructure:"env"` +} + +// configDir returns the lstk configuration directory. +func configDir() (string, error) { + configHome, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get user config directory: %w", err) + } + return filepath.Join(configHome, "lstk"), nil +} + +// ConfigDir returns the lstk configuration directory path. +func ConfigDir() (string, error) { + return configDir() +} + +// Init initializes Viper with the configuration file and defaults. +func Init() error { + dir, err := configDir() + if err != nil { + return err + } + + // Ensure config directory exists + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(dir) + + viper.SetDefault("containers", []map[string]any{ + { + "image": "localstack/localstack-pro:latest", + "name": "localstack-aws", + "port": "4566", + "health_path": "/_localstack/health", + }, + }) + + // Read config file if it exists + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return fmt.Errorf("failed to read config file: %w", err) + } + } + + return nil +} + +// Get returns the current configuration. +func Get() (*Config, error) { + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + return &cfg, nil +} + +// ConfigFilePath returns the path to the config file. +func ConfigFilePath() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.yaml"), nil +} diff --git a/internal/container/start.go b/internal/container/start.go index 259f409..0d115ae 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -7,6 +7,7 @@ import ( "time" "github.com/localstack/lstk/internal/auth" + "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/runtime" ) @@ -15,17 +16,22 @@ func Start(ctx context.Context, rt runtime.Runtime, onProgress func(string)) err if err != nil { return err } - env := []string{"LOCALSTACK_AUTH_TOKEN=" + token} - // TODO: hardcoded for now, later should be configurable - containers := []runtime.ContainerConfig{ - { - Image: "localstack/localstack-pro:latest", - Name: "localstack-aws", - Port: "4566", - HealthPath: "/_localstack/health", + cfg, err := config.Get() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + containers := make([]runtime.ContainerConfig, len(cfg.Containers)) + for i, c := range cfg.Containers { + env := append(c.Env, "LOCALSTACK_AUTH_TOKEN="+token) + containers[i] = runtime.ContainerConfig{ + Image: c.Image, + Name: c.Name, + Port: c.Port, + HealthPath: c.HealthPath, Env: env, - }, + } } for _, config := range containers { From 45ad350340746fc406d6c84611dfd76394192320 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Tue, 3 Feb 2026 14:01:14 +0100 Subject: [PATCH 2/9] Add support for env vars --- internal/container/start.go | 2 +- internal/runtime/runtime.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index 0d115ae..406f36b 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -30,7 +30,7 @@ func Start(ctx context.Context, rt runtime.Runtime, onProgress func(string)) err Name: c.Name, Port: c.Port, HealthPath: c.HealthPath, - Env: env, +Env: env, } } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7a95884..5d4516b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -7,7 +7,7 @@ type ContainerConfig struct { Name string Port string HealthPath string - Env []string // e.g., ["KEY=value", "FOO=bar"] +Env []string // e.g., ["KEY=value", "FOO=bar"] } type PullProgress struct { From 4f207b64c8eb38501806e9484decc7afa75e5a25 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Tue, 3 Feb 2026 15:28:51 +0100 Subject: [PATCH 3/9] Adapt config values --- internal/config/config.go | 50 +++++++++++++++++++++++++++++++------ internal/container/start.go | 11 +++++--- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index b3a5368..e929066 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,20 @@ import ( "github.com/spf13/viper" ) +// EmulatorType represents a supported emulator type. +type EmulatorType string + +const ( + EmulatorAWS EmulatorType = "aws" + EmulatorSnowflake EmulatorType = "snowflake" + EmulatorAzure EmulatorType = "azure" +) + +// emulatorImages maps emulator types to their Docker images. +var emulatorImages = map[EmulatorType]string{ + EmulatorAWS: "localstack/localstack-pro", +} + // Config holds the application configuration. type Config struct { Containers []ContainerConfig `mapstructure:"containers"` @@ -15,11 +29,33 @@ type Config struct { // ContainerConfig holds the configuration for a single container. type ContainerConfig struct { - Image string `mapstructure:"image"` - Name string `mapstructure:"name"` - Port string `mapstructure:"port"` - HealthPath string `mapstructure:"health_path"` - Env []string `mapstructure:"env"` + Type EmulatorType `mapstructure:"type"` + Tag string `mapstructure:"tag"` + Port string `mapstructure:"port"` + HealthPath string `mapstructure:"health_path"` + Env []string `mapstructure:"env"` +} + +// Image returns the full Docker image reference for this container. +func (c *ContainerConfig) Image() (string, error) { + baseImage, ok := emulatorImages[c.Type] + if !ok { + return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) + } + tag := c.Tag + if tag == "" { + tag = "latest" + } + return fmt.Sprintf("%s:%s", baseImage, tag), nil +} + +// Name returns the generated container name based on type and tag. +func (c *ContainerConfig) Name() string { + tag := c.Tag + if tag == "" || tag == "latest" { + return fmt.Sprintf("localstack-%s", c.Type) + } + return fmt.Sprintf("localstack-%s-%s", c.Type, tag) } // configDir returns the lstk configuration directory. @@ -54,8 +90,8 @@ func Init() error { viper.SetDefault("containers", []map[string]any{ { - "image": "localstack/localstack-pro:latest", - "name": "localstack-aws", + "type": "aws", + "tag": "latest", "port": "4566", "health_path": "/_localstack/health", }, diff --git a/internal/container/start.go b/internal/container/start.go index 406f36b..27e4d3f 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -24,13 +24,18 @@ func Start(ctx context.Context, rt runtime.Runtime, onProgress func(string)) err containers := make([]runtime.ContainerConfig, len(cfg.Containers)) for i, c := range cfg.Containers { + image, err := c.Image() + if err != nil { + return err + } + env := append(c.Env, "LOCALSTACK_AUTH_TOKEN="+token) containers[i] = runtime.ContainerConfig{ - Image: c.Image, - Name: c.Name, + Image: image, + Name: c.Name(), Port: c.Port, HealthPath: c.HealthPath, -Env: env, + Env: env, } } From 1d7bc33c2ff4842d0a08ddfbdd926f9bf4337c47 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Tue, 3 Feb 2026 15:31:36 +0100 Subject: [PATCH 4/9] Move to toml --- internal/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index e929066..f700e24 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -85,7 +85,7 @@ func Init() error { } viper.SetConfigName("config") - viper.SetConfigType("yaml") + viper.SetConfigType("toml") viper.AddConfigPath(dir) viper.SetDefault("containers", []map[string]any{ @@ -122,5 +122,5 @@ func ConfigFilePath() (string, error) { if err != nil { return "", err } - return filepath.Join(dir, "config.yaml"), nil + return filepath.Join(dir, "config.toml"), nil } From dd4a31b519b89ca4c617e116bf41b6f26553773a Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Tue, 3 Feb 2026 15:55:36 +0100 Subject: [PATCH 5/9] Move to toml, create file on startup --- internal/config/config.go | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index f700e24..755795f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/viper" ) -// EmulatorType represents a supported emulator type. type EmulatorType string const ( @@ -17,17 +16,14 @@ const ( EmulatorAzure EmulatorType = "azure" ) -// emulatorImages maps emulator types to their Docker images. var emulatorImages = map[EmulatorType]string{ EmulatorAWS: "localstack/localstack-pro", } -// Config holds the application configuration. type Config struct { Containers []ContainerConfig `mapstructure:"containers"` } -// ContainerConfig holds the configuration for a single container. type ContainerConfig struct { Type EmulatorType `mapstructure:"type"` Tag string `mapstructure:"tag"` @@ -36,7 +32,6 @@ type ContainerConfig struct { Env []string `mapstructure:"env"` } -// Image returns the full Docker image reference for this container. func (c *ContainerConfig) Image() (string, error) { baseImage, ok := emulatorImages[c.Type] if !ok { @@ -49,7 +44,7 @@ func (c *ContainerConfig) Image() (string, error) { return fmt.Sprintf("%s:%s", baseImage, tag), nil } -// Name returns the generated container name based on type and tag. +// Name returns the container name: "localstack-{type}" or "localstack-{type}-{tag}" if tag != latest func (c *ContainerConfig) Name() string { tag := c.Tag if tag == "" || tag == "latest" { @@ -58,7 +53,6 @@ func (c *ContainerConfig) Name() string { return fmt.Sprintf("localstack-%s-%s", c.Type, tag) } -// configDir returns the lstk configuration directory. func configDir() (string, error) { configHome, err := os.UserConfigDir() if err != nil { @@ -67,19 +61,16 @@ func configDir() (string, error) { return filepath.Join(configHome, "lstk"), nil } -// ConfigDir returns the lstk configuration directory path. func ConfigDir() (string, error) { return configDir() } -// Init initializes Viper with the configuration file and defaults. func Init() error { dir, err := configDir() if err != nil { return err } - // Ensure config directory exists if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } @@ -97,9 +88,12 @@ func Init() error { }, }) - // Read config file if it exists if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + if err := viper.SafeWriteConfig(); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + } else { return fmt.Errorf("failed to read config file: %w", err) } } @@ -107,7 +101,6 @@ func Init() error { return nil } -// Get returns the current configuration. func Get() (*Config, error) { var cfg Config if err := viper.Unmarshal(&cfg); err != nil { @@ -116,7 +109,6 @@ func Get() (*Config, error) { return &cfg, nil } -// ConfigFilePath returns the path to the config file. func ConfigFilePath() (string, error) { dir, err := configDir() if err != nil { From 1fcd4bd77fc7c2a4bdba4f940b5abc818ee7956a Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Tue, 3 Feb 2026 15:55:47 +0100 Subject: [PATCH 6/9] Fix indentation --- internal/runtime/runtime.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 5d4516b..7a95884 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -7,7 +7,7 @@ type ContainerConfig struct { Name string Port string HealthPath string -Env []string // e.g., ["KEY=value", "FOO=bar"] + Env []string // e.g., ["KEY=value", "FOO=bar"] } type PullProgress struct { From 1eebdec266d694b383af1ad9d46aa24dd89ed86a Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Tue, 3 Feb 2026 15:56:00 +0100 Subject: [PATCH 7/9] Add configuration integration test --- test/integration/config_test.go | 58 +++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 test/integration/config_test.go diff --git a/test/integration/config_test.go b/test/integration/config_test.go new file mode 100644 index 0000000..adb6034 --- /dev/null +++ b/test/integration/config_test.go @@ -0,0 +1,58 @@ +package integration_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigFileCreatedOnStartup(t *testing.T) { + tmpHome := t.TempDir() + + var configDir string + var env []string + + switch runtime.GOOS { + case "darwin": + configDir = filepath.Join(tmpHome, "Library", "Application Support", "lstk") + env = append(os.Environ(), "HOME="+tmpHome) + case "linux": + configDir = filepath.Join(tmpHome, ".config", "lstk") + env = append(os.Environ(), "HOME="+tmpHome, "XDG_CONFIG_HOME=") + case "windows": + configDir = filepath.Join(tmpHome, "AppData", "Roaming", "lstk") + env = append(os.Environ(), "APPDATA="+filepath.Join(tmpHome, "AppData", "Roaming")) + default: + t.Skipf("unsupported OS: %s", runtime.GOOS) + } + + configFile := filepath.Join(configDir, "config.toml") + + cmd := exec.Command("../../bin/lstk", "start") + cmd.Env = env + cmd.Start() + defer cmd.Process.Kill() + + // Poll for config file creation - check every 50ms, timeout after 2s + require.Eventually(t, func() bool { + _, err := os.Stat(configFile) + return err == nil + }, 2*time.Second, 20*time.Millisecond, "config.toml should be created") + + assert.DirExists(t, configDir, "config directory should be created at OS-specific location") + + content, err := os.ReadFile(configFile) + require.NoError(t, err) + + configStr := string(content) + assert.Contains(t, configStr, `type = 'aws'`) + assert.Contains(t, configStr, `tag = 'latest'`) + assert.Contains(t, configStr, `port = '4566'`) + assert.Contains(t, configStr, `health_path = '/_localstack/health'`) +} From 97910274af7805dc2343140af4ca44b8992b499f Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Tue, 3 Feb 2026 16:49:08 +0100 Subject: [PATCH 8/9] Flatten config writing if file is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Carole Lavillonnière --- internal/config/config.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 755795f..e980e19 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -93,9 +93,10 @@ func Init() error { if err := viper.SafeWriteConfig(); err != nil { return fmt.Errorf("failed to write config file: %w", err) } - } else { - return fmt.Errorf("failed to read config file: %w", err) + return nil } + + return fmt.Errorf("failed to read config file: %w", err) } return nil From 98e8d3e920b577f6ef4b469a44df380408231a99 Mon Sep 17 00:00:00 2001 From: Silvio Vasiljevic Date: Tue, 3 Feb 2026 16:56:34 +0100 Subject: [PATCH 9/9] Make health path hard-coded --- internal/config/config.go | 28 +++++++++++++++++++--------- internal/container/start.go | 6 +++++- test/integration/config_test.go | 1 - 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index e980e19..eb1c743 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,16 +20,19 @@ var emulatorImages = map[EmulatorType]string{ EmulatorAWS: "localstack/localstack-pro", } +var emulatorHealthPaths = map[EmulatorType]string{ + EmulatorAWS: "/_localstack/health", +} + type Config struct { Containers []ContainerConfig `mapstructure:"containers"` } type ContainerConfig struct { - Type EmulatorType `mapstructure:"type"` - Tag string `mapstructure:"tag"` - Port string `mapstructure:"port"` - HealthPath string `mapstructure:"health_path"` - Env []string `mapstructure:"env"` + Type EmulatorType `mapstructure:"type"` + Tag string `mapstructure:"tag"` + Port string `mapstructure:"port"` + Env []string `mapstructure:"env"` } func (c *ContainerConfig) Image() (string, error) { @@ -53,6 +56,14 @@ func (c *ContainerConfig) Name() string { return fmt.Sprintf("localstack-%s-%s", c.Type, tag) } +func (c *ContainerConfig) HealthPath() (string, error) { + path, ok := emulatorHealthPaths[c.Type] + if !ok { + return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) + } + return path, nil +} + func configDir() (string, error) { configHome, err := os.UserConfigDir() if err != nil { @@ -81,10 +92,9 @@ func Init() error { viper.SetDefault("containers", []map[string]any{ { - "type": "aws", - "tag": "latest", - "port": "4566", - "health_path": "/_localstack/health", + "type": "aws", + "tag": "latest", + "port": "4566", }, }) diff --git a/internal/container/start.go b/internal/container/start.go index 27e4d3f..da52c24 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -28,13 +28,17 @@ func Start(ctx context.Context, rt runtime.Runtime, onProgress func(string)) err if err != nil { return err } + healthPath, err := c.HealthPath() + if err != nil { + return err + } env := append(c.Env, "LOCALSTACK_AUTH_TOKEN="+token) containers[i] = runtime.ContainerConfig{ Image: image, Name: c.Name(), Port: c.Port, - HealthPath: c.HealthPath, + HealthPath: healthPath, Env: env, } } diff --git a/test/integration/config_test.go b/test/integration/config_test.go index adb6034..583951d 100644 --- a/test/integration/config_test.go +++ b/test/integration/config_test.go @@ -54,5 +54,4 @@ func TestConfigFileCreatedOnStartup(t *testing.T) { assert.Contains(t, configStr, `type = 'aws'`) assert.Contains(t, configStr, `tag = 'latest'`) assert.Contains(t, configStr, `port = '4566'`) - assert.Contains(t, configStr, `health_path = '/_localstack/health'`) }