Add status command to the boundary client cache (#3871)

* Add status command to the boundary client cache
This commit is contained in:
Todd authored and Johan Brandhorst-Satzkorn committed 2024-01-05 07:43:20 -08:00
1 parent 3a7ea64a31
commit 71b09222ae
17 files changed
+995 -23

No files matched your search

+211
View File
@@ -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
}
+5
View File
@@ -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
}
+34 -10
View File
@@ -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"
}
@@ -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 {
+2 -2
View File
@@ -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))
}
}
+2 -2
View File
@@ -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))
}
}
+52 -5
View File
@@ -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)
})
}
+222
View File
@@ -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
}
+254
View File
@@ -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
}
+5 -1
View File
@@ -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)
})
@@ -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 {
@@ -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
}
@@ -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
+5
View File
@@ -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),
+5
View File
@@ -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),
+5
View File
@@ -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),
+5
View File
@@ -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),