Skip to content
Draft
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
46 changes: 36 additions & 10 deletions internal/awsconfig/awsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const (
ProfileName = "localstack"
configSectionName = "profile localstack" // ~/.aws/config uses "profile <name>" 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"
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
129 changes: 115 additions & 14 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,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
Expand All @@ -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",
Expand All @@ -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 {
Expand Down
56 changes: 43 additions & 13 deletions internal/awsconfig/ini.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -25,29 +34,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 (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)
}
14 changes: 12 additions & 2 deletions test/integration/awsconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading