Skip to content
Merged
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/non_interactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestNonInteractiveFlagIsRegistered(t *testing.T) {
flag := root.PersistentFlags().Lookup("non-interactive")
if flag == nil {
t.Fatal("expected --non-interactive flag to be registered on root command")
return
}
if flag.DefValue != "false" {
t.Fatalf("expected default value to be false, got %q", flag.DefValue)
Expand Down
12 changes: 9 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,17 @@ func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *teleme
// TODO: replace map with a typed payload struct once event schema is finalised
tel.Emit(ctx, "cli_cmd", map[string]any{"cmd": "lstk start", "params": []string{}})

platformClient := api.NewPlatformClient(cfg.APIEndpoint)
opts := container.StartOptions{
PlatformClient: api.NewPlatformClient(cfg.APIEndpoint),
AuthToken: cfg.AuthToken,
ForceFileKeyring: cfg.ForceFileKeyring,
WebAppURL: cfg.WebAppURL,
LocalStackHost: cfg.LocalStackHost,
}
if isInteractiveMode(cfg) {
return ui.Run(ctx, rt, version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL)
return ui.Run(ctx, rt, version.Version(), opts)
}
return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL, false)
return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), opts, false)
}
Comment on lines 90 to 94
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thought: Both of these have quite a long list of parameters now. Is there a way to do this more nicely? 🤔

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

True! Introduced:

type StartOptions struct {
	PlatformClient   api.PlatformAPI
	AuthToken        string
	ForceFileKeyring bool
	WebAppURL        string
	LocalStackHost   string
}

These are all the user options, they are passed in each case: with or without TUI.


func isInteractiveMode(cfg *env.Env) bool {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ require (
github.com/stretchr/testify v1.11.1
go.uber.org/mock v0.6.0
golang.org/x/term v0.40.0
gopkg.in/ini.v1 v1.67.1
gotest.tools/v3 v3.5.2
)

Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
Expand Down Expand Up @@ -151,8 +152,14 @@ 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.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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=
Expand Down Expand Up @@ -210,6 +217,9 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/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/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=
Expand Down
256 changes: 256 additions & 0 deletions internal/awsconfig/awsconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
package awsconfig

import (
"context"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"

"gopkg.in/ini.v1"

"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/output"
)

const (
profileName = "localstack"
configSectionName = "profile localstack" // ~/.aws/config uses "profile <name>" as section header
credsSectionName = "localstack" // ~/.aws/credentials uses just the profile name
// TODO: make region configurable (e.g. from container env or lstk config)
defaultRegion = "us-east-1"
)

func credentialsDefaults() map[string]string {
return map[string]string{
"aws_access_key_id": "test",
"aws_secret_access_key": "test",
}
}

// isValidLocalStackEndpoint returns true if endpoint_url in ~/.aws/config points to
// the same LocalStack instance as resolvedHost. localhost, 127.0.0.1, and
// localhost.localstack.cloud are treated as interchangeable since all three
// resolve to the local machine.
func isValidLocalStackEndpoint(endpointURL, resolvedHost string) bool {
u, err := url.Parse(endpointURL)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
if u.Host == resolvedHost {
return true
}
// If the resolved host is one of the two known local hostnames, accept the
// other as equally valid — they both reach the same local service.
resolvedHostname, resolvedPort, err := net.SplitHostPort(resolvedHost)
if err != nil || !isLocalStackLocalHost(resolvedHostname) {
return false
}
return u.Port() == resolvedPort && isLocalStackLocalHost(u.Hostname())
}

func isLocalStackLocalHost(host string) bool {
return host == "127.0.0.1" || host == "localhost" || host == endpoint.Hostname
}

func awsPaths() (configPath, credentialsPath string, err error) {
home, err := os.UserHomeDir()
if err != nil {
return "", "", err
}
return filepath.Join(home, ".aws", "config"), filepath.Join(home, ".aws", "credentials"), nil
}

// profileStatus holds which AWS profile files need to be written or updated.
type profileStatus struct {
configNeeded bool
credsNeeded bool
}

func (s profileStatus) anyNeeded() bool {
return s.configNeeded || s.credsNeeded
}

func (s profileStatus) filesToModify() []string {
var files []string
if s.configNeeded {
files = append(files, "~/.aws/config")
}
if s.credsNeeded {
files = append(files, "~/.aws/credentials")
}
return files
}

// checkProfileStatus determines which AWS profile files need to be written or updated.
func checkProfileStatus(configPath, credsPath, resolvedHost string) (profileStatus, error) {
configNeeded, err := configNeedsWrite(configPath, resolvedHost)
if err != nil {
return profileStatus{}, err
}
credsNeeded, err := credsNeedWrite(credsPath)
if err != nil {
return profileStatus{}, err
}
return profileStatus{configNeeded: configNeeded, credsNeeded: credsNeeded}, nil
}

func configNeedsWrite(path, resolvedHost string) (bool, error) {
f, err := ini.Load(path)
if errors.Is(err, os.ErrNotExist) {
return true, nil
}
if err != nil {
return false, err
}
section, err := f.GetSection(configSectionName)
if err != nil {
return true, nil // section doesn't exist
}
endpointKey, err := section.GetKey("endpoint_url")
if err != nil || !isValidLocalStackEndpoint(endpointKey.Value(), resolvedHost) {
return true, nil
}
if !section.HasKey("region") {
return true, nil
}
return false, nil
}

func credsNeedWrite(path string) (bool, error) {
f, err := ini.Load(path)
if errors.Is(err, os.ErrNotExist) {
return true, nil
}
if err != nil {
return false, err
}
section, err := f.GetSection(credsSectionName)
if err != nil {
return true, nil // section doesn't exist
}
for k, expected := range credentialsDefaults() {
key, err := section.GetKey(k)
if err != nil || key.Value() != expected {
return true, nil
}
}
return false, nil
}

// profileExists reports whether the localstack profile section is present in both
// ~/.aws/config and ~/.aws/credentials.
func profileExists() (bool, error) {
configPath, credsPath, err := awsPaths()
if err != nil {
return false, err
}
configOK, err := sectionExists(configPath, configSectionName)
if err != nil {
return false, err
}
credsOK, err := sectionExists(credsPath, credsSectionName)
if err != nil {
return false, err
}
return configOK && credsOK, nil
}

// writeProfile writes the localstack profile to ~/.aws/config and ~/.aws/credentials,
// creating or updating sections as needed.
func writeProfile(host string) error {
configPath, credsPath, err := awsPaths()
if err != nil {
return err
}
configKeys := map[string]string{
"region": defaultRegion,
"output": "json",
"endpoint_url": "http://" + host,
}
if err := upsertSection(configPath, configSectionName, configKeys); err != nil {
return fmt.Errorf("failed to write %s: %w", configPath, err)
}
if err := upsertSection(credsPath, credsSectionName, credentialsDefaults()); err != nil {
return fmt.Errorf("failed to write %s: %w", credsPath, err)
}
return nil
}

func writeConfigProfile(configPath, host string) error {
keys := map[string]string{
"region": defaultRegion,
"output": "json",
"endpoint_url": "http://" + host,
}
return upsertSection(configPath, configSectionName, keys)
}

func writeCredsProfile(credsPath string) error {
return upsertSection(credsPath, credsSectionName, credentialsDefaults())
}

// Setup checks for the localstack AWS profile and prompts to create or update it if needed.
// resolvedHost must be a host:port string (e.g. "localhost.localstack.cloud:4566").
// In non-interactive mode, emits a note instead of prompting.
func Setup(ctx context.Context, sink output.Sink, interactive bool, resolvedHost string) error {
configPath, credsPath, err := awsPaths()
if err != nil {
output.EmitWarning(sink, fmt.Sprintf("could not determine AWS config paths: %v", err))
return nil
}

status, err := checkProfileStatus(configPath, credsPath, resolvedHost)
if err != nil {
output.EmitWarning(sink, fmt.Sprintf("could not check AWS profile: %v", err))
return nil
}
if !status.anyNeeded() {
return nil
}

if !interactive {
output.EmitNote(sink, fmt.Sprintf("No complete LocalStack AWS profile found. Run lstk interactively to configure one, or add a [profile %s] section to ~/.aws/config manually.", profileName))
return nil
}

files := strings.Join(status.filesToModify(), " and ")
responseCh := make(chan output.InputResponse, 1)
output.EmitUserInputRequest(sink, output.UserInputRequestEvent{
Prompt: fmt.Sprintf("Set up LocalStack AWS profile in %s?", files),
Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}},
ResponseCh: responseCh,
})

select {
case resp := <-responseCh:
if resp.Cancelled || resp.SelectedKey == "n" {
return nil
}
if status.configNeeded {
if err := writeConfigProfile(configPath, resolvedHost); err != nil {
output.EmitWarning(sink, fmt.Sprintf("could not update ~/.aws/config: %v", err))
return nil
}
}
if status.credsNeeded {
if err := writeCredsProfile(credsPath); err != nil {
output.EmitWarning(sink, fmt.Sprintf("could not update ~/.aws/credentials: %v", err))
return nil
}
}
output.EmitSuccess(sink, fmt.Sprintf("LocalStack AWS profile written to %s", files))
output.EmitNote(sink, fmt.Sprintf("Try: aws s3 mb s3://test --profile %s", profileName))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

praise: YASSS! ❤️ This will resolve DRG-363.

case <-ctx.Done():
return ctx.Err()
}

return nil
}

Loading
Loading