Files
threshold/internal/clientcache/internal/cache/repository_targets.go
T
Jeff Mitchell 3a4ee032d9 Paginate targets in cache (#5101)
* Update Go API to support pagination
* Implement pagination of targets in cache

(cherry picked from commit cd25fad42f325fc050b7ea3bec3336780d2b5e16)
2024-09-24 14:18:53 +00:00

414 lines
14 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"database/sql"
"encoding/json"
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/util"
"github.com/hashicorp/mql"
)
// TargetRetrievalFunc is a function that retrieves targets
// from the provided boundary addr using the provided token.
type TargetRetrievalFunc func(ctx context.Context, addr, authTok string, refreshTok RefreshTokenValue, inPage *targets.TargetListResult, opt ...Option) (ret *targets.TargetListResult, refreshToken RefreshTokenValue, err error)
func defaultTargetFunc(ctx context.Context, addr, authTok string, refreshTok RefreshTokenValue, inPage *targets.TargetListResult, opt ...Option) (*targets.TargetListResult, RefreshTokenValue, error) {
const op = "cache.defaultTargetFunc"
conf, err := api.DefaultConfig()
if err != nil {
return nil, "", errors.Wrap(ctx, err, op)
}
opts, err := getOpts(opt...)
if err != nil {
return nil, "", errors.Wrap(ctx, err, op)
}
conf.Addr = addr
conf.Token = authTok
client, err := api.NewClient(conf)
if err != nil {
return nil, "", errors.Wrap(ctx, err, op)
}
tarClient := targets.NewClient(client)
var l *targets.TargetListResult
switch inPage {
case nil:
l, err = tarClient.List(ctx, "global", targets.WithRecursive(true), targets.WithListToken(string(refreshTok)), targets.WithClientDirectedPagination(!opts.withUseNonPagedListing))
default:
l, err = tarClient.ListNextPage(ctx, inPage, targets.WithListToken(string(refreshTok)))
}
if err != nil {
if api.ErrInvalidListToken.Is(err) {
return nil, "", err
}
return nil, "", errors.Wrap(ctx, err, op)
}
if l.ResponseType == "" {
return nil, "", ErrRefreshNotSupported
}
return l, RefreshTokenValue(l.ListToken), nil
}
// refreshTargets uses attempts to refresh the targets for the provided user
// using the provided tokens. If available, it uses the refresh tokens in
// storage to retrieve and apply only the delta.
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):
return errors.New(ctx, errors.InvalidParameter, op, "user is nil")
case u.Id == "":
return errors.New(ctx, errors.InvalidParameter, op, "user id is missing")
}
const resourceType = targetResourceType
opts, err := getOpts(opt...)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if opts.withTargetRetrievalFunc == nil {
opts.withTargetRetrievalFunc = defaultTargetFunc
}
var oldRefreshTokenVal RefreshTokenValue
oldRefreshToken, err := r.lookupRefreshToken(ctx, u, resourceType)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if oldRefreshToken != nil {
oldRefreshTokenVal = oldRefreshToken.RefreshToken
}
// Find and use a token for retrieving targets
var gotResponse bool
var currentPage *targets.TargetListResult
var newRefreshToken RefreshTokenValue
var foundAuthToken string
var unsupportedCacheRequest bool
var retErr error
for at, t := range tokens {
currentPage, newRefreshToken, err = opts.withTargetRetrievalFunc(ctx, u.Address, t, oldRefreshTokenVal, currentPage)
if api.ErrInvalidListToken.Is(err) {
event.WriteSysEvent(ctx, op, "old list token is no longer valid, starting new initial fetch", "user_id", u.Id)
if err := r.deleteRefreshToken(ctx, u, resourceType); err != nil {
return errors.Wrap(ctx, err, op)
}
// try again without the refresh token
oldRefreshToken = nil
currentPage, newRefreshToken, err = opts.withTargetRetrievalFunc(ctx, u.Address, t, "", currentPage)
}
if err != nil {
if err == ErrRefreshNotSupported {
unsupportedCacheRequest = true
} else {
retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for token %q", at.Id)))
continue
}
}
foundAuthToken = t
gotResponse = true
break
}
if retErr != nil {
if saveErr := r.saveError(r.serverCtx, u, resourceType, retErr); saveErr != nil {
return stderrors.Join(err, errors.Wrap(ctx, saveErr, op))
}
}
if !gotResponse {
return retErr
}
var numDeleted int
var numUpserted int
var clearPerformed bool
for {
_, err = r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(_ db.Reader, w db.Writer) error {
var err error
if (oldRefreshToken == nil || unsupportedCacheRequest) && !clearPerformed {
if numDeleted, err = w.Exec(ctx, "delete from target where fk_user_id = @fk_user_id",
[]any{sql.Named("fk_user_id", u.Id)}); err != nil {
return err
}
clearPerformed = true
}
switch {
case unsupportedCacheRequest:
if err := upsertRefreshToken(ctx, w, u, resourceType, sentinelNoRefreshToken); err != nil {
return err
}
case newRefreshToken != "":
if err := upsertTargets(ctx, w, u, currentPage.Items); err != nil {
return err
}
numUpserted += len(currentPage.Items)
if err := upsertRefreshToken(ctx, w, u, resourceType, newRefreshToken); err != nil {
return err
}
default:
// controller supports caching, but doesn't have any resources
}
if !unsupportedCacheRequest && len(currentPage.RemovedIds) > 0 {
if numDeleted, err = w.Exec(ctx, "delete from target where id in @ids",
[]any{sql.Named("ids", currentPage.RemovedIds)}); err != nil {
return err
}
}
return nil
})
if unsupportedCacheRequest || currentPage.ResponseType == "" || currentPage.ResponseType == "complete" {
break
}
currentPage, newRefreshToken, err = opts.withTargetRetrievalFunc(ctx, u.Address, foundAuthToken, newRefreshToken, currentPage)
if err != nil {
break
}
}
if err != nil {
return errors.Wrap(ctx, err, op)
}
if unsupportedCacheRequest {
return ErrRefreshNotSupported
}
event.WriteSysEvent(ctx, op, "targets updated", "deleted", numDeleted, "upserted", numUpserted, "user_id", u.Id)
return nil
}
// checkCachingTargets fetches all targets for the provided user. If the
// response has at least one target and a refresh token, it makes the targets
// cachable and stores the refresh token. If there is no refresh token in the
// response it marks this user as unable to cache the data. If no data and no
// refresh token is stored it is unknown if the targets are cachable, the user
// is not marked as unknown.
func (r *Repository) checkCachingTargets(ctx context.Context, u *user, tokens map[AuthToken]string, opt ...Option) error {
const op = "cache.(Repository).checkCachingTargets"
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")
}
const resourceType = targetResourceType
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.TargetListResult
var newRefreshToken RefreshTokenValue
var unsupportedCacheRequest bool
var retErr error
for at, t := range tokens {
resp, newRefreshToken, err = opts.withTargetRetrievalFunc(ctx, u.Address, t, "", nil, WithUseNonPagedListing(true))
if err != nil {
if err == ErrRefreshNotSupported {
unsupportedCacheRequest = true
} else {
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(r.serverCtx, u, resourceType, retErr); saveErr != nil {
return stderrors.Join(err, errors.Wrap(ctx, saveErr, op))
}
}
if !gotResponse {
return retErr
}
var numDeleted int
_, err = r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(reader db.Reader, w db.Writer) error {
switch {
case unsupportedCacheRequest:
// Since we know the controller doesn't support caching, we mark the
// user as unable to cache the data.
if err := upsertRefreshToken(ctx, w, u, resourceType, sentinelNoRefreshToken); err != nil {
return err
}
case newRefreshToken != "":
// Now that there is a refresh token, the data can be cached, so
// cache it and store the refresh token for future refreshes. First
// remove any values, then add the new ones
var err error
if numDeleted, err = w.Exec(ctx, "delete from target where fk_user_id = @fk_user_id",
[]any{sql.Named("fk_user_id", u.Id)}); err != nil {
return err
}
if err := upsertTargets(ctx, w, u, resp.Items); err != nil {
return err
}
if err := upsertRefreshToken(ctx, w, u, resourceType, newRefreshToken); err != nil {
return err
}
default:
// We know the controller supports caching, but doesn't have a
// refresh token so clear out any refresh token we have for this resource.
if err := deleteRefreshToken(ctx, w, u, resourceType); err != nil {
return err
}
}
return nil
})
if err != nil {
return errors.Wrap(ctx, err, op)
}
if unsupportedCacheRequest {
return ErrRefreshNotSupported
}
event.WriteSysEvent(ctx, op, "targets updated", "deleted", numDeleted, "upserted", len(resp.Items), "user_id", u.Id)
return nil
}
// upsertTargets upserts the provided targets to be stored for the provided user.
func upsertTargets(ctx context.Context, w db.Writer, u *user, in []*targets.Target) error {
const op = "cache.upsertTargets"
switch {
case util.IsNil(w):
return errors.New(ctx, errors.InvalidParameter, op, "writer is nil")
case !w.IsTx(ctx):
return errors.New(ctx, errors.InvalidParameter, op, "writer isn't in a transaction")
case util.IsNil(u):
return errors.New(ctx, errors.InvalidParameter, op, "user is nil")
}
for _, t := range in {
item, err := json.Marshal(t)
if err != nil {
return errors.Wrap(ctx, err, op)
}
newTarget := &Target{
FkUserId: u.Id,
Id: t.Id,
Name: t.Name,
Description: t.Description,
Address: t.Address,
ScopeId: t.ScopeId,
Type: t.Type,
Item: string(item),
}
onConflict := db.OnConflict{
Target: db.Columns{"fk_user_id", "id"},
Action: db.SetColumns([]string{"name", "description", "address", "scope_id", "type", "item"}),
}
if err := w.Create(ctx, newTarget, db.WithOnConflict(&onConflict)); err != nil {
return errors.Wrap(ctx, err, op)
}
}
return nil
}
func (r *Repository) ListTargets(ctx context.Context, authTokenId string, opt ...Option) (*SearchResult, error) {
const op = "cache.(Repository).ListTargets"
switch {
case authTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing")
}
ret, err := r.searchTargets(ctx, "true", nil, append(opt, withAuthTokenId(authTokenId))...)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
func (r *Repository) QueryTargets(ctx context.Context, authTokenId, query string, opt ...Option) (*SearchResult, error) {
const op = "cache.(Repository).QueryTargets"
switch {
case authTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing")
case query == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "query is missing")
}
w, err := mql.Parse(query, Target{}, mql.WithIgnoredFields("FkUserId", "Item"))
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithCode(errors.InvalidParameter))
}
ret, err := r.searchTargets(ctx, w.Condition, w.Args, append(opt, withAuthTokenId(authTokenId))...)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return ret, nil
}
func (r *Repository) searchTargets(ctx context.Context, condition string, searchArgs []any, opt ...Option) (*SearchResult, error) {
const op = "cache.(Repository).searchTargets"
switch {
case condition == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "condition is missing")
}
opts, err := getOpts(opt...)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
switch {
case opts.withAuthTokenId != "" && opts.withUserId != "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "both user id and auth token id were provided")
case opts.withAuthTokenId == "" && opts.withUserId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "neither user id nor auth token id were provided")
case opts.withAuthTokenId != "":
condition = fmt.Sprintf("%s and fk_user_id in (select user_id from auth_token where id = ?)", condition)
searchArgs = append(searchArgs, opts.withAuthTokenId)
case opts.withUserId != "":
condition = fmt.Sprintf("%s and fk_user_id = ?", condition)
searchArgs = append(searchArgs, opts.withUserId)
}
var cachedTargets []*Target
if err := r.rw.SearchWhere(ctx, &cachedTargets, condition, searchArgs, db.WithLimit(opts.withMaxResultSetSize+1)); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
retTargets := make([]*targets.Target, 0, len(cachedTargets))
for _, cachedTar := range cachedTargets {
var tar targets.Target
if err := json.Unmarshal([]byte(cachedTar.Item), &tar); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
retTargets = append(retTargets, &tar)
}
sr := &SearchResult{
Targets: retTargets,
}
if opts.withMaxResultSetSize > 0 && len(sr.Targets) > opts.withMaxResultSetSize {
sr.Targets = sr.Targets[:opts.withMaxResultSetSize]
sr.Incomplete = true
}
return sr, nil
}
type Target struct {
FkUserId string `gorm:"primaryKey"`
Id string `gorm:"primaryKey"`
Type string `gorm:"default:null"`
Name string `gorm:"default:null"`
Description string `gorm:"default:null"`
Address string `gorm:"default:null"`
ScopeId string `gorm:"default:null"`
Item string `gorm:"default:null"`
}
func (*Target) TableName() string {
return "target"
}