mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-21 05:55:42 +08:00
[scopes] scope-aware generic resource backend (#67682)
* scope-aware generic service * refactor to use WithScopePrefix
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package scopes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
)
|
||||
|
||||
// ResourceCursorPrefix prefixes cursors for scoped resources in a
|
||||
// logical resource stream.
|
||||
//
|
||||
// The prefix starts with '~', which is not allowed in backend-safe resource
|
||||
// names and sorts after all backend-safe name bytes, preserving historical
|
||||
// name-only cursors for unscoped resources while ordering scoped resources
|
||||
// after unscoped resources.
|
||||
const ResourceCursorPrefix = "~scoped/"
|
||||
|
||||
// ResourceCursorScopedStart returns the first cursor in the scoped portion of
|
||||
// the logical resource stream.
|
||||
func ResourceCursorScopedStart() string {
|
||||
return ResourceCursorPrefix
|
||||
}
|
||||
|
||||
// IsScopedResourceCursor returns true if cursor is in the scoped portion of the
|
||||
// logical resource stream.
|
||||
func IsScopedResourceCursor(cursor string) bool {
|
||||
return strings.HasPrefix(cursor, ResourceCursorPrefix)
|
||||
}
|
||||
|
||||
// MakeResourceCursor returns the cursor for a scoped or unscoped resource in a
|
||||
// logical, lexicographically ordered resource stream.
|
||||
//
|
||||
// Resource cursors are intended for pagination tokens, range bounds, and
|
||||
// in-memory cache indexes. They are not backend storage keys and must not be
|
||||
// used to construct backend keys.
|
||||
//
|
||||
// Unscoped resource cursors preserve the historical name-only format:
|
||||
//
|
||||
// <name>
|
||||
//
|
||||
// Scoped resource cursors use a synthetic prefix that cannot appear in
|
||||
// backend-safe resource names and sorts after all backend-safe name bytes:
|
||||
//
|
||||
// ~scoped/<encoded-scope>/<name>
|
||||
//
|
||||
// The scope component is encoded with EncodeForKey so that scoped cursors
|
||||
// preserve scope ordering and can safely use '/' as the cursor separator.
|
||||
func MakeResourceCursor(scope, name string) (string, error) {
|
||||
if scope == "" {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
encodedScope, err := EncodeForKey(scope)
|
||||
if err != nil {
|
||||
return "", trace.Wrap(err)
|
||||
}
|
||||
|
||||
return ResourceCursorPrefix + encodedScope + separator + name, nil
|
||||
}
|
||||
|
||||
// ParseResourceCursor parses a resource cursor produced by [MakeResourceCursor]
|
||||
// into its scope and name components.
|
||||
//
|
||||
// Unscoped cursors are interpreted as historical name-only cursors. Scoped
|
||||
// cursors must use the scoped cursor format:
|
||||
//
|
||||
// ~scoped/<encoded-scope>/<name>
|
||||
func ParseResourceCursor(cursor string) (QualifiedName, error) {
|
||||
encodedScopeAndName, ok := strings.CutPrefix(cursor, ResourceCursorPrefix)
|
||||
if !ok {
|
||||
return QualifiedName{Name: cursor}, nil
|
||||
}
|
||||
|
||||
encodedScope, name, ok := strings.Cut(encodedScopeAndName, separator)
|
||||
if !ok {
|
||||
return QualifiedName{}, trace.BadParameter("scoped resource cursor %q missing name separator", cursor)
|
||||
}
|
||||
if encodedScope == "" {
|
||||
return QualifiedName{}, trace.BadParameter("scoped resource cursor %q has empty encoded scope", cursor)
|
||||
}
|
||||
if name == "" {
|
||||
return QualifiedName{}, trace.BadParameter("scoped resource cursor %q has empty name", cursor)
|
||||
}
|
||||
if strings.Contains(name, separator) {
|
||||
return QualifiedName{}, trace.BadParameter("scoped resource cursor %q has invalid name", cursor)
|
||||
}
|
||||
|
||||
scope, err := DecodeFromKey(encodedScope)
|
||||
if err != nil {
|
||||
return QualifiedName{}, trace.Wrap(err)
|
||||
}
|
||||
|
||||
return QualifiedName{Scope: scope, Name: name}, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package scopes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResourceCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("unscoped", func(t *testing.T) {
|
||||
cursor, err := MakeResourceCursor("", "name")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "name", cursor)
|
||||
require.False(t, IsScopedResourceCursor(cursor))
|
||||
|
||||
parsed, err := ParseResourceCursor(cursor)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, QualifiedName{Name: "name"}, parsed)
|
||||
})
|
||||
|
||||
t.Run("scoped", func(t *testing.T) {
|
||||
cursor, err := MakeResourceCursor("/aa/bb", "name")
|
||||
require.NoError(t, err)
|
||||
require.True(t, IsScopedResourceCursor(cursor))
|
||||
require.Contains(t, cursor, ResourceCursorPrefix)
|
||||
|
||||
parsed, err := ParseResourceCursor(cursor)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, QualifiedName{Scope: "/aa/bb", Name: "name"}, parsed)
|
||||
})
|
||||
|
||||
t.Run("scoped start", func(t *testing.T) {
|
||||
require.Equal(t, ResourceCursorPrefix, ResourceCursorScopedStart())
|
||||
require.True(t, IsScopedResourceCursor(ResourceCursorScopedStart()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseResourceCursorErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cursor := range []string{
|
||||
ResourceCursorPrefix,
|
||||
ResourceCursorPrefix + "/name",
|
||||
ResourceCursorPrefix + "++/",
|
||||
ResourceCursorPrefix + "++/nested/name",
|
||||
ResourceCursorPrefix + "invalid/name",
|
||||
} {
|
||||
t.Run(cursor, func(t *testing.T) {
|
||||
_, err := ParseResourceCursor(cursor)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceCursorSort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resources := []QualifiedName{
|
||||
{Scope: "/bb", Name: "aaa"},
|
||||
{Scope: "", Name: "zzz"},
|
||||
{Scope: "/aa", Name: "bbb"},
|
||||
{Scope: "/aa/bb", Name: "aaa"},
|
||||
{Scope: "", Name: "aaa"},
|
||||
{Scope: "/aa", Name: "aaa"},
|
||||
}
|
||||
|
||||
cursors := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
cursor, err := MakeResourceCursor(resource.Scope, resource.Name)
|
||||
require.NoError(t, err)
|
||||
cursors = append(cursors, cursor)
|
||||
}
|
||||
|
||||
slices.Sort(cursors)
|
||||
|
||||
var got []QualifiedName
|
||||
for _, cursor := range cursors {
|
||||
resource, err := ParseResourceCursor(cursor)
|
||||
require.NoError(t, err)
|
||||
got = append(got, resource)
|
||||
}
|
||||
|
||||
require.Equal(t, []QualifiedName{
|
||||
// Unscoped cursors preserve historical name-only ordering and sort before
|
||||
// all scoped cursors.
|
||||
{Scope: "", Name: "aaa"},
|
||||
{Scope: "", Name: "zzz"},
|
||||
// Scoped cursors sort by encoded scope, then name within the same scope.
|
||||
{Scope: "/aa", Name: "aaa"},
|
||||
{Scope: "/aa", Name: "bbb"},
|
||||
{Scope: "/aa/bb", Name: "aaa"},
|
||||
{Scope: "/bb", Name: "aaa"},
|
||||
}, got)
|
||||
}
|
||||
+10
-2
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
// QualifiedNameSeparator is the separator between scope and name in a
|
||||
// scope-qualified name. This separator must never appear in scope segements.
|
||||
// scope-qualified name. This separator must never appear in scope segments.
|
||||
const QualifiedNameSeparator = "::"
|
||||
|
||||
// QualifiedName pairs a scope with a resource name to uniquely identify a scoped
|
||||
@@ -35,6 +35,10 @@ const QualifiedNameSeparator = "::"
|
||||
// is necessary to fully specify the unique identifier of a scoped resource. Internally,
|
||||
// teleport APIs should generally continue to use separate scope and name fields, as
|
||||
// should structured logs/events.
|
||||
//
|
||||
// A QualifiedName may be used in APIs that need to refer to a resource that
|
||||
// may be scoped or unscoped. In these cases the Scope field may be empty, and
|
||||
// WeakValidate and StrongValidate will return an error.
|
||||
type QualifiedName struct {
|
||||
// Scope is the resource's scope path, e.g. "/staging/west".
|
||||
Scope string
|
||||
@@ -42,8 +46,12 @@ type QualifiedName struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// String returns encodes the string representation of the QualifiedName.
|
||||
// String returns the string representation of the QualifiedName.
|
||||
// If the Scope is empty, the Name is returned verbatim.
|
||||
func (q QualifiedName) String() string {
|
||||
if q.Scope == "" {
|
||||
return q.Name
|
||||
}
|
||||
return q.Scope + QualifiedNameSeparator + q.Name
|
||||
}
|
||||
|
||||
|
||||
@@ -252,17 +252,19 @@ func (s *Service[T]) Resources(ctx context.Context, startKey, endKey string) ite
|
||||
return resource, true
|
||||
}
|
||||
|
||||
return stream.TakeWhile(
|
||||
stream.FilterMap(s.backend.Items(ctx, params), mapFn),
|
||||
func(r T) bool {
|
||||
// We promise the consumers of this function that the returned
|
||||
// results an exclusive of the endkey but the underlying Items
|
||||
// method returns us a stream that's inclusive of the end key - so
|
||||
// if the user has provided us an end-key, we manually filter them
|
||||
// out to convert from inclusive to exclusive.
|
||||
return endKey == "" || r.GetName() < endKey
|
||||
},
|
||||
)
|
||||
items := s.backend.Items(ctx, params)
|
||||
if endKey != "" {
|
||||
exclusiveEndKey := s.backendPrefix.AppendKey(backend.KeyFromString(endKey))
|
||||
items = stream.TakeWhile(items, func(item backend.Item) bool {
|
||||
// We promise consumers that the returned results are exclusive of
|
||||
// endKey, but the underlying Items method returns an inclusive end
|
||||
// key. Compare the full backend key so composite relative keys such
|
||||
// as "<prefix>/<name>" are handled correctly.
|
||||
return item.Key.Compare(exclusiveEndKey) < 0
|
||||
})
|
||||
}
|
||||
|
||||
return stream.FilterMap(items, mapFn)
|
||||
}
|
||||
|
||||
// ListResources returns a paginated list of resources.
|
||||
|
||||
@@ -45,7 +45,12 @@ import (
|
||||
// testResource for testing the generic service.
|
||||
type testResource struct {
|
||||
types.ResourceHeader
|
||||
Spec testResourceSpec
|
||||
Spec testResourceSpec
|
||||
Scope string
|
||||
}
|
||||
|
||||
func (r *testResource) GetScope() string {
|
||||
return r.Scope
|
||||
}
|
||||
|
||||
func newTestResource(name string) *testResource {
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package generic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"iter"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
|
||||
"github.com/gravitational/teleport/lib/backend"
|
||||
"github.com/gravitational/teleport/lib/itertools/stream"
|
||||
"github.com/gravitational/teleport/lib/scopes"
|
||||
)
|
||||
|
||||
// ScopedResource is a resource type that has a scope. The scope may be empty
|
||||
// on any individual resource of the type, that resource may be referred to as
|
||||
// "unscoped" despite the type being scoped.
|
||||
type ScopedResource interface {
|
||||
Resource
|
||||
// GetScope returns the scope of the resource.
|
||||
GetScope() string
|
||||
}
|
||||
|
||||
// ScopeAwareService is a generic service for interacting with namespaced
|
||||
// scoped resources in the backend. Scoped resources will be stored in a
|
||||
// separate key range from resources with an empty scope, and namespaced by
|
||||
// their scope. The ScopeAwareService transparently handles listing all scoped
|
||||
// and unscoped resources, as well as creating and querying individual resources
|
||||
// from the correct key range.
|
||||
type ScopeAwareService[T ScopedResource] struct {
|
||||
// UnscopedService is the underlying service for resources with an empty scope.
|
||||
// Resources will be keyed at <backend_prefix>/<name>
|
||||
UnscopedService *Service[T]
|
||||
// ScopedService is the underlying service for resources with a scope.
|
||||
// Resources will be keyed at <scoped_prefix>/<backend_prefix>/<encoded_scope>/<name>
|
||||
ScopedService *Service[T]
|
||||
}
|
||||
|
||||
// ScopeAwareServiceConfig holds configuration options for ScopeAwareService.
|
||||
type ScopeAwareServiceConfig[T ScopedResource] struct {
|
||||
// Backend used to persist the resource.
|
||||
Backend backend.Backend
|
||||
// ResourceKind is the friendly name of the resource.
|
||||
ResourceKind string
|
||||
// UnscopedBackendPrefix used when constructing the [backend.Item.Key] for unscoped resources.
|
||||
UnscopedBackendPrefix backend.Key
|
||||
// ScopedBackendPrefix used when constructing the [backend.Item.Key] for scoped resources.
|
||||
ScopedBackendPrefix backend.Key
|
||||
// PageLimit
|
||||
PageLimit uint
|
||||
// MarshlFunc converts the resource to bytes for persistence.
|
||||
MarshalFunc MarshalFunc[T]
|
||||
// UnmarshalFunc converts the bytes read from the backend to the resource.
|
||||
UnmarshalFunc UnmarshalFunc[T]
|
||||
// ValidateFunc optionally validates the resource prior to persisting it. Any errors
|
||||
// returned from the validation function will prevent writes to the backend.
|
||||
ValidateFunc func(T) error
|
||||
// RunWhileLockedRetryInterval is the interval to retry the RunWhileLocked function.
|
||||
// If set to 0, the default interval of 250ms will be used.
|
||||
// WARNING: If set to a negative value, the RunWhileLocked function will retry immediately.
|
||||
RunWhileLockedRetryInterval time.Duration
|
||||
}
|
||||
|
||||
// NewScopeAwareService returns a new scope-aware service.
|
||||
func NewScopeAwareService[T ScopedResource](cfg *ScopeAwareServiceConfig[T]) (*ScopeAwareService[T], error) {
|
||||
unscopedService, err := NewService(&ServiceConfig[T]{
|
||||
Backend: cfg.Backend,
|
||||
ResourceKind: cfg.ResourceKind,
|
||||
PageLimit: cfg.PageLimit,
|
||||
BackendPrefix: cfg.UnscopedBackendPrefix,
|
||||
MarshalFunc: cfg.MarshalFunc,
|
||||
UnmarshalFunc: cfg.UnmarshalFunc,
|
||||
ValidateFunc: cfg.ValidateFunc,
|
||||
RunWhileLockedRetryInterval: cfg.RunWhileLockedRetryInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
scopedService, err := NewService(&ServiceConfig[T]{
|
||||
Backend: cfg.Backend,
|
||||
ResourceKind: cfg.ResourceKind,
|
||||
PageLimit: cfg.PageLimit,
|
||||
BackendPrefix: cfg.ScopedBackendPrefix,
|
||||
MarshalFunc: cfg.MarshalFunc,
|
||||
UnmarshalFunc: cfg.UnmarshalFunc,
|
||||
ValidateFunc: cfg.ValidateFunc,
|
||||
RunWhileLockedRetryInterval: cfg.RunWhileLockedRetryInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
return &ScopeAwareService[T]{
|
||||
UnscopedService: unscopedService,
|
||||
ScopedService: scopedService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Resources returns a stream of resources within the unified scope-aware range
|
||||
// [startKey, endKey). Unscoped resources are ordered before scoped resources.
|
||||
//
|
||||
// The startKey and endKey values are resource cursors as defined by
|
||||
// [scopes.MakeResourceCursor]. An unscoped cursor is the resource name. A scoped
|
||||
// cursor uses [scopes.ResourceCursorPrefix] followed by the scoped backend
|
||||
// service's relative key. [scopes.ResourceCursorScopedStart] is the boundary
|
||||
// between unscoped and scoped resources.
|
||||
func (s *ScopeAwareService[T]) Resources(ctx context.Context, startKey, endKey string) iter.Seq2[T, error] {
|
||||
var streams []stream.Stream[T]
|
||||
|
||||
if !scopes.IsScopedResourceCursor(startKey) {
|
||||
unscopedEndKey := endKey
|
||||
if scopes.IsScopedResourceCursor(endKey) {
|
||||
unscopedEndKey = ""
|
||||
}
|
||||
streams = append(streams, s.UnscopedService.Resources(ctx, startKey, unscopedEndKey))
|
||||
}
|
||||
|
||||
if endKey != "" && !scopes.IsScopedResourceCursor(endKey) {
|
||||
return stream.Chain(streams...)
|
||||
}
|
||||
if endKey == scopes.ResourceCursorScopedStart() {
|
||||
return stream.Chain(streams...)
|
||||
}
|
||||
|
||||
scopedStartKey := ""
|
||||
if scopes.IsScopedResourceCursor(startKey) {
|
||||
scopedStartKey = strings.TrimPrefix(startKey, scopes.ResourceCursorPrefix)
|
||||
}
|
||||
scopedEndKey := strings.TrimPrefix(endKey, scopes.ResourceCursorPrefix)
|
||||
streams = append(streams, s.ScopedService.Resources(ctx, scopedStartKey, scopedEndKey))
|
||||
|
||||
return stream.Chain(streams...)
|
||||
}
|
||||
|
||||
// GetResources returns all unscoped and scoped resources.
|
||||
func (s *ScopeAwareService[T]) GetResources(ctx context.Context) ([]T, error) {
|
||||
return stream.Collect(s.Resources(ctx, "", ""))
|
||||
}
|
||||
|
||||
// ListResources returns a page of resources over the unified scoped
|
||||
// and unscoped collection. It always returns all unscoped resources before
|
||||
// matching scoped resources.
|
||||
func (s *ScopeAwareService[T]) ListResources(ctx context.Context, pageSize int, nextToken string) ([]T, string, error) {
|
||||
return s.ListResourcesWithFilter(ctx, pageSize, nextToken, func(T) bool { return true })
|
||||
}
|
||||
|
||||
// ListResourcesWithFilter returns a page of matching resources over the
|
||||
// unified scoped and unscoped collection. It always returns all matching
|
||||
// unscoped resources before matching scoped resources.
|
||||
func (s *ScopeAwareService[T]) ListResourcesWithFilter(ctx context.Context, pageSize int, nextToken string, matcher func(T) bool) ([]T, string, error) {
|
||||
if pageSize <= 0 || pageSize > int(s.UnscopedService.pageLimit) {
|
||||
pageSize = int(s.UnscopedService.pageLimit)
|
||||
}
|
||||
|
||||
// Check if the token was scoped, if so the caller has already paged over
|
||||
// all unscoped resources and we should return the next page of scoped
|
||||
// resources.
|
||||
if scopes.IsScopedResourceCursor(nextToken) {
|
||||
nextToken = strings.TrimPrefix(nextToken, scopes.ResourceCursorPrefix)
|
||||
resources, nextToken, err := s.ScopedService.ListResourcesWithFilter(ctx, pageSize, nextToken, matcher)
|
||||
if nextToken != "" {
|
||||
nextToken = scopes.ResourceCursorPrefix + nextToken
|
||||
}
|
||||
return resources, nextToken, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// Fetch the next page of matching unscoped resources.
|
||||
resources, nextToken, err := s.UnscopedService.ListResourcesWithFilter(ctx, pageSize, nextToken, matcher)
|
||||
if err != nil {
|
||||
return nil, "", trace.Wrap(err)
|
||||
}
|
||||
|
||||
if nextToken != "" {
|
||||
// There are remaining unscoped resources, return this page.
|
||||
return resources, nextToken, nil
|
||||
}
|
||||
if len(resources) >= pageSize {
|
||||
// The page is full but nextToken is empty indicating there are no more
|
||||
// unscoped resources. Return with scopedPageTokenPrefix so the next
|
||||
// page begins with scoped resources.
|
||||
return resources, scopes.ResourceCursorScopedStart(), nil
|
||||
}
|
||||
|
||||
// Reached the end of unscoped resources within pageSize, try to fill in
|
||||
// the page with scoped resources.
|
||||
remainingPageSize := pageSize - len(resources)
|
||||
scopedResources, nextToken, err := s.ScopedService.ListResourcesWithFilter(ctx, remainingPageSize, "", matcher)
|
||||
if nextToken != "" {
|
||||
nextToken = scopes.ResourceCursorPrefix + nextToken
|
||||
}
|
||||
return append(resources, scopedResources...), nextToken, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// GetResource returns a resource, if it exists, for the given scope-qualified name.
|
||||
// If the scope is empty, it returns an unscoped resource from the unscoped key range.
|
||||
// If the scope is non-empty, it returns a scoped resource from the scoped key range.
|
||||
func (s *ScopeAwareService[T]) GetResource(ctx context.Context, scopedName scopes.QualifiedName) (T, error) {
|
||||
svc, err := s.WithScopePrefix(scopedName.Scope)
|
||||
if err != nil {
|
||||
var nul T
|
||||
return nul, trace.Wrap(err)
|
||||
}
|
||||
return svc.GetResource(ctx, scopedName.Name)
|
||||
}
|
||||
|
||||
// DeleteResource deletes a resource for the given scope-qualified name.
|
||||
// If the scope is empty, it deletes an unscoped resource from the unscoped key range.
|
||||
// If the scope is non-empty, it deletes a scoped resource from the scoped key range.
|
||||
func (s *ScopeAwareService[T]) DeleteResource(ctx context.Context, scopedName scopes.QualifiedName) error {
|
||||
svc, err := s.WithScopePrefix(scopedName.Scope)
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
return svc.DeleteResource(ctx, scopedName.Name)
|
||||
}
|
||||
|
||||
// DeleteAllResources deletes all scoped and unscoped resources.
|
||||
func (s *ScopeAwareService[T]) DeleteAllResources(ctx context.Context) error {
|
||||
return trace.NewAggregate(
|
||||
s.UnscopedService.DeleteAllResources(ctx),
|
||||
s.ScopedService.DeleteAllResources(ctx),
|
||||
)
|
||||
}
|
||||
|
||||
// CreateResource creates the given scoped resource if it doesn't already
|
||||
// exist. If the scope is empty, it will be inserted in the unscoped key range,
|
||||
// else it will be inserted in the scoped key range.
|
||||
func (s *ScopeAwareService[T]) CreateResource(ctx context.Context, resource T) (T, error) {
|
||||
svc, err := s.WithScopePrefix(resource.GetScope())
|
||||
if err != nil {
|
||||
var nul T
|
||||
return nul, trace.Wrap(err)
|
||||
}
|
||||
return svc.CreateResource(ctx, resource)
|
||||
}
|
||||
|
||||
// UpsertResource upserts the given scoped resource. If the scope is empty, it
|
||||
// will be inserted in the unscoped key range, else it will be inserted in the
|
||||
// scoped key range.
|
||||
func (s *ScopeAwareService[T]) UpsertResource(ctx context.Context, resource T) (T, error) {
|
||||
svc, err := s.WithScopePrefix(resource.GetScope())
|
||||
if err != nil {
|
||||
var nul T
|
||||
return nul, trace.Wrap(err)
|
||||
}
|
||||
return svc.UpsertResource(ctx, resource)
|
||||
}
|
||||
|
||||
// UpdateResource updates the given scoped resource. If the scope is empty, it
|
||||
// will be updated in the unscoped key range, else it will be updated in the
|
||||
// scoped key range.
|
||||
func (s *ScopeAwareService[T]) UpdateResource(ctx context.Context, resource T) (T, error) {
|
||||
svc, err := s.WithScopePrefix(resource.GetScope())
|
||||
if err != nil {
|
||||
var nul T
|
||||
return nul, trace.Wrap(err)
|
||||
}
|
||||
return svc.UpdateResource(ctx, resource)
|
||||
}
|
||||
|
||||
// ConditionalUpdateResource updates the given scoped resource if the revision
|
||||
// matches. If the scope is empty, it will be updated in the unscoped key
|
||||
// range, else it will be updated in the scoped key range.
|
||||
func (s *ScopeAwareService[T]) ConditionalUpdateResource(ctx context.Context, resource T) (T, error) {
|
||||
svc, err := s.WithScopePrefix(resource.GetScope())
|
||||
if err != nil {
|
||||
var nul T
|
||||
return nul, trace.Wrap(err)
|
||||
}
|
||||
return svc.ConditionalUpdateResource(ctx, resource)
|
||||
}
|
||||
|
||||
// WithScopePrefix returns the unscoped service when scope is empty, otherwise
|
||||
// returns the scoped service with the encoded scope appended to its backend prefix.
|
||||
func (s *ScopeAwareService[T]) WithScopePrefix(scope string) (*Service[T], error) {
|
||||
if scope == "" {
|
||||
return s.UnscopedService, nil
|
||||
}
|
||||
encodedScope, err := scopes.EncodeForKey(scope)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
return s.ScopedService.WithPrefix(encodedScope), nil
|
||||
}
|
||||
|
||||
// WithScopedResourcePrefix returns a [*Service] with a prefix for the given
|
||||
// scope-qualified name appended to the backend prefix.
|
||||
//
|
||||
// If the given scope is empty, it will return the UnscopedService with the
|
||||
// given name as an added prefix.
|
||||
//
|
||||
// If the given scope is non-empty, it will return the ScopedService with the
|
||||
// encoded scope and the name as an added prefix.
|
||||
//
|
||||
// This may be appropriate for dependent resources keyed by a unique scoped
|
||||
// resource, i.e. members of a scoped access list.
|
||||
func (s *ScopeAwareService[T]) WithScopedResourcePrefix(scopedName scopes.QualifiedName) (*Service[T], error) {
|
||||
if scopedName.Scope == "" {
|
||||
return s.UnscopedService.WithPrefix(scopedName.Name), nil
|
||||
}
|
||||
encodedScope, err := scopes.EncodeForKey(scopedName.Scope)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
return s.ScopedService.WithPrefix(encodedScope, scopedName.Name), nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package generic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jonboulle/clockwork"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/gravitational/teleport/api/utils/clientutils"
|
||||
"github.com/gravitational/teleport/lib/backend"
|
||||
"github.com/gravitational/teleport/lib/backend/memory"
|
||||
"github.com/gravitational/teleport/lib/scopes"
|
||||
)
|
||||
|
||||
func newScopedTestResource(sqn scopes.QualifiedName) *testResource {
|
||||
r := newTestResource(sqn.Name)
|
||||
r.Scope = sqn.Scope
|
||||
return r
|
||||
}
|
||||
|
||||
func TestScopeAwareService(t *testing.T) {
|
||||
memBackend, err := memory.New(memory.Config{
|
||||
Context: t.Context(),
|
||||
Clock: clockwork.NewFakeClock(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
service, err := NewScopeAwareService(&ScopeAwareServiceConfig[*testResource]{
|
||||
Backend: memBackend,
|
||||
ResourceKind: "generic resource",
|
||||
UnscopedBackendPrefix: backend.NewKey("generic_prefix"),
|
||||
ScopedBackendPrefix: backend.NewKey("scoped", "generic_prefix"),
|
||||
PageLimit: 200,
|
||||
UnmarshalFunc: unmarshalResource,
|
||||
MarshalFunc: marshalResource,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var resources []*testResource
|
||||
|
||||
// Create some unscoped resources.
|
||||
const numUnscopedResources = 10
|
||||
for nameIndex := range numUnscopedResources {
|
||||
resources = append(resources, newTestResource(fmt.Sprintf("name%d", nameIndex)))
|
||||
}
|
||||
|
||||
// Add a bunch of scoped resources at various scopes.
|
||||
for baseScopeIndex := range 10 {
|
||||
// Add ten at this base scope.
|
||||
for nameIndex := range 10 {
|
||||
resources = append(resources, newScopedTestResource(scopes.QualifiedName{
|
||||
Scope: fmt.Sprintf("/base%d", baseScopeIndex),
|
||||
Name: fmt.Sprintf("name%d", nameIndex),
|
||||
}))
|
||||
}
|
||||
|
||||
// Add ten more at each of 10 sub scopes.
|
||||
for subScopeIndex := range 10 {
|
||||
for nameIndex := range 10 {
|
||||
resources = append(resources, newScopedTestResource(scopes.QualifiedName{
|
||||
Scope: fmt.Sprintf("/base%d/sub%d", baseScopeIndex, subScopeIndex),
|
||||
Name: fmt.Sprintf("name%d", nameIndex),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expectedResourceNames := make(map[scopes.QualifiedName]struct{}, len(resources))
|
||||
for _, resource := range resources {
|
||||
expectedResourceNames[scopes.QualifiedName{
|
||||
Scope: resource.GetScope(),
|
||||
Name: resource.GetName(),
|
||||
}] = struct{}{}
|
||||
}
|
||||
|
||||
checkExpectedResources := func(t *testing.T, expectedResourceNames map[scopes.QualifiedName]struct{}, resources iter.Seq2[*testResource, error]) {
|
||||
t.Helper()
|
||||
|
||||
expected := maps.Clone(expectedResourceNames)
|
||||
|
||||
for resource, err := range resources {
|
||||
require.NoError(t, err)
|
||||
|
||||
key := scopes.QualifiedName{
|
||||
Scope: resource.GetScope(),
|
||||
Name: resource.GetName(),
|
||||
}
|
||||
|
||||
assert.Contains(t, expected, key, "found unexpected resource %v", key)
|
||||
delete(expected, key)
|
||||
}
|
||||
assert.Empty(t, expected, "did not find expected resources")
|
||||
}
|
||||
|
||||
expectedResourcesInCursorRange := func(t *testing.T, startKey, endKey string) map[scopes.QualifiedName]struct{} {
|
||||
t.Helper()
|
||||
|
||||
expected := make(map[scopes.QualifiedName]struct{})
|
||||
for _, resource := range resources {
|
||||
cursor, err := scopes.MakeResourceCursor(resource.GetScope(), resource.GetName())
|
||||
require.NoError(t, err)
|
||||
if startKey != "" && cursor < startKey {
|
||||
continue
|
||||
}
|
||||
if endKey != "" && cursor >= endKey {
|
||||
continue
|
||||
}
|
||||
expected[scopes.QualifiedName{Scope: resource.GetScope(), Name: resource.GetName()}] = struct{}{}
|
||||
}
|
||||
return expected
|
||||
}
|
||||
|
||||
// Create all the resources.
|
||||
for _, resource := range resources {
|
||||
_, err := service.CreateResource(t.Context(), resource)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("Resources", func(t *testing.T) {
|
||||
checkExpectedResources(t, expectedResourceNames, service.Resources(t.Context(), "", ""))
|
||||
|
||||
foundScoped := false
|
||||
for resource, err := range service.Resources(t.Context(), "", "") {
|
||||
require.NoError(t, err)
|
||||
if foundScoped && resource.GetScope() == "" {
|
||||
t.Fatal("found unscoped resource after scoped resource")
|
||||
}
|
||||
foundScoped = foundScoped || resource.GetScope() != ""
|
||||
}
|
||||
|
||||
countResources := func(resources iter.Seq2[*testResource, error]) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
for _, err := range resources {
|
||||
require.NoError(t, err)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// The scoped start cursor is the boundary between unscoped resources and
|
||||
// scoped resources in the unified logical resource stream.
|
||||
require.Equal(t, numUnscopedResources,
|
||||
countResources(service.Resources(t.Context(), "", scopes.ResourceCursorScopedStart())))
|
||||
require.Equal(t, len(resources)-numUnscopedResources,
|
||||
countResources(service.Resources(t.Context(), scopes.ResourceCursorScopedStart(), "")))
|
||||
|
||||
// Verify a range that starts in unscoped resources and ends in scoped
|
||||
// resources, forcing Resources to concatenate both underlying services.
|
||||
scopedEnd, err := scopes.MakeResourceCursor("/base1", "name0")
|
||||
require.NoError(t, err)
|
||||
checkExpectedResources(t,
|
||||
expectedResourcesInCursorRange(t, "name5", scopedEnd),
|
||||
service.Resources(t.Context(), "name5", scopedEnd),
|
||||
)
|
||||
})
|
||||
|
||||
// Check all resources can be listed, at various page sizes.
|
||||
for _, pageSize := range []int{0, 1, 3, 5, 10, 11, 99, 100, 101} {
|
||||
t.Run(fmt.Sprintf("pageSize=%d", pageSize), func(t *testing.T) {
|
||||
t.Run("unfiltered", func(t *testing.T) {
|
||||
resourceIter := clientutils.ResourcesWithPageSize(t.Context(), service.ListResources, pageSize)
|
||||
checkExpectedResources(t, expectedResourceNames, resourceIter)
|
||||
})
|
||||
|
||||
t.Run("filtered", func(t *testing.T) {
|
||||
iter := clientutils.ResourcesWithPageSize(t.Context(), func(ctx context.Context, pageSize int, nextPageToken string) ([]*testResource, string, error) {
|
||||
return service.ListResourcesWithFilter(ctx, pageSize, nextPageToken, func(resource *testResource) bool {
|
||||
return strings.HasPrefix(resource.GetScope(), "/base7/")
|
||||
})
|
||||
}, pageSize)
|
||||
count := 0
|
||||
for resource, err := range iter {
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(resource.GetScope(), "/base7/"))
|
||||
count++
|
||||
}
|
||||
require.Equal(t, 100, count)
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// Check that unscoped resources sort before scoped resources.
|
||||
foundScoped := false
|
||||
for resource, err := range clientutils.Resources(t.Context(), service.ListResources) {
|
||||
require.NoError(t, err)
|
||||
scope := resource.GetScope()
|
||||
if foundScoped && scope == "" {
|
||||
t.Fatal("found unscoped resource after scoped resource")
|
||||
}
|
||||
foundScoped = foundScoped || scope != ""
|
||||
}
|
||||
|
||||
for resourceName := range expectedResourceNames {
|
||||
// Get should work.
|
||||
resource, err := service.GetResource(t.Context(), resourceName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update should work.
|
||||
resource.Spec.PropA = "updated"
|
||||
resource, err = service.UpdateResource(t.Context(), resource)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "updated", resource.Spec.PropA)
|
||||
|
||||
// Try ConditionalUpdate with incorrect revision.
|
||||
rev := resource.Metadata.Revision
|
||||
resource.Metadata.Revision = ""
|
||||
_, err = service.ConditionalUpdateResource(t.Context(), resource)
|
||||
require.Error(t, err)
|
||||
|
||||
// ConditionalUpdate with correct revision.
|
||||
resource.Metadata.Revision = rev
|
||||
resource.Spec.PropA = "conditional_updated"
|
||||
resource, err = service.ConditionalUpdateResource(t.Context(), resource)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "conditional_updated", resource.Spec.PropA)
|
||||
|
||||
// Delete the resource and Get should fail.
|
||||
err = service.DeleteResource(t.Context(), resourceName)
|
||||
require.NoError(t, err)
|
||||
_, err = service.GetResource(t.Context(), resourceName)
|
||||
require.Error(t, err)
|
||||
|
||||
// Upsert the resource.
|
||||
resource.Spec.PropA = "upserted"
|
||||
resource, err = service.UpsertResource(t.Context(), resource)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "upserted", resource.Spec.PropA)
|
||||
}
|
||||
|
||||
// Make sure all the expected resources are still there.
|
||||
resourceIter := clientutils.Resources(t.Context(), service.ListResources)
|
||||
checkExpectedResources(t, expectedResourceNames, resourceIter)
|
||||
|
||||
// Delete all resources and make sure they're gone.
|
||||
err = service.DeleteAllResources(t.Context())
|
||||
require.NoError(t, err)
|
||||
page, _, err := service.ListResources(t.Context(), 1, "")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, page)
|
||||
}
|
||||
Reference in New Issue
Block a user