diff --git a/internal/clientcache/cmd/daemon/status.go b/internal/clientcache/cmd/daemon/status.go new file mode 100644 index 000000000..1779d7725 --- /dev/null +++ b/internal/clientcache/cmd/daemon/status.go @@ -0,0 +1,211 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + stderr "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/internal/clientcache/internal/daemon" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/version" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +var errDaemonNotRunning = stderr.New("daemon is not running") + +var ( + _ cli.Command = (*AddTokenCommand)(nil) + _ cli.CommandAutocomplete = (*AddTokenCommand)(nil) +) + +type StatusCommand struct { + *base.Command +} + +func (c *StatusCommand) Synopsis() string { + return "Get the status information of the running boundary daemon" +} + +func (c *StatusCommand) Help() string { + helpText := ` +Usage: boundary daemon status [options] + + Get the status of the boundary daemon: + + $ boundary daemon status + + For a full list of examples, please see the documentation. + +` + c.Flags().Help() + return strings.TrimSpace(helpText) +} + +func (c *StatusCommand) Flags() *base.FlagSets { + set := c.FlagSet(base.FlagSetClient | base.FlagSetOutputFormat) + return set +} + +func (c *StatusCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *StatusCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *StatusCommand) Run(args []string) int { + ctx := c.Context + f := c.Flags() + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + resp, err := c.Status(ctx) + if err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + + res := &daemon.StatusResult{} + apiErr, err := resp.Decode(res) + if err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + if apiErr != nil { + c.PrintApiError(apiErr, "Error from daemon when getting the status") + return base.CommandApiError + + } + switch base.Format(c.UI) { + case "json": + c.UI.Output(resp.Body.String()) + default: + c.UI.Output(printStatusTable(res)) + } + return base.CommandSuccess +} + +func (c *StatusCommand) Status(ctx context.Context) (*api.Response, error) { + dotPath, err := DefaultDotDirectory(ctx) + if err != nil { + return nil, err + } + + return status(ctx, dotPath) +} + +func status(ctx context.Context, daemonPath string) (*api.Response, error) { + client, err := api.NewClient(nil) + if err != nil { + return nil, fmt.Errorf("Error creating a new API client: %w", err) + } + addr := daemon.SocketAddress(daemonPath) + _, err = os.Stat(strings.TrimPrefix(addr, "unix://")) + if strings.HasPrefix(addr, "unix://") && err != nil { + return nil, errDaemonNotRunning + } + if err := client.SetAddr(addr); err != nil { + return nil, fmt.Errorf("Error when setting the client's address: %w", err) + } + // Because this is using the real lib it can pick up from stored locations + // like the system keychain. Explicitly clear the token for now + client.SetToken("") + + req, err := client.NewRequest(ctx, "GET", "/status", nil) + if err != nil { + return nil, err + } + req.Header.Add(daemon.VersionHeaderKey, version.Get().VersionNumber()) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} + +func printStatusTable(status *daemon.StatusResult) string { + nonAttributeMap := map[string]any{ + "User Count": len(status.Users), + } + if len(status.SocketAddress) > 0 { + nonAttributeMap["Domain Socket"] = status.SocketAddress + } + if status.Uptime > 0 { + nonAttributeMap["Uptime"] = status.Uptime.Round(time.Second) + } + + maxLength := base.MaxAttributesLength(nonAttributeMap, nil, nil) + + ret := []string{ + "", + "Status:", + base.WrapMap(2, maxLength+2, nonAttributeMap), + } + + if len(status.Users) > 0 { + ret = append(ret, printUsersTable(status.Users)...) + } + return base.WrapForHelpText(ret) +} + +func printUsersTable(us []daemon.UserStatus) []string { + ret := []string{ + "", + } + for _, u := range us { + ret = append(ret, " User:") + nonAttributeMap := map[string]any{ + "Id": u.Id, + "Address": u.Address, + "AuthToken Count": len(u.AuthTokens), + } + maxLength := base.MaxAttributesLength(nonAttributeMap, nil, nil) + ret = append(ret, + base.WrapMap(4, maxLength+4, nonAttributeMap), + ) + + for _, at := range u.AuthTokens { + nonAttributeMap := map[string]any{ + "Id": at.Id, + } + if at.KeyringReferences > 0 { + nonAttributeMap["In Keyring"] = "true" + } + maxLength := base.MaxAttributesLength(nonAttributeMap, nil, nil) + ret = append(ret, + " AuthToken:", + base.WrapMap(6, maxLength+6, nonAttributeMap), + ) + } + + for _, r := range u.Resources { + nonAttributeMap := map[string]any{ + "Count": r.Count, + } + if r.RefreshToken != nil { + nonAttributeMap["Since Last Refresh"] = r.RefreshToken.LastUsed.Round(time.Second) + nonAttributeMap["Since Full Fetch"] = r.RefreshToken.Age.Round(time.Second) + } + if r.LastError != nil { + nonAttributeMap["Since Last Error"] = r.LastError.LastReturned.Round(time.Second) + nonAttributeMap["Last Error Message"] = r.LastError.Error + } + maxLength := base.MaxAttributesLength(nonAttributeMap, nil, nil) + ret = append(ret, + fmt.Sprintf(" %s:", strings.Title(r.Name)), + base.WrapMap(6, maxLength+6, nonAttributeMap), + ) + } + } + return ret +} diff --git a/internal/clientcache/internal/cache/refresh.go b/internal/clientcache/internal/cache/refresh.go index 37cee1ec2..e903bbfb7 100644 --- a/internal/clientcache/internal/cache/refresh.go +++ b/internal/clientcache/internal/cache/refresh.go @@ -19,6 +19,11 @@ type RefreshService struct { } func NewRefreshService(ctx context.Context, r *Repository) (*RefreshService, error) { + const op = "cache.NewRefreshService" + switch { + case util.IsNil(r): + return nil, errors.New(ctx, errors.InvalidParameter, op, "repository is nil") + } return &RefreshService{repo: r}, nil } diff --git a/internal/clientcache/internal/cache/repository.go b/internal/clientcache/internal/cache/repository.go index c44c5aa89..f699280f0 100644 --- a/internal/clientcache/internal/cache/repository.go +++ b/internal/clientcache/internal/cache/repository.go @@ -56,11 +56,11 @@ func NewRepository(ctx context.Context, conn *db.DB, idToAuthToken *sync.Map, ke }, nil } -func (r *Repository) SaveError(ctx context.Context, u *user, resourceType resourceType, err error) error { - const op = "cache.(Repository).StoreError" +func (r *Repository) saveError(ctx context.Context, u *user, resourceType resourceType, err error) error { + const op = "cache.(Repository).saveError" switch { - case resourceType == "": - return errors.New(ctx, errors.InvalidParameter, op, "resource type is empty") + case !resourceType.valid(): + return errors.New(ctx, errors.InvalidParameter, op, "resource type is invalid") case err == nil: return errors.New(ctx, errors.InvalidParameter, op, "error is nil") case u == nil: @@ -68,9 +68,9 @@ func (r *Repository) SaveError(ctx context.Context, u *user, resourceType resour case u.Id == "": return errors.New(ctx, errors.InvalidParameter, op, "user id is empty") } - apiErr := &ApiError{ + apiErr := &apiError{ UserId: u.Id, - ResourceType: string(resourceType), + ResourceType: resourceType, Error: err.Error(), } onConflict := db.OnConflict{ @@ -83,13 +83,37 @@ func (r *Repository) SaveError(ctx context.Context, u *user, resourceType resour return nil } -type ApiError struct { - UserId string `gorm:"primaryKey"` - ResourceType string `gorm:"primaryKey"` +func (r *Repository) lookupError(ctx context.Context, u *user, resourceType resourceType) (*apiError, error) { + const op = "cache.(Repository).lookupError" + switch { + case !resourceType.valid(): + return nil, errors.New(ctx, errors.InvalidParameter, op, "resource type is invalid") + case u == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "user is nil") + case u.Id == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "user id is empty") + } + apiErr := &apiError{ + UserId: u.Id, + ResourceType: resourceType, + } + err := r.rw.LookupById(ctx, apiErr) + if err != nil { + if errors.IsNotFoundError(err) { + return nil, nil + } + return nil, errors.Wrap(ctx, err, op) + } + return apiErr, nil +} + +type apiError struct { + UserId string `gorm:"primaryKey"` + ResourceType resourceType `gorm:"primaryKey"` Error string CreateTime time.Time } -func (*ApiError) TableName() string { +func (*apiError) TableName() string { return "api_error" } diff --git a/internal/clientcache/internal/cache/repository_refresh_token.go b/internal/clientcache/internal/cache/repository_refresh_token.go index e7058e792..46c2076ec 100644 --- a/internal/clientcache/internal/cache/repository_refresh_token.go +++ b/internal/clientcache/internal/cache/repository_refresh_token.go @@ -6,6 +6,7 @@ package cache import ( "context" "database/sql" + "time" "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/errors" @@ -112,6 +113,8 @@ func upsertRefreshToken(ctx context.Context, writer db.Writer, u *user, rt resou UserId: u.Id, ResourceType: rt, RefreshToken: tok, + UpdateTime: time.Now(), + CreateTime: time.Now(), } switch tok { @@ -120,7 +123,7 @@ func upsertRefreshToken(ctx context.Context, writer db.Writer, u *user, rt resou default: onConflict := &db.OnConflict{ Target: db.Columns{"user_id", "resource_type"}, - Action: db.SetColumns([]string{"refresh_token"}), + Action: db.SetColumns([]string{"refresh_token", "update_time"}), } if err := writer.Create(ctx, refTok, db.WithOnConflict(onConflict)); err != nil { return errors.Wrap(ctx, err, op) @@ -150,6 +153,8 @@ type refreshToken struct { UserId string `gorm:"primaryKey"` ResourceType resourceType `gorm:"primaryKey"` RefreshToken RefreshTokenValue + UpdateTime time.Time `gorm:"default:(strftime('%Y-%m-%d %H:%M:%f','now'))"` + CreateTime time.Time `gorm:"default:(strftime('%Y-%m-%d %H:%M:%f','now'))"` } func (*refreshToken) TableName() string { diff --git a/internal/clientcache/internal/cache/repository_sessions.go b/internal/clientcache/internal/cache/repository_sessions.go index 3ef6dd82c..c60cb7d63 100644 --- a/internal/clientcache/internal/cache/repository_sessions.go +++ b/internal/clientcache/internal/cache/repository_sessions.go @@ -95,7 +95,7 @@ func (r *Repository) refreshSessions(ctx context.Context, u *user, tokens map[Au } if retErr != nil { - if saveErr := r.SaveError(ctx, u, resourceType, retErr); saveErr != nil { + if saveErr := r.saveError(ctx, u, resourceType, retErr); saveErr != nil { return stderrors.Join(err, errors.Wrap(ctx, saveErr, op)) } } @@ -171,7 +171,7 @@ func (r *Repository) fullFetchSessions(ctx context.Context, u *user, tokens map[ } if retErr != nil { - if saveErr := r.SaveError(ctx, u, resourceType, retErr); saveErr != nil { + if saveErr := r.saveError(ctx, u, resourceType, retErr); saveErr != nil { return stderrors.Join(err, errors.Wrap(ctx, saveErr, op)) } } diff --git a/internal/clientcache/internal/cache/repository_targets.go b/internal/clientcache/internal/cache/repository_targets.go index 614d21dae..c517b352e 100644 --- a/internal/clientcache/internal/cache/repository_targets.go +++ b/internal/clientcache/internal/cache/repository_targets.go @@ -92,7 +92,7 @@ func (r *Repository) refreshTargets(ctx context.Context, u *user, tokens map[Aut break } if retErr != nil { - if saveErr := r.SaveError(ctx, u, resourceType, retErr); saveErr != nil { + if saveErr := r.saveError(ctx, u, resourceType, retErr); saveErr != nil { return stderrors.Join(err, errors.Wrap(ctx, saveErr, op)) } } @@ -167,7 +167,7 @@ func (r *Repository) fullFetchTargets(ctx context.Context, u *user, tokens map[A break } if retErr != nil { - if saveErr := r.SaveError(ctx, u, resourceType, retErr); saveErr != nil { + if saveErr := r.saveError(ctx, u, resourceType, retErr); saveErr != nil { return stderrors.Join(err, errors.Wrap(ctx, saveErr, op)) } } diff --git a/internal/clientcache/internal/cache/repository_test.go b/internal/clientcache/internal/cache/repository_test.go index 43e995ed0..307b989e4 100644 --- a/internal/clientcache/internal/cache/repository_test.go +++ b/internal/clientcache/internal/cache/repository_test.go @@ -44,7 +44,7 @@ func sliceBasedAuthTokenBoundaryReader(s []*authtokens.AuthToken) BoundaryTokenR } } -func TestRepository_SaveError(t *testing.T) { +func TestRepository_saveError(t *testing.T) { ctx := context.Background() s, err := cachedb.Open(ctx) require.NoError(t, err) @@ -64,15 +64,62 @@ func TestRepository_SaveError(t *testing.T) { require.NoError(t, r.rw.Create(ctx, u)) t.Run("empty resource type", func(t *testing.T) { - assert.ErrorContains(t, r.SaveError(ctx, u, "", testErr), "resource type is empty") + assert.ErrorContains(t, r.saveError(ctx, u, "", testErr), "resource type is invalid") }) t.Run("nil error", func(t *testing.T) { - assert.ErrorContains(t, r.SaveError(ctx, u, testResource, nil), "error is nil") + assert.ErrorContains(t, r.saveError(ctx, u, testResource, nil), "error is nil") }) t.Run("nil user", func(t *testing.T) { - assert.ErrorContains(t, r.SaveError(ctx, nil, testResource, testErr), "user is nil") + assert.ErrorContains(t, r.saveError(ctx, nil, testResource, testErr), "user is nil") }) t.Run("success", func(t *testing.T) { - assert.NoError(t, r.SaveError(ctx, u, testResource, testErr)) + assert.NoError(t, r.saveError(ctx, u, testResource, testErr)) + }) +} + +func TestRepository_lookupError(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + r, err := NewRepository(ctx, s, &sync.Map{}, + mapBasedAuthTokenKeyringLookup(map[ringToken]*authtokens.AuthToken{}), + sliceBasedAuthTokenBoundaryReader(nil)) + require.NoError(t, err) + + testResource := targetResourceType + testErr := fmt.Errorf("test error for %q", testResource) + + u := &user{ + Id: "u1", + Address: "addr", + } + require.NoError(t, r.rw.Create(ctx, u)) + + t.Run("empty resource type", func(t *testing.T) { + got, err := r.lookupError(ctx, u, "unknown") + assert.ErrorContains(t, err, "resource type is invalid") + assert.Nil(t, got) + }) + t.Run("nil user", func(t *testing.T) { + got, err := r.lookupError(ctx, nil, testResource) + assert.ErrorContains(t, err, "user is nil") + assert.Nil(t, got) + }) + t.Run("empty user id", func(t *testing.T) { + got, err := r.lookupError(ctx, &user{Address: "address"}, testResource) + assert.ErrorContains(t, err, "user id is empty") + assert.Nil(t, got) + }) + t.Run("not found", func(t *testing.T) { + got, err := r.lookupError(ctx, u, sessionResourceType) + assert.NoError(t, err) + assert.Nil(t, got) + }) + t.Run("found", func(t *testing.T) { + r.saveError(ctx, u, sessionResourceType, testErr) + got, err := r.lookupError(ctx, u, sessionResourceType) + assert.NoError(t, err) + assert.NotNil(t, got) }) } diff --git a/internal/clientcache/internal/cache/status.go b/internal/clientcache/internal/cache/status.go new file mode 100644 index 000000000..11fdf0ece --- /dev/null +++ b/internal/clientcache/internal/cache/status.go @@ -0,0 +1,222 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" +) + +// RefreshTokenStatus is the status of a resource token +type RefreshTokenStatus struct { + Age time.Duration + LastUsed time.Duration +} + +type ErrorStatus struct { + Error string + LastReturned time.Duration +} + +// ResourceStatus contains the status of a specific resource type contained in +// the cache for a specific user. +type ResourceStatus struct { + Name string + Count int + LastError *ErrorStatus + RefreshToken *RefreshTokenStatus +} + +// AuthTokenStatus contains the status of an auth token tracked in the cache for +// a specific user. +type AuthTokenStatus struct { + Id string + KeyringReferences int + KeyringlessReferences int +} + +// UserStatus contains the status of a specific user tracked by the cache +type UserStatus struct { + // The Id of the user this status is for + Id string + // The boundary address for this user + Address string + // The auth tokens used by this user to authenticate with the boundary instance + AuthTokens []AuthTokenStatus + // The resources tracked by the cache for this user + Resources []ResourceStatus +} + +// Status contains the status of the cache. +type Status struct { + Users []UserStatus +} + +// StatusService provides a service that gives status information about the +// cache. +type StatusService struct { + repo *Repository +} + +// NewStatusService returns a new StatusService +func NewStatusService(ctx context.Context, r *Repository) (*StatusService, error) { + const op = "cache.NewStatusService" + switch { + case util.IsNil(r): + return nil, errors.New(ctx, errors.InvalidParameter, op, "repository is nil") + } + return &StatusService{repo: r}, nil +} + +// Status returns the Status of the cache repository +func (s *StatusService) Status(ctx context.Context) (*Status, error) { + const op = "cache.(StatusService).Status" + ret := &Status{} + users, err := s.repo.listUsers(ctx) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + for _, u := range users { + us := UserStatus{ + Id: u.Id, + Address: u.Address, + } + + { + toks, err := s.repo.listTokens(ctx, u) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + for _, t := range toks { + ts := &AuthTokenStatus{ + Id: t.Id, + } + kt, err := s.repo.listKeyringTokens(ctx, t) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + ts.KeyringReferences = len(kt) + if _, ok := s.repo.idToKeyringlessAuthToken.Load(t.Id); ok { + ts.KeyringlessReferences = 1 + } + us.AuthTokens = append(us.AuthTokens, *ts) + } + } + + for _, rt := range []resourceType{targetResourceType, sessionResourceType} { + ts, err := s.resourceStatus(ctx, u, rt) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + us.Resources = append(us.Resources, *ts) + } + + ret.Users = append(ret.Users, us) + } + return ret, nil +} + +func (s *StatusService) resourceStatus(ctx context.Context, u *user, rt resourceType) (*ResourceStatus, error) { + const op = "cache.(StatusService).resourceStatus" + switch { + case u == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "user is nil") + case u.Id == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "user id is empty") + case !rt.valid(): + return nil, errors.New(ctx, errors.InvalidParameter, op, "resource type is invalid") + } + ret := ResourceStatus{ + Name: string(rt), + } + + refTok, err := s.refreshTokenStatus(ctx, u, rt) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + ret.RefreshToken = refTok + + errStatus, err := s.errorStatus(ctx, u, rt) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + ret.LastError = errStatus + + err = func() error { + query := fmt.Sprintf("select count(*) from %s where user_id = @user_id", rt) + r, err := s.repo.rw.Query(ctx, query, []any{sql.Named("user_id", u.Id)}) + if err != nil { + return errors.Wrap(ctx, err, op) + } + defer r.Close() + r.Next() + var count int + if err := s.repo.rw.ScanRows(ctx, r, &count); err != nil { + return errors.Wrap(ctx, err, op) + } + ret.Count = count + return nil + }() + if err != nil { + return nil, err + } + + return &ret, nil +} + +func (s *StatusService) errorStatus(ctx context.Context, u *user, rt resourceType) (*ErrorStatus, error) { + const op = "cache.(StatusService).errorStatus" + switch { + case u == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "user is nil") + case u.Id == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "user id is empty") + case !rt.valid(): + return nil, errors.New(ctx, errors.InvalidParameter, op, "resource type is invalid") + } + apiErr, err := s.repo.lookupError(ctx, u, rt) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + if apiErr == nil { + return nil, nil + } + return &ErrorStatus{ + Error: apiErr.Error, + LastReturned: time.Since(apiErr.CreateTime), + }, nil +} + +func (s *StatusService) refreshTokenStatus(ctx context.Context, u *user, rt resourceType) (*RefreshTokenStatus, error) { + const op = "cache.(StatusService).refreshTokenStatus" + switch { + case u == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "user is nil") + case u.Id == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "user id is empty") + case !rt.valid(): + return nil, errors.New(ctx, errors.InvalidParameter, op, "resource type is invalid") + } + refTok := &refreshToken{ + UserId: u.Id, + ResourceType: rt, + } + if err := s.repo.rw.LookupById(ctx, refTok); err != nil { + if errors.IsNotFoundError(err) { + return nil, nil + } + return nil, errors.Wrap(ctx, err, op) + } + + ret := &RefreshTokenStatus{ + LastUsed: time.Since(refTok.UpdateTime), + Age: time.Since(refTok.CreateTime), + } + return ret, nil +} diff --git a/internal/clientcache/internal/cache/status_test.go b/internal/clientcache/internal/cache/status_test.go new file mode 100644 index 000000000..be88e9e7c --- /dev/null +++ b/internal/clientcache/internal/cache/status_test.go @@ -0,0 +1,254 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStatusService(t *testing.T) { + ctx := context.Background() + + t.Run("nil repo", func(t *testing.T) { + s, err := NewStatusService(ctx, nil) + assert.Error(t, err) + assert.Nil(t, s) + }) + + t.Run("success", func(t *testing.T) { + s, err := cachedb.Open(ctx) + require.NoError(t, err) + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(nil), sliceBasedAuthTokenBoundaryReader(nil)) + require.NoError(t, err) + + service, err := NewStatusService(ctx, r) + assert.NoError(t, err) + assert.NotNil(t, service) + }) +} + +func TestStatus(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + boundaryAddr := "address" + u1 := &user{Id: "u1", Address: boundaryAddr} + at1a := &authtokens.AuthToken{ + Id: "at_1a", + Token: "at_1a_token", + UserId: u1.Id, + ExpirationTime: time.Now().Add(time.Minute), + } + at1b := &authtokens.AuthToken{ + Id: "at_1b", + Token: "at_1b_token", + UserId: u1.Id, + ExpirationTime: time.Now().Add(time.Minute), + } + + u2 := &user{Id: "u2", Address: boundaryAddr} + at2 := &authtokens.AuthToken{ + Id: "at_2", + Token: "at_2_token", + UserId: u2.Id, + ExpirationTime: time.Now().Add(time.Minute), + } + + boundaryAuthTokens := []*authtokens.AuthToken{at1a, at1b, at2} + atMap := map[ringToken]*authtokens.AuthToken{ + {k: "default", t: "default"}: at1a, + {k: "default2", t: "default2"}: at1b, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + ss, err := NewStatusService(ctx, r) + require.NoError(t, err) + + t.Run("empty status", func(t *testing.T) { + got, err := ss.Status(ctx) + assert.NoError(t, err) + assert.Equal(t, &Status{}, got) + }) + + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{ + KeyringType: "default", + TokenName: "default", + AuthTokenId: at1a.Id, + })) + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{ + KeyringType: "default2", + TokenName: "default2", + AuthTokenId: at1b.Id, + })) + require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1b.Token)) + require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at2.Token)) + + t.Run("users with no resources", func(t *testing.T) { + got, err := ss.Status(ctx) + assert.NoError(t, err) + assert.Equal(t, &Status{ + Users: []UserStatus{ + { + Id: u1.Id, + Address: u1.Address, + AuthTokens: []AuthTokenStatus{ + { + Id: at1a.Id, + KeyringReferences: 1, + KeyringlessReferences: 0, + }, + { + Id: at1b.Id, + KeyringReferences: 1, + KeyringlessReferences: 1, + }, + }, + Resources: []ResourceStatus{ + { + Name: string(targetResourceType), + Count: 0, + }, + { + Name: string(sessionResourceType), + Count: 0, + }, + }, + }, + { + Id: u2.Id, + Address: u2.Address, + AuthTokens: []AuthTokenStatus{ + { + Id: at2.Id, + KeyringReferences: 0, + KeyringlessReferences: 1, + }, + }, + Resources: []ResourceStatus{ + { + Name: string(targetResourceType), + Count: 0, + }, + { + Name: string(sessionResourceType), + Count: 0, + }, + }, + }, + }, + }, got) + }) + + t.Run("users with errors", func(t *testing.T) { + require.NoError(t, r.saveError(ctx, u1, targetResourceType, fmt.Errorf("test error"))) + + got, err := ss.Status(ctx) + assert.NoError(t, err) + + lastErr := got.Users[0].Resources[0].LastError + assert.NotNil(t, lastErr) + assert.Equal(t, "test error", lastErr.Error) + assert.NotZero(t, lastErr.LastReturned) + }) + + t.Run("users with resources", func(t *testing.T) { + require.NoError(t, r.saveError(ctx, u1, targetResourceType, fmt.Errorf("test error"))) + + ts := []*targets.Target{ + target("1"), + target("2"), + target("3"), + target("4"), + } + err = r.refreshTargets(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*targets.Target{ts}, [][]string{nil}))) + require.NoError(t, err) + + err = r.refreshTargets(ctx, u2, map[AuthToken]string{{Id: "id"}: "something"}, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*targets.Target{ts[:2]}, [][]string{nil}))) + require.NoError(t, err) + + sess := []*sessions.Session{ + session("1"), + session("2"), + session("3"), + } + err := r.refreshSessions(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{sess}, [][]string{nil}))) + require.NoError(t, err) + + got, err := ss.Status(ctx) + assert.NoError(t, err) + + assert.Len(t, got.Users, 2) + assert.Equal(t, Map(got.Users, func(i UserStatus) string { + return i.Id + }), []string{"u1", "u2"}) + + // User 1 status + assert.Equal(t, Map(got.Users[0].AuthTokens, func(i AuthTokenStatus) string { + return i.Id + }), []string{"at_1a", "at_1b"}) + + assert.Equal(t, Map(got.Users[0].Resources, func(i ResourceStatus) string { + return i.Name + }), []string{string(targetResourceType), string(sessionResourceType)}) + + assert.Equal(t, Map(got.Users[0].Resources, func(i ResourceStatus) int { + return i.Count + }), []int{4, 3}) + + assert.Equal(t, Map(got.Users[0].Resources, func(i ResourceStatus) bool { + return i.LastError == nil + }), []bool{false, true}, "expected an error for target resource and none for session resource") + + assert.Equal(t, Map(got.Users[0].Resources, func(i ResourceStatus) bool { + return i.RefreshToken == nil + }), []bool{false, false}) + + // User 2 status + assert.Equal(t, Map(got.Users[1].AuthTokens, func(i AuthTokenStatus) string { + return i.Id + }), []string{"at_2"}) + + assert.Equal(t, Map(got.Users[1].Resources, func(i ResourceStatus) string { + return i.Name + }), []string{string(targetResourceType), string(sessionResourceType)}) + + assert.Equal(t, Map(got.Users[1].Resources, func(i ResourceStatus) int { + return i.Count + }), []int{2, 0}) + + assert.Equal(t, Map(got.Users[1].Resources, func(i ResourceStatus) bool { + return i.LastError == nil + }), []bool{true, true}) + + assert.Equal(t, Map(got.Users[1].Resources, func(i ResourceStatus) bool { + return i.RefreshToken == nil + }), []bool{false, true}, "targets expected to have a refresh token and sessions aren't") + }) +} + +// Map maps a slice of one type into a slice of another using the provided map +// function +func Map[T, U any](in []T, f func(T) U) []U { + var ret []U + for _, t := range in { + ret = append(ret, f(t)) + } + return ret +} diff --git a/internal/clientcache/internal/cache/store_test.go b/internal/clientcache/internal/cache/store_test.go index 21d40443f..979fbd6ce 100644 --- a/internal/clientcache/internal/cache/store_test.go +++ b/internal/clientcache/internal/cache/store_test.go @@ -189,8 +189,11 @@ func TestRefreshToken(t *testing.T) { ResourceType: targetResourceType, RefreshToken: "something", } + before := time.Now().Truncate(1 * time.Millisecond) require.NoError(t, rw.Create(ctx, tok)) require.NoError(t, rw.LookupById(ctx, tok)) + assert.GreaterOrEqual(t, tok.UpdateTime, before) + assert.GreaterOrEqual(t, tok.CreateTime, before) assert.NotEmpty(t, tok.RefreshToken) }) @@ -208,8 +211,9 @@ func TestRefreshToken(t *testing.T) { } require.NoError(t, rw.Create(ctx, tok)) + tok.UpdateTime = time.Now().Add(-(24 * 365 * time.Hour)) tok.RefreshToken = "updated" - n, err := rw.Update(ctx, tok, []string{"RefreshToken"}, nil) + n, err := rw.Update(ctx, tok, []string{"UpdateTime", "RefreshToken"}, nil) assert.NoError(t, err) assert.Equal(t, 1, n) }) diff --git a/internal/clientcache/internal/daemon/server.go b/internal/clientcache/internal/daemon/server.go index facc02e37..8da512129 100644 --- a/internal/clientcache/internal/daemon/server.go +++ b/internal/clientcache/internal/daemon/server.go @@ -236,11 +236,17 @@ func (s *CacheServer) Serve(ctx context.Context, cmd Commander, opt ...Option) e }() mux := http.NewServeMux() - searchTargetsFn, err := newSearchHandlerFunc(ctx, repo) + searchFn, err := newSearchHandlerFunc(ctx, repo) if err != nil { return errors.Wrap(ctx, err, op) } - mux.HandleFunc("/v1/search", searchTargetsFn) + mux.HandleFunc("/v1/search", searchFn) + + statusFn, err := newStatusHandlerFunc(ctx, repo, l.Addr().String()) + if err != nil { + return errors.Wrap(ctx, err, op) + } + mux.HandleFunc("/v1/status", statusFn) tokenFn, err := newTokenHandlerFunc(ctx, repo, tic) if err != nil { diff --git a/internal/clientcache/internal/daemon/status_handler.go b/internal/clientcache/internal/daemon/status_handler.go new file mode 100644 index 000000000..47ccc5e63 --- /dev/null +++ b/internal/clientcache/internal/daemon/status_handler.go @@ -0,0 +1,155 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" +) + +// RefreshTokenStatus is the status of a resource token +type RefreshTokenStatus struct { + Age time.Duration `json:"age,omitempty"` + LastUsed time.Duration `json:"last_used,omitempty"` +} + +type ErrorStatus struct { + Error string `json:"error,omitempty"` + LastReturned time.Duration `json:"last_returned,omitempty"` +} + +// ResourceStatus contains the status of a specific resource type contained in +// the cache for a specific user. +type ResourceStatus struct { + Name string `json:"name,omitempty"` + Count int `json:"count"` + LastError *ErrorStatus `json:"last_error,omitempty"` + RefreshToken *RefreshTokenStatus `json:"refresh_token,omitempty"` +} + +// AuthTokenStatus contains the status of an auth token tracked in the cache for +// a specific user. +type AuthTokenStatus struct { + Id string `json:"id,omitempty"` + KeyringReferences int `json:"keyring_references,omitempty"` + KeyringlessReferences int `json:"keyringless_references,omitempty"` +} + +// UserStatus contains the status of a specific user tracked by the cache +type UserStatus struct { + // The Id of the user this status is for + Id string `json:"id,omitempty"` + // The boundary address for this user + Address string `json:"address,omitempty"` + // The auth tokens used by this user to authenticate with the boundary instance + AuthTokens []AuthTokenStatus `json:"auth_tokens,omitempty"` + // The resources tracked by the cache for this user + Resources []ResourceStatus `json:"resources,omitempty"` +} + +// StatusResult is the struct returned to status requests. +type StatusResult struct { + Uptime time.Duration `json:"uptime,omitempty"` + SocketAddress string `json:"socket_address,omitempty"` + Users []UserStatus `json:"users,omitempty"` +} + +func newStatusHandlerFunc(ctx context.Context, repo *cache.Repository, socketAddr string) (http.HandlerFunc, error) { + const op = "daemon.newStatusHandlerFunc" + switch { + case util.IsNil(repo): + return nil, errors.New(ctx, errors.InvalidParameter, op, "repository is missing") + } + started := time.Now() + + s, err := cache.NewStatusService(ctx, repo) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + res, err := s.Status(ctx) + if err != nil { + switch { + case errors.Match(errors.T(errors.InvalidParameter), err): + writeError(w, err.Error(), http.StatusBadRequest) + default: + writeError(w, err.Error(), http.StatusInternalServerError) + } + return + } + if res == nil { + writeError(w, "nil StatusResult generated", http.StatusInternalServerError) + return + } + + apiRes := toApiStatus(res, started, socketAddr) + j, err := json.Marshal(apiRes) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(j) + }, nil +} + +// toApiStatus converts a domain status result to an api status result +func toApiStatus(in *cache.Status, started time.Time, socketAddr string) *StatusResult { + if in == nil { + return nil + } + + out := &StatusResult{ + Uptime: time.Since(started), + SocketAddress: socketAddr, + } + + for _, inU := range in.Users { + outU := UserStatus{ + Id: inU.Id, + Address: inU.Address, + } + for _, inAt := range inU.AuthTokens { + outU.AuthTokens = append(outU.AuthTokens, AuthTokenStatus{ + Id: inAt.Id, + KeyringReferences: inAt.KeyringReferences, + KeyringlessReferences: inAt.KeyringlessReferences, + }) + } + for _, inR := range inU.Resources { + var outErr *ErrorStatus + if inR.LastError != nil { + outErr = &ErrorStatus{ + Error: inR.LastError.Error, + LastReturned: inR.LastError.LastReturned, + } + } + var outRefTok *RefreshTokenStatus + if inR.RefreshToken != nil { + outRefTok = &RefreshTokenStatus{ + Age: inR.RefreshToken.Age, + LastUsed: inR.RefreshToken.LastUsed, + } + } + outU.Resources = append(outU.Resources, ResourceStatus{ + Name: inR.Name, + Count: inR.Count, + LastError: outErr, + RefreshToken: outRefTok, + }) + } + + out.Users = append(out.Users, outU) + } + return out +} diff --git a/internal/clientcache/internal/db/schema.sql b/internal/clientcache/internal/db/schema.sql index aa22a891c..650170d03 100644 --- a/internal/clientcache/internal/db/schema.sql +++ b/internal/clientcache/internal/db/schema.sql @@ -37,9 +37,28 @@ create table if not exists refresh_token( constraint only_known_resource_types_allowed, refresh_token text not null check (length(refresh_token) > 0), + update_time timestamp not null default (strftime('%Y-%m-%d %H:%M:%f','now')), + create_time timestamp not null default (strftime('%Y-%m-%d %H:%M:%f','now')), primary key (user_id, resource_type) ); +create trigger immutable_columns_refresh_token before update on refresh_token +for each row +when + new.create_time <> old.create_time +begin + select raise(abort, 'immutable column'); +end; + + +create trigger update_time_column_refresh_token before update on refresh_token +for each row +when + new.refresh_token <> old.refresh_token +begin + update refresh_token set update_time = datetime('now','localtime') where rowid == new.rowid; +end; + -- Contains the boundary auth token create table if not exists auth_token ( -- id is the boundary id of the auth token diff --git a/internal/cmd/search_cmd_darwin.go b/internal/cmd/search_cmd_darwin.go index 24d5bcc08..752358c1e 100644 --- a/internal/cmd/search_cmd_darwin.go +++ b/internal/cmd/search_cmd_darwin.go @@ -30,6 +30,11 @@ func init() { Command: base.NewCommand(ui), }, nil } + Commands["daemon status"] = func() (cli.Command, error) { + return &daemon.StatusCommand{ + Command: base.NewCommand(ui), + }, nil + } Commands["search"] = func() (cli.Command, error) { return &search.SearchCommand{ Command: base.NewCommand(ui), diff --git a/internal/cmd/search_cmd_freebsd.go b/internal/cmd/search_cmd_freebsd.go index 707bd8291..55957d0f5 100644 --- a/internal/cmd/search_cmd_freebsd.go +++ b/internal/cmd/search_cmd_freebsd.go @@ -32,6 +32,11 @@ func init() { Command: base.NewCommand(ui), }, nil } + Commands["daemon status"] = func() (cli.Command, error) { + return &daemon.StatusCommand{ + Command: base.NewCommand(ui), + }, nil + } Commands["search"] = func() (cli.Command, error) { return &search.SearchCommand{ Command: base.NewCommand(ui), diff --git a/internal/cmd/search_cmd_linux.go b/internal/cmd/search_cmd_linux.go index 24d5bcc08..752358c1e 100644 --- a/internal/cmd/search_cmd_linux.go +++ b/internal/cmd/search_cmd_linux.go @@ -30,6 +30,11 @@ func init() { Command: base.NewCommand(ui), }, nil } + Commands["daemon status"] = func() (cli.Command, error) { + return &daemon.StatusCommand{ + Command: base.NewCommand(ui), + }, nil + } Commands["search"] = func() (cli.Command, error) { return &search.SearchCommand{ Command: base.NewCommand(ui), diff --git a/internal/cmd/search_cmd_windows.go b/internal/cmd/search_cmd_windows.go index 707bd8291..55957d0f5 100644 --- a/internal/cmd/search_cmd_windows.go +++ b/internal/cmd/search_cmd_windows.go @@ -32,6 +32,11 @@ func init() { Command: base.NewCommand(ui), }, nil } + Commands["daemon status"] = func() (cli.Command, error) { + return &daemon.StatusCommand{ + Command: base.NewCommand(ui), + }, nil + } Commands["search"] = func() (cli.Command, error) { return &search.SearchCommand{ Command: base.NewCommand(ui),