diff --git a/README.md b/README.md index ceb73b6..9a09a00 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Confita is a library that loads configuration from multiple backends and stores - [etcd](https://github.com/coreos/etcd) - [Consul](https://www.consul.io/) - [Vault](https://www.vaultproject.io/) +- [Amazon SSM](https://docs.aws.amazon.com/systems-manager/latest/userguide/what-is-systems-manager.html) ## Install diff --git a/backend/ssm/ssm.go b/backend/ssm/ssm.go new file mode 100644 index 0000000..c2d1ae4 --- /dev/null +++ b/backend/ssm/ssm.go @@ -0,0 +1,88 @@ +package ssm + +import ( + "context" + "strings" + + "github.com/aws/aws-sdk-go/service/ssm" + "github.com/aws/aws-sdk-go/service/ssm/ssmiface" + "github.com/heetch/confita/backend" +) + +type Backend struct { + client ssmiface.SSMAPI + ssmPath string + cache map[string][]byte +} + +func NewBackend(ssm ssmiface.SSMAPI, path string) *Backend { + return &Backend{client: ssm, ssmPath: path} +} + +func (b *Backend) Get(ctx context.Context, key string) ([]byte, error) { + if b.cache == nil { + err := b.fetchParams(ctx) + if err != nil { + return nil, err + } + } + + return b.fromCache(ctx, key) +} + +func (b *Backend) Name() string { + return "ssm" +} + +func (b *Backend) fetchParams(ctx context.Context) error { + b.cache = make(map[string][]byte) + + ssmInput := &ssm.GetParametersByPathInput{ + Path: &b.ssmPath, + Recursive: newBool(true), + WithDecryption: newBool(true), + MaxResults: newInt64(10), + } + + for { + res, err := b.client.GetParametersByPathWithContext(ctx, ssmInput) + if err != nil { + return err + } + + for _, p := range res.Parameters { + if p.Name != nil && p.Value != nil { + path := strings.Split(*p.Name, "/") + key := path[len(path)-1] + if key != "" { + b.cache[key] = []byte(*p.Value) + } + } + } + + if res.NextToken == nil { + break + } + + ssmInput.NextToken = res.NextToken + } + + return nil +} + +func (b *Backend) fromCache(ctx context.Context, key string) ([]byte, error) { + v, ok := b.cache[key] + if !ok { + return nil, backend.ErrNotFound + } + + return v, nil +} + +func newBool(b bool) *bool { + return &b +} + +func newInt64(i int64) *int64 { + return &i +} diff --git a/backend/ssm/ssm_test.go b/backend/ssm/ssm_test.go new file mode 100644 index 0000000..7bd09cf --- /dev/null +++ b/backend/ssm/ssm_test.go @@ -0,0 +1,181 @@ +package ssm + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/service/ssm" + "github.com/aws/aws-sdk-go/service/ssm/ssmiface" + "github.com/heetch/confita/backend" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockSSM struct { + mock.Mock + ssmiface.SSMAPI +} + +func (_m *mockSSM) GetParametersByPathWithContext(_a0 context.Context, _a1 *ssm.GetParametersByPathInput, _a2 ...request.Option) (*ssm.GetParametersByPathOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *ssm.GetParametersByPathOutput + if rf, ok := ret.Get(0).(func(context.Context, *ssm.GetParametersByPathInput, ...request.Option) *ssm.GetParametersByPathOutput); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ssm.GetParametersByPathOutput) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *ssm.GetParametersByPathInput, ...request.Option) error); ok { + r1 = rf(_a0, _a1, _a2...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +func TestAWSError(t *testing.T) { + client := new(mockSSM) + ssmOpts := getSSMOpts("/borked/") + ctx := context.Background() + expected := fmt.Errorf("aws down") + client.On("GetParametersByPathWithContext", ctx, ssmOpts).Return( + nil, expected) + + b := NewBackend(client, "/borked/") + _, actual := b.Get(context.Background(), "some_key") + require.Equal(t, expected, actual) +} + +func TestNilNameAndValue(t *testing.T) { + client := new(mockSSM) + ssmOpts := getSSMOpts("/sup/") + ctx := context.Background() + + client.On("GetParametersByPathWithContext", ctx, ssmOpts).Return(&ssm.GetParametersByPathOutput{ + Parameters: []*ssm.Parameter{ + { + Name: nil, + Value: nil, + }, + { + Name: ptrString("/sup/key"), + Value: nil, + }, + }, + }, nil) + + b := NewBackend(client, "/sup/") + + _, actual := b.Get(context.Background(), "key") + require.Equal(t, backend.ErrNotFound, actual) +} + +func TestEmptyKey(t *testing.T) { + client := new(mockSSM) + ssmOpts := getSSMOpts("/sup/") + ctx := context.Background() + + client.On("GetParametersByPathWithContext", ctx, ssmOpts).Return(&ssm.GetParametersByPathOutput{ + Parameters: []*ssm.Parameter{ + { + Name: ptrString("/sup/"), + Value: ptrString("a value"), + }, + }, + }, nil) + + b := NewBackend(client, "/sup/") + + _, actual := b.Get(context.Background(), "") + require.Equal(t, backend.ErrNotFound, actual) +} + +func TestKeyNotFound(t *testing.T) { + client := new(mockSSM) + ssmOpts := getSSMOpts("/whatevs/") + ctx := context.Background() + client.On("GetParametersByPathWithContext", ctx, ssmOpts).Return( + &ssm.GetParametersByPathOutput{}, nil) + + b := NewBackend(client, "/whatevs/") + _, actual := b.Get(context.Background(), "some_key") + require.Equal(t, backend.ErrNotFound, actual) +} + +func ptrString(str string) *string { + return &str +} + +func TestKeysFound(t *testing.T) { + client := new(mockSSM) + ctx := context.Background() + ssmOpts := getSSMOpts("/yo/whatup/") + client.On("GetParametersByPathWithContext", ctx, ssmOpts).Return( + &ssm.GetParametersByPathOutput{ + Parameters: []*ssm.Parameter{ + {Name: ptrString("/yo/whatup/a_key"), Value: ptrString("wow")}, + {Name: ptrString("/yo/whatup/some_key"), Value: ptrString("wondrous")}, + }, + }, nil) + + b := NewBackend(client, "/yo/whatup/") + actual, err := b.Get(context.Background(), "a_key") + require.Nil(t, err) + require.Equal(t, "wow", string(actual)) + actual, err = b.Get(context.Background(), "some_key") + require.Nil(t, err) + require.Equal(t, "wondrous", string(actual)) +} + +func TestSSMPagedCall(t *testing.T) { + client := new(mockSSM) + ctx := context.Background() + firstOpts := getSSMOpts("/a/path/") + client.On("GetParametersByPathWithContext", ctx, firstOpts).Return( + &ssm.GetParametersByPathOutput{ + Parameters: []*ssm.Parameter{}, + NextToken: ptrString("/a/path/your_key"), + }, nil) + + secondOpts := getSSMOpts("/a/path/") + secondOpts.NextToken = ptrString("/a/path/your_key") + client.On("GetParametersByPathWithContext", ctx, secondOpts).Return( + &ssm.GetParametersByPathOutput{ + Parameters: []*ssm.Parameter{ + {Name: ptrString("/a/path/your_key"), Value: ptrString("shazam")}, + {Name: ptrString("/a/path/another_key"), Value: ptrString("kazam")}, + }, + NextToken: nil, + }, nil) + + b := NewBackend(client, "/a/path/") + actual, err := b.Get(context.Background(), "your_key") + require.Nil(t, err) + require.Equal(t, "shazam", string(actual)) + actual, err = b.Get(context.Background(), "another_key") + require.Nil(t, err) + require.Equal(t, "kazam", string(actual)) +} + +func getSSMOpts(path string) *ssm.GetParametersByPathInput { + return &ssm.GetParametersByPathInput{ + Path: &path, + Recursive: newBool(true), + WithDecryption: newBool(true), + MaxResults: newInt64(10), + } +} diff --git a/go.mod b/go.mod index 100e7c5..0f8da47 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect + github.com/aws/aws-sdk-go v1.23.20 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/cenkalti/backoff v2.1.1+incompatible // indirect diff --git a/go.sum b/go.sum index 66c279b..9894951 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/aws/aws-sdk-go v1.23.20 h1:2CBuL21P0yKdZN5urf2NxKa1ha8fhnY+A3pBCHFeZoA= +github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= @@ -182,6 +184,8 @@ github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKe github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jefferai/jsonx v1.0.0 h1:Xoz0ZbmkpBvED5W9W1B5B/zc3Oiq7oXqiW7iRV3B6EI= github.com/jefferai/jsonx v1.0.0/go.mod h1:OGmqmi2tTeI/PS+qQfBDToLHHJIy/RMp24fPo8vFvoQ= +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/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= @@ -288,6 +292,7 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/soheilhy/cmux v0.1.4 h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=