diff --git a/internal/awsconfig/awsconfig.go b/internal/awsconfig/awsconfig.go index 343bfd97..17791930 100644 --- a/internal/awsconfig/awsconfig.go +++ b/internal/awsconfig/awsconfig.go @@ -22,6 +22,9 @@ const ( ProfileName = "localstack" configSectionName = "profile localstack" // ~/.aws/config uses "profile " as section header credsSectionName = "localstack" // ~/.aws/credentials uses just the profile name + // servicesSectionName holds the per-service endpoint overrides referenced by the + // "services" key in configSectionName (see servicesSectionBody). + servicesSectionName = "services localstack" // TODO: make region configurable (e.g. from container env or lstk config) defaultRegion = "us-east-1" ) @@ -61,6 +64,21 @@ 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 =" for AWS CLI to treat it as nested. +// +// indent is that line's leading whitespace; pass "" to match Section.Body() after a +// reload, which strips it (see loadOptions). +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 +128,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 +146,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 +207,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 +221,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..555be0a0 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,73 @@ func TestWriteProfile(t *testing.T) { } } +// TestWriteConfigProfileWritesS3ServicesOverride guards `aws --profile localstack` +// (used directly, not through one of lstk's own proxies) needing an S3 endpoint +// override in ~/.aws/config, since AWS CLI's virtual-host-style S3 addressing +// otherwise resolves buckets against the wrong host. +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 within the profile section is not guaranteed (upsertSection ranges + // over a map), so assert on parsed values rather than the 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) + } + // The raw section body IS deterministic (we control its exact bytes, unlike the + // key=value profile section above), but Body() strips the continuation line's + // leading whitespace on load — assert on the on-disk bytes to catch a regression + // that drops the indentation the AWS CLI needs to treat it as nested. + 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) + } + + // A second write of the same host must be idempotent and must not need a + // re-write (it must not, for instance, re-load-and-corrupt the indentation of + // the raw services section written above). + 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 +217,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 +270,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 { diff --git a/internal/awsconfig/ini.go b/internal/awsconfig/ini.go index b6beaa86..78b56795 100644 --- a/internal/awsconfig/ini.go +++ b/internal/awsconfig/ini.go @@ -9,8 +9,17 @@ import ( "gopkg.in/ini.v1" ) +// loadOptions marks servicesSectionName unparseable so its indented-continuation-line +// content (see servicesSectionBody) round-trips through Load unchanged instead of +// being reparsed as ordinary key=value pairs, which would flatten and corrupt it. +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 +34,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 +63,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 (see servicesSectionBody): upsertSection would wrap its +// multi-line value in triple quotes 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..8fe6b875 100644 --- a/test/integration/awsconfig_test.go +++ b/test/integration/awsconfig_test.go @@ -172,8 +172,18 @@ 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 "\r\n" 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") + // `aws --profile localstack` used directly (not through an lstk proxy) needs an + // S3 endpoint override, since AWS CLI's virtual-host-style S3 addressing + // otherwise resolves buckets against the wrong host. AWS CLI's config parser + // requires this endpoint_url line to stay indented under "s3 =" to treat it as + // nested rather than a sibling key in the section. + 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")