diff --git a/awssmpfs/awssmp.go b/awssmpfs/awssmp.go new file mode 100644 index 00000000..9f126596 --- /dev/null +++ b/awssmpfs/awssmp.go @@ -0,0 +1,481 @@ +package awssmpfs + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "path" + "sort" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/hairyhenderson/go-fsimpl" + "github.com/hairyhenderson/go-fsimpl/internal" +) + +// withSSMClienter is an fs.FS that can be configured to use the given Simple +// Systems Manager client. +type withSSMClienter interface { + WithSSMClient(ssmclient SimpleSystemsManagerClient) fs.FS +} + +// WithSSMClientFS overrides the AWS Simple Systems Manager client used by fs, +// if the filesystem supports it (i.e. has a WithSMClient method). This can be +// used for configuring specialized client options. +// +// Note that this should not be used together with WithHTTPClient. If you wish +// only to override the HTTP client, use WithHTTPClient alone. +func WithSSMClientFS(ssmclient SimpleSystemsManagerClient, fsys fs.FS) fs.FS { + if fsys, ok := fsys.(withSSMClienter); ok { + return fsys.WithSSMClient(ssmclient) + } + + return fsys +} + +type awssmpFS struct { + ctx context.Context + base *url.URL + httpclient *http.Client + ssmclient SimpleSystemsManagerClient + root string +} + +// New provides a filesystem (an fs.FS) backed by the AWS Simple Systems Manager +// Parameter Store, rooted at the given URL. Note that the URL may be either a +// regular hierarchical URL (like "aws+smp:///foo/bar") or an opaque URI (like +// "aws+smp:foo/bar"), depending on how values are organized in the Parameter +// Store. +// +// A context can be given by using WithContextFS. +func New(u *url.URL) (fs.FS, error) { + if u.Scheme != "aws+smp" { + return nil, fmt.Errorf("invalid URL scheme %q", u.Scheme) + } + + root := u.Path + if root == "" { + root = u.Opaque + } + + return &awssmpFS{ + ctx: context.Background(), + base: u, + root: root, + }, nil +} + +// FS is used to register this filesystem with an fsimpl.FSMux +// +//nolint:gochecknoglobals +var FS = fsimpl.FSProviderFunc(New, "aws+smp") + +var ( + _ fs.FS = (*awssmpFS)(nil) + _ fs.ReadFileFS = (*awssmpFS)(nil) + _ fs.ReadDirFS = (*awssmpFS)(nil) + _ fs.SubFS = (*awssmpFS)(nil) + _ internal.WithContexter = (*awssmpFS)(nil) + _ internal.WithHTTPClienter = (*awssmpFS)(nil) + _ withSSMClienter = (*awssmpFS)(nil) +) + +func (f awssmpFS) WithContext(ctx context.Context) fs.FS { + fsys := f + fsys.ctx = ctx + + return &fsys +} + +func (f awssmpFS) WithHTTPClient(client *http.Client) fs.FS { + fsys := f + fsys.httpclient = client + + return &fsys +} + +func (f awssmpFS) WithSSMClient(ssmclient SimpleSystemsManagerClient) fs.FS { + fsys := f + fsys.ssmclient = ssmclient + + return &fsys +} + +func (f *awssmpFS) getClient(ctx context.Context) (SimpleSystemsManagerClient, error) { + if f.ssmclient != nil { + return f.ssmclient, nil + } + + opts := [](func(*config.LoadOptions) error){} + if f.httpclient != nil { + opts = append(opts, config.WithHTTPClient(f.httpclient)) + } + + cfg, err := config.LoadDefaultConfig(ctx, opts...) + if err != nil { + return nil, err + } + + f.ssmclient = ssm.NewFromConfig(cfg) + + return f.ssmclient, nil +} + +func (f *awssmpFS) Sub(name string) (fs.FS, error) { + if !internal.ValidPath(name) { + return nil, &fs.PathError{Op: "sub", Path: name, Err: fs.ErrInvalid} + } + + if name == "." || name == "" { + return f, nil + } + + fsys := *f + fsys.root = path.Join(fsys.root, name) + + return &fsys, nil +} + +func (f *awssmpFS) Open(name string) (fs.File, error) { + if !internal.ValidPath(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + + smclient, err := f.getClient(f.ctx) + if err != nil { + return nil, err + } + + file := &awssmpFile{ + ctx: f.ctx, + name: strings.TrimPrefix(path.Base(name), "."), + root: strings.TrimPrefix(path.Join(f.root, path.Dir(name)), "."), + client: smclient, + } + + if name == "." { + file.fi = internal.DirInfo(file.name, time.Time{}) + + return file, nil + } + + return file, nil +} + +func (f *awssmpFS) ReadDir(name string) ([]fs.DirEntry, error) { + if !internal.ValidPath(name) { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid} + } + + ssmclient, err := f.getClient(f.ctx) + if err != nil { + return nil, err + } + + dir := &awssmpFile{ + ctx: f.ctx, + name: name, + root: f.root, + client: ssmclient, + fi: internal.DirInfo(name, time.Time{}), + } + + des, err := dir.ReadDir(-1) + if err != nil { + return nil, &fs.PathError{Op: "readDir", Path: name, Err: err} + } + + return des, nil +} + +// ReadFile implements fs.ReadFileFS. +// +// This implementation is slightly more performant than calling Open and then +// reading the resulting fs.File. +func (f *awssmpFS) ReadFile(name string) ([]byte, error) { + if !internal.ValidPath(name) { + return nil, &fs.PathError{Op: "readFile", Path: name, Err: fs.ErrInvalid} + } + + smclient, err := f.getClient(f.ctx) + if err != nil { + return nil, err + } + + out, err := smclient.GetParameter(f.ctx, &ssm.GetParameterInput{ + Name: aws.String(path.Join(f.root, name)), + }) + if err != nil { + return nil, &fs.PathError{Op: "readFile", Path: name, Err: convertAWSError(err)} + } + + // TODO: probably handle SecureString and StringList? + + return []byte(*out.Parameter.Value), nil +} + +type awssmpFile struct { + ctx context.Context + fi fs.FileInfo + client SimpleSystemsManagerClient + body io.Reader + name string + root string + + children []*awssmpFile + diroff int +} + +var _ fs.ReadDirFile = (*awssmpFile)(nil) + +func (f *awssmpFile) Close() error { + // no-op - no state is kept + return nil +} + +func (f *awssmpFile) Read(p []byte) (int, error) { + if f.body == nil { + err := f.getParameter() + if err != nil { + return 0, &fs.PathError{Op: "read", Path: f.name, Err: err} + } + } + + return f.body.Read(p) +} + +func (f *awssmpFile) Stat() (fs.FileInfo, error) { + if f.fi != nil { + return f.fi, nil + } + + err := f.getParameter() + if err == nil { + return f.fi, nil + } + + if !errors.Is(err, fs.ErrNotExist) { + return nil, &fs.PathError{Op: "stat", Path: f.name, Err: err} + } + + // may be a directory, so attempt to list one child + // no need for special handling for opaque paths, as "." will never hit this + // code path (Open sets f.fi to a DirInfo) + params, err := f.client.GetParametersByPath(f.ctx, &ssm.GetParametersByPathInput{ + Path: aws.String(path.Join(f.root, f.name) + "/"), + WithDecryption: true, + }) + if err != nil { + return nil, &fs.PathError{Op: "stat", Path: f.name, Err: convertAWSError(err)} + } + + if len(params.Parameters) == 0 { + return nil, &fs.PathError{Op: "stat", Path: f.name, Err: fs.ErrNotExist} + } + + f.fi = internal.DirInfo(f.name, time.Time{}) + + return f.fi, nil +} + +// convertAWSError converts an AWS error to an error suitable for returning +// from the package. We don't want to leak SDK error types. +func convertAWSError(err error) error { + // We can't find the parameter that you asked for. + var nfErr *types.ParameterNotFound + if errors.As(err, &nfErr) { + return fmt.Errorf("%w: %s", fs.ErrNotExist, nfErr.ErrorMessage()) + } + + // An error occurred on the server side. + var internalErr *types.InternalServerError + if errors.As(err, &internalErr) { + return fmt.Errorf("internal error: %s: %s", internalErr.ErrorCode(), internalErr.ErrorMessage()) + } + + // You provided an invalid value for a parameter. + var paramErr *types.InvalidParameters + if errors.As(err, ¶mErr) { + return fmt.Errorf("%w: %s", fs.ErrInvalid, paramErr.ErrorMessage()) + } + + return err +} + +// getParameter gets the parameter value from AWS SSM Parameter Store and +// populates body and fi. SDK errors will not be leaked, instead they will be +// converted to more general errors. +func (f *awssmpFile) getParameter() error { + fullPath := path.Join(f.root, f.name) + + out, err := f.client.GetParameter(f.ctx, &ssm.GetParameterInput{ + Name: aws.String(fullPath), + }) + if err != nil { + return fmt.Errorf("getParameter: %w", convertAWSError(err)) + } + + body := *out.Parameter.Value + + seclen := int64(len(body)) + f.body = strings.NewReader(body) + + // parameter versions are immutable, so the created date for this version + // is also the last modified date + modTime := out.Parameter.LastModifiedDate + if modTime == nil { + modTime = &time.Time{} + } + + // populate fi + f.fi = internal.FileInfo(f.name, seclen, 0o644, *modTime, "") + + return nil +} + +// listPrefix returns the prefix for this directory +func (f *awssmpFile) listPrefix() string { + // when listing "." at the root (or opaque root), avoid returning "//" + if f.name == "." && (f.root == "" || f.root == "/") { + return f.root + } + + return path.Join(f.root, f.name) + "/" +} + +func (f *awssmpFile) listParameters() ([]types.Parameter, error) { + prefix := f.listPrefix() + paramList := []types.Parameter{} + + for token := (*string)(nil); ; { + out, err := f.client.GetParametersByPath(f.ctx, &ssm.GetParametersByPathInput{ + Path: aws.String(prefix), + WithDecryption: true, + NextToken: token, + }) + if err != nil { + return nil, fmt.Errorf("listParameters: %w", err) + } + + // trim the prefix from the names so we can use them as filenames + for _, param := range out.Parameters { + name := strings.TrimPrefix(*param.Name, prefix) + if prefix != "/" { + name = strings.TrimPrefix(name, "/") + } + + *param.Name = name + } + + paramList = append(paramList, out.Parameters...) + + token = out.NextToken + if token == nil { + break + } + } + + // no such thing as empty directories in SSM PS, they're artificial + if len(paramList) == 0 { + return nil, fmt.Errorf("%w (or empty): %q", fs.ErrNotExist, prefix) + } + + return paramList, nil +} + +// list assignes a sorted list of the children of this directory to f.children +func (f *awssmpFile) list() error { + paramList, err := f.listParameters() + if err != nil { + return err + } + + // a set of files that we've already seen - we don't want to add duplicates + seen := map[string]struct{}{} + + for _, entry := range paramList { + parts := strings.SplitN(*entry.Name, "/", 2) + name := parts[0] + + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + + child := awssmpFile{ + ctx: f.ctx, + name: name, + root: path.Join(f.root, f.name), + client: f.client, + } + + if len(parts) > 1 { + // given that directories are artificial, they have a zero time + child.fi = internal.DirInfo(name, time.Time{}) + } else { + fi, err := child.Stat() + if err != nil { + return err + } + + child.fi = fi + } + + f.children = append(f.children, &child) + } + + // the AWS SDK doesn't sort the list of children, so we do it here + sort.Slice(f.children, func(i, j int) bool { + return f.children[i].name < f.children[j].name + }) + + return nil +} + +// If n > 0, ReadDir returns at most n DirEntry structures. +// In this case, if ReadDir returns an empty slice, it will return +// a non-nil error explaining why. +// At the end of a directory, the error is io.EOF. +// +// If n <= 0, ReadDir returns all the DirEntry values from the directory +// in a single slice. In this case, if ReadDir succeeds (reads all the way +// to the end of the directory), it returns the slice and a nil error. +// If it encounters an error before the end of the directory, +// ReadDir returns the DirEntry list read until that point and a non-nil error. +func (f *awssmpFile) ReadDir(n int) ([]fs.DirEntry, error) { + if f.children == nil { + if err := f.list(); err != nil { + return nil, fmt.Errorf("list: %w", err) + } + } + + if n > 0 && f.diroff >= len(f.children) { + return nil, io.EOF + } + + low := f.diroff + high := f.diroff + n + + // clamp high at the max, and ensure it's higher than low + if high >= len(f.children) || high <= low { + high = len(f.children) + } + + entries := make([]fs.DirEntry, high-low) + for i := low; i < high; i++ { + entries[i-low] = internal.FileInfoDirEntry(f.children[i].fi) + } + + f.diroff = high + + return entries, nil +} diff --git a/awssmpfs/awssmp_test.go b/awssmpfs/awssmp_test.go new file mode 100644 index 00000000..3ea8cdba --- /dev/null +++ b/awssmpfs/awssmp_test.go @@ -0,0 +1,278 @@ +package awssmpfs + +import ( + "io" + "io/fs" + "testing" + "testing/fstest" + + "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/aws/aws-sdk-go/aws" + "github.com/hairyhenderson/go-fsimpl/internal/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupAWSSMPFsys(t *testing.T, dir string) fs.FS { + fsys, err := New(tests.MustURL("aws+smp:///" + dir)) + assert.NoError(t, err) + + fsys = WithSSMClientFS(clientWithValues(t, map[string]*testVal{ + "noleadingslash": vs("shouldn't be read"), + "noleading/slash": vs("shouldn't be read"), + "/notsub/bogus": vs("not part of /sub"), + "/sub/a/aa": vs("aaa"), + "/sub/a/ab": vs("aab"), + "/sub/a/ac": vs("aac"), + // "/sub/b/ba/baa": vb([]byte("bbabaa")), + // "/sub/b/ba/bab": vb([]byte("bbabab")), + // "/sub/b/bb/bba": vb([]byte("bbbbba")), + // "/sub/b/bb/bbb": vb([]byte("bbbbbb")), + // "/sub/b/bb/bbc": vb([]byte("bbbbbc")), + // "/sub/b/bc/bca": vb([]byte{0xde, 0xad, 0xbe, 0xef}), + // "/sub/blah": vb([]byte("blah")), + "/sub/c/ca/caa": vs("ccacaa"), + "/sub/c/cb": vs("ccb"), + }), fsys) + + return fsys +} + +func setupOpaqueAWSSMPFsys(t *testing.T, prefix string) fs.FS { + fsys, err := New(tests.MustURL("aws+smp:" + prefix)) + assert.NoError(t, err) + + fsys = WithSSMClientFS(clientWithValues(t, map[string]*testVal{ + "/notlisted/bogus": vs("not listed because of the / prefix"), + "/nope": vs("not listed because of the / prefix"), + // "blah": vb([]byte("blah")), + "a/aa": vs("aaa"), + // "b/ba/baa": vb([]byte("bbabaa")), + // "b/ba/bab": vb([]byte("bbabab")), + // "b/bb/bba": vb([]byte("bbbbba")), + // "b/bb/bbb": vb([]byte("bbbbbb")), + // "b/bb/bbc": vb([]byte("bbbbbc")), + // "b/bc/bca": vb([]byte{0xde, 0xad, 0xbe, 0xef}), + "c/cb": vs("ccb"), + }), fsys) + + return fsys +} + +func TestAWSSMPFS_TestFS(t *testing.T) { + fsys := setupAWSSMPFsys(t, "sub") + + assert.NoError(t, fstest.TestFS(fsys, + "a", "b", "c", + "b/ba", "b/bb", "b/bc", "c/ca", + "a/aa", "a/ab", "a/ac", + "b/ba/baa", "b/ba/bab", + "b/bb/bba", "b/bb/bbb", "b/bb/bbc", + "b/bc/bca", + "c/ca/caa", + "c/cb", + )) + + // test opaque + fsys = setupOpaqueAWSSMPFsys(t, "b") + assert.NoError(t, fstest.TestFS(fsys, + "ba", "bb", "bc", + "ba/baa", "ba/bab", + "bb/bba", "bb/bbb", "bb/bbc", + "bc/bca")) +} + +func TestAWSSMPFS_New(t *testing.T) { + _, err := New(tests.MustURL("https://example.com")) + assert.Error(t, err) +} + +func TestAWSSMPFS_Stat(t *testing.T) { + fsys := setupAWSSMPFsys(t, "sub") + + f, err := fsys.Open(".") + assert.NoError(t, err) + + fi, err := f.Stat() + assert.NoError(t, err) + assert.True(t, fi.IsDir()) + assert.Equal(t, "", fi.Name()) + + f, err = fsys.Open("missing") + assert.NoError(t, err) + + _, err = f.Stat() + assert.ErrorIs(t, err, fs.ErrNotExist) + + _, err = fs.Stat(fsys, "noleadingslash") + assert.ErrorIs(t, err, fs.ErrNotExist) + + fi, err = fs.Stat(fsys, "a") + assert.NoError(t, err) + assert.True(t, fi.IsDir()) + + fsys = setupOpaqueAWSSMPFsys(t, "") + + f, err = fsys.Open(".") + assert.NoError(t, err) + + fi, err = f.Stat() + assert.NoError(t, err) + assert.True(t, fi.IsDir()) + + fsys = setupOpaqueAWSSMPFsys(t, "b") + + // make sure the file "blah" isn't misinterpreted as "b/lah" + _, err = fs.Stat(fsys, "lah") + assert.ErrorIs(t, err, fs.ErrNotExist) + + // stat shouldn't leak AWS error types when GetParametersByPath errors + fsys, err = New(tests.MustURL("aws+smp:///")) + assert.NoError(t, err) + + // shouldn't leak AWS error types when GetParameter errors + fsys = WithSSMClientFS(clientWithValues(t, + map[string]*testVal{}, + &types.InvalidParameters{}, + ), fsys) + + _, err = fs.Stat(fsys, "blah") + assert.ErrorIs(t, err, fs.ErrInvalid) +} + +func TestAWSSMPFS_Read(t *testing.T) { + fsys := setupAWSSMPFsys(t, "") + + f, err := fsys.Open("sub/a/aa") + assert.NoError(t, err) + + b, err := io.ReadAll(f) + assert.NoError(t, err) + assert.Equal(t, "aaa", string(b)) + + fsys, err = fs.Sub(fsys, "sub/b/bb") + assert.NoError(t, err) + + f, err = fsys.Open("bbc") + assert.NoError(t, err) + + defer f.Close() + + b, err = io.ReadAll(f) + assert.NoError(t, err) + assert.Equal(t, "bbbbbc", string(b)) + + _, err = f.Read(b) + assert.Error(t, err) + + // read shouldn't leak AWS error types when GetParameter errors + fsys, err = New(tests.MustURL("aws+smp:///")) + assert.NoError(t, err) + + fsys = WithSSMClientFS(clientWithValues(t, + map[string]*testVal{}, + &types.InternalServerError{Message: aws.String("foo")}, + ), fsys) + + _, err = fs.ReadFile(fsys, "blah") + assert.EqualError(t, err, "readFile blah: internal error: InternalServiceError: foo") +} + +func TestAWSSMPFS_ReadFile(t *testing.T) { + fsys := setupAWSSMPFsys(t, "") + + b, err := fs.ReadFile(fsys, "sub/a/aa") + assert.NoError(t, err) + assert.Equal(t, "aaa", string(b)) + + fsys, err = fs.Sub(fsys, "sub/a") + assert.NoError(t, err) + + b, err = fs.ReadFile(fsys, "ab") + assert.NoError(t, err) + assert.Equal(t, "aab", string(b)) +} + +func TestAWSSMPFS_ReadDir(t *testing.T) { + fsys, err := New(tests.MustURL("aws+smp:///")) + assert.NoError(t, err) + + fsys = WithSSMClientFS(clientWithValues(t, nil), fsys) + + _, err = fs.ReadDir(fsys, "dir1") + assert.Error(t, err) + assert.ErrorIs(t, err, fs.ErrNotExist) + + fsys = setupAWSSMPFsys(t, "") + + de, err := fs.ReadDir(fsys, ".") + assert.NoError(t, err) + assert.Len(t, de, 2) + + for i, ex := range []string{"notsub", "sub"} { + assert.Equal(t, ex, de[i].Name()) + assert.True(t, de[i].IsDir()) + } + + fsys = setupAWSSMPFsys(t, "sub") + + de, err = fs.ReadDir(fsys, ".") + assert.NoError(t, err) + assert.Len(t, de, 4) + + testdata := []struct { + name string + dir bool + }{ + {"a", true}, {"b", true}, {"blah", false}, {"c", true}, + } + for i, d := range testdata { + var fi fs.FileInfo + fi, err = de[i].Info() + assert.NoError(t, err) + assert.Equal(t, d.name, fi.Name()) + assert.Equal(t, d.dir, fi.IsDir()) + } + + de, err = fs.ReadDir(fsys, "b") + assert.NoError(t, err) + assert.Len(t, de, 3) + + f, err := fsys.Open("b/bb") + assert.NoError(t, err) + require.Implements(t, (*fs.ReadDirFile)(nil), f) + + dir := f.(fs.ReadDirFile) + + de, err = dir.ReadDir(-1) + assert.NoError(t, err) + assert.Len(t, de, 3) +} + +func TestAWSSMPFS_ReadDir_Opaque(t *testing.T) { + fsys := setupOpaqueAWSSMPFsys(t, "") + + de, err := fs.ReadDir(fsys, ".") + require.NoError(t, err) + require.Len(t, de, 4) + + fi, err := de[0].Info() + assert.NoError(t, err) + assert.Equal(t, "a", fi.Name()) + assert.True(t, fi.IsDir()) + + fi, err = de[1].Info() + assert.NoError(t, err) + assert.Equal(t, "b", fi.Name()) + assert.True(t, fi.IsDir()) + + fi, err = de[2].Info() + assert.NoError(t, err) + assert.Equal(t, "blah", fi.Name()) + assert.False(t, fi.IsDir()) + + fi, err = de[3].Info() + assert.NoError(t, err) + assert.Equal(t, "c", fi.Name()) + assert.True(t, fi.IsDir()) +} diff --git a/awssmpfs/doc.go b/awssmpfs/doc.go new file mode 100644 index 00000000..afc85c37 --- /dev/null +++ b/awssmpfs/doc.go @@ -0,0 +1,36 @@ +// Package awssmpfs provides an interface to the AWS Simple Systems Manager +// Parameter Store which allows you to interact with the Simple Systems Manager +// Parameter Store API as a standard filesystem. +// +// This filesystem's behaviour complies with fstest.TestFS. +// +// Usage +// +// To use this filesystem, call New with a base URL. All reads from the +// filesystem are relative to this base URL. Only the scheme "aws+smp" is +// supported. The URL may be an opaque URI (with no leading "/" in the path), in +// which case parameters with names starting with "/" are ignored. If the URL +// path does begin with "/", parameters with names not starting with "/" are +// instead ignored. +// +// To scope the filesystem to a specific path, use that path on the URL. For +// example, for a filesystem that can only read parameters with names starting +// with "/prod/foo/", you would use a URL like: +// +// aws+smp:///prod/foo/ +// +// And for a filesystem that can only read parameters with names starting with +// "prod/bar/", you would use the following opaque URI: +// +// aws+smp:prod/bar/ +// +// Configuration +// +// The AWS Simple Systems Manager client is configured using the default +// credential chain (see https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk/#specifying-credentials +// for more information). +// +// If you require more customized configuration, you can override the default +// client with the WithSSMClientFS function. +// +package awssmpfs diff --git a/awssmpfs/fake_client_test.go b/awssmpfs/fake_client_test.go new file mode 100644 index 00000000..d2c8f9ba --- /dev/null +++ b/awssmpfs/fake_client_test.go @@ -0,0 +1,146 @@ +package awssmpfs + +import ( + "context" + "fmt" + "math/rand" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" +) + +type fakeClient struct { + t *testing.T + params map[string]*testVal + getErr error + listErr error +} + +var _ SimpleSystemsManagerClient = (*fakeClient)(nil) + +func (c *fakeClient) GetParameter(ctx context.Context, params *ssm.GetParameterInput, + optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) { + c.t.Helper() + + if c.getErr != nil { + return nil, c.getErr + } + + name := *params.Name + if val, ok := c.params[name]; ok { + out := ssm.GetParameterOutput{ + Parameter: &types.Parameter{ + Name: params.Name, + Value: &val.s, + }, + } + + return &out, nil + } + + return nil, &types.ParameterNotFound{ + Message: aws.String("Simple Systems Manager can't find the specified parameter."), + } +} + +func (c *fakeClient) GetParametersByPath(ctx context.Context, params *ssm.GetParametersByPathInput, + optFns ...func(*ssm.Options)) (out *ssm.GetParametersByPathOutput, err error) { + c.t.Helper() + + if c.listErr != nil { + return nil, c.listErr + } + + offset := 0 + if params.NextToken != nil { + offset, err = strconv.Atoi(*params.NextToken) + if err != nil { + return nil, fmt.Errorf("invalid nextToken for fakeClient %q: %w", *params.NextToken, err) + } + } + + paramList := []types.Parameter{} + + for k := range c.params { + cond := strings.HasPrefix(k, *params.Path) + + if cond { + paramList = append(paramList, types.Parameter{ + Name: aws.String(k), + }) + } + } + + // sort so pagination works + sort.Slice(paramList, func(i, j int) bool { + return aws.ToString(paramList[i].Name) < aws.ToString(paramList[j].Name) + }) + + if params.MaxResults == 0 { + // default to 2 results so we trigger pagination + params.MaxResults = 2 + } + + l := len(paramList) + m := int(params.MaxResults) + + high := offset + m + + var nextToken *string + + switch { + case high < l: + paramList = paramList[offset:high] + nextToken = aws.String(strconv.Itoa(high)) + case offset < l: + paramList = paramList[offset:] + default: + paramList = nil + } + + // un-sort for a slightly more realistic test + rand.Seed(time.Now().UnixNano()) + rand.Shuffle(len(paramList), func(i, j int) { + paramList[i], paramList[j] = paramList[j], paramList[i] + }) + + return &ssm.GetParametersByPathOutput{ + Parameters: paramList, + NextToken: nextToken, + }, nil +} + +func clientWithValues(t *testing.T, params map[string]*testVal, errs ...error) *fakeClient { + t.Helper() + + c := &fakeClient{t: t, params: params} + + switch len(errs) { + case 1: + c.getErr = errs[0] + case 2: + c.getErr = errs[0] + c.listErr = errs[1] + } + + return c +} + +type testVal struct { + s string + l []string +} + +func vs(s string) *testVal { + return &testVal{s: s} +} + +func vl(l ...string) *testVal { + return &testVal{l: l} +} diff --git a/awssmpfs/types.go b/awssmpfs/types.go new file mode 100644 index 00000000..f5b25434 --- /dev/null +++ b/awssmpfs/types.go @@ -0,0 +1,16 @@ +package awssmpfs + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/service/ssm" +) + +type SimpleSystemsManagerClient interface { + GetParameter(ctx context.Context, + params *ssm.GetParameterInput, + optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) + GetParametersByPath(ctx context.Context, + params *ssm.GetParametersByPathInput, + optFns ...func(*ssm.Options)) (*ssm.GetParametersByPathOutput, error) +} diff --git a/go.mod b/go.mod index 95d30390..b70d385c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2 v1.16.7 github.com/aws/aws-sdk-go-v2/config v1.15.14 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.13 + github.com/aws/aws-sdk-go-v2/service/ssm v1.27.0 github.com/fsouza/fake-gcs-server v1.38.3 github.com/go-git/go-billy/v5 v5.3.1 github.com/go-git/go-git/v5 v5.4.2 diff --git a/go.sum b/go.sum index e0a879a1..3d8e4131 100644 --- a/go.sum +++ b/go.sum @@ -167,6 +167,7 @@ github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4 github.com/aws/aws-sdk-go v1.44.56 h1:bT+lExwagH7djxb6InKUVkEKGPAj5aAPnV85/m1fKro= github.com/aws/aws-sdk-go v1.44.56/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= +github.com/aws/aws-sdk-go-v2 v1.16.3/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= github.com/aws/aws-sdk-go-v2 v1.16.4/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= github.com/aws/aws-sdk-go-v2 v1.16.7 h1:zfBwXus3u14OszRxGcqCDS4MfMCv10e8SMJ2r8Xm0Ns= github.com/aws/aws-sdk-go-v2 v1.16.7/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw= @@ -188,10 +189,12 @@ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.14 h1:qpJmFbypCfwPok5PGTSnQy1NKbv4Hn8xGsee9l4xOPE= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.14/go.mod h1:IOYB+xOZik8YgdTlnDSwbvKmCkikA3nVue8/Qnfzs0c= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.10/go.mod h1:F+EZtuIwjlv35kRJPyBGcsA4f7bnSoz15zOQ2lJq1Z4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.11/go.mod h1:tmUB6jakq5DFNcXsXOA/ZQ7/C8VnSKYkx58OI7Fh79g= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14 h1:2C0pYHcUBmdzPj+EKNC4qj97oK6yjrUhc1KoSodglvk= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14/go.mod h1:kdjrMwHwrC3+FsKhNcCMJ7tUVj/8uSD5CZXeQ4wV6fM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3/go.mod h1:ssOhaLpRlh88H3UmEcsBoVKq309quMvm3Ds8e9d4eJM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.4/go.mod h1:8glyUqVIM4AmeenIsPo0oVh3+NUwnsQml2OFupfQW+0= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.5/go.mod h1:fV1AaS2gFc1tM0RCb015FJ0pvWVUfJZANzjwoO4YakM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8 h1:2J+jdlBJWEmTyAwC82Ym68xCykIvnSnIN18b8xHGlcc= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8/go.mod h1:ZIV8GYoC6WLBW5KGs+o4rsc65/ozd+eQ0L31XF5VDwk= @@ -223,6 +226,8 @@ github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.13/go.mod h1:ByZbrzJwj github.com/aws/aws-sdk-go-v2/service/sns v1.17.4/go.mod h1:kElt+uCcXxcqFyc+bQqZPFD9DME/eC6oHBXvFzQ9Bcw= github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM= github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0= +github.com/aws/aws-sdk-go-v2/service/ssm v1.27.0 h1:RoSBbDIXQfd8QnZ4LF5pjx3k+TYCbouiivt57M7lnVU= +github.com/aws/aws-sdk-go-v2/service/ssm v1.27.0/go.mod h1:xXm3aLL7B/5EyUik6l08kLpkn23Fr0UOi8dND5IhOOg= github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU= github.com/aws/aws-sdk-go-v2/service/sso v1.11.7/go.mod h1:TFVe6Rr2joVLsYQ1ABACXgOC6lXip/qpX2x5jWg/A9w= github.com/aws/aws-sdk-go-v2/service/sso v1.11.12 h1:760bUnTX/+d693FT6T6Oa7PZHfEQT9XMFZeM5IQIB0A=