From f7f2f1b7711a13330bab7618468b2635b1ef3644 Mon Sep 17 00:00:00 2001 From: Todd Date: Mon, 9 Oct 2023 09:47:33 -0700 Subject: [PATCH] Collect resource info in repository_s.go (#3769) * Collect resource info in repository_s.go * Move search logic into the domain (#3794) --- .../cmd/commands/daemon/search_handler.go | 94 +++---- internal/cmd/commands/daemon/server.go | 2 +- internal/cmd/commands/search/search_test.go | 6 +- internal/daemon/cache/repository_refresh.go | 89 +------ .../daemon/cache/repository_refresh_test.go | 82 ++---- internal/daemon/cache/repository_sessions.go | 76 +++++- .../daemon/cache/repository_sessions_test.go | 34 ++- internal/daemon/cache/repository_targets.go | 73 +++++- .../daemon/cache/repository_targets_test.go | 28 ++- internal/daemon/cache/search.go | 196 +++++++++++++++ internal/daemon/cache/search_test.go | 233 ++++++++++++++++++ 11 files changed, 663 insertions(+), 250 deletions(-) create mode 100644 internal/daemon/cache/search.go create mode 100644 internal/daemon/cache/search_test.go diff --git a/internal/cmd/commands/daemon/search_handler.go b/internal/cmd/commands/daemon/search_handler.go index 369aff52f5..e6722b4d87 100644 --- a/internal/cmd/commands/daemon/search_handler.go +++ b/internal/cmd/commands/daemon/search_handler.go @@ -12,7 +12,6 @@ import ( "github.com/hashicorp/boundary/api/sessions" "github.com/hashicorp/boundary/api/targets" "github.com/hashicorp/boundary/internal/daemon/cache" - "github.com/hashicorp/boundary/internal/daemon/controller/handlers" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/util" ) @@ -32,27 +31,31 @@ const ( authTokenIdKey = "auth_token_id" ) -func newSearchTargetsHandlerFunc(ctx context.Context, repo *cache.Repository) (http.HandlerFunc, error) { - const op = "daemon.newSearchTargetsHandlerFunc" +func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository) (http.HandlerFunc, error) { + const op = "daemon.newSearchHandlerFunc" switch { case util.IsNil(repo): return nil, errors.New(ctx, errors.InvalidParameter, op, "repository is missing") } + + s, err := cache.NewSearchService(ctx, repo) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - filter, err := handlers.NewFilter(ctx, r.URL.Query().Get(filterKey)) - if err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - return - } - resource := r.URL.Query().Get(resourceKey) authTokenId := r.URL.Query().Get(authTokenIdKey) + searchableResource := cache.ToSearchableResource(resource) switch { case resource == "": writeError(w, "resource is a required field but was empty", http.StatusBadRequest) return + case !searchableResource.Valid(): + writeError(w, "provided resource is not a valid searchable resource", http.StatusBadRequest) + return case authTokenId == "": writeError(w, fmt.Sprintf("%s is a required field but was empty", authTokenIdKey), http.StatusBadRequest) return @@ -65,18 +68,14 @@ func newSearchTargetsHandlerFunc(ctx context.Context, repo *cache.Repository) (h } query := r.URL.Query().Get(queryKey) - - var res *SearchResult - switch resource { - case "targets": - res, err = searchTargets(r.Context(), repo, authTokenId, query, filter) - case "sessions": - res, err = searchSessions(r.Context(), repo, authTokenId, query, filter) - default: - writeError(w, fmt.Sprintf("search doesn't support %q resource", resource), http.StatusBadRequest) - return - } - + filter := r.URL.Query().Get(filterKey) + + res, err := s.Search(ctx, cache.SearchParams{ + AuthTokenId: authTokenId, + Resource: searchableResource, + Query: query, + Filter: filter, + }) if err != nil { switch { case errors.Match(errors.T(errors.InvalidParameter), err): @@ -84,12 +83,15 @@ func newSearchTargetsHandlerFunc(ctx context.Context, repo *cache.Repository) (h default: writeError(w, err.Error(), http.StatusInternalServerError) } + return } if res == nil { writeError(w, "nil SearchResult generated", http.StatusInternalServerError) + return } - j, err := json.Marshal(res) + apiRes := toApiResult(res) + j, err := json.Marshal(apiRes) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -99,50 +101,10 @@ func newSearchTargetsHandlerFunc(ctx context.Context, repo *cache.Repository) (h }, nil } -func searchTargets(ctx context.Context, repo *cache.Repository, authTokenId, query string, filter *handlers.Filter) (*SearchResult, error) { - var found []*targets.Target - var err error - switch query { - case "": - found, err = repo.ListTargets(ctx, authTokenId) - default: - found, err = repo.QueryTargets(ctx, authTokenId, query) - } - if err != nil { - return nil, err - } - - finalTars := make([]*targets.Target, 0, len(found)) - for _, item := range found { - if filter.Match(item) { - finalTars = append(finalTars, item) - } - } +// toApiResult converts a domain search result to an api search result +func toApiResult(sr *cache.SearchResult) *SearchResult { return &SearchResult{ - Targets: finalTars, - }, nil -} - -func searchSessions(ctx context.Context, repo *cache.Repository, authTokenId, query string, filter *handlers.Filter) (*SearchResult, error) { - var found []*sessions.Session - var err error - switch query { - case "": - found, err = repo.ListSessions(ctx, authTokenId) - default: - found, err = repo.QuerySessions(ctx, authTokenId, query) + Targets: sr.Targets, + Sessions: sr.Sessions, } - if err != nil { - return nil, err - } - - finalSess := make([]*sessions.Session, 0, len(found)) - for _, item := range found { - if filter.Match(item) { - finalSess = append(finalSess, item) - } - } - return &SearchResult{ - Sessions: finalSess, - }, nil } diff --git a/internal/cmd/commands/daemon/server.go b/internal/cmd/commands/daemon/server.go index 18f5cf854e..4c7113e4c1 100644 --- a/internal/cmd/commands/daemon/server.go +++ b/internal/cmd/commands/daemon/server.go @@ -232,7 +232,7 @@ func (s *cacheServer) serve(ctx context.Context, cmd Commander, l net.Listener, }() mux := http.NewServeMux() - searchTargetsFn, err := newSearchTargetsHandlerFunc(ctx, repo) + searchTargetsFn, err := newSearchHandlerFunc(ctx, repo) if err != nil { return errors.Wrap(ctx, err, op) } diff --git a/internal/cmd/commands/search/search_test.go b/internal/cmd/commands/search/search_test.go index 541b07167c..497388fdb8 100644 --- a/internal/cmd/commands/search/search_test.go +++ b/internal/cmd/commands/search/search_test.go @@ -93,7 +93,7 @@ func TestSearch(t *testing.T) { flagQuery: "name=name", resource: "hosts", }, - apiErrContains: "doesn't support \"hosts\" resource", + apiErrContains: "provided resource is not a valid searchable resource", }, { name: "unknown auth token id", @@ -105,10 +105,10 @@ func TestSearch(t *testing.T) { apiErrContains: "Forbidden", }, { - name: "query on unsupported column", + name: "unsupported column", fb: filterBy{ authTokenId: at.Id, - flagQuery: "item % tar", + flagQuery: "item % 'tar'", resource: "targets", }, apiErrContains: "invalid column \"item\"", diff --git a/internal/daemon/cache/repository_refresh.go b/internal/daemon/cache/repository_refresh.go index f86d7f50b7..c8447e5971 100644 --- a/internal/daemon/cache/repository_refresh.go +++ b/internal/daemon/cache/repository_refresh.go @@ -6,59 +6,14 @@ package cache import ( "context" stderrors "errors" - "fmt" "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/authtokens" - "github.com/hashicorp/boundary/api/sessions" - "github.com/hashicorp/boundary/api/targets" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/event" "github.com/hashicorp/boundary/internal/util" ) -// TargetRetrievalFunc is a function that retrieves targets -// from the provided boundary addr using the provided token. -type TargetRetrievalFunc func(ctx context.Context, addr, token string) ([]*targets.Target, error) - -func defaultTargetFunc(ctx context.Context, addr, token string) ([]*targets.Target, error) { - const op = "cache.defaultTargetFunc" - client, err := api.NewClient(&api.Config{ - Addr: addr, - Token: token, - }) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - tarClient := targets.NewClient(client) - l, err := tarClient.List(ctx, "global", targets.WithRecursive(true)) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - return l.Items, nil -} - -// SessionRetrievalFunc is a function that retrieves sessions -// from the provided boundary addr using the provided token. -type SessionRetrievalFunc func(ctx context.Context, addr, token string) ([]*sessions.Session, error) - -func defaultSessionFunc(ctx context.Context, addr, token string) ([]*sessions.Session, error) { - const op = "cache.defaultSessionFunc" - client, err := api.NewClient(&api.Config{ - Addr: addr, - Token: token, - }) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - sClient := sessions.NewClient(client) - l, err := sClient.List(ctx, "global", sessions.WithRecursive(true)) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - return l.Items, nil -} - // cleanAndPickAuthTokens removes from the cache all auth tokens which are // evicted from the cache or no longer stored in a keyring and returns the // remaining ones. @@ -139,22 +94,10 @@ func (r *Repository) Refresh(ctx context.Context, opt ...Option) error { return errors.Wrap(ctx, err, op) } - opts, err := getOpts(opt...) - if err != nil { - return errors.Wrap(ctx, err, op) - } - us, err := r.listUsers(ctx) if err != nil { return errors.Wrap(ctx, err, op) } - if opts.withTargetRetrievalFunc == nil { - opts.withTargetRetrievalFunc = defaultTargetFunc - } - if opts.withSessionRetrievalFunc == nil { - opts.withSessionRetrievalFunc = defaultSessionFunc - } - var retErr error for _, u := range us { tokens, err := r.cleanAndPickAuthTokens(ctx, u) @@ -163,35 +106,13 @@ func (r *Repository) Refresh(ctx context.Context, opt ...Option) error { continue } - // Find and use a token for retrieving targets - for at, t := range tokens { - resp, err := opts.withTargetRetrievalFunc(ctx, u.Address, t) - if err != nil { - retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for token %q", at.Id))) - continue - } - - event.WriteSysEvent(ctx, op, fmt.Sprintf("updating %d targets for user %v", len(resp), u)) - if err := r.refreshTargets(ctx, u, resp); err != nil { - retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for user %v", u))) - } - break + if err := r.refreshTargets(ctx, u, tokens, opt...); err != nil { + retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op)) } - - // Find and use a token for retrieving sessions - for at, t := range tokens { - resp, err := opts.withSessionRetrievalFunc(ctx, u.Address, t) - if err != nil { - retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for token %q", at.Id))) - continue - } - - event.WriteSysEvent(ctx, op, fmt.Sprintf("updating %d sessions for user %v", len(resp), u)) - if err := r.refreshSessions(ctx, u, resp); err != nil { - retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for user %v", u))) - } - break + if err := r.refreshSessions(ctx, u, tokens, opt...); err != nil { + retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op)) } + } return retErr } diff --git a/internal/daemon/cache/repository_refresh_test.go b/internal/daemon/cache/repository_refresh_test.go index 71b40c3911..47aec60b3b 100644 --- a/internal/daemon/cache/repository_refresh_test.go +++ b/internal/daemon/cache/repository_refresh_test.go @@ -16,19 +16,19 @@ import ( "github.com/hashicorp/boundary/api/authtokens" "github.com/hashicorp/boundary/api/sessions" "github.com/hashicorp/boundary/api/targets" - "github.com/hashicorp/boundary/internal/daemon/controller" - "github.com/hashicorp/boundary/internal/daemon/worker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/exp/maps" - - _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/targets/tcp" ) -// noopRetrievalFn is a function that satisfies the Refresh's With*RetrievalFn -// and returns nil, nil always -func noopRetrievalFn[T any](context.Context, string, string) ([]T, error) { - return nil, nil +// testStaticResourceRetrievalFunc returns a function that always returns the +// provided slice and a nil error. The returned function can be passed into the +// options that provide a resource retrieval func such as +// WithTargetRetrievalFunc and WithSessionRetrievalFunc. +func testStaticResourceRetrievalFunc[T any](ret []T) func(context.Context, string, string) ([]T, error) { + return func(ctx context.Context, s1, s2 string) ([]T, error) { + return ret, nil + } } func TestCleanAndPickTokens(t *testing.T) { @@ -295,7 +295,7 @@ func TestRefresh(t *testing.T) { target("3"), } assert.NoError(t, r.Refresh(ctx, - WithSessionRetrievalFunc(noopRetrievalFn[*sessions.Session]), + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)), WithTargetRetrievalFunc(func(ctx context.Context, addr, token string) ([]*targets.Target, error) { require.Equal(t, boundaryAddr, addr) require.Equal(t, at.Token, token) @@ -308,7 +308,7 @@ func TestRefresh(t *testing.T) { t.Run("empty response clears it out", func(t *testing.T) { assert.NoError(t, r.Refresh(ctx, - WithSessionRetrievalFunc(noopRetrievalFn[*sessions.Session]), + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)), WithTargetRetrievalFunc(func(ctx context.Context, addr, token string) ([]*targets.Target, error) { require.Equal(t, boundaryAddr, addr) require.Equal(t, at.Token, token) @@ -328,12 +328,8 @@ func TestRefresh(t *testing.T) { session("3"), } assert.NoError(t, r.Refresh(ctx, - WithTargetRetrievalFunc(noopRetrievalFn[*targets.Target]), - WithSessionRetrievalFunc(func(ctx context.Context, addr, token string) ([]*sessions.Session, error) { - require.Equal(t, boundaryAddr, addr) - require.Equal(t, at.Token, token) - return retSess, nil - }))) + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)), + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(retSess)))) cachedSessions, err := r.ListSessions(ctx, at.Id) assert.NoError(t, err) @@ -341,12 +337,8 @@ func TestRefresh(t *testing.T) { t.Run("empty response clears it out", func(t *testing.T) { assert.NoError(t, r.Refresh(ctx, - WithTargetRetrievalFunc(noopRetrievalFn[*targets.Target]), - WithSessionRetrievalFunc(func(ctx context.Context, addr, token string) ([]*sessions.Session, error) { - require.Equal(t, boundaryAddr, addr) - require.Equal(t, at.Token, token) - return nil, nil - }))) + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)), + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)))) cachedTargets, err := r.ListSessions(ctx, at.Id) assert.NoError(t, err) @@ -357,7 +349,7 @@ func TestRefresh(t *testing.T) { t.Run("error propogates up", func(t *testing.T) { innerErr := stdErrors.New("test error") err := r.Refresh(ctx, - WithSessionRetrievalFunc(noopRetrievalFn[*sessions.Session]), + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)), WithTargetRetrievalFunc(func(ctx context.Context, addr, token string) ([]*targets.Target, error) { require.Equal(t, boundaryAddr, addr) require.Equal(t, at.Token, token) @@ -365,7 +357,7 @@ func TestRefresh(t *testing.T) { })) assert.ErrorContains(t, err, innerErr.Error()) err = r.Refresh(ctx, - WithTargetRetrievalFunc(noopRetrievalFn[*targets.Target]), + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)), WithSessionRetrievalFunc(func(ctx context.Context, addr, token string) ([]*sessions.Session, error) { require.Equal(t, boundaryAddr, addr) require.Equal(t, at.Token, token) @@ -389,8 +381,8 @@ func TestRefresh(t *testing.T) { assert.Len(t, us, 1) r.Refresh(ctx, - WithSessionRetrievalFunc(noopRetrievalFn[*sessions.Session]), - WithTargetRetrievalFunc(noopRetrievalFn[*targets.Target])) + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)), + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil))) ps, err = r.listTokens(ctx, u) require.NoError(t, err) @@ -403,44 +395,6 @@ func TestRefresh(t *testing.T) { }) } -func TestDefaultTargetRetrievalFunc(t *testing.T) { - tc := controller.NewTestController(t, nil) - tc.Client().SetToken(tc.Token().Token) - tarClient := targets.NewClient(tc.Client()) - - tar1, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar1"), targets.WithTcpTargetDefaultPort(1)) - require.NoError(t, err) - require.NotNil(t, tar1) - tar2, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar2"), targets.WithTcpTargetDefaultPort(2)) - require.NoError(t, err) - require.NotNil(t, tar2) - - got, err := defaultTargetFunc(tc.Context(), tc.ApiAddrs()[0], tc.Token().Token) - assert.NoError(t, err) - assert.Contains(t, got, tar1.Item) - assert.Contains(t, got, tar2.Item) -} - -func TestDefaultSessionRetrievalFunc(t *testing.T) { - tc := controller.NewTestController(t, nil) - tc.Client().SetToken(tc.Token().Token) - tarClient := targets.NewClient(tc.Client()) - _ = worker.NewTestWorker(t, &worker.TestWorkerOpts{ - InitialUpstreams: tc.ClusterAddrs(), - WorkerAuthKms: tc.Config().WorkerAuthKms, - }) - - tar1, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar1"), targets.WithTcpTargetDefaultPort(1), targets.WithAddress("address")) - require.NoError(t, err) - require.NotNil(t, tar1) - _, err = tarClient.AuthorizeSession(tc.Context(), tar1.Item.Id) - assert.NoError(t, err) - - got, err := defaultSessionFunc(tc.Context(), tc.ApiAddrs()[0], tc.Token().Token) - assert.NoError(t, err) - assert.Len(t, got, 1) -} - func target(suffix string) *targets.Target { return &targets.Target{ Id: fmt.Sprintf("target_%s", suffix), diff --git a/internal/daemon/cache/repository_sessions.go b/internal/daemon/cache/repository_sessions.go index 58d969c11c..1b3b13ab06 100644 --- a/internal/daemon/cache/repository_sessions.go +++ b/internal/daemon/cache/repository_sessions.go @@ -7,46 +7,99 @@ import ( "context" "database/sql" "encoding/json" - stdErrors "errors" + stderrors "errors" "fmt" + "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/sessions" "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/event" "github.com/hashicorp/boundary/internal/types/resource" "github.com/hashicorp/boundary/internal/util" "github.com/hashicorp/mql" ) -func (r *Repository) refreshSessions(ctx context.Context, u *user, sessions []*sessions.Session) error { +// SessionRetrievalFunc is a function that retrieves sessions +// from the provided boundary addr using the provided token. +type SessionRetrievalFunc func(ctx context.Context, addr, token string) ([]*sessions.Session, error) + +func defaultSessionFunc(ctx context.Context, addr, token string) ([]*sessions.Session, error) { + const op = "cache.defaultSessionFunc" + client, err := api.NewClient(&api.Config{ + Addr: addr, + Token: token, + }) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + sClient := sessions.NewClient(client) + l, err := sClient.List(ctx, "global", sessions.WithRecursive(true)) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return l.Items, nil +} + +func (r *Repository) refreshSessions(ctx context.Context, u *user, tokens map[AuthToken]string, opt ...Option) error { const op = "cache.(Repository).refreshSessions" switch { case util.IsNil(u): return errors.New(ctx, errors.InvalidParameter, op, "user is nil") case u.Id == "": return errors.New(ctx, errors.InvalidParameter, op, "user id is missing") + case u.Address == "": + return errors.New(ctx, errors.InvalidParameter, op, "user boundary address is missing") } - foundU := u.clone() - if err := r.rw.LookupById(ctx, foundU); err != nil { - // if this user isn't known, error out. - return errors.Wrap(ctx, err, op, errors.WithMsg("looking up user")) + opts, err := getOpts(opt...) + if err != nil { + return errors.Wrap(ctx, err, op) + } + if opts.withSessionRetrievalFunc == nil { + opts.withSessionRetrievalFunc = defaultSessionFunc + } + + // Find and use a token for retrieving sessions + var gotResponse bool + var resp []*sessions.Session + var retErr error + for at, t := range tokens { + resp, err = opts.withSessionRetrievalFunc(ctx, u.Address, t) + if err != nil { + // TODO: If we get an error about the token no longer having + // permissions, remove it. + retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for token %q", at.Id))) + continue + } + gotResponse = true + break } - _, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { + if retErr != nil { + if saveErr := r.SaveError(ctx, u, resource.Session.String(), retErr); saveErr != nil { + return stderrors.Join(err, errors.Wrap(ctx, saveErr, op)) + } + } + if !gotResponse { + return retErr + } + + event.WriteSysEvent(ctx, op, fmt.Sprintf("updating %d sessions for user %v", len(resp), u)) + _, err = r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { // TODO: Instead of deleting everything, use refresh tokens and apply the delta if _, err := w.Exec(ctx, "delete from session where user_id = @user_id", - []any{sql.Named("user_id", foundU.Id)}); err != nil { + []any{sql.Named("user_id", u.Id)}); err != nil { return err } - for _, s := range sessions { + for _, s := range resp { item, err := json.Marshal(s) if err != nil { return err } newSession := &Session{ - UserId: foundU.Id, + UserId: u.Id, Id: s.Id, Type: s.Type, Status: s.Status, @@ -64,9 +117,6 @@ func (r *Repository) refreshSessions(ctx context.Context, u *user, sessions []*s return nil }) if err != nil { - if saveErr := r.SaveError(ctx, u, resource.Session.String(), err); saveErr != nil { - return stdErrors.Join(err, errors.Wrap(ctx, saveErr, op)) - } return errors.Wrap(ctx, err, op) } return nil diff --git a/internal/daemon/cache/repository_sessions_test.go b/internal/daemon/cache/repository_sessions_test.go index af3755c200..f192c78791 100644 --- a/internal/daemon/cache/repository_sessions_test.go +++ b/internal/daemon/cache/repository_sessions_test.go @@ -10,10 +10,15 @@ import ( "github.com/hashicorp/boundary/api/authtokens" "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/daemon/controller" + "github.com/hashicorp/boundary/internal/daemon/worker" "github.com/hashicorp/boundary/internal/db" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/exp/maps" + + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/targets/tcp" ) func TestRepository_refreshSessions(t *testing.T) { @@ -109,7 +114,8 @@ func TestRepository_refreshSessions(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := r.refreshSessions(ctx, tc.u, tc.sess) + err := r.refreshSessions(ctx, tc.u, map[AuthToken]string{{Id: "id"}: "something"}, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(tc.sess))) if tc.errorContains == "" { assert.NoError(t, err) rw := db.New(s.conn) @@ -192,7 +198,8 @@ func TestRepository_ListSessions(t *testing.T) { Type: "tcp", }, } - require.NoError(t, r.refreshSessions(ctx, u1, ss)) + require.NoError(t, r.refreshSessions(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(ss)))) t.Run("wrong user gets no sessions", func(t *testing.T) { l, err := r.ListSessions(ctx, kt2.AuthTokenId) @@ -298,7 +305,8 @@ func TestRepository_QuerySessions(t *testing.T) { Type: "tcp", }, } - require.NoError(t, r.refreshSessions(ctx, u1, ss)) + require.NoError(t, r.refreshSessions(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(ss)))) t.Run("wrong token gets no sessions", func(t *testing.T) { l, err := r.QuerySessions(ctx, kt2.AuthTokenId, query) @@ -312,3 +320,23 @@ func TestRepository_QuerySessions(t *testing.T) { assert.ElementsMatch(t, l, ss[0:2]) }) } + +func TestDefaultSessionRetrievalFunc(t *testing.T) { + tc := controller.NewTestController(t, nil) + tc.Client().SetToken(tc.Token().Token) + tarClient := targets.NewClient(tc.Client()) + _ = worker.NewTestWorker(t, &worker.TestWorkerOpts{ + InitialUpstreams: tc.ClusterAddrs(), + WorkerAuthKms: tc.Config().WorkerAuthKms, + }) + + tar1, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar1"), targets.WithTcpTargetDefaultPort(1), targets.WithAddress("address")) + require.NoError(t, err) + require.NotNil(t, tar1) + _, err = tarClient.AuthorizeSession(tc.Context(), tar1.Item.Id) + assert.NoError(t, err) + + got, err := defaultSessionFunc(tc.Context(), tc.ApiAddrs()[0], tc.Token().Token) + assert.NoError(t, err) + assert.Len(t, got, 1) +} diff --git a/internal/daemon/cache/repository_targets.go b/internal/daemon/cache/repository_targets.go index fc0331f7b5..df0693ee3f 100644 --- a/internal/daemon/cache/repository_targets.go +++ b/internal/daemon/cache/repository_targets.go @@ -7,18 +7,41 @@ import ( "context" "database/sql" "encoding/json" - stdErrors "errors" + stderrors "errors" "fmt" + "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/targets" "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/event" "github.com/hashicorp/boundary/internal/types/resource" "github.com/hashicorp/boundary/internal/util" "github.com/hashicorp/mql" ) -func (r *Repository) refreshTargets(ctx context.Context, u *user, targets []*targets.Target) error { +// TargetRetrievalFunc is a function that retrieves targets +// from the provided boundary addr using the provided token. +type TargetRetrievalFunc func(ctx context.Context, addr, token string) ([]*targets.Target, error) + +func defaultTargetFunc(ctx context.Context, addr, token string) ([]*targets.Target, error) { + const op = "cache.defaultTargetFunc" + client, err := api.NewClient(&api.Config{ + Addr: addr, + Token: token, + }) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + tarClient := targets.NewClient(client) + l, err := tarClient.List(ctx, "global", targets.WithRecursive(true)) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return l.Items, nil +} + +func (r *Repository) refreshTargets(ctx context.Context, u *user, tokens map[AuthToken]string, opt ...Option) error { const op = "cache.(Repository).refreshTargets" switch { case util.IsNil(u): @@ -27,26 +50,53 @@ func (r *Repository) refreshTargets(ctx context.Context, u *user, targets []*tar return errors.New(ctx, errors.InvalidParameter, op, "user id is missing") } - foundU := u.clone() - if err := r.rw.LookupById(ctx, foundU); err != nil { - // if this user isn't known, error out. - return errors.Wrap(ctx, err, op, errors.WithMsg("looking up user")) + opts, err := getOpts(opt...) + if err != nil { + return errors.Wrap(ctx, err, op) + } + if opts.withTargetRetrievalFunc == nil { + opts.withTargetRetrievalFunc = defaultTargetFunc + } + + // Find and use a token for retrieving targets + var gotResponse bool + var resp []*targets.Target + var retErr error + for at, t := range tokens { + resp, err = opts.withTargetRetrievalFunc(ctx, u.Address, t) + if err != nil { + // TODO: If we get an error about the token no longer having + // permissions, remove it. + retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for token %q", at.Id))) + continue + } + gotResponse = true + break + } + if retErr != nil { + if saveErr := r.SaveError(ctx, u, resource.Target.String(), retErr); saveErr != nil { + return stderrors.Join(err, errors.Wrap(ctx, saveErr, op)) + } + } + if !gotResponse { + return retErr } - _, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { + event.WriteSysEvent(ctx, op, fmt.Sprintf("updating %d targets for user %v", len(resp), u)) + _, err = r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { // TODO: Instead of deleting everything, use refresh tokens and apply the delta if _, err := w.Exec(ctx, "delete from target where user_id = @user_id", - []any{sql.Named("user_id", foundU.Id)}); err != nil { + []any{sql.Named("user_id", u.Id)}); err != nil { return err } - for _, t := range targets { + for _, t := range resp { item, err := json.Marshal(t) if err != nil { return err } newTarget := &Target{ - UserId: foundU.Id, + UserId: u.Id, Id: t.Id, Name: t.Name, Description: t.Description, @@ -64,9 +114,6 @@ func (r *Repository) refreshTargets(ctx context.Context, u *user, targets []*tar return nil }) if err != nil { - if saveErr := r.SaveError(ctx, u, resource.Target.String(), err); saveErr != nil { - return stdErrors.Join(err, errors.Wrap(ctx, saveErr, op)) - } return errors.Wrap(ctx, err, op) } return nil diff --git a/internal/daemon/cache/repository_targets_test.go b/internal/daemon/cache/repository_targets_test.go index 5063f0488c..5539553f75 100644 --- a/internal/daemon/cache/repository_targets_test.go +++ b/internal/daemon/cache/repository_targets_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/boundary/api/authtokens" "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/daemon/controller" "github.com/hashicorp/boundary/internal/db" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -107,7 +108,8 @@ func TestRepository_refreshTargets(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := r.refreshTargets(ctx, tc.u, tc.targets) + err := r.refreshTargets(ctx, tc.u, map[AuthToken]string{{Id: "id"}: "something"}, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(tc.targets))) if tc.errorContains == "" { assert.NoError(t, err) rw := db.New(s.conn) @@ -186,7 +188,8 @@ func TestRepository_ListTargets(t *testing.T) { SessionMaxSeconds: 333, }, } - require.NoError(t, r.refreshTargets(ctx, u1, ts)) + require.NoError(t, r.refreshTargets(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(ts)))) t.Run("wrong user gets no targets", func(t *testing.T) { l, err := r.ListTargets(ctx, kt2.AuthTokenId) @@ -289,7 +292,8 @@ func TestRepository_QueryTargets(t *testing.T) { SessionMaxSeconds: 333, }, } - require.NoError(t, r.refreshTargets(ctx, u1, ts)) + require.NoError(t, r.refreshTargets(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(ts)))) t.Run("wrong token gets no targets", func(t *testing.T) { l, err := r.QueryTargets(ctx, kt2.AuthTokenId, query) @@ -303,3 +307,21 @@ func TestRepository_QueryTargets(t *testing.T) { assert.ElementsMatch(t, l, ts[0:2]) }) } + +func TestDefaultTargetRetrievalFunc(t *testing.T) { + tc := controller.NewTestController(t, nil) + tc.Client().SetToken(tc.Token().Token) + tarClient := targets.NewClient(tc.Client()) + + tar1, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar1"), targets.WithTcpTargetDefaultPort(1)) + require.NoError(t, err) + require.NotNil(t, tar1) + tar2, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar2"), targets.WithTcpTargetDefaultPort(2)) + require.NoError(t, err) + require.NotNil(t, tar2) + + got, err := defaultTargetFunc(tc.Context(), tc.ApiAddrs()[0], tc.Token().Token) + assert.NoError(t, err) + assert.Contains(t, got, tar1.Item) + assert.Contains(t, got, tar2.Item) +} diff --git a/internal/daemon/cache/search.go b/internal/daemon/cache/search.go new file mode 100644 index 0000000000..3d8bf47ebb --- /dev/null +++ b/internal/daemon/cache/search.go @@ -0,0 +1,196 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/go-bexpr" +) + +type SearchableResource string + +const ( + Unknown SearchableResource = "unknown" + Targets SearchableResource = "targets" + Sessions SearchableResource = "sessions" +) + +func (r SearchableResource) Valid() bool { + switch r { + case Targets, Sessions: + return true + } + return false +} + +func ToSearchableResource(s string) SearchableResource { + switch { + case strings.EqualFold(s, string(Targets)): + return Targets + case strings.EqualFold(s, string(Sessions)): + return Sessions + } + return Unknown +} + +// SearchParams contains the parameters for searching in the cache +type SearchParams struct { + // the name of the resource. eg. "targets" or "sessions" + Resource SearchableResource + // the auth token id for the user id that has resources synced to the cache + AuthTokenId string + // the optional mql query to use when searching the resources. + Query string + // the optional bexpr filter string that all results will be filtered by + Filter string +} + +// SearchResult returns the results from searching the cache. +type SearchResult struct { + Targets []*targets.Target + Sessions []*sessions.Session +} + +// SearchService is a domain service that can search across all resources in the +// cache. +type SearchService struct { + searchableResources map[SearchableResource]resourceSearcher +} + +// QueryAndLister defines the methods needed for listing an querying the +// resources supported by the boundary frontend. +type QueryAndLister interface { + QueryTargets(context.Context, string, string) ([]*targets.Target, error) + ListTargets(context.Context, string) ([]*targets.Target, error) + QuerySessions(context.Context, string, string) ([]*sessions.Session, error) + ListSessions(context.Context, string) ([]*sessions.Session, error) +} + +func NewSearchService(ctx context.Context, repo QueryAndLister) (*SearchService, error) { + const op = "cache.NewSearchService" + switch { + case util.IsNil(repo): + return nil, errors.New(ctx, errors.InvalidParameter, op, "repo is nil") + } + return &SearchService{ + searchableResources: map[SearchableResource]resourceSearcher{ + Targets: &resourceSearchFns[*targets.Target]{ + list: repo.ListTargets, + query: repo.QueryTargets, + searchResult: func(t []*targets.Target) *SearchResult { + return &SearchResult{Targets: t} + }, + }, + Sessions: &resourceSearchFns[*sessions.Session]{ + list: repo.ListSessions, + query: repo.QuerySessions, + searchResult: func(s []*sessions.Session) *SearchResult { + return &SearchResult{Sessions: s} + }, + }, + }, + }, nil +} + +// Search returns a SearchResult based on the provided SearchParams. If the +// SearchParams doesn't have a valid searchable resource or an auth token id +// an error is returned. If the auth token id is unrecognized or is associated +// with a user id which doesn't have any resources associated with it an empty +// SearchResult is returned. SearchResult will only have at most one field +// populated. +func (s *SearchService) Search(ctx context.Context, params SearchParams) (*SearchResult, error) { + const op = "cache.(SearchService).Search" + switch { + case !params.Resource.Valid(): + return nil, errors.New(ctx, errors.InvalidParameter, op, "invalid resource") + case params.AuthTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth token id") + } + rSearcher, ok := s.searchableResources[params.Resource] + if !ok { + return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("resource name %q is not recognized", params.Resource)) + } + resp, err := rSearcher.search(ctx, params) + if err != nil { + err = errors.Wrap(ctx, err, op) + } + return resp, err +} + +// resourceSearchFns is a struct that collects all the functions needed to +// perform a search on a specific resource type. +type resourceSearchFns[T any] struct { + // list takes a context and an auth token and returns all resources for the + // user of that auth token. If the provided auth token is not in the cache + // an empty slice and no error is returned. + list func(context.Context, string) ([]T, error) + // query takes a context, an auth token, and a query string and returns all + // resources for that auth token that matches the provided query parameter. + // If the provided auth token is not in the cache an empty slice and no + // error is returned. + query func(context.Context, string, string) ([]T, error) + // searchResult is a function which provides a SearchResult based on the + // type of T. SearchResult contains different fields for the different + // resource types returned, so for example if T is *targets.Target the + // returned SearchResult will have it's "Targets" field populated so the + // searchResult should take the passed in paramater and assign it to the + // appropriate field in the SearchResult. + searchResult func([]T) *SearchResult +} + +// resourceSearcher is an interface that only resourceSearchFns[T] is expected +// to satisfy. Specifying this interface allows the code to have a map with +// resourceSearchFns values which have different bound generic types. +type resourceSearcher interface { + search(ctx context.Context, p SearchParams) (*SearchResult, error) +} + +// search will perform a query using the provided query string or a list if the +// provided query string is empty and filter than based on the provided filter. +// The results are tied to the user id associated with the provided auth token id. +// If the auth token id or the associated user are not in the cache no error +// is returned and the returned SearchResults will be empty. +// search implements searcher. +func (l *resourceSearchFns[T]) search(ctx context.Context, p SearchParams) (*SearchResult, error) { + const op = "daemon.(resourceSearchFns).search" + + var found []T + var err error + switch p.Query { + case "": + found, err = l.list(ctx, p.AuthTokenId) + default: + found, err = l.query(ctx, p.AuthTokenId, p.Query) + } + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + if p.Filter == "" { + return l.searchResult(found), nil + } + + e, err := bexpr.CreateEvaluator(p.Filter, bexpr.WithTagName("json")) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("couldn't build filter"), errors.WithCode(errors.InvalidParameter)) + } + finalResults := make([]T, 0, len(found)) + for _, item := range found { + if m, err := e.Evaluate(filterItem{item}); err == nil && m { + finalResults = append(finalResults, item) + } + } + return l.searchResult(finalResults), nil +} + +type filterItem struct { + Item any `json:"item"` +} diff --git a/internal/daemon/cache/search_test.go b/internal/daemon/cache/search_test.go new file mode 100644 index 0000000000..7465b244c6 --- /dev/null +++ b/internal/daemon/cache/search_test.go @@ -0,0 +1,233 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "sync" + "testing" + + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSearchService(t *testing.T) { + ctx := context.Background() + + t.Run("nil repo", func(t *testing.T) { + ss, err := NewSearchService(ctx, nil) + assert.Error(t, err) + assert.ErrorContains(t, err, "repo is nil") + assert.Nil(t, ss) + }) + + t.Run("success", func(t *testing.T) { + s, err := Open(ctx) + require.NoError(t, err) + r, err := NewRepository(ctx, s, &sync.Map{}, + mapBasedAuthTokenKeyringLookup(nil), + sliceBasedAuthTokenBoundaryReader(nil)) + assert.NoError(t, err) + + ss, err := NewSearchService(ctx, r) + assert.NoError(t, err) + assert.NotNil(t, ss) + }) +} + +func TestSearch_Errors(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx) + require.NoError(t, err) + r, err := NewRepository(ctx, s, &sync.Map{}, + mapBasedAuthTokenKeyringLookup(nil), + sliceBasedAuthTokenBoundaryReader(nil)) + assert.NoError(t, err) + + ss, err := NewSearchService(ctx, r) + require.NoError(t, err) + require.NotNil(t, ss) + + cases := []struct { + name string + params SearchParams + errorContains string + }{ + { + name: "missing resource", + params: SearchParams{ + Resource: "", + AuthTokenId: "at_1", + }, + errorContains: "invalid resource", + }, + { + name: "missing auth token id", + params: SearchParams{ + Resource: "targets", + AuthTokenId: "", + }, + errorContains: "missing auth token id", + }, + { + name: "unrecognized resource", + params: SearchParams{ + Resource: "unknown", + AuthTokenId: "at_1", + }, + errorContains: "invalid resource", + }, + { + name: "bad filter", + params: SearchParams{ + Resource: "targets", + AuthTokenId: "at_1", + Filter: "unknown=filter?syntax!", + }, + errorContains: "couldn't build filter", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := ss.Search(ctx, tc.params) + assert.Error(t, err) + assert.ErrorContains(t, err, tc.errorContains) + assert.Nil(t, res) + }) + } +} + +func TestSearch(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx) + require.NoError(t, err) + + at := &AuthToken{ + Id: "at_1", + UserId: "u_1", + } + { + u := &user{Id: at.UserId, Address: "address"} + rw := db.New(s.conn) + require.NoError(t, rw.Create(ctx, u)) + require.NoError(t, rw.Create(ctx, at)) + + targets := []any{ + &Target{UserId: u.Id, Id: "t_1", Name: "one", Item: `{"id": "t_1", "name": "one"}`}, + &Target{UserId: u.Id, Id: "t_2", Name: "two", Item: `{"id": "t_2", "name": "two"}`}, + } + require.NoError(t, rw.CreateItems(ctx, targets)) + + sessions := []any{ + &Session{UserId: u.Id, Id: "s_1", Endpoint: "one", Item: `{"id": "s_1", "endpoint": "one"}`}, + &Session{UserId: u.Id, Id: "s_2", Endpoint: "two", Item: `{"id": "s_2", "endpoint": "two"}`}, + } + require.NoError(t, rw.CreateItems(ctx, sessions)) + } + + r, err := NewRepository(ctx, s, &sync.Map{}, + mapBasedAuthTokenKeyringLookup(nil), + sliceBasedAuthTokenBoundaryReader(nil)) + assert.NoError(t, err) + + ss, err := NewSearchService(ctx, r) + require.NoError(t, err) + require.NotNil(t, ss) + + t.Run("List targets", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: at.Id, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Targets: []*targets.Target{ + {Id: "t_1", Name: "one"}, + {Id: "t_2", Name: "two"}, + }}, got) + }) + + t.Run("query targets", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: at.Id, + Query: `name="one"`, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Targets: []*targets.Target{ + {Id: "t_1", Name: "one"}, + }}, got) + }) + + t.Run("query targets bad column", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: at.Id, + Query: `item % "one"`, + }) + assert.Error(t, err) + assert.ErrorContains(t, err, `invalid column "item"`) + assert.Nil(t, got) + }) + + t.Run("Filter targets", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: at.Id, + Filter: `"/item/name" matches "one"`, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Targets: []*targets.Target{ + {Id: "t_1", Name: "one"}, + }}, got) + }) + + t.Run("List sessions", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "sessions", + AuthTokenId: at.Id, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Sessions: []*sessions.Session{ + {Id: "s_1", Endpoint: "one"}, + {Id: "s_2", Endpoint: "two"}, + }}, got) + }) + + t.Run("query sessions", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "sessions", + AuthTokenId: at.Id, + Query: `endpoint="one"`, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Sessions: []*sessions.Session{ + {Id: "s_1", Endpoint: "one"}, + }}, got) + }) + + t.Run("Filter sessions", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "sessions", + AuthTokenId: at.Id, + Filter: `"/item/endpoint" matches "one"`, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Sessions: []*sessions.Session{ + {Id: "s_1", Endpoint: "one"}, + }}, got) + }) + + t.Run("unrecognized auth token", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: "unrecognized", + }) + assert.NoError(t, err) + assert.Equal(t, &SearchResult{Targets: []*targets.Target{}}, got) + }) +}