diff --git a/internal/awsconfig/awsconfig.go b/internal/awsconfig/awsconfig.go index 343bfd97..33c1bab6 100644 --- a/internal/awsconfig/awsconfig.go +++ b/internal/awsconfig/awsconfig.go @@ -19,9 +19,10 @@ import ( ) const ( - ProfileName = "localstack" - configSectionName = "profile localstack" // ~/.aws/config uses "profile " as section header - credsSectionName = "localstack" // ~/.aws/credentials uses just the profile name + ProfileName = "localstack" + configSectionName = "profile localstack" // ~/.aws/config uses "profile " as section header + credsSectionName = "localstack" // ~/.aws/credentials uses just the profile name + servicesSectionName = "services localstack" // referenced by the "services" key in configSectionName // TODO: make region configurable (e.g. from container env or lstk config) defaultRegion = "us-east-1" ) @@ -61,6 +62,20 @@ func isLocalStackLocalHost(host string) bool { return host == "127.0.0.1" || host == "localhost" || host == endpoint.Hostname } +// s3EndpointFor derives the S3 endpoint, like the Terraform and CDK proxies do. +func s3EndpointFor(host string) string { + _, s3Endpoint := endpoint.S3Addressing("http://" + host) + return s3Endpoint +} + +// servicesSectionBody builds the "[services localstack]" S3 endpoint override. +// endpoint_url must stay indented under "s3 =" so AWS CLI treats it as nested. +// indent is that line's leading whitespace. Pass "" after a reload, since +// Section.Body() strips it. +func servicesSectionBody(s3Endpoint, indent string) string { + return "s3 =" + ini.LineBreak + indent + "endpoint_url = " + s3Endpoint +} + func awsPaths() (configPath, credentialsPath string, err error) { home, err := os.UserHomeDir() if err != nil { @@ -110,7 +125,7 @@ func CheckProfileStatus(resolvedHost string) (profileStatus, error) { } func configNeedsWrite(path, resolvedHost string) (bool, error) { - f, err := ini.Load(path) + f, err := loadINI(path) if errors.Is(err, os.ErrNotExist) { return true, nil } @@ -128,11 +143,19 @@ func configNeedsWrite(path, resolvedHost string) (bool, error) { if !section.HasKey("region") { return true, nil } + servicesKey, err := section.GetKey("services") + if err != nil || servicesKey.Value() != ProfileName { + return true, nil + } + servicesSection, err := f.GetSection(servicesSectionName) + if err != nil || servicesSection.Body() != servicesSectionBody(s3EndpointFor(resolvedHost), "") { + return true, nil + } return false, nil } func credsNeedWrite(path string) (bool, error) { - f, err := ini.Load(path) + f, err := loadINI(path) if errors.Is(err, os.ErrNotExist) { return true, nil } @@ -181,15 +204,10 @@ func writeProfile(host string) error { 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 { + if err := writeConfigProfile(configPath, host); err != nil { return fmt.Errorf("failed to write %s: %w", configPath, err) } - if err := upsertSection(credsPath, credsSectionName, credentialsDefaults()); err != nil { + if err := writeCredsProfile(credsPath); err != nil { return fmt.Errorf("failed to write %s: %w", credsPath, err) } return nil @@ -200,8 +218,13 @@ func writeConfigProfile(configPath, host string) error { "region": defaultRegion, "output": "json", "endpoint_url": "http://" + host, + "services": ProfileName, + } + if err := upsertSection(configPath, configSectionName, keys); err != nil { + return err } - return upsertSection(configPath, configSectionName, keys) + s3Endpoint := s3EndpointFor(host) + return upsertRawSection(configPath, servicesSectionName, servicesSectionBody(s3Endpoint, " ")+ini.LineBreak) } func writeCredsProfile(credsPath string) error { diff --git a/internal/awsconfig/awsconfig_test.go b/internal/awsconfig/awsconfig_test.go index 6c9a39c1..9e1768f4 100644 --- a/internal/awsconfig/awsconfig_test.go +++ b/internal/awsconfig/awsconfig_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" ) @@ -133,6 +134,64 @@ func TestWriteProfile(t *testing.T) { } } +func TestWriteConfigProfileWritesS3ServicesOverride(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, ".aws", "config") + + if err := writeConfigProfile(configPath, "localhost.localstack.cloud:4566"); err != nil { + t.Fatal(err) + } + + // Key order is not guaranteed since upsertSection ranges over a map, so + // check parsed values instead of raw file text. + f, err := loadINI(configPath) + if err != nil { + t.Fatal(err) + } + profile, err := f.GetSection(configSectionName) + if err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "region": "us-east-1", + "output": "json", + "endpoint_url": "http://localhost.localstack.cloud:4566", + "services": "localstack", + } { + if got := profile.Key(key).Value(); got != want { + t.Errorf("profile key %q = %q, want %q", key, got, want) + } + } + + services, err := f.GetSection(servicesSectionName) + if err != nil { + t.Fatal(err) + } + // Body() strips the continuation line's leading whitespace on load, so check + // the on-disk bytes instead to catch a regression that drops the indentation. + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + wantServicesBlock := "[services localstack]\n" + + "s3 =\n" + + " endpoint_url = http://s3.localhost.localstack.cloud:4566\n" + if !strings.Contains(string(raw), wantServicesBlock) { + t.Errorf("config content missing indented services block\ngot:\n%s\nwant substring:\n%s", raw, wantServicesBlock) + } + if got := services.Body(); got != "s3 =\nendpoint_url = http://s3.localhost.localstack.cloud:4566" { + t.Errorf("services section body = %q", got) + } + + needed, err := configNeedsWrite(configPath, "localhost.localstack.cloud:4566") + if err != nil { + t.Fatal(err) + } + if needed { + t.Error("config should not need a write immediately after writeConfigProfile") + } +} + func TestCheckProfileStatus(t *testing.T) { tests := []struct { name string @@ -149,13 +208,42 @@ func TestCheckProfileStatus(t *testing.T) { wantCreds: true, }, { - name: "valid profile needs nothing", + name: "valid profile needs nothing", + configContent: "[profile localstack]\nregion = us-east-1\noutput = json\n" + + "endpoint_url = http://localhost.localstack.cloud:4566\nservices = localstack\n\n" + + "[services localstack]\ns3 =\n endpoint_url = http://s3.localhost.localstack.cloud:4566\n", + credsContent: "[localstack]\naws_access_key_id = test\naws_secret_access_key = test\n", + resolvedHost: "localhost.localstack.cloud:4566", + wantConfig: false, + wantCreds: false, + }, + { + name: "missing services key needs write", configContent: "[profile localstack]\nregion = us-east-1\noutput = json\nendpoint_url = http://localhost.localstack.cloud:4566\n", credsContent: "[localstack]\naws_access_key_id = test\naws_secret_access_key = test\n", resolvedHost: "localhost.localstack.cloud:4566", - wantConfig: false, + wantConfig: true, wantCreds: false, }, + { + name: "missing services section needs write", + configContent: "[profile localstack]\nregion = us-east-1\noutput = json\n" + + "endpoint_url = http://localhost.localstack.cloud:4566\nservices = localstack\n", + credsContent: "[localstack]\naws_access_key_id = test\naws_secret_access_key = test\n", + resolvedHost: "localhost.localstack.cloud:4566", + wantConfig: true, + wantCreds: false, + }, + { + name: "stale s3 endpoint in services section needs write", + configContent: "[profile localstack]\nregion = us-east-1\noutput = json\n" + + "endpoint_url = http://localhost.localstack.cloud:4566\nservices = localstack\n\n" + + "[services localstack]\ns3 =\n endpoint_url = http://s3.some-other-host:4566\n", + credsContent: "[localstack]\naws_access_key_id = test\naws_secret_access_key = test\n", + resolvedHost: "localhost.localstack.cloud:4566", + wantConfig: true, + wantCreds: false, + }, { name: "missing endpoint_url", configContent: "[profile localstack]\nregion = us-east-1\n", @@ -173,20 +261,24 @@ func TestCheckProfileStatus(t *testing.T) { wantCreds: false, }, { - name: "wrong credentials", - configContent: "[profile localstack]\nregion = us-east-1\noutput = json\nendpoint_url = http://127.0.0.1:4566\n", - credsContent: "[localstack]\naws_access_key_id = wrong\naws_secret_access_key = wrong\n", - resolvedHost: "127.0.0.1:4566", - wantConfig: false, - wantCreds: true, + name: "wrong credentials", + configContent: "[profile localstack]\nregion = us-east-1\noutput = json\n" + + "endpoint_url = http://127.0.0.1:4566\nservices = localstack\n\n" + + "[services localstack]\ns3 =\n endpoint_url = http://127.0.0.1:4566\n", + credsContent: "[localstack]\naws_access_key_id = wrong\naws_secret_access_key = wrong\n", + resolvedHost: "127.0.0.1:4566", + wantConfig: false, + wantCreds: true, }, { - name: "127.0.0.1 profile valid when DNS now resolves to localhost.localstack.cloud", - configContent: "[profile localstack]\nregion = us-east-1\noutput = json\nendpoint_url = http://127.0.0.1:4566\n", - credsContent: "[localstack]\naws_access_key_id = test\naws_secret_access_key = test\n", - resolvedHost: "localhost.localstack.cloud:4566", - wantConfig: false, - wantCreds: false, + name: "127.0.0.1 profile valid when DNS now resolves to localhost.localstack.cloud", + configContent: "[profile localstack]\nregion = us-east-1\noutput = json\n" + + "endpoint_url = http://127.0.0.1:4566\nservices = localstack\n\n" + + "[services localstack]\ns3 =\n endpoint_url = http://s3.localhost.localstack.cloud:4566\n", + credsContent: "[localstack]\naws_access_key_id = test\naws_secret_access_key = test\n", + resolvedHost: "localhost.localstack.cloud:4566", + wantConfig: false, + wantCreds: false, }, } for _, tc := range tests { @@ -344,4 +436,3 @@ func TestIsValidLocalStackEndpoint(t *testing.T) { }) } } - diff --git a/internal/awsconfig/ini.go b/internal/awsconfig/ini.go index b6beaa86..a85de1c5 100644 --- a/internal/awsconfig/ini.go +++ b/internal/awsconfig/ini.go @@ -9,8 +9,16 @@ import ( "gopkg.in/ini.v1" ) +// loadOptions marks servicesSectionName unparseable so its indented content +// round-trips through Load unchanged instead of being flattened into key=value pairs. +var loadOptions = ini.LoadOptions{UnparseableSections: []string{servicesSectionName}} + +func loadINI(path string) (*ini.File, error) { + return ini.LoadSources(loadOptions, path) +} + func sectionExists(path, sectionName string) (bool, error) { - f, err := ini.Load(path) + f, err := loadINI(path) if errors.Is(err, os.ErrNotExist) { return false, nil } @@ -25,20 +33,28 @@ func sectionExists(path, sectionName string) (bool, error) { return false, nil } -func upsertSection(path, sectionName string, keys map[string]string) error { +func openOrCreate(path string) (*ini.File, error) { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err + return nil, err } - var f *ini.File if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - f = ini.Empty() - } else { - var err error - f, err = ini.Load(path) - if err != nil { - return err - } + return ini.Empty(), nil + } + return loadINI(path) +} + +func saveAndChmod(f *ini.File, path string) error { + if err := f.SaveTo(path); err != nil { + return err + } + return os.Chmod(path, 0600) +} + +func upsertSection(path, sectionName string, keys map[string]string) error { + f, err := openOrCreate(path) + if err != nil { + return err } section := f.Section(sectionName) // gets or creates the section @@ -46,8 +62,21 @@ func upsertSection(path, sectionName string, keys map[string]string) error { section.Key(k).SetValue(v) } - if err := f.SaveTo(path); err != nil { + return saveAndChmod(f, path) +} + +// upsertRawSection writes a section's raw text instead of key=value pairs. +// Needed for the services block, since upsertSection would quote a multi-line +// value instead of writing it as-is. +func upsertRawSection(path, sectionName, body string) error { + f, err := openOrCreate(path) + if err != nil { return err } - return os.Chmod(path, 0600) + + if _, err := f.NewRawSection(sectionName, body); err != nil { + return err + } + + return saveAndChmod(f, path) } diff --git a/test/integration/awsconfig_test.go b/test/integration/awsconfig_test.go index ee0d1a46..339e72b7 100644 --- a/test/integration/awsconfig_test.go +++ b/test/integration/awsconfig_test.go @@ -172,8 +172,13 @@ func TestSetupAWSCreatesAWSProfileWhenConfirmed(t *testing.T) { configContent, err := os.ReadFile(filepath.Join(tmpHome, ".aws", "config")) require.NoError(t, err, "~/.aws/config should have been created") - assert.Contains(t, string(configContent), "[profile localstack]") - assert.Contains(t, string(configContent), "endpoint_url") + // ini.v1 writes CRLF line endings on Windows, so normalize before matching + // multi-line substrings below. + normalizedConfig := strings.ReplaceAll(string(configContent), "\r\n", "\n") + assert.Contains(t, normalizedConfig, "[profile localstack]") + assert.Contains(t, normalizedConfig, "endpoint_url") + assert.Contains(t, normalizedConfig, "services = localstack") + assert.Contains(t, normalizedConfig, "[services localstack]\ns3 =\n endpoint_url = http") credsContent, err := os.ReadFile(filepath.Join(tmpHome, ".aws", "credentials")) require.NoError(t, err, "~/.aws/credentials should have been created")