Skip to content
Open
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
49 changes: 36 additions & 13 deletions internal/awsconfig/awsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import (
)

const (
ProfileName = "localstack"
configSectionName = "profile localstack" // ~/.aws/config uses "profile <name>" as section header
credsSectionName = "localstack" // ~/.aws/credentials uses just the profile name
ProfileName = "localstack"
configSectionName = "profile localstack" // ~/.aws/config uses "profile <name>" 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"
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
121 changes: 106 additions & 15 deletions internal/awsconfig/awsconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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 {
Expand Down Expand Up @@ -344,4 +436,3 @@ func TestIsValidLocalStackEndpoint(t *testing.T) {
})
}
}

55 changes: 42 additions & 13 deletions internal/awsconfig/ini.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -25,29 +33,50 @@ 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
for k, v := range keys {
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)
}
9 changes: 7 additions & 2 deletions test/integration/awsconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading