[scopes] scope-aware generic resource backend (#67682)

* scope-aware generic service

* refactor to use WithScopePrefix
This commit is contained in:
Nic Klaassen
2026-06-22 16:29:46 +00:00
committed by GitHub
parent 26f257c850
commit 37579cb6b1
7 changed files with 841 additions and 14 deletions
+110
View File
@@ -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
}
+113
View File
@@ -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
View File
@@ -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
}
+13 -11
View File
@@ -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.
+6 -1
View File
@@ -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 {
+324
View File
@@ -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)
}