diff --git a/Makefile b/Makefile index 3eaabf9..d4eb140 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,3 @@ test: cd aws && go vet ./... && go test -v ./... -race -cover + cd awsEnv && go vet ./... && go test -v ./... -race -cover diff --git a/README.md b/README.md index 706f27c..c2ce67d 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,38 @@ go run main.go - _Note_: decryption of the value is automatically requested. - `ssmb64://` – Get base64 encoded binary value from [parameter store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) - _Note_: decryption of the value is automatically requested. + +## AWS via Env + +Pre-process config values by reading secrets from AWS. + +```go +// main.go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/hookactions/fig/awsEnv" +) + +func main() { + fig, err := awsEnv.New() + if err != nil { + panic(err) + } + + fmt.Println(fig.GetEnv(context.Background(), "MY_VAR")) +} +``` + +```bash +MY_VAR=sm://foo go run main.go +``` + +### Supported prefixes +- `sm://` – Get string value from [secrets manager](https://aws.amazon.com/secrets-manager/) +- `ssm://` – Get string value from [parameter store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) + - _Note_: decryption of the value is automatically requested. diff --git a/awsEnv/env.go b/awsEnv/env.go new file mode 100644 index 0000000..0c7a58a --- /dev/null +++ b/awsEnv/env.go @@ -0,0 +1,92 @@ +package awsEnv + +import ( + "context" + "os" + "regexp" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/external" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager/secretsmanageriface" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/ssmiface" + "github.com/pkg/errors" +) + +var ( + secretsManagerStringRe = regexp.MustCompile("^sm://") + parameterStoreStringRe = regexp.MustCompile("^ssm://") +) + +func checkPrefixAndStrip(re *regexp.Regexp, s string) (string, bool) { + if re.MatchString(s) { + return re.ReplaceAllString(s, ""), true + } + return s, false +} + +type Fig struct { + DecryptParameterStoreValues bool + + secretsManager secretsmanageriface.ClientAPI + parameterStore ssmiface.ClientAPI +} + +func New() (*Fig, error) { + awsConfig, err := external.LoadDefaultAWSConfig() + if err != nil { + return nil, errors.Wrap(err, "fig/awsEnv: error loading default aws config") + } + + fig := &Fig{ + DecryptParameterStoreValues: true, + + secretsManager: secretsmanager.New(awsConfig), + parameterStore: ssm.New(awsConfig), + } + + return fig, nil +} + +func (f *Fig) GetEnv(ctx context.Context, key string) string { + value := os.Getenv(key) + return f.processConfigItem(ctx, key, value) +} + +func (f *Fig) processConfigItem(ctx context.Context, key string, value string) string { + if v, ok := checkPrefixAndStrip(secretsManagerStringRe, value); ok { + return f.LoadStringValueFromSecretsManager(ctx, v) + } else if v, ok := checkPrefixAndStrip(parameterStoreStringRe, v); ok { + return f.LoadStringValueFromParameterStore(ctx, v, f.DecryptParameterStoreValues) + } + return value +} + +func (f *Fig) LoadStringValueFromSecretsManager(ctx context.Context, name string) string { + resp, err := f.requestSecret(ctx, name) + if err != nil { + panic("fig/aws/LoadStringValueFromSecretsManager: error loading secret, " + err.Error()) + } + + return *resp.SecretString +} + +func (f *Fig) requestSecret(ctx context.Context, name string) (*secretsmanager.GetSecretValueResponse, error) { + input := &secretsmanager.GetSecretValueInput{SecretId: aws.String(name)} + return f.secretsManager.GetSecretValueRequest(input).Send(ctx) +} + +func (f *Fig) LoadStringValueFromParameterStore(ctx context.Context, name string, decrypt bool) string { + resp, err := f.requestParameter(ctx, name, decrypt) + if err != nil { + panic("fig/aws/LoadStringValueFromParameterStore: error loading value, " + err.Error()) + } + + return *resp.Parameter.Value +} + +func (f *Fig) requestParameter(ctx context.Context, name string, decrypt bool) (*ssm.GetParameterResponse, error) { + input := &ssm.GetParameterInput{Name: aws.String(name), WithDecryption: aws.Bool(decrypt)} + return f.parameterStore.GetParameterRequest(input).Send(ctx) +} diff --git a/awsEnv/env_test.go b/awsEnv/env_test.go new file mode 100644 index 0000000..1e01b36 --- /dev/null +++ b/awsEnv/env_test.go @@ -0,0 +1,164 @@ +package awsEnv + +import ( + "context" + "encoding/base64" + "net/http" + "os" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager/secretsmanageriface" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/ssmiface" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockSecretManagerClient struct { + secretsmanageriface.ClientAPI + + checkInput func(*secretsmanager.GetSecretValueInput) + stringValue *string + binaryValue []byte +} + +func (m *mockSecretManagerClient) GetSecretValueRequest(in *secretsmanager.GetSecretValueInput) secretsmanager.GetSecretValueRequest { + if m.checkInput != nil { + m.checkInput(in) + } + + req := &aws.Request{ + Data: &secretsmanager.GetSecretValueOutput{ + SecretString: m.stringValue, + SecretBinary: m.binaryValue, + }, + HTTPRequest: new(http.Request), + } + return secretsmanager.GetSecretValueRequest{Request: req, Input: in, Copy: m.GetSecretValueRequest} +} + +type mockParameterStoreClient struct { + ssmiface.ClientAPI + + checkInput func(*ssm.GetParameterInput) + stringValue *string + binaryValue []byte +} + +func (m *mockParameterStoreClient) GetParameterRequest(in *ssm.GetParameterInput) ssm.GetParameterRequest { + if m.checkInput != nil { + m.checkInput(in) + } + + var value *string + + if m.stringValue != nil { + value = m.stringValue + } else if m.binaryValue != nil { + value = aws.String(base64.StdEncoding.EncodeToString(m.binaryValue)) + } + + req := &aws.Request{ + Data: &ssm.GetParameterOutput{ + Parameter: &ssm.Parameter{ + Value: value, + }, + }, + HTTPRequest: new(http.Request), + } + return ssm.GetParameterRequest{Request: req, Input: in, Copy: m.GetParameterRequest} +} + +func TestFig_GetEnv(t *testing.T) { + t.Run("NonPrefixedValues", func(t *testing.T) { + fig := &Fig{} + ctx := context.Background() + + require.NoError(t, os.Setenv("FOO_1", "bar")) + require.NoError(t, os.Setenv("FOO_BAR_BAZ", "test")) + + defer os.Unsetenv("FOO_1") + defer os.Unsetenv("FOO_BAR_BAZ") + + assert.Equal(t, "bar", fig.GetEnv(ctx, "FOO_1")) + assert.Equal(t, "test", fig.GetEnv(ctx, "FOO_BAR_BAZ")) + }) + + t.Run("SecretsManager", func(t *testing.T) { + manager := &mockSecretManagerClient{} + + fig := &Fig{ + DecryptParameterStoreValues: true, + secretsManager: manager, + } + ctx := context.Background() + + t.Run("String", func(t *testing.T) { + t.Run("Simple", func(t *testing.T) { + require.NoError(t, os.Setenv("foo", "sm://foo_bar")) + defer os.Unsetenv("foo") + + manager.checkInput = func(input *secretsmanager.GetSecretValueInput) { + assert.Equal(t, "foo_bar", *input.SecretId) + } + manager.stringValue = aws.String("baz") + + assert.Equal(t, "baz", fig.GetEnv(ctx, "foo")) + }) + + // "complex" in the sense that this would break using strings.TrimPrefix(...) + t.Run("Complex", func(t *testing.T) { + require.NoError(t, os.Setenv("foo", "sm://small_foo_bar")) + defer os.Unsetenv("foo") + + manager.checkInput = func(input *secretsmanager.GetSecretValueInput) { + assert.Equal(t, "small_foo_bar", *input.SecretId) + } + manager.stringValue = aws.String("baz") + + assert.Equal(t, "baz", fig.GetEnv(ctx, "foo")) + }) + }) + }) + + t.Run("ParameterStore", func(t *testing.T) { + storeClient := &mockParameterStoreClient{} + + fig := &Fig{ + DecryptParameterStoreValues: true, + parameterStore: storeClient, + } + ctx := context.Background() + + t.Run("String", func(t *testing.T) { + t.Run("Simple", func(t *testing.T) { + require.NoError(t, os.Setenv("foo", "ssm://foo_bar")) + defer os.Unsetenv("foo") + + storeClient.checkInput = func(input *ssm.GetParameterInput) { + assert.Equal(t, "foo_bar", *input.Name) + assert.True(t, *input.WithDecryption) + } + storeClient.stringValue = aws.String("baz") + + assert.Equal(t, "baz", fig.GetEnv(ctx, "foo")) + }) + + // "complex" in the sense that this would break using strings.TrimPrefix(...) + t.Run("Complex", func(t *testing.T) { + require.NoError(t, os.Setenv("foo", "ssm://ssmall_foo_bar")) + defer os.Unsetenv("foo") + + storeClient.checkInput = func(input *ssm.GetParameterInput) { + assert.Equal(t, "ssmall_foo_bar", *input.Name) + assert.True(t, *input.WithDecryption) + } + storeClient.stringValue = aws.String("baz") + + assert.Equal(t, "baz", fig.GetEnv(ctx, "foo")) + }) + }) + }) +} diff --git a/awsEnv/go.mod b/awsEnv/go.mod new file mode 100644 index 0000000..3b7841f --- /dev/null +++ b/awsEnv/go.mod @@ -0,0 +1,9 @@ +module github.com/hookactions/fig/env + +go 1.12 + +require ( + github.com/aws/aws-sdk-go-v2 v0.9.0 + github.com/pkg/errors v0.8.1 + github.com/stretchr/testify v1.2.2 +) diff --git a/awsEnv/go.sum b/awsEnv/go.sum new file mode 100644 index 0000000..e02dd0f --- /dev/null +++ b/awsEnv/go.sum @@ -0,0 +1,19 @@ +github.com/aws/aws-sdk-go-v2 v0.9.0 h1:dWtJKGRFv3UZkMBQaIzMsF0/y4ge3iQPWTzeC4r/vl4= +github.com/aws/aws-sdk-go-v2 v0.9.0/go.mod h1:sa1GePZ/LfBGI4dSq30f6uR4Tthll8axxtEPvlpXZ8U= +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/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=